# secrets-engine CLI ## Terminology (Gitea is overloaded — be explicit) | Term we use | Means | Example | Not to be confused with | | --- | --- | --- | --- | | **org** | the Gitea organisation | `coulomb` | the npm scope | | **repo** | the Gitea repository / product | `whynot-design` | an org; a Gitea "project" board | | **npm scope** | the `@`-prefix npm name | `@whynot` | the org or the repo | | **npm package** | the published artifact | `@whynot/design` | the repo it's built from | | **lane / catalog id** | a secrets-engine secret lane | `whynot-design-npm-publish` | — | A catalog entry carries `org` + `repo` explicitly (never a bare "owner"), and the npm registry/scope live in `delivery_config.npm` as data — the engine never hardcodes a registry. The pilot publishes `@whynot/design` from the `coulomb/whynot-design` repo to `https://forgejo.coulomb.social/api/packages/coulomb/npm/`. Existing production lanes additionally distinguish mount ownership, native delivery auth, and workload delivery. See [catalog-admission.md](catalog-admission.md). In particular, `mount_management: existing` makes mount handling non-mutating; it does not authorize secrets-engine to replace an existing ESO/Kubernetes delivery path. ## Install ```bash uv venv && uv pip install -e ".[dev]" source .venv/bin/activate secrets-engine --version ``` ## Environment | Var | Default | Purpose | | --- | --- | --- | | `BAO_ADDR` | `http://127.0.0.1:8200` | OpenBao address | | `BAO_TOKEN` | _(unset)_ | Named `env` OpenBao token; never a fallback from `service-jwt` | | `SECRETS_ENGINE_HUB_URL` | `http://127.0.0.1:8000` | State Hub for decisions + evidence (empty to disable) | | `SECRETS_ENGINE_CATALOG` | `./catalog` | catalog directory | | `SECRETS_ENGINE_EVIDENCE` | `./.evidence` | local non-secret evidence log | | `SECRETS_ENGINE_UNSAFE_DEMO` | _(unset)_ | allow a prod-labeled lane only when Hub is disabled and OpenBao is loopback; throwaway demos only | | `SECRETS_ENGINE_KEYCAPE_TOKEN_URL` | _(unset)_ | KeyCape token endpoint for `service-jwt` | | `SECRETS_ENGINE_KEYCAPE_ISSUER` | _(unset)_ | KeyCape issuer; must match the JWT login contract | | `SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE` | _(unset)_ | mode-0600 out-of-repo client secret | | `SECRETS_ENGINE_OPENBAO_JWT_LOGIN` | _(unset)_ | platform JWT mount/role contract YAML | ## Commands ```text secrets-engine catalog list secrets-engine catalog show secrets-engine decision inspect secrets-engine plan --stage secrets-engine apply --stage [--dry-run] [--bootstrap-token-file F] secrets-engine provision --stage --field NAME (--from-file F | --generate) secrets-engine verify [--field NAME] [--positive] [--negative] [--negative-token-file F] secrets-engine handoff --stage --role-id-file F --secret-id-file F secrets-engine exec --catalog [--field NAME] [--mode auto|npm-config|exec-env] -- CMD... secrets-engine policy publication secrets-engine route [--json] secrets-engine revoke [--dry-run] secrets-engine session revoke --accessor-file F [--stage stage] secrets-engine lifecycle suspend [--dry-run] secrets-engine lifecycle deactivate [--dry-run] secrets-engine lifecycle destroy [--dry-run] [--confirm-destroy ] secrets-engine audit [--json] ``` `policy publication` resolves a lane's effective publication scope and the env var the token is injected under, per the netkingdom publication-scope policy (see [docs/publication-scope-policy.md](publication-scope-policy.md)). `` is a catalog id or a decision/CCR ref (matched against `approval.decision_ref`). `plan` and `apply --dry-run` never mutate OpenBao. For decision-gated lanes they may render with `decision: ` when the approval object is not reachable. Production live commands remain disabled until State Hub exposes the durable exact-action authorization object. A legacy local decision is accepted for a prod-labeled lane only with `SECRETS_ENGINE_UNSAFE_DEMO=1`, an empty Hub URL, and loopback OpenBao; the demo scripts set those three conditions themselves. `handoff` is for `kind: auth-capability` lanes such as `warden-sign`. It mints a fresh AppRole `secret_id` and writes `role_id` plus `secret_id` to caller-chosen mode-0600 files outside Git worktrees. It never prints the `secret_id`; use the resulting files only for attended out-of-band delivery. `revoke` currently means **deactivate native secrets-engine access**. For engine-managed delivery auth it deletes the lane AppRole and policy; it always preserves KV metadata/values, existing externally managed auth, and workload delivery. There is no general KV-destruction command. `provision` uses CAS-aware create/patch behavior with a strict temporary input reference: values are absent from argv, sibling fields are preserved, and stale writes fail. With no `--field`, `verify` checks every declared KV field positively and runs one path-level negative probe. KV denial requires `--negative-token-file` with a real unrelated identity's mode-0600 token file outside Git; absence fails the check without calling OpenBao. `route` likewise requires every declared field and reports only missing field names. An explicit `--field` narrows positive verification; production use will bind such subsets to the action approval. Explicit lifecycle commands separate intent: - `suspend` removes the engine-managed AppRole but preserves policy and KV; - `deactivate` removes the engine-managed AppRole and policy but preserves KV; - `destroy --dry-run` renders deactivation plus irreversible KV metadata deletion. Live execution is fail-closed even with exact catalog-id confirmation until `SECRETS-WP-0007-T04` supplies the canonical exact-action approval contract. All three preserve externally managed auth and workload delivery. The legacy `revoke` command is a compatibility alias for safe native deactivation, never KV destruction. Exec and verification AppRole logins are scoped sessions. The issued token self-revokes on every exit path before exec starts (or when verification ends), and evidence stores only a short accessor fingerprint plus cleanup outcome. `audit` is a read-only, local evidence summary. It reports action and result counts, canonical decision references, session cleanup outcomes, and State Hub delivery outcomes for one cataloged lane. Its parser allowlists those fields and does not echo arbitrary JSONL detail. State Hub failures are recorded locally as append-only companion receipts. Edge-relay queued receipts and their non-secret outbox ids are recorded too; replay remains an operator/State Hub responsibility. Live `apply`, `provision`, `verify`, `handoff`, `exec`, `revoke`, `suspend`, and `deactivate` share one evidence guard. It records an attempt before lane approval is resolved and a terminal outcome on every normal or exceptional exit. Rejected approval and backend/input failures record only approval state and exception class, not exception text. This evidence describes what the CLI observed; it is not an authorization decision and does not replace OpenBao audit logs. ## Exit codes | Code | Meaning | | --- | --- | | 0 | success | | 2 | catalog error (missing/invalid lane) | | 3 | decision error (unapproved / superseded / missing) | | 4 | policy guard error (out-of-stage / wildcard / broad admin) | | 5 | backend error (OpenBao unreachable / failed) | | 6 | provisioning error (bad file mode / inside repo / missing field) | | 7 | verification failed | | 8 | delivery error | ## End-to-end demo ```bash SECRETS_ENGINE_HUB_URL="" bash scripts/demo-e2e.sh ``` Boots a throwaway in-memory OpenBao dev server and runs the whole pilot chain: plan → apply → provision → verify(+/-) → exec (npm-config injection) → route → revoke. Nothing is persisted; the token is a throwaway local string. ## npm publish wiring (dry-run) ```bash bash scripts/npm-publish-demo.sh ``` Boots a throwaway OpenBao, applies + provisions the lane with a *fake* token, and runs a **real `npm publish --dry-run`** through `secrets-engine exec` against a scratch package. Proves npm in the child resolves its auth token from the temp `.npmrc` secrets-engine injected, builds the tarball, and reaches the publish step — while the parent shell never holds the token. For a real publish, provision a real npm automation token the same way and drop `--dry-run`. ## Auth-capability: warden-sign ```bash # Preview the non-KV policy/AppRole lane; no mutation. SECRETS_ENGINE_HUB_URL="" secrets-engine apply warden-sign --stage prod --dry-run # Live apply and handoff require an approved decision and an out-of-repo # bootstrap token file. Values are written to files, not stdout. BAO_ADDR=https://bao.coulomb.social \ secrets-engine handoff warden-sign --stage prod \ --bootstrap-token-file ~/.secrets-engine/bootstrap/prod-warden-sign.token \ --role-id-file ~/.secrets-engine/handoff/warden-sign.role_id \ --secret-id-file ~/.secrets-engine/handoff/warden-sign.secret_id ``` See [warden-sign-auth-capability.md](warden-sign-auth-capability.md) for the full runbook and State Hub pointer payload. ## Pilot closeout: whynot-design real publish ```bash scripts/whynot-real-publish-preflight.sh ``` The real publish path is documented in [whynot-design-real-publish-closeout.md](whynot-design-real-publish-closeout.md). ## Pilot: whynot-design npm publish ```bash # 1. inspect the approved decision secrets-engine decision inspect whynot-design-npm-publish # 2. preview the OpenBao changes (no mutation) secrets-engine plan whynot-design-npm-publish --stage prod # 3. apply policy + approle secrets-engine apply whynot-design-npm-publish --stage prod # 4. provision the token from a mode-0600 file OUTSIDE the repo secrets-engine provision whynot-design-npm-publish --stage prod \ --field npm_token --from-file ~/.secrets-engine/whynot.token # 5. prove access without printing the value secrets-engine verify whynot-design-npm-publish --field npm_token --positive --negative \ --negative-token-file /secure/path/unrelated.token # 6. publish with the token injected into the child only secrets-engine exec --catalog whynot-design-npm-publish -- npm publish ```