The origin moved to the private Forgejo instance at git.dev.egbt.com
(egbt/secrets). Git history, all 16 branches, 13 tags, and all 13 releases
were migrated with identical SHAs and release bodies.
- CLAUDE.md: replace the "Codeberg operations" section with "Forge
operations", switching tea invocations to `--login egbt --repo
egbt/secrets`. Records three things that are easy to get wrong:
Forgejo's SSH listens on port 2222 (port 22 is the host's own sshd, so
a bare git@host:path fails with "Permission denied (publickey)"); the
host is Tailscale-only, so off-VPN failures are connectivity, not
credentials; and the stale `codeberg` tea login is still configured and
can be silently fallen back to in non-interactive mode.
- CLAUDE.md: note the instance has an Actions runner available, though no
workflow is configured yet — local bats remains the merge gate.
- README.md: clone URLs now point at the new host.
- CHANGELOG.md: release links now point at the new host.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrUoYuUMoTj91rzV4vxGPB
Workspace patterns came from package.json's `workspaces` key only, at both
call sites. pnpm declares them in pnpm-workspace.yaml instead, so no pnpm
monorepo ever resolved a workspace: `push -w` refused outright, and plain
`push` failed silently — _maybe_workspace_env_files returned 0 the moment
the key was absent, leaving the auto-discovery that covers push's root-only
scan inert and printing "Nothing new to add", indistinguishable from a repo
with genuinely nothing new. That silence cost two sessions on the same repo.
Both call sites now resolve through one shared source, _workspace_patterns:
package.json when it declares any, else pnpm-workspace.yaml's `packages:`
block. The YAML read is deliberately not a parser — block sequence only,
stopping at the next top-level key so pnpm 10's onlyBuiltDependencies:/
catalog: cannot leak in as globs, with quote/comment handling and a symlink
refusal.
Also fixes yarn's object form. `.workspaces // .workspaces.packages | .[]`
short-circuits on the truthy object, iterating its values and yielding the
pattern array as one token; only npm's array form ever worked. Note the
obvious reorder is NOT the fix — `.workspaces.packages` errors on an array —
so the filter is type-aware.
Patterns are validated before reaching the unquoted glob expansion (no
absolute paths, `..`, metacharacters, or whitespace; pnpm `!` negations
skipped), matching the .secrets-store/.secrets-files posture. jq is now
required only when package.json is the source. A monorepo-shaped root that
resolves nothing warns and points at `secrets add` instead of returning in
silence, and `-w`'s error names pnpm-workspace.yaml when that is the file
present.
Scope note: the workspace re-scan still runs only for projects that already
have a .secrets.json — push's root-scan-only first push is by design
(EGB-677 E13), and this bug is the fallback covering it never engaging.
test/workspaces.bats: 18 new tests. Full suite 371/371 green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrUoYuUMoTj91rzV4vxGPB
`tea` autodetection fails in this repo, and the machine carries a second
login (`egbt`) pointing at a different forge that tea silently falls back
to in non-interactive mode. Document the explicit
`--login codeberg --repo egbt/secrets` form for every verb.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrUoYuUMoTj91rzV4vxGPB
EGB-1230: `cmd_pull` synced the store with `git pull >/dev/null 2>&1` under
`set -euo pipefail`. A store that couldn't fast-forward killed the script at
that line with git's exit 128 and nothing on stdout or stderr — a banner, no
restored files, no reason, and invisible in a pipeline. The sync now routes
through `_store_sync_pull`, which guards the pull, captures git's output as
the diagnosis, and dies naming the store path and `secrets sync`. It is now
`--ff-only` to match the push path, so a plain pull can no longer manufacture
a merge commit in the store.
EGB-1231: once a store diverged there was no way out — push demanded a
fast-forward and pointed at pull, which couldn't fast-forward either, so the
advice looped and recovery meant hand-running git next to encrypted blobs.
Adds `secrets sync`: fetch, stash, rebase onto the remote, restore the stash,
then a confirmation-gated push of local commits (`--yes` to skip the prompt,
`--dry-run` to report only). Non-destructive by construction — no merge, no
force-push, no `reset --hard`, no `stash drop`; a rebase conflict names the
conflicting files and leaves the store exactly as found. `secrets which` gains
a `remote:` line reporting ahead/behind/dirty, and push's dead-end message now
points at `sync`.
test/sync.bats: 25 new tests. Full suite 353/353 green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrUoYuUMoTj91rzV4vxGPB
Reconcile the multi-recipient branch (cut from v0.6.1.0) with origin/main
at v0.7.4.0. The two feature lines are largely orthogonal; the one real
integration point is the external-blob encrypt path:
- EGB-712 added an additive-v2 dual-write loop (_external_blob_write_targets,
writing v2 + any v1 twin). EGB-283 routes every encrypt site through
RECIPIENT_ARGS for N-recipient encryption. Resolution keeps the dual-write
loop but encrypts each target to the full recipient set
(age "${RECIPIENT_ARGS[@]}" per write target), so dual-write and
multi-recipient compose. cmd_push loads recipients before both external
push sites; legacy single-key rekey keeps its fresh-keypair pubkey path.
Version: 0.6.2.0 + 0.7.4.0 -> 0.7.5.0. Docs (CLAUDE.md/README/CHANGELOG)
merged to carry both feature sets; subcommand list now includes
recipients/reencrypt and upgrade.
Tests: full `bats test/` green except 6 pre-existing host-environment
failures (4 chmod-600 restore assertions + 2 jq-PATH-shadow tests, all
macOS-authored), none touching merged code. recipients.bats 34/34 pass;
external/dual-write area passes except the same mode-600 host artifacts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pairs the EGB-713 skew WARNING with a fix path. `secrets upgrade` fast-forwards
the tool's own git checkout (git -C "$SCRIPT_DIR" pull --ff-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 the operator sees
whether the nudge is cleared. `secrets upgrade --check` reports availability
without pulling. Thin and explicit: no auto-update, no background polling
(security tool). Directed errors for not-a-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.
Wired into the dispatcher (upgrade) shift; cmd_upgrade "$@") and cmd_help.
Tests: test/upgrade.bats (8) run a relocated script copy in a throwaway git
repo with a bare upstream, so the real checkout is never touched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EGB-677 stage-1 structural cleanups, no behavior change for the v2 happy path:
1. cmd_which reuses _json_external_entries (the push/pull extractor) instead of
its own duplicated jq @tsv projection, so `which` applies the same
properties->gradle-properties normalization + skip-with-warning rules the
sync path does and can't drift from it.
2. The two external-manifest read guards are factored into _json_readable
(plain regular file, silent) / _legacy_readable (warn+skip a symlinked
.secrets-files), shared by _external_entries_for_push/_pull.
3. The legacy (manifest-less) pull path now warns when nested <project>/<relpath>.age
blobs exist that its non-recursive globs can't see (external/ excluded —
pull_external_files handles those), so it never silently under-restores.
Tests: +4 in test/manifest.bats (normalized which display, malformed external
skipped by which, nested-blob warning fires, external-only no false warning).
Full suite green (286/286).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a --json flag to `secrets list` that emits a structured object for
tooling/CI instead of the human table — feeds the EGB-671 install scripts,
which need to enumerate a cloned store programmatically.
Contract: {"store", "projects":[{"name","entries":[...]}]}, each entry
self-describing via a type discriminator — {type:dotenv,path} or
{type:external,subtype:properties|file,path}. cmd_list_json mirrors the same
recursive store walk as the human list (nested <project>/<relpath>.age +
external/<slug>.age); jq assembles the JSON so paths escape correctly and
stdout stays pure JSON (the non-default-store hint is suppressed; jq is a
hard dep only in --json mode).
Tests: 7 new bats cases (dotenv, nested relpath, external properties + file
subtypes, empty store, pure-stdout-under-notice, store path). Full suite
261 pass / 0 fail.
VERSION 0.7.1.0 -> 0.7.2.0; CHANGELOG/README/CLAUDE.md updated.
- README: test suite total 236 -> 237
- CLAUDE.md: manifest.bats 77 -> 78 tests
- CLAUDE.md: note `secrets which` now prints the .secrets.json schema
version in its manifest header line alongside `format: vN`
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stage 2 of EGB-677. Makes the store self-describing and unifies the legacy
external `properties` blob suffix, via a copy-forward migration that never
destroys data until an explicit, gated finalize.
Scope decision (see eureka): the EGB-677 CEO plan's "flatten dotenv blobs to
basename" was dropped as LOSSY — it discards the restore relpath that makes
the store self-describing and adds basename collisions. Engineering analysis
(4 parallel design agents) showed the store is already relpath-self-describing;
the only real v1→v2 delta is the `properties` suffix. This implements the
minimal, safe v2 that achieves the epic's self-describing goal.
What's added:
- `.secrets-format` marker (committed, one line `2`). Absence ⇒ v1 (every
pre-EGB-703 store). `_store_format()` reads it; `init` stamps fresh stores
born-v2. `secrets which` prints `format: vN` (EGB-700 folded in).
- `_external_blob_suffix(type)` — single source of truth for the external
suffix (v2: gradle-properties → properties; file unchanged). push/pull/verify
all route through it, so v1 and v2 stores never disagree on blob location.
- `secrets migrate` — per-project copy-forward (writes `.properties.age` twins
beside v1 blobs; idempotent; needs the project manifest), `--dry-run`
(reports old→new, writes nothing), `--finalize` (store-wide, the only
destructive step: gates on `verify --all` green + every v1 blob twinned,
cuts a `pre-v2-migrate-<sha>` recovery tag, stamps the marker, then drops v1
blobs; refuses without `--yes`/operator confirmation).
rekey and verify --all stay format-agnostic (recursive find walk) — no change.
21 new bats tests (test/migrate.bats): marker/born-v2, format-aware suffix,
v1 back-compat, dry-run, copy-forward idempotency, no-manifest die, finalize
gates (verify-not-green refusal, untwinned refusal, recovery tag, confirmation),
and full v1→window→finalize round-trip. Updated 4 existing tests for the
born-v2 suffix. Full suite 231/231, bash 3.2 clean.
Read-only integrity check, the safety net for the stage-2 store migration.
Default mode (current project) cross-checks $PWD/.secrets.json against the
store both ways — declared-but-missing blobs and orphaned blobs (no manifest
entry) — and decrypt-tests every dotenv + external blob with the current key,
streaming plaintext to /dev/null so nothing is ever written to disk. `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), the same walk rekey/list use,
so nested manifest blobs are covered. Exits non-zero on any finding so it can
gate `migrate --finalize` and CI.
12 bats tests (clean, nested+external, missing blob, decrypt failure, orphan,
missing external, no-manifest die, symlink refusal, --all clean/corrupt/orphan,
nested decrypt failure). Full suite 205/205. bash 3.2 clean.
CLAUDE.md: manifest.bats 58 -> 60 tests (actual @test count).
README.md: total 191 -> 193 tests across both files.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add .ship-policy.json (opts out AI adversarial/red-team/security-specialist
review; requires local operator sign-off) and test/run-security.sh (the
operator-local security regression subset). Document the policy in CLAUDE.md
and README, fix stale test counts (manifest.bats 41->58, total 174->191), and
update the storage-recursion note to reflect rekey/list now walking the full
project tree.
Red-team + specialist findings from /ship pre-landing review:
- CRITICAL: ensure_store_protections ran BEFORE `git pull --ff-only` in
commit_and_push_secrets; a pull bringing history without .gitignore
left the post-pull window unprotected and `git add -A` committed AND
pushed key.txt (reproduced end-to-end). Heal now runs after the pull,
immediately before staging.
- CRITICAL: .gitignore can't untrack an already-tracked key.txt. Heal
now defensively `git rm --cached`s a tracked key with a history-scrub
warning.
- Content-aware heal: a present .gitignore missing the key.txt line is
rewritten, not skipped (security specialist).
- Init guard now renders the real sanitized clone URL from
.secrets-store when available, mirroring EGB-282's check_initialized.
- Tests: positive heal-message assertions, non-empty key guard in the
init test, 4 new regression tests (126 total).
Three fixes, found from a real Ubuntu second-machine setup failure:
1. cmd_init: if key.txt exists but the store has no .git, die BEFORE
git init with directed guidance to clone the existing secrets repo
instead. Previously init ran git init, then age-keygen died on the
existing key, leaving a half-initialized store (.git but no
.gitignore or hook).
2. ensure_store_protections: push/pull/rekey now restore a missing
store .gitignore (and pre-commit hook) before any `git add -A`.
Without the .gitignore, add -A would commit key.txt to the remote.
3. cmd_rekey: the re-encrypt loop used a bare "$dir"* glob, which never
matches dotfiles — .env blobs were decrypted to the tmpdir but never
re-encrypted, leaving them on the OLD key (undecryptable) while the
new key overwrote key.txt. Glob now matches the decrypt loop
("$dir"* "$dir".*). Exposed by the self-heal test: with nothing
staged, the empty commit failed mid-rekey.
Tests: 5 new bats tests (118 total) — init guard, .gitignore self-heal
on push/pull/rekey, and a rekey round-trip that survives key rotation.
The Codeberg migration commit updated README/CHANGELOG but missed the
"distributed via git clone from GitHub" line in CLAUDE.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adversarial review of the diff surfaced 6 non-critical findings, all fixed:
- pull now warns + reports when a merge fails (read-only $HOME, full disk)
instead of silently exiting 0 as if it synced
- push skips a multi-line (continuation) managed value with a warning rather
than writing a dangling backslash that would corrupt the target
- absent-key pull check escapes regex '.' so systemProp.foo can't false-match
- fix garbled 'Merged 0\n0 key(s)' count when a blob has no '=' lines
- slug gets a cksum suffix so paths that clean to the same string (a/b vs a_b)
don't overwrite each other's blob
- docs: backup happens before each merge, not just the first
Tests: 112 -> 113 (continuation-value skip).
Add a committed .secrets-files manifest that lets secrets track designated
keys from files outside the project root (motivating case:
~/.gradle/gradle.properties for Android Clerk publishable keys, which
Android Studio GUI builds read but terminal env vars can't reach).
- push extracts only the named keys, encrypts under <project>/external/
- pull MERGES them into the target, preserving unrelated keys/comments/order
- pure-bash merge (no sed/regex): exact-string key match, opaque values
- path validator: basename gradle.properties, within $HOME, no symlink/..
- external/ subdir keeps blobs out of the dotenv *.age globs; rekey + list
recurse explicitly
- which reads back the manifest; list shows [external]; pre-commit blocks
plaintext gradle.properties
Also fixes two latent bugs in 'secrets rekey' (never completed before, no
prior test): age-keygen refusing to overwrite key.txt, and an EXIT trap
referencing an out-of-scope local under set -u.
Tests: 80 -> 104.
Reviewed via /autoplan (CEO/Eng/DX). EGB-531.
* chore: ignore .gstack/ (per-project local state)
* feat: optional remote URL in .secrets-store (EGB-282)
A second whitespace-separated token after the store name in .secrets-store
is treated as the store's git remote URL. When a teammate clones a project
bound to a store they don't have locally yet, the directed missing-store
error now fills in `git clone <url> <path>` so they can copy-paste instead
of asking the original setter for the URL.
Backward compatible: single-token .secrets-store files (the v0.1.0.x
format) continue to work and produce the existing `<their-store-remote>`
placeholder.
Security hardening (caught by adversarial review during /ship):
- The rendered git clone line is meant to be copy-pasted by a teammate.
Without sanitization, `work evil.git;rm -rf ~` would render verbatim
and execute `rm -rf ~` on paste. The parser now rejects URLs containing
shell metacharacters (;&|<>$`(){}*?!"'\\), control characters (incl.
ANSI escape sequences that could spoof terminal output), and embedded
whitespace. Rejected URLs are dropped with a stderr warning; the error
falls back to the safe placeholder.
- Switched from `set -- $line` to `read -r spec rest` so the URL field
isn't glob-expanded or word-split — important so `work *` from a
populated directory doesn't leak filenames into the URL field.
Tests 72 → 80. New: backward compat, SSH+HTTPS+~/-prefix URL forms,
comment-and-URL form, four named injection vectors (shell metachar,
backtick, $(), ANSI escape), multi-token URL, glob char, and a positive
test asserting standard git URL chars round-trip unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: bump version and changelog (v0.1.1.0)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CLI tool with no platform deploy. Released via merge to main +
optional version tags. Recorded so /land-and-deploy skips the
dry-run on subsequent invocations.
* feat: multi-store support via .secrets-store + --store flag
Layer four-rule store resolution on top of the existing SECRETS_DIR primitive
so users can manage multiple isolated encrypted stores (work vs personal,
per-client, etc.) without giving up the tool's small-bash-script pitch.
Resolution order (highest first):
1. --store <dir> flag (parsed in main pre-pass)
2. .secrets-store file in cwd or any ancestor up to $HOME
3. SECRETS_DIR env var (legacy escape hatch)
4. ~/.secrets default
resolve_store() updates both SECRETS_DIR and KEY_FILE so existing single-store
codepaths just work. New cmd_which / where / status report the active store.
cmd_init, push, pull, push_workspaces, pull_workspaces, list, rm, rekey, run,
which all call resolve_store at entry.
Hardening from the EGB-281 adversarial review:
- F1: cmd_run EXIT trap is now a named function (not string-interpolated),
so paths with apostrophes still get plaintext cleaned up
- F2: symlinked .secrets-store files are skipped, never read
- F3/F4: --store flag rejects flag-shaped values and empty --store=
- F5: HOME unset is detected up-front with a directed error
- F11: check_initialized / check_key give context-aware errors that name
both recovery paths (git clone vs secrets init) when a teammate clones
a project bound to a non-existent store on their machine
Tests: 37 → 66 (29 new). HOME=\$TEST_TMPDIR added to test setup so the
walk-up logic stays bounded inside fixtures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: add Multiple stores section to README
Five subsections walk users through: how store resolution works, how to
set up a second store on a machine, how to bind a project, how teammates
join a bound project, and how to undo or change a binding. SECRETS_DIR
table entry now points readers at the new --store flag and .secrets-store
file as the preferred mechanisms.
* chore: bump version and changelog (v0.1.0.0)
First formal release. EGB-281 adds multi-store support; this commit
seeds the VERSION file (4-digit MAJOR.MINOR.PATCH.MICRO) and the
CHANGELOG.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Updated file tracking to include `.dev.vars` alongside `.env` and `.env.*`.
- Improved pre-commit hook to block plaintext secret files, including `.dev.vars`.
- Added `clear` command to remove plaintext secret files from the current directory and workspaces.
- Enhanced tests to cover new functionality for `.dev.vars` and the `clear` command.
- Updated documentation to reflect changes in tracked files and command usage.
- Introduced `--workspaces` flag for `push` and `pull` commands to handle environment files in monorepos.
- Updated README and CLAUDE.md to reflect new workspace functionality and installation instructions.
- Enhanced test suite with cases for workspace operations, ensuring proper encryption and decryption of environment files.
- Improved error handling for missing package.json and workspaces field.
- Increased test coverage from 20 to 25 tests.