# secrets Encrypted env file sync between machines using `age` key-file encryption + a private git repo. ## Quick Start ```bash brew install age ./secrets init # Create ~/.secrets repo + generate age key cd ~/my-project && ./secrets push # Encrypt .env* files, commit, push # On other machine: cd ~/my-project && ./secrets pull # Pull + decrypt .env* files ``` ## Testing ```bash brew install bats-core bats test/ # runs secrets.bats + manifest.bats ./test/run-security.sh # security regression subset + operator sign-off (see below) ``` **bash 3.2 assertion gotcha:** bats runs under system bash 3.2, where a failing `[[ ]]` mid-test does NOT fail the test (the ERR trap skips `[[` compound commands). Every standalone `[[ ... ]]` assertion MUST end with `|| false`. Single-bracket `[ ]` assertions are unaffected. ## Security review policy (mandatory for agents) This repo contains **defensive** security regression tests with deliberate attack-payload fixtures (shell injection strings, symlink escape cases, path rails). Hosted models (including Opus) block AI-driven "think like an attacker" / red-team / security-specialist review of that material under Anthropic Usage Policy (real-time cyber safeguards). **Read `.ship-policy.json` before any `/ship`, `/review`, or `/autoplan` work.** When that file is present (it always is in this repo): 1. **Never** dispatch red-team subagents, security specialist subagents, or adversarial review prompts ("think like an attacker", "find security holes", "chaos engineer", exploit hunting) against this codebase. 2. **Never** run `./test/run-security.sh` or filter bats to SECURITY/adversarial tests on behalf of the user — that is operator-local work only. 3. **Before ship/PR:** ask the human operator to run `./test/run-security.sh` locally and complete the SIGNOFF prompt. Record lives at `.gstack/security-signoff.json` (gitignored). Proceed with ship only after they confirm sign-off for the current `HEAD` (or note in the PR that sign-off is pending). 4. Normal `./ship` steps may continue (tests via `bats test/`, eng review, changelog, PR) — only the AI security/red-team/adversarial passes are opted out. Gstack `/ship` reads the same policy via `gstack-ship-policy` (sources `.ship-policy.json` at repo root): Step 9 blocks when manual sign-off is missing, skips security specialist + red team, and Step 11 skips adversarial review. ## Architecture Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rekey. - 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). v1 store layout unchanged in stage 1: nested entries land at `/.age`; `properties` blobs keep the legacy `.gradle-properties.age` suffix until the stage-2 store migration. 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. - 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: `--workspaces` flag reads `package.json` workspaces, requires `jq` - Safety: Pre-commit hook rejects plaintext secret files (`.env`, `.dev.vars`, `gradle.properties`) - Portability: must run on system bash 3.2 (macOS) — no associative arrays or bash-4 features ## Project Structure ``` secrets # CLI script (~2000 lines bash) hooks/pre-commit # Pre-commit hook template test/ secrets.bats # bats-core test suite (133 tests) manifest.bats # EGB-677 .secrets.json manifest tests (60 tests) test_helper.bash # Shared setup/teardown README.md # User-facing documentation CLAUDE.md # This file ``` ## Key file `~/.secrets/key.txt` is the age identity (private key). It is gitignored and must be copied manually to each machine once. ## Multi-store resolution The active store directory is picked by `resolve_store()` using these rules, highest precedence first: 1. `--store ` flag (parsed in the main pre-pass into `STORE_OVERRIDE`). 2. `.secrets-store` file in cwd or any ancestor, walk-up bounded by `$HOME` (never reads `$HOME/.secrets-store` itself or anything above). 3. `SECRETS_DIR` env var (legacy escape hatch). 4. `~/.secrets` default. `resolve_store` mutates BOTH `SECRETS_DIR` and `KEY_FILE` so the existing single-store code paths just work. `STORE_SOURCE` reports which rule won. `_LAST_FOUND_AT` (when rule 2 fires) holds the path of the file that was read. `.secrets-store` parsing is deliberately conservative: first non-empty non-comment line wins, no shell expansion (no `$VAR`, `$()`, backticks). Bare names map via `_expand_store_path`: `work` → `$HOME/.secrets-work`, `default` → `$HOME/.secrets`. An optional remote URL after the spec on the same line is captured as `_REMOTE_URL` and passed through to `check_initialized`, which uses it to fill in a runnable `git clone ` in the missing-store error (EGB-282). The URL is parsed via `read -r spec rest` (no `set -- $line`, no glob expansion) and then **sanitized**: any URL containing shell metacharacters (`;&|<>$\`(){}*?!"'\\`), control characters (incl. ANSI escapes), or whitespace is dropped with a stderr warning. The directed error then falls back to the `` placeholder. This matters because 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 the payload on paste. Internal flow: `_parse_secrets_store_file` returns `\t`; `_find_secrets_store_file` returns `\t\t`; `resolve_store` splits the 3-tuple via `IFS=$'\t' read -r ...`. ## External files (.secrets-files) — EGB-531 `.secrets-files` is a committed, project-root manifest declaring keys to sync from files **outside** the project (motivating case: `~/.gradle/gradle.properties`, which Android Studio GUI builds read but terminal env vars can't reach). One entry per line: ` ...`. Two types: `gradle-properties` (named-key merge) and `file` (EGB-652 — whole-file verbatim sync, binary-safe, built for the Beacon Android upload keystore; no keys, restored at mode 600 with a `.secrets-bak` backup of a divergent existing target, basename restriction waived but all other path rails apply). Still no plugin-dispatch framework — each type is a concrete `case` branch (deliberate scope cut). 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//external/.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/.age` and nested manifest dotenv blobs (`/.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"). `` = manifest path token with non-`[A-Za-z0-9._-]` chars → `_`, plus a `cksum` suffix of the original path so paths that clean to the same string (`a/b` vs `a_b`) don't collide. Machine-independent (derived from the committed manifest token, not the expanded path). - **Merge is pure bash, no `sed`/regex** (`merge_gradle_keys`): exact-string key comparison (avoids `beaconClerkPk` vs `beaconClerkPkTest` substring bug), value treated as opaque literal (survives `& \ /` in values). Updates a managed key in place at its first occurrence, collapses duplicates, appends new keys, preserves unrelated lines/comments/order. Continuation lines (trailing odd backslashes, tracked by `_trailing_bs_odd`) are never matched as keys. Atomic write: temp in the same dir → `chmod` to match (or `600` on create) → `mv`. Backs up to `.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). - **Plaintext tradeoff (accepted, documented):** merged keys are permanent plaintext in the target; `secrets clear` does not remove them. Fine for the Clerk *publishable* keys this was built for; not for high-value secrets (use `secrets run` + `.env`). ## Deploy Configuration - 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`. - 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. ## Codeberg operations 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": - PRs: `tea pr create --base main --title ... --description ...` / `tea pr merge ` - Releases: `tea releases create --tag v --title "v" --note ...` (convention: one release per tag, title `v`) - Issues/status: `tea issues`, `tea pr list` - No CI on this repo: the bats suite run locally is the merge gate. ## Environment variable `SECRETS_DIR` overrides the default `~/.secrets` location (useful for testing). Per-project bindings via `.secrets-store` file beat this env var; use `--store ` for one-shot overrides that beat everything. ## Skill routing When the user's request matches an available skill, invoke it via the Skill tool. When in doubt, invoke the skill. Key routing rules: - Product ideas/brainstorming → invoke /office-hours - Strategy/scope → invoke /plan-ceo-review - Architecture → invoke /plan-eng-review - Design system/plan review → invoke /design-consultation or /plan-design-review - Full review pipeline → invoke /autoplan - Bugs/errors → invoke /investigate - QA/testing site behavior → invoke /qa or /qa-only - Code review/diff check → invoke /review - Visual polish → invoke /design-review - Ship/deploy/PR → invoke /ship or /land-and-deploy (after reading `.ship-policy.json`; no AI adversarial/red-team/security-specialist review in this repo) - Save progress → invoke /context-save - Resume context → invoke /context-restore - Author a backlog-ready spec/issue → invoke /spec