From ca7f1aad7c469e53ca9ac5e4011100a84bef882e Mon Sep 17 00:00:00 2001 From: TechAlchemistX Date: Sat, 20 Jun 2026 17:52:08 -0400 Subject: [PATCH] docs: overhaul documentation for clarity, consistency, and a leaner README Full pass across the documentation surface following the v0.19 release: - Rewrite README as a lean landing page whose sections link into docs/; add two infographics (the three-file model and the stability growth chart). - Trim verbosity across backends, reference, comparisons, and examples. - Remove em dashes repo-wide; strip internal KB/wiki references and ticket IDs from the docs and the CHANGELOG (full-detail snapshot retained in the KB). - Condense and clean the CHANGELOG: link every tagged release and consolidate duplicate per-release subsections. - Backend pages: metadata as bullet lists, split the CLI version into its own field, drop platform-specific install commands, bump the Tested stamp to v0.19.0. - Comparison tables now lead with SecretEnv, then the compared tool. - Consolidate redaction docs into reference/redact.md; fix the security.md "Reading the Table" prose to match the table. - Move ci-cd.md into guides/; add a stability page and an org-rollout guide; fix See-also/Related links across the reference docs. Co-Authored-By: Claude Opus 4.8 (1M context) --- AUTHORS.md | 4 +- CHANGELOG.md | 1561 ++++++----------- CLA.md | 10 +- CONTRIBUTING.md | 92 +- README.md | 728 ++------ SECURITY.md | 2 +- docs/.vitepress/config.ts | 9 +- docs/README.md | 44 +- docs/backends/1password.md | 53 +- docs/backends/README.md | 30 +- docs/backends/aws-secrets.md | 63 +- docs/backends/aws-ssm.md | 55 +- docs/backends/azure.md | 90 +- docs/backends/bitwarden-sm.md | 80 +- docs/backends/cf-kv.md | 53 +- docs/backends/conjur.md | 74 +- docs/backends/doppler.md | 65 +- docs/backends/gcp.md | 81 +- docs/backends/infisical.md | 76 +- docs/backends/keeper.md | 62 +- docs/backends/keychain.md | 82 +- docs/backends/local.md | 62 +- docs/backends/openbao.md | 52 +- docs/backends/vault.md | 30 +- docs/ci-cd.md | 225 --- docs/comparisons/README.md | 20 +- docs/comparisons/vs-direnv.md | 50 +- docs/comparisons/vs-dotenv.md | 51 +- .../vs-external-secrets-operator.md | 44 +- docs/comparisons/vs-fnox.md | 129 +- docs/comparisons/vs-op-run.md | 38 +- docs/comparisons/vs-pulumi-esc.md | 40 +- docs/comparisons/vs-sops.md | 40 +- docs/comparisons/vs-vault-and-conjur.md | 54 +- docs/guides/ci-cd.md | 218 +++ docs/guides/rollout.md | 44 + docs/index.md | 20 +- docs/public/stability-growth.svg | 56 + docs/public/three-file-model.svg | 115 ++ docs/reference/adding-a-backend.md | 74 +- docs/reference/cli-reference-full.md | 31 +- docs/reference/configuration.md | 86 +- docs/reference/fragment-vocabulary.md | 39 +- docs/reference/mcp.md | 162 +- docs/reference/migrate.md | 113 +- docs/reference/opentelemetry.md | 150 +- docs/reference/profiles.md | 44 +- docs/reference/redact.md | 72 +- docs/reference/registry.md | 33 +- docs/reference/three-file-model-deep.md | 106 +- docs/security.md | 167 +- docs/stability.md | 48 + examples/README.md | 47 +- examples/cascade-local-then-vault/README.md | 45 +- examples/ci-github-actions/README.md | 65 +- .../multi-cloud-aws-and-1password/README.md | 43 +- examples/secretenv-toml-canonical/README.md | 37 +- examples/single-backend-aws-ssm/README.md | 40 +- examples/single-backend-keychain/README.md | 42 +- examples/single-backend-local/README.md | 32 +- scripts/smoke-test/README.md | 60 +- .../fixtures/vscode-mcp-copilot/README.md | 4 +- 62 files changed, 2585 insertions(+), 3557 deletions(-) delete mode 100644 docs/ci-cd.md create mode 100644 docs/guides/ci-cd.md create mode 100644 docs/guides/rollout.md create mode 100644 docs/public/stability-growth.svg create mode 100644 docs/public/three-file-model.svg create mode 100644 docs/stability.md diff --git a/AUTHORS.md b/AUTHORS.md index eada961..71095a7 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -2,8 +2,8 @@ Contributors who have signed the [Contributor License Agreement](CLA.md) by making a signed-off Contribution to SecretEnv. -Adding Your name here is required the first time You contribute — see [CONTRIBUTING.md](CONTRIBUTING.md) §CLA. +Adding Your name here is required the first time You contribute. See [CONTRIBUTING.md](CONTRIBUTING.md) §CLA. --- -- Mandeep Patel — project maintainer, first contributed 2026-04-17 +- Mandeep Patel , project maintainer, first contributed 2026-04-17 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e24a60..342d2e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,1032 +1,737 @@ # Changelog -All notable changes to SecretEnv are documented here. The format is based on -[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project -adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +All notable changes to SecretEnv are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Dates are `YYYY-MM-DD` (UTC). -Dates are in `YYYY-MM-DD` (UTC). - -In addition to the Keep-a-Changelog sections (`Added` / `Changed` / `Deprecated` -/ `Removed` / `Fixed` / `Security`), SecretEnv cycles use a project-specific -**`Known limitations`** subsection — introduced in v0.14.0 — to document -behaviour that ships honestly but is incomplete by design (planned phase-out, -deferred follow-up, or contract that would surprise an operator). Future -cycles should reach for this subsection rather than burying limitations in -prose. Cross-reference the kb wiki for the long-form ticket. +Alongside the standard sections (Added, Changed, Deprecated, Removed, Fixed, Security), releases from v0.14.0 onward use a `Known limitations` subsection for behavior that ships honestly but is incomplete by design. ## [0.19.0] - 2026-06-14 -**Headline:** v0.19 is the **second non-backend hardening minor** ("hardening #2"), consuming the v0.18 carry-forward queue and folding in the deferred post-release documentation phase. No new backends (total stays at **15**); no new crates (workspace package count is **24** — the v0.18 build-log's "22" reflects a pre-existing undercount; v0.19 added zero). Live-backend smoke: **779 PASS / 0 FAIL / 2 expected SKIP** across all 15 backends. Authoritative plan: `kb/wiki/build-plan-v0.19-hardening.md`. Ledger: `kb/wiki/v0.19-deferred-items.md`. PR #90. +Second non-backend hardening minor (hardening #2), consuming the v0.18 carry-forward queue and the deferred post-release documentation phase; no new backends (total 15), no new crates (workspace count 24); live-backend smoke 779 PASS / 0 FAIL / 2 expected SKIP across all 15 backends. ### Added - -- **`--otel-include-error-detail` is now live (Sec-F-5 wire-up).** The flag was reserved in v0.18 (flag + scrubber shipped, but no production call site emitted the attribute). As of v0.19 a failed `secretenv.backend.fetch` actually emits the scrubbed `secretenv.backend.error.message` span attribute when the flag is set (default OFF; attribute structurally absent otherwise). The raw error chain is passed through the SEC-INV-20 `BackendErrorStderr` scrubber before emission — the newtype's only constructor is the scrubber, so holding a `BackendErrorStderr` is the proof that scrubbing occurred. A new `#[tokio::test]` in `secretenv-core::runner` (`sec_f5_error_detail_emitted_only_when_opted_in`) uses `LocalTraceCapture` to assert present-when-ON / absent-when-OFF. Section 36 Block G adds 3 live smoke assertions (1289–1291) validated against Jaeger. Closes [[v0.18-deferred-items#v0.18-Sec-F-5]]. +- `--otel-include-error-detail` now live: a failed `secretenv.backend.fetch` emits the scrubbed `secretenv.backend.error.message` span attribute when set (default OFF, attribute absent otherwise); raw error chain passes through the `BackendErrorStderr` scrubber (its only constructor); new `#[tokio::test]` `sec_f5_error_detail_emitted_only_when_opted_in` in `secretenv-core::runner` uses `LocalTraceCapture` to assert present-when-ON/absent-when-OFF; section 36 Block G adds 3 live smoke assertions (1289-1291). ### Changed - -- **`OperatorDecision` marker-type family split (Arch-W-1, BREAKING).** `OperatorDecision` is retained as the neutral on-disk serde union (audit log + JSON-Lines persistence unchanged, including the `"dryrun"` lowercase rename from v0.18). Two new marker types were introduced: `MutationOperatorDecision` (the four non-dry-run variants `Approved` / `Denied` / `Timeout` / `AutoApproved` — **no `DryRun`**, so mutation tools structurally cannot receive a dry-run decision) and `MigrateOperatorDecision` (those four plus `DryRun`). A new `Decision` trait (`to_audit() -> OperatorDecision`) is the single projection point for echo + audit-write so they cannot diverge. Consequence: the 4 dead `Ok(OperatorDecision::DryRun) => ` match arms in `mutation_runner.rs` + `tools/mod.rs` are now compile-time impossible and were deleted. The audit trio called this "the textbook make-illegal-states-unrepresentable refactor" and "the highest-quality hardening landing in the project's enum-lift lineage." -- **`SecretEnvSpan::record_migrate_source_backend_type` and `record_migrate_dest_backend_type` now take `BackendType`** (were `&str`). This closes the Phase-7 M-4 half-closure left open in v0.18 (`record_command` + `record_backend_type` were closed then; the migrate-specific setter pair was deferred as W-4). The migrate call sites each wrap a runtime `Backend::backend_type()` string via `BackendType::from_runtime_str`. **(BREAKING)** See `### BREAKING` below. The current typed-setter total on `SecretEnvSpan` is **57** (v0.19 added zero new setters; the "52" figure in the [0.18.0] notes is a pre-existing undercount). -- **`LocalTraceCapture::install` uses an `InstalledFlagGuard` RAII guard (W-7).** The guard clears the `INSTALLED` `AtomicBool` on unwind if the OTel-SDK setup panics mid-install; `disarm()` hands ownership to the capture's `Drop` on success. Prevents the flag from being permanently stranded true by an install that failed partway through (would have blocked a subsequent legitimate install). No public-API change. Closes [[v0.18-deferred-items]] §D (Arch-F-8 / W-7). -- **Internal `build_env` / `fetch_one` gained an `otel_include_error_detail: bool` parameter** for the Sec-F-5 wire-up. These are private functions in `secretenv-core`; not a public-API break. -- **`secretenv.backend.probe.{level,outcome}` vocabularies unified (Arch-W-2 partial, BREAKING).** Both attributes previously carried two disjoint closed-enum vocabularies writing the same key — the span setters used `BackendProbeLevel` (`connectivity` / `full`) and `BackendProbeOutcome` (`success` / `timeout` / `permission_denied` / `error`), while the `backend.probe.count` metric used `ProbeLevel` (`l1-cli` / `l2-auth` / `l3-read`) and `ProbeOutcome` (`ok` / `cli-missing` / `not-authenticated` / `registry-unreachable` / `timeout` / `unknown`). The span-side enums are **removed**; the richer metric enums `ProbeLevel` (the documented L1/L2/L3 doctor ladder) and `ProbeOutcome` are now the single vocabulary for both span and metric. The run-path `secretenv.backend.probe` span — which wraps a real `backend.get()` (a read) — now emits `l3-read` (replacing the inaccurate `connectivity` placeholder) and `ok` / `unknown` (the unclassified fetch error folds to the `unknown` catch-all). The metric path remains unwired pending the v0.20 Arch-W-2 `probe()`-trait work, so only the span emits today. +- `OperatorDecision` marker-type family split (BREAKING): `OperatorDecision` retained as neutral on-disk serde union (audit log + JSON-Lines unchanged, including the `"dryrun"` lowercase rename); two new marker types `MutationOperatorDecision` (`Approved`/`Denied`/`Timeout`/`AutoApproved`, no `DryRun`) and `MigrateOperatorDecision` (those four plus `DryRun`); new `Decision` trait (`to_audit() -> OperatorDecision`) is the single projection point; the 4 dead `Ok(OperatorDecision::DryRun)` match arms in `mutation_runner.rs` + `tools/mod.rs` deleted. +- `SecretEnvSpan::record_migrate_source_backend_type` and `record_migrate_dest_backend_type` now take `BackendType` (were `&str`); migrate call sites wrap runtime strings via `BackendType::from_runtime_str`; typed-setter total on `SecretEnvSpan` is 57 (BREAKING). +- `LocalTraceCapture::install` uses an `InstalledFlagGuard` RAII guard: clears the `INSTALLED` `AtomicBool` on unwind if OTel-SDK setup panics mid-install; `disarm()` hands ownership to the capture's `Drop` on success; no public-API change. +- Internal `build_env`/`fetch_one` gained an `otel_include_error_detail: bool` parameter for the wire-up (private functions, not a public-API break). +- `secretenv.backend.probe.{level,outcome}` vocabularies unified (partial, BREAKING): span-side enums `BackendProbeLevel` (`connectivity`/`full`) and `BackendProbeOutcome` (`success`/`timeout`/`permission_denied`/`error`) removed; the metric enums `ProbeLevel` (`l1-cli`/`l2-auth`/`l3-read`) and `ProbeOutcome` (`ok`/`cli-missing`/`not-authenticated`/`registry-unreachable`/`timeout`/`unknown`) are now the single vocabulary for both span and metric; the run-path `secretenv.backend.probe` span now emits `l3-read` and `ok`/`unknown`; metric path stays unwired pending v0.20. ### BREAKING - -Four deliberate public-API breaks in `secretenv-telemetry`, plus the `secretenv-mcp` `OperatorDecision` family split (Arch-W-1). Within-workspace callers are all migrated; the list is for downstream library embedders. - -- **`SecretEnvSpan::record_migrate_source_backend_type(BackendType)`** — was `(&str)`. Migrate: pass a `BackendType` variant directly, or `BackendType::from_runtime_str(s)` to parse a runtime string from `Backend::backend_type()`. -- **`SecretEnvSpan::record_migrate_dest_backend_type(BackendType)`** — was `(&str)`. Same migration path. -- **`SecretEnvSpan::record_backend_probe_level(ProbeLevel)`** — was `record_backend_probe_level(BackendProbeLevel)`; the `BackendProbeLevel` enum (`Connectivity` / `Full`) is **removed**. Migrate: pass a `ProbeLevel` variant (`L1Cli` / `L2Auth` / `L3Read`). The two enums wrote the same `secretenv.backend.probe.level` attribute with conflicting value sets; `ProbeLevel` is now canonical. -- **`SecretEnvSpan::record_backend_probe_outcome(ProbeOutcome)`** — was `record_backend_probe_outcome(BackendProbeOutcome)`; the `BackendProbeOutcome` enum (`Success` / `Timeout` / `PermissionDenied` / `Error`) is **removed**. Migrate: pass a `ProbeOutcome` variant (`Ok` / `CliMissing` / `NotAuthenticated` / `RegistryUnreachable` / `Timeout` / `Unknown`). The two enums wrote the same `secretenv.backend.probe.outcome` attribute with conflicting value sets; `ProbeOutcome` is now canonical. -- **`secretenv-mcp` `OperatorDecision` family split (Arch-W-1):** - - `enforce_mutation_policy` now returns `MutationOperatorDecision` (was `OperatorDecision`). Migrate: use `MutationOperatorDecision` at the call site; the `DryRun` variant no longer exists in this type — its absence is the point. (`prompt_via_elicitation` / `prompt_via_tty` are private helpers that also changed return type but are not public API.) - - `audit_migrate` now takes `MigrateOperatorDecision` (was `OperatorDecision`). Migrate: pass the appropriate marker type. - - `echo_decision` is now generic over `impl Decision` (was a concrete `OperatorDecision` parameter). Migrate: any concrete `MutationOperatorDecision` or `MigrateOperatorDecision` value satisfies the bound; call `.to_audit()` if you need the on-disk `OperatorDecision` union directly. +- `SecretEnvSpan::record_migrate_source_backend_type(BackendType)` (was `&str`): pass a `BackendType` variant or `BackendType::from_runtime_str(s)`. +- `SecretEnvSpan::record_migrate_dest_backend_type(BackendType)` (was `&str`): same migration path. +- `SecretEnvSpan::record_backend_probe_level(ProbeLevel)` (was `BackendProbeLevel`, now removed): pass a `ProbeLevel` variant (`L1Cli`/`L2Auth`/`L3Read`). +- `SecretEnvSpan::record_backend_probe_outcome(ProbeOutcome)` (was `BackendProbeOutcome`, now removed): pass a `ProbeOutcome` variant (`Ok`/`CliMissing`/`NotAuthenticated`/`RegistryUnreachable`/`Timeout`/`Unknown`). +- `secretenv-mcp` `OperatorDecision` family split: `enforce_mutation_policy` now returns `MutationOperatorDecision` (no `DryRun` variant); `audit_migrate` now takes `MigrateOperatorDecision`; `echo_decision` is now generic over `impl Decision` (call `.to_audit()` for the on-disk `OperatorDecision` union). ### Fixed - -- **`aggregate_errors` now panics explicitly on empty input (Arch-F-1 / Code-F-3).** `secretenv-core::runner::aggregate_errors` is documented (in CONTRIBUTING and in Phase 3 of this cycle) as "our one production panic" — unreachable on empty input by construction. The Phase 7 audit found the contract was unenforced: the `len() == 1` guard skipped `swap_remove`, so empty input fell through to a malformed `"0 secrets failed…"` error object rather than panicking. An explicit `assert!(!errors.is_empty(), …)` now matches the documented invariant; the CONTRIBUTING exemplar was corrected; a `#[should_panic]` test was added. +- `aggregate_errors` now panics explicitly on empty input: the `len() == 1` guard previously skipped `swap_remove` so empty input fell through to a malformed `"0 secrets failed"` object; added `assert!(!errors.is_empty(), ...)`, corrected the CONTRIBUTING exemplar, added a `#[should_panic]` test. ### Security - -- **SEC-INV-20 scrubber gains a scheme-less userinfo arm (v0.19 Sec-F-1).** The prior scrubber arms caught `scheme://user:pass@host` but left a scheme-less `user:secret@vault.internal:8200/path` credential fragment intact when the password portion was shorter than 32 characters (the token-entropy threshold). A new optional `(?:[^\s'"/@]+@)?` userinfo-prefix arm was added to the two bare-host arms; it is gated by the same port-or-path requirement that prevents the scrubber from over-stripping bare email addresses in prose. +2 regression tests. **Note:** this is distinct from the v0.18 Sec-F-1 fix (which added the bare `host:port` without-path arm); this is a separate finding surfaced by the v0.19 Phase 7 security audit. +- The backend error-message scrubber gains a scheme-less userinfo arm: prior arms left a scheme-less `user:secret@vault.internal:8200/path` fragment intact when the password was under 32 chars; new optional `(?:[^\s'"/@]+@)?` userinfo-prefix arm added to the two bare-host arms, gated by the same port-or-path requirement; +2 regression tests; distinct from the v0.18 fix. ### Documentation - -This Phase 8b clean-sweep covers three categories of accuracy fixes: - -- **`docs/reference/opentelemetry.md` status notes refreshed:** §2/§3 "not yet shipped / reserved in v0.18.0" note on `secretenv.backend.error.message` updated to reflect that the attribute is now live when `--otel-include-error-detail` is set. -- **README OTel section corrected** — the phrase "traces, metrics, and logs" was inaccurate (`LoggerProvider` / the logs signal is deferred to v0.20; events surface via the tracing bridge as span events). Corrected to "traces and metrics." -- **Rustdoc accuracy fixes:** `record_migrate_source_backend_type` / `record_migrate_dest_backend_type` doc softened from "instance-name string cannot reach the attribute" to "a typo'd literal at the call site cannot reach the attribute" (`BackendType::Unknown(String)` preserves an unrecognized runtime string). `echo_decision` doc no longer states "two enums" (post-Arch-W-1 there are three decision types plus the echo twin). `fresh_run_id` doc softened (see [0.18.0] edit in this sweep). -- **CONTRIBUTING Arch-W-9** — new subsection documenting the make-illegal-states-unrepresentable + `Decision`-trait marker-type idiom (see the accompanying CONTRIBUTING edit). +- `docs/reference/opentelemetry.md` §2/§3 note on `secretenv.backend.error.message` updated to reflect it is now live when `--otel-include-error-detail` is set. +- README OTel section corrected from "traces, metrics, and logs" to "traces and metrics" (logs signal deferred to v0.20). +- Rustdoc fixes: `record_migrate_source_backend_type`/`record_migrate_dest_backend_type` doc softened to "a typo'd literal at the call site cannot reach the attribute"; `echo_decision` doc no longer states "two enums"; `fresh_run_id` doc softened. +- CONTRIBUTING: new subsection documenting the make-illegal-states-unrepresentable + `Decision`-trait marker-type idiom. ### Known limitations - -- **OTel `LoggerProvider` not installed** — `tracing::*!` events still surface as span events via the `tracing-opentelemetry` bridge, not as native OTel `LogRecord` instances. Deferred to v0.20. -- **Flat span topology** — `secretenv.run` and its children emit as siblings rather than a parent-child tree. Trace UIs show all spans at root level. Deferred to v0.20 cycle pending a deliberate `BoxedSpan` exposure decision. -- **`secretenv.exec.flush` schema-reserved span not emitted** — Drop cannot fire across `execve`; emitting this span correctly requires `pre_exec` hook + manual flush sequencing. Deferred to v0.20 cycle. -- **Code-F-1 (pre-existing): migrate early-error paths log `AutoApproved` before the policy gate ran** — the Arch-W-1 rename faithfully preserved this v0.18 behavior (not a v0.19 regression). Needs a design decision (dedicated pre-gate tag, or skip the append). Deferred to v0.19.x / v0.20. -- **OTEL-SMOKE-CI-HARDENING: section 36 not in the `--local-only`/CI gate** — 3 pre-existing flush-timing flakes (assertions 1210/1283/1237) prevent reliable CI execution of the OTel smoke block in GHA (execve flush race under CI load). Section 36 still runs on a full smoke and with explicit `--sections 36`. Own-cycle fix deferred. +- OTel `LoggerProvider` not installed: `tracing::*!` events surface as span events via the `tracing-opentelemetry` bridge, not native OTel `LogRecord`. Deferred to v0.20. +- Flat span topology: `secretenv.run` and children emit as siblings, not a parent-child tree. Deferred to v0.20 pending a `BoxedSpan` exposure decision. +- `secretenv.exec.flush` schema-reserved span not emitted: Drop cannot fire across `execve`; needs `pre_exec` hook + manual flush. Deferred to v0.20. +- Pre-existing: migrate early-error paths log `AutoApproved` before the policy gate ran; the rename preserved this v0.18 behavior; needs a design decision. Deferred to v0.19.x / v0.20. +- OTEL-SMOKE-CI-HARDENING: section 36 not in the `--local-only`/CI gate; 3 pre-existing flush-timing flakes (assertions 1210/1283/1237) block reliable CI execution; still runs on a full smoke and via `--sections 36`. Own-cycle fix deferred. ## [0.18.0] - 2026-06-03 -**Headline:** v0.18 is the **named non-backend hardening minor** consuming the ~25-item carry-forward queue from the v0.17 OpenTelemetry cycle. Modeled on v0.4 (functionality-only release between v0.3 cloud trio and v0.5 Keychain). No new backends; backend total stays at **15**, workspace crate count stays at **22**. Ships ~5 deliberate pre-launch BREAKING public-API changes (see `### BREAKING`). Authoritative plan: `kb/wiki/build-plan-v0.18-hardening.md`. Carry-forward ledgers: `kb/wiki/v0.17-deferred-items.md` + `kb/wiki/v0.18-deferred-items.md`. Live-backend smoke: 796 PASS / 0 FAIL / 3 expected SKIP across all 15 backends. +Named non-backend hardening minor consuming the ~25-item carry-forward queue from the v0.17 OpenTelemetry cycle (modeled on v0.4); no new backends (total 15), workspace crate count stays 22; ships ~5 deliberate pre-launch BREAKING public-API changes; live-backend smoke 796 PASS / 0 FAIL / 3 expected SKIP across all 15 backends. ### Added - -- **`--otel-include-error-detail` flag on `secretenv run`** (and `RunOptions::otel_include_error_detail` in `secretenv-core`). Opt-in toggle for emitting the scrubbed backend stderr text on the `secretenv.backend.error.message` `OTel` span attribute. Default OFF — attribute is structurally absent. **Reserved in v0.18.0:** the flag, the SEC-INV-20 scrubber, and the typed setter all ship, but no production call site emits the attribute yet, so setting the flag is currently a no-op; wire-up is tracked as v0.18-Sec-F-5. The opt-in surface is shipped now so it's stable. Closes [[v0.17-deferred-items#D-5.1]] (surface; emission deferred). -- **`BackendErrorStderr` newtype + SEC-INV-20 shape scrubber** (`secretenv_telemetry::BackendErrorStderr`, re-exported from `secretenv_core`). Three-pass conservative regex set strips URI shapes, AWS 12-digit account IDs, and high-entropy tokens (32+ chars of base64-ish alphabet). Newtype's only constructor IS the scrubber — holding a `BackendErrorStderr` is the proof obligation that scrubbing occurred. -- **`SecretEnvSpan::record_backend_error_message_scrubbed(&BackendErrorStderr, opt_in: bool)` typed setter**. Dual-state ALLOW: opt-in emits the scrubbed payload, opt-out leaves the attribute structurally absent. SecretEnvSpan setter count grows 38 → 39. -- **`LocalTraceCaptureError` typed-error enum** with `AlreadyInstalled` variant, returned by `LocalTraceCapture::install`. -- **`MutationSpanName` closed enum** (`secretenv_telemetry::span::MutationSpanName`) — 8 variants covering every mutation span name (4 MCP mutation tools + 4 migrate phases). `as_str()` returns the canonical OTel name; `all()` returns every variant for sampler iteration and regression-test coverage. -- **`SecretEnvSpan::start_mutation(MutationSpanName) -> (Self, SpanGuard)` typed constructor** — the sole entry point for starting mutation spans. Closes [[v0.17-deferred-items#Sec-F-5]] / [[v0.17-deferred-items#Code-L3]] / Phase 7 H-1 follow-up. -- **`SecretEnvCommand` closed enum** (`secretenv_telemetry::span::SecretEnvCommand`) — 7 variants (Run / Get / Migrate / Doctor / Redact / Mcp / Registry); `#[non_exhaustive]` for forward-compat. -- **`BackendType` closed enum** (`secretenv_telemetry::span::BackendType`) — 15 canonical backend variants + `Unknown(String)` fallback; `BackendType::from_runtime_str(&str)` parses a `Backend::backend_type()` return. Closes Phase 7 M-4. -- **`[mcp].allow_cli_overrides` config knob** (default `true` — preserves v0.16 behavior). When `false`, user-scope vetoes per-IDE profile argv overrides like Gemini's `--allow-mutations=always`; rendered IDE configs strip the suppressed args + emit a `tracing::warn!` listing them. New public function `secretenv_mcp::setup::render_config_with_overrides(profile, binary, allow_cli_overrides)`. Closes [[v0.17-deferred-items#v0.16-F-3]]. -- **`secretenv mcp setup --check-overrides`** — read-only operator-discovery subcommand. Scans every supported IDE config path and reports any present SecretEnv MCP argv overrides + flags which would be vetoed by the current user-scope `allow_cli_overrides`. Mutually exclusive with all other `mcp setup` flags. Closes [[v0.17-deferred-items#v0.16-R-3]]. -- **`OperatorDecision::DryRun` + `OperatorDecisionEcho::DryRun` variants** — the migrate audit log now records a dedicated entry for `dry_run = true` migrations so operators can prove the agent attempted the migration even when no backend state changed. Closes [[v0.17-deferred-items#v0.16-M-12]]. -- **`SecretEnvSpan::record_migrate_collapsed(bool)` setter** + policy entry — forward-compat slot for the dual-control collapse detection. Emitted as `false` in v0.18 (no backend currently exposes an atomic `cas_set` surface); future collapse paths flip the bit at parent migrate span scope without spec churn. Closes [[v0.17-deferred-items#v0.16-M-9]] (partial; the underlying collapse-detection logic stays deferred). -- **5 schema-reserved OTel spans now emit** (Arch-M6 subset, 5 of 6): - - `secretenv.manifest.load` — `Manifest::load_from` - - `secretenv.registry.load` — `resolve_registry` - - `secretenv.backend.probe` — `fetch_one` (sibling of `secretenv.backend.fetch` — parent-child linkage deferred under Arch-M1 to v0.20) - - `secretenv.exec.prepare` — `exec_with_env` - - `secretenv.doctor.registry` — `run_doctor`'s per-registry cascade-reachability pass -- **5 new closed enums** for Phase 4 attributes, all `#[non_exhaustive]`: `ManifestOutcome` (Ok / NotFound / ParseError / ValidationError), `RegistrySelectionKind` (ByName / Uri), `BackendProbeLevel` (Connectivity / Full), `BackendProbeOutcome` (Success / Timeout / PermissionDenied / Error), `DoctorCheckLevel` (Quick / Standard / Extensive). -- **13 new typed setters on `SecretEnvSpan`** for the schema-reserved span attributes: `record_manifest_path_relative`, `record_manifest_alias_count`, `record_manifest_default_count`, `record_manifest_outcome`, `record_registry_selection`, `record_registry_source_count`, `record_registry_source_index`, `record_backend_probe_level`, `record_backend_probe_outcome`, `record_backend_fetch_attempt`, `record_doctor_check_level`, `record_doctor_backend_count`, `record_doctor_failure_count`. SecretEnvSpan setter count grows 39 → 52 (D-3.1 rolling closure). +- `--otel-include-error-detail` flag on `secretenv run` (and `RunOptions::otel_include_error_detail`): opt-in toggle to emit scrubbed backend stderr on `secretenv.backend.error.message` span attribute (default OFF, attribute absent); reserved in v0.18.0 (flag, scrubber, typed setter all ship but no production caller emits yet, wire-up tracked for a later release). +- `BackendErrorStderr` newtype + shape scrubber (`secretenv_telemetry::BackendErrorStderr`, re-exported from `secretenv_core`): three-pass conservative regex set strips URI shapes, AWS 12-digit account IDs, and high-entropy tokens (32+ chars); the newtype's only constructor is the scrubber. +- `SecretEnvSpan::record_backend_error_message_scrubbed(&BackendErrorStderr, opt_in: bool)` typed setter: opt-in emits the scrubbed payload, opt-out leaves the attribute absent; setter count grows 38 to 39. +- `LocalTraceCaptureError` typed-error enum with `AlreadyInstalled` variant, returned by `LocalTraceCapture::install`. +- `MutationSpanName` closed enum (`secretenv_telemetry::span::MutationSpanName`): 8 variants (4 MCP mutation tools + 4 migrate phases); `as_str()` returns canonical OTel name, `all()` returns every variant. +- `SecretEnvSpan::start_mutation(MutationSpanName) -> (Self, SpanGuard)` typed constructor: sole entry point for starting mutation spans. +- `SecretEnvCommand` closed enum (`secretenv_telemetry::span::SecretEnvCommand`): 7 variants (Run/Get/Migrate/Doctor/Redact/Mcp/Registry), `#[non_exhaustive]`. +- `BackendType` closed enum (`secretenv_telemetry::span::BackendType`): 15 canonical backend variants + `Unknown(String)` fallback; `BackendType::from_runtime_str(&str)` parses a `Backend::backend_type()` return. +- `[mcp].allow_cli_overrides` config knob (default `true`): when `false`, user-scope vetoes per-IDE profile argv overrides (e.g. Gemini's `--allow-mutations=always`), rendered IDE configs strip suppressed args + emit a `tracing::warn!`; new public `secretenv_mcp::setup::render_config_with_overrides(profile, binary, allow_cli_overrides)`. +- `secretenv mcp setup --check-overrides`: read-only operator-discovery subcommand scanning every supported IDE config path, reporting present SecretEnv MCP argv overrides + which would be vetoed; mutually exclusive with all other `mcp setup` flags. +- `OperatorDecision::DryRun` + `OperatorDecisionEcho::DryRun` variants: migrate audit log now records a dedicated entry for `dry_run = true` migrations. +- `SecretEnvSpan::record_migrate_collapsed(bool)` setter + policy entry: forward-compat slot for dual-control collapse detection, emitted as `false` in v0.18 (no backend exposes atomic `cas_set` yet). +- 5 schema-reserved OTel spans now emit (5 of 6): `secretenv.manifest.load` (`Manifest::load_from`), `secretenv.registry.load` (`resolve_registry`), `secretenv.backend.probe` (`fetch_one`), `secretenv.exec.prepare` (`exec_with_env`), `secretenv.doctor.registry` (`run_doctor` per-registry cascade-reachability pass). +- 5 new closed enums for schema-reserved attributes, all `#[non_exhaustive]`: `ManifestOutcome` (Ok/NotFound/ParseError/ValidationError), `RegistrySelectionKind` (ByName/Uri), `BackendProbeLevel` (Connectivity/Full), `BackendProbeOutcome` (Success/Timeout/PermissionDenied/Error), `DoctorCheckLevel` (Quick/Standard/Extensive). +- 13 new typed setters on `SecretEnvSpan` for schema-reserved span attributes: `record_manifest_path_relative`, `record_manifest_alias_count`, `record_manifest_default_count`, `record_manifest_outcome`, `record_registry_selection`, `record_registry_source_count`, `record_registry_source_index`, `record_backend_probe_level`, `record_backend_probe_outcome`, `record_backend_fetch_attempt`, `record_doctor_check_level`, `record_doctor_backend_count`, `record_doctor_failure_count`; setter count grows 39 to 52. ### Changed - -- **`LocalTraceCapture::install()` returns `Result`** (was `-> Self`). Module-level `INSTALLED: AtomicBool` guard prevents a second live install from silently swapping the global `TracerProvider`. Drop clears the flag for the next legitimate install. The only existing call site (`secretenv doctor --trace`) now bubbles the error via `anyhow`. Closes [[v0.17-deferred-items#Sec-M-2]] + [[v0.17-deferred-items#Arch-F-6]]. -- **`TelemetryGuard::Drop` is now bounded** at 1s via a worker-thread + `recv_timeout` pattern (extracted as the private `run_bounded_or_detach(timeout, work)` helper, shared with `flush_before_exec`). CTRL-C against `secretenv run` with a slow/unreachable OTLP collector no longer hangs the shell. On timeout, the worker thread is detached and a `tracing::warn!` event fires (same shape as the pre-exec timeout). Closes [[v0.17-deferred-items#Sec-M-3]]. -- **`MutationNonDroppableSampler::is_mutation_span()` walks `MutationSpanName::all()`** instead of a hand-maintained `&[&str]` allowlist (which has been removed). The closed enum is now the single source of truth for both the span name (via `MutationSpanName::as_str` at the call site through `SecretEnvSpan::start_mutation`) and the sampler whitelist (via the predicate iteration). Adding a new mutation span = adding a variant; nothing else moves. Migrated all 8 mutation call sites in `secretenv-migrate` + `secretenv-mcp::tools` to `start_mutation`. -- **`resolve_confirm_via` returns `Result`** (was `Result`). New internal closed enum `ResolvedConfirmVia` mirrors every `ConfirmVia` variant EXCEPT `Auto` so the downstream match cannot witness an `Auto` value at the type level — the v0.17 `unreachable!("Auto should have been resolved to a concrete surface")` is gone. `OperatorDecision` and `OperatorDecisionEcho` both gain `#[non_exhaustive]` to absorb the new `DryRun` variant + future-proof for v0.19+ additions. Closes [[v0.17-deferred-items#v0.16-Sec-F-2]]. -- **`secretenv_telemetry::init` signature is now `init(service_version: &str) -> Result`** (was `init() -> Result<...>`). The `service.version` resource attribute now flows from the calling binary's `env!("CARGO_PKG_VERSION")` rather than the telemetry crate's own version, so library embedders can set their own. Closes [[v0.17-deferred-items#Arch-F-5]]. **(BREAKING)** Downstream callers update from `init()` to `init(env!("CARGO_PKG_VERSION"))`. -- **`InitError::Exporter` carries a structured cause** — `InitError::Exporter(#[from] opentelemetry_otlp::ExporterBuildError)` instead of `Exporter(String)`. Downstream `?` propagates the OTel SDK error chain. `InitError` also gains `#[non_exhaustive]`. Closes [[v0.17-deferred-items#Arch-F-7]] + [[v0.17-deferred-items#Arch-L2]]. -- **`init_with_env` is `#[doc(hidden)]`** — still `pub` (callers compile unchanged) but absent from public rustdoc. Test-injection seam, not part of the supported surface. Closes [[v0.17-deferred-items#Arch-L3]]. -- **`LocalTraceSpan` is `#[non_exhaustive]`** — adding fields like `trace_id` for cross-process correlation in v0.19+ is no longer a breaking change. Closes [[v0.17-deferred-items#Code-M1]] + v1.0 watchlist W-11. -- **`SecretEnvSpan::record_command` takes `SecretEnvCommand`** (was `&str`). 3 call sites migrated (`secretenv-core::runner` x1, `secretenv-migrate` x1, plus a test). Closes [[v0.17-deferred-items#Phase-7-M-4]] (partial — `record_command` half). -- **`SecretEnvSpan::record_backend_type` takes `BackendType`** (was `&str`). 3 call sites migrated (`secretenv-core::runner` x2, `secretenv-cli::doctor` x1). Closes [[v0.17-deferred-items#Phase-7-M-4]] (partial — `record_backend_type` half). -- **`fresh_run_id()` fallback is no longer all-zeros.** When `getrandom` fails (extremely rare on supported platforms; never observed in production through v0.17), emits a `tracing::warn!` event ONCE per process AND returns a process+time-derived hex string (effectively never the all-zero sentinel) derived from `process::id()` XOR low/high 64 bits of `SystemTime::now().duration_since(UNIX_EPOCH).as_nanos()`. The v0.17 all-zero sentinel (which operators saw as `run_id=00000...0` without explanation) is gone. Closes [[v0.17-deferred-items#Arch-M3]] + [[v0.17-deferred-items#Code-L2]] + [[v0.17-deferred-items#Arch-L4]] + v1.0 watchlist W-17. +- `LocalTraceCapture::install()` returns `Result` (was `-> Self`): module-level `INSTALLED: AtomicBool` guard prevents a second live install from swapping the global `TracerProvider`; Drop clears the flag; the `secretenv doctor --trace` call site bubbles via `anyhow`. +- `TelemetryGuard::Drop` is now bounded at 1s via a worker-thread + `recv_timeout` pattern (extracted as private `run_bounded_or_detach(timeout, work)`, shared with `flush_before_exec`); CTRL-C against `secretenv run` with a slow OTLP collector no longer hangs; on timeout the worker detaches and a `tracing::warn!` fires. +- `MutationNonDroppableSampler::is_mutation_span()` walks `MutationSpanName::all()` instead of a hand-maintained `&[&str]` allowlist (removed); the closed enum is the single source of truth; migrated all 8 mutation call sites in `secretenv-migrate` + `secretenv-mcp::tools` to `start_mutation`. +- `resolve_confirm_via` returns `Result` (was `Result`): new internal closed enum `ResolvedConfirmVia` mirrors every `ConfirmVia` variant except `Auto`, eliminating the v0.17 `unreachable!`; `OperatorDecision` and `OperatorDecisionEcho` both gain `#[non_exhaustive]`. +- `secretenv_telemetry::init` signature is now `init(service_version: &str) -> Result` (was `init()`): `service.version` resource attribute now flows from the calling binary's `env!("CARGO_PKG_VERSION")` (BREAKING). +- `InitError::Exporter` carries a structured cause: `InitError::Exporter(#[from] opentelemetry_otlp::ExporterBuildError)` instead of `Exporter(String)`; `InitError` also gains `#[non_exhaustive]`. +- `init_with_env` is `#[doc(hidden)]`: still `pub` but absent from public rustdoc (test-injection seam). +- `LocalTraceSpan` is `#[non_exhaustive]`: adding fields like `trace_id` in v0.19+ is no longer breaking. +- `SecretEnvSpan::record_command` takes `SecretEnvCommand` (was `&str`); 3 call sites migrated. +- `SecretEnvSpan::record_backend_type` takes `BackendType` (was `&str`); 3 call sites migrated. +- `fresh_run_id()` fallback is no longer all-zeros: when `getrandom` fails it emits a `tracing::warn!` once per process and returns a process+time-derived hex string from `process::id()` XOR low/high 64 bits of `SystemTime::now()` nanos; the v0.17 all-zero sentinel is gone. ### BREAKING - -Five deliberate public-API breaks in `secretenv-telemetry`, bundled into this single minor per the pre-launch breaking-change posture (install base is effectively zero; the window closes at public announcement). Within-workspace callers are all migrated; the list is for downstream library embedders. - -- **`secretenv_telemetry::init()` → `init(service_version: &str)`.** Migrate: `init(env!("CARGO_PKG_VERSION"))`. The `service.version` resource attribute now flows from the calling binary, not the telemetry crate. -- **`LocalTraceCapture::install()` now returns `Result`** (was `-> Self`). Migrate: handle/propagate the `AlreadyInstalled` error (e.g. via `?`). -- **`InitError::Exporter(#[from] opentelemetry_otlp::ExporterBuildError)`** (was `Exporter(String)`); `InitError` is now `#[non_exhaustive]`. Migrate: match on the structured cause rather than a string, and add a wildcard arm for the non-exhaustive enum. -- **`SecretEnvSpan::record_command(SecretEnvCommand)`** (was `record_command(&str)`). Migrate: pass a `SecretEnvCommand` variant instead of a string literal. -- **`SecretEnvSpan::record_backend_type(BackendType)`** (was `record_backend_type(&str)`). Migrate: pass a `BackendType` variant, or `BackendType::from_runtime_str(s)` to parse a runtime string. +- `secretenv_telemetry::init()` to `init(service_version: &str)`: migrate to `init(env!("CARGO_PKG_VERSION"))`. +- `LocalTraceCapture::install()` now returns `Result` (was `-> Self`): handle/propagate the `AlreadyInstalled` error via `?`. +- `InitError::Exporter(#[from] opentelemetry_otlp::ExporterBuildError)` (was `Exporter(String)`), `InitError` now `#[non_exhaustive]`: match on the structured cause and add a wildcard arm. +- `SecretEnvSpan::record_command(SecretEnvCommand)` (was `&str`): pass a `SecretEnvCommand` variant. +- `SecretEnvSpan::record_backend_type(BackendType)` (was `&str`): pass a `BackendType` variant or `BackendType::from_runtime_str(s)`. ### Fixed - -- **`secretenv.manifest.path` no longer leaks an absolute path** when the manifest path has no filename component (`/`, `..`, empty). `Manifest::load_from` now emits a `` sentinel instead of the raw path, closing a SEC-INV path-leak gap. (Phase 7b Code-L-1.) -- **`SEC-INV-20` scrubber now strips bare `host:port` clusters without a trailing path** (e.g. `vault.prod.internal:8200`) — the prior URI regex required a `/path` suffix, so a path-less internal hostname survived into a scrubbed span attribute. New regex arm is gated on a literal port so dotted prose (filenames, module paths) is not over-stripped. (Phase 7b Sec-F-1.) -- **`OperatorDecision::DryRun` contract-violation arms are now observable** — the non-migrate MCP tool arms that treat a `DryRun` decision as a contract violation now populate `error_message` AND append an audit-log entry, instead of refusing silently. (Phase 7b Code-F-3.) -- **`resolve_confirm_via` no longer silently absorbs unknown `ConfirmVia` variants** — the `ConfirmVia::Auto | _` wildcard is split into an explicit `Auto` arm plus a `_` arm that errors with a build-version-mismatch message, so a future `#[non_exhaustive]` variant cannot drift into the auto-resolution path. The sibling `AllowMutations` consumer gains the same guard. (Phase 7b Sec-F-2 / Code-F-1 / Sec-F-4.) +- `secretenv.manifest.path` no longer leaks an absolute path when the manifest path has no filename component (`/`, `..`, empty): `Manifest::load_from` emits a `` sentinel. +- The backend error-message scrubber now strips bare `host:port` clusters without a trailing path (e.g. `vault.prod.internal:8200`): prior URI regex required a `/path` suffix; new arm gated on a literal port to avoid over-stripping dotted prose. +- `OperatorDecision::DryRun` contract-violation arms are now observable: non-migrate MCP tool arms treating a `DryRun` decision as a violation now populate `error_message` and append an audit-log entry instead of refusing silently. +- `resolve_confirm_via` no longer silently absorbs unknown `ConfirmVia` variants: the `ConfirmVia::Auto | _` wildcard split into explicit `Auto` arm plus a `_` arm erroring with a build-version-mismatch message; sibling `AllowMutations` consumer gains the same guard. ### Security - -- **D-5.2 — TS-12 stderr-in-otel regression test.** `crates/secretenv-telemetry/tests/ts12_stderr_in_otel.rs` synthesizes a backend stderr containing `vault.prod.internal:8200/v1/secret/payments/stripe`, passes it through the new setter with `opt_in = true`, and asserts the URL fragment + path segments (`payments`, `stripe`, the literal URL, the port, the scheme) are structurally absent from the emitted span attribute. The opt-out arm asserts the attribute is structurally absent entirely. Closes [[v0.17-deferred-items#D-5.2]]. -- **`MutationSpanName` structural binding regression test.** `crates/secretenv-telemetry/tests/mutation_span_name_structural_binding.rs` walks every `MutationSpanName::all()` variant, drives each through `start_mutation` against an `AlwaysOff` inner sampler, and asserts the non-droppable wrapper force-records every variant. A new variant gets coverage for free; a typo at a new call site that doesn't go through `start_mutation` is structurally impossible since the typed constructor is the sole entry point. Plus a predicate-only test and a negative-coverage test on 8 non-mutation names. +- Stderr-in-otel regression test: `crates/secretenv-telemetry/tests/ts12_stderr_in_otel.rs` passes a backend stderr containing `vault.prod.internal:8200/v1/secret/payments/stripe` through the setter with `opt_in = true` and asserts URL fragment + path segments + literal URL + port + scheme are absent from the emitted attribute; opt-out arm asserts the attribute is absent entirely. +- `MutationSpanName` structural binding regression test: `crates/secretenv-telemetry/tests/mutation_span_name_structural_binding.rs` walks every `MutationSpanName::all()` variant through `start_mutation` against an `AlwaysOff` inner sampler and asserts the non-droppable wrapper force-records each; plus a predicate-only test and a negative-coverage test on 8 non-mutation names. ### Hardening - -- **6 `as u64` truncating casts → `u64::try_from(...).unwrap_or(u64::MAX)`** in `secretenv-core::runner`. Matches the project convention. Closes [[v0.17-deferred-items#Code-M2]]. -- **Duplicate `fetch_ms` recomputation in `fetch_one` collapsed.** The probe/fetch block now returns `(fetch_result, ms)` as a tuple; the outer scope reuses `fetch_ms` for the metric emission. Closes [[v0.17-deferred-items#Code-M3]]. -- **`MigrationPlan.transaction_id` doc comment** documenting the move-into-`MigrateReport` invariant. Closes [[v0.17-deferred-items#Code-M4]]. -- **`RegistrySelection::registry_label()` decision lock-in** — kept as `Option<&str>` (call sites handle the borrow correctly). New companion `registry_label_for_telemetry() -> &str` returns the `REGISTRY_NAME_DIRECT_URI` sentinel for direct-URI selections. Closes [[v0.17-deferred-items#Code-L1]] + v1.0 watchlist W-18. -- **`LocalTraceCapture::drain` saturation behavior documented** (`try_from` saturates at `u64::MAX` for post-year-584-million `SystemTime`; `map_or(0, ...)` covers pre-epoch clock skew). Closes [[v0.17-deferred-items#Code-L4]]. -- **`_guard` lifetime comment on the `secretenv.redact.filter_event` span site** prevents future remove-as-unused. Closes [[v0.17-deferred-items#Code-L5]]. -- **`secretenv_telemetry::REGISTRY_NAME_DIRECT_URI` + `PROCESS_COMMAND_NAME_EMPTY` constants** lift the v0.17 `""` and `""` magic strings. Closes [[v0.17-deferred-items#Code-N2]] + [[v0.17-deferred-items#Code-N3]] + v1.0 watchlist W-15 + W-16. -- **`SpanGuard._private: ()` keep decision** documented — the sealing is load-bearing against external crates, not cosmetic. Closes [[v0.17-deferred-items#Code-N4]]. -- **`doctor.rs::probe_otel_reachability` honors `OTEL_EXPORTER_OTLP_PROTOCOL`** — `http/protobuf` and `http/json` select port 4318; anything else keeps the 4317 default. Operators running OTLP/HTTP exporters no longer see a false "unreachable" diagnostic. Closes [[v0.17-deferred-items#Code-N5]] + Phase 7 M-2. -- **`host.name` FQDN documentation note** added to `docs/reference/opentelemetry.md` §2 attribute matrix. Closes [[v0.17-deferred-items#Sec-L-3]]. -- **Dead typed-setter keep decision** — `record_alias_count`, `record_cascade_layer_index`, `record_backend_cli_name`, etc. retained as spec'd ALLOW surface for future callers (Phase 4 wired several previously-dormant setters this cycle, validating the keep-as-forward-compat pattern). Closes [[v0.17-deferred-items#Sec-L-4]]. +- 6 `as u64` truncating casts changed to `u64::try_from(...).unwrap_or(u64::MAX)` in `secretenv-core::runner`. +- Duplicate `fetch_ms` recomputation in `fetch_one` collapsed: the probe/fetch block returns `(fetch_result, ms)`; the outer scope reuses `fetch_ms` for metric emission. +- `MigrationPlan.transaction_id` doc comment documenting the move-into-`MigrateReport` invariant. +- `RegistrySelection::registry_label()` kept as `Option<&str>`; new companion `registry_label_for_telemetry() -> &str` returns the `REGISTRY_NAME_DIRECT_URI` sentinel for direct-URI selections. +- `LocalTraceCapture::drain` saturation behavior documented (`try_from` saturates at `u64::MAX` for post-year-584-million `SystemTime`; `map_or(0, ...)` covers pre-epoch clock skew). +- `_guard` lifetime comment on the `secretenv.redact.filter_event` span site to prevent future remove-as-unused. +- `secretenv_telemetry::REGISTRY_NAME_DIRECT_URI` + `PROCESS_COMMAND_NAME_EMPTY` constants lift the v0.17 `""` and `""` magic strings. +- `SpanGuard._private: ()` keep decision documented: the sealing is load-bearing against external crates. +- `doctor.rs::probe_otel_reachability` honors `OTEL_EXPORTER_OTLP_PROTOCOL`: `http/protobuf` and `http/json` select port 4318, anything else keeps the 4317 default. +- `host.name` FQDN documentation note added to `docs/reference/opentelemetry.md` §2 attribute matrix. +- Dead typed-setter keep decision: `record_alias_count`, `record_cascade_layer_index`, `record_backend_cli_name`, etc. retained as spec'd ALLOW surface for future callers. ### Documentation - -- **`docs/reference/opentelemetry.md` attribute-matrix accuracy fixes** so the spec matches what the binary actually emits: `secretenv.backend.probe.level` documented as `connectivity` / `full` (was the aspirational `l1_cli` / `l2_auth` / `l3_read`); `secretenv.registry.selection` as `by_name` / `uri` (was `named` / `direct-uri`); `secretenv.manifest.path` clarified as basename-only with the `` sentinel; §6 mutation-span set completed with `secretenv.migrate.delete` (the eighth `MutationSpanName` variant) + a note that `secretenv.migrate.probe` is read-only and excluded. (Phase 9 architecture-audit Arch-P9-2.) -- **`host.name` FQDN leakage note** added to §2 of the attribute matrix (also listed under Hardening). (Sec-L-3.) -- **`--otel-include-error-detail --help`** clarified to state the flag is reserved in v0.18.0 (parses + scrubber ships, but no production caller emits yet). (Sec-P9-3.) - -A comprehensive documentation refresh (README OpenTelemetry section, the v0.18 build-log/roadmap cross-links, the remaining Phase 9 code-review doc NITs) is deferred to a dedicated post-release documentation pass; the changes above are the accuracy-critical subset required for an honest release. +- `docs/reference/opentelemetry.md` attribute-matrix accuracy fixes: `secretenv.backend.probe.level` documented as `connectivity`/`full` (was `l1_cli`/`l2_auth`/`l3_read`); `secretenv.registry.selection` as `by_name`/`uri` (was `named`/`direct-uri`); `secretenv.manifest.path` clarified as basename-only with `` sentinel; §6 mutation-span set completed with `secretenv.migrate.delete` + a note that `secretenv.migrate.probe` is read-only and excluded. +- `host.name` FQDN leakage note added to §2 of the attribute matrix (also under Hardening). +- `--otel-include-error-detail --help` clarified to state the flag is reserved in v0.18.0 (parses + scrubber ships, no production caller emits yet). ### Known limitations - -- **OTel `LoggerProvider` not installed** — `tracing::*!` events still surface as span events via the `tracing-opentelemetry` bridge, not as native OTel `LogRecord` instances. Deferred to v0.19+ per [`v0.17-deferred-items`](kb/wiki/v0.17-deferred-items.md#phase-7-m-3--loggerprovider-not-installed). -- **Flat span topology** — `secretenv.run` and its children emit as siblings rather than a parent-child tree. Trace UIs show all spans at root level. Deferred to v0.20 cycle pending a deliberate `BoxedSpan` exposure decision. -- **`secretenv.exec.flush` schema-reserved span not emitted** — Drop cannot fire across `execve`; emitting this span correctly requires `pre_exec` hook + manual flush sequencing. Deferred to v0.20 cycle. The other five Phase 4 schema-reserved spans (`manifest.load`, `registry.load`, `backend.probe`, `exec.prepare`, `doctor.registry`) ship in v0.18. -- **D-3.1 typed-setter coverage** — ~13 of the spec's ALLOW attributes still have no `SecretEnvSpan` setter; each lands in the cycle that wires its first caller (rolling convention from v0.17). +- OTel `LoggerProvider` not installed: `tracing::*!` events surface as span events via the `tracing-opentelemetry` bridge, not native OTel `LogRecord`. Deferred to v0.19+. +- Flat span topology: `secretenv.run` and children emit as siblings, not a parent-child tree. Deferred to v0.20 pending a `BoxedSpan` exposure decision. +- `secretenv.exec.flush` schema-reserved span not emitted: Drop cannot fire across `execve`; needs `pre_exec` hook + manual flush. Deferred to v0.20 (the other five schema-reserved spans ship in v0.18). +- Typed-setter coverage: ~13 of the spec's ALLOW attributes still have no `SecretEnvSpan` setter; each lands in the cycle that wires its first caller. ## [0.17.0] - 2026-05-28 -**Headline:** First-class OpenTelemetry instrumentation lands. Traces (spans) and metrics ship across the full resolution flow + v0.14 redact + v0.15 migrate + v0.16 MCP surfaces. Zero startup cost when no `OTEL_*` env vars are set (the OTel SDK is linked but no providers are installed). Authoritative attribute matrix locked in `docs/reference/opentelemetry.md` §2 (51 ALLOW · 25 DENY · structurally enforced via the typed `SecretEnvSpan` builder — no generic `set_attribute(k, v)` escape hatch). - -v0.16.0 → v0.17.0: backend total stays at **15**; workspace gains the `secretenv-telemetry` first OTel-enabled publish + 9 (Phase 8b) + 4 (Phase 8c) + 3 (Phase 9b) new typed-attribute setters totalling 38 in `SecretEnvSpan`; 4 new metric call-sites against the 10 Phase 4 instruments. +First-class OpenTelemetry instrumentation: traces and metrics across resolution, redact, migrate, and MCP surfaces with zero startup cost when no `OTEL_*` env vars are set; backend total stays at 15; adds the `secretenv-telemetry` crate (first OTel-enabled publish) with 38 typed `SecretEnvSpan` setters and 10 metric instruments. ### Added - -- **OpenTelemetry traces.** The `secretenv.run` root span wraps the entire resolution + exec lifecycle, with per-alias `secretenv.resolution` and per-fetch `secretenv.backend.fetch` children. Ends explicitly before `execve` (Drop can't fire across exec). 4 spec-mandated attrs on each: `run.dry_run` / `run.verbose` / `run.outcome` / `run.failed_alias_count` plus `resolution.outcome` + `resolution.latency_ms` plus `backend.fetch.outcome` + `backend.fetch.duration_ms`. -- **OpenTelemetry metrics.** 10 typed instruments: `resolution.duration` + `resolution.count` histograms/counters, `backend.fetch.duration` + `backend.probe.count`, `redact.events`, `mcp.tool.calls` + `mcp.tool.duration`, `doctor.failure.count`, `migrate.operation.count`, `registry.alias_count` gauge. Cardinality-safe by construction — `alias.name` is structurally absent from every histogram/gauge signature. -- **Redact span emission** (`secretenv.redact.filter_event`). One span per non-empty stdout/stderr stream in runtime-pipe mode, one span on the post-hoc `secretenv redact ` CLI path. Carries `mode` (runtime / post-hoc / disabled), `stream` (stdout / stderr), `match_count`, `byte_count`. SEC-INV-19: `redact.alias_name` is **never** emitted — alias names live only in the operator-local terminal substitution token. -- **Migrate phase tree** (`secretenv.registry.migrate` root + 5 child spans: `probe` / `read` / `write` / `pointer_flip` / `delete`). Mutation non-droppable sampler keeps every child + root in the trace stream even under aggressive ratio sampling (SEC-INV-22). -- **MCP tool spans.** All 14 MCP tools emit `secretenv.mcp.tool.` spans with `tool_name`, `client_name` (from rmcp peer.client_info), and — for the 4 alias-mutation tools (`set_alias` / `delete_alias` / `migrate_alias` / `gen_password`) — `argument_alias_name`. SEC-INV-12: `argument_reason` is **never** emitted (prompt-injection vehicle; lives in the audit log only). -- **Mutation non-droppable sampler.** `MutationNonDroppableSampler` wraps any operator-configured sampler and forces `RecordAndSample` for the 8 mutation span names (4 MCP + 4 migrate phases). Override-safe: `OTEL_TRACES_SAMPLER=traceidratio OTEL_TRACES_SAMPLER_ARG=0.0001` still emits every mutation. Smoke-tested live. -- **Doctor OTel surfaces.** `secretenv doctor --extensive` adds an OTel section reporting `OTEL_EXPORTER_OTLP_ENDPOINT` + TCP-connect reachability (no test span emitted). `secretenv doctor --trace` renders a local-capture span table (uses an in-process `InMemorySpanExporter`; no collector required). -- **`secretenv run --verbose`** — per-alias resolution timing table on stderr; no collector required. -- **W3C TRACEPARENT propagation.** Inbound only — `secretenv.run` becomes a child of the parent trace when run from a CI system that sets `TRACEPARENT` / `TRACESTATE`. Outbound propagation to the exec'd child binary is deferred to v1.0+. -- **Section 36 smoke harness** (`scripts/smoke-test/run-tests.sh` ~430 LOC across Blocks A–E). Docker Jaeger collector lifecycle (`scripts/smoke-test/lib/otel-collector.sh`). Isolated local-backend fixture (`scripts/smoke-test/fixtures/v0.17-otel/`). 60 assertions covering trace emission, redact spans, migrate phase tree, MCP tool attrs, console metric exporter shape, SEC-INV negative checks. Soft-SKIPs when `docker` or `jq` missing. Operator runbook at `kb/wiki/runbooks/v0.17-otel-smoke.md`. -- **Compile-fail SEC-INV guards.** trybuild gates at `crates/secretenv-telemetry/tests/ui_sec_inv_04/` (no `set_attribute` escape hatch + no `record_alias_uri_*` setter) + `ui_sec_inv_12/` (no `record_mcp_argument_reason`) + `ui_sec_inv_19/` (no `record_redact_alias_name`). CI grep gate `scripts/check_tracing_leaks.sh` extended with 7 leak patterns covering bare-macro / `Span::record(...)` / `event!()` forms. +- OpenTelemetry traces: `secretenv.run` root span wrapping resolution + exec, with `secretenv.resolution` (per-alias) and `secretenv.backend.fetch` (per-fetch) children; ends before `execve`; attrs `run.dry_run`/`run.verbose`/`run.outcome`/`run.failed_alias_count`, `resolution.outcome`/`resolution.latency_ms`, `backend.fetch.outcome`/`backend.fetch.duration_ms`. +- OpenTelemetry metrics: 10 typed instruments: `resolution.duration`, `resolution.count`, `backend.fetch.duration`, `backend.probe.count`, `redact.events`, `mcp.tool.calls`, `mcp.tool.duration`, `doctor.failure.count`, `migrate.operation.count`, `registry.alias_count` gauge; `alias.name` structurally absent from every histogram/gauge. +- Redact span emission (`secretenv.redact.filter_event`): one span per non-empty stdout/stderr stream in runtime-pipe mode, one on the post-hoc `secretenv redact ` path; carries `mode`, `stream`, `match_count`, `byte_count`; `redact.alias_name` never emitted. +- Migrate phase tree: `secretenv.registry.migrate` root plus 5 children (`probe`/`read`/`write`/`pointer_flip`/`delete`); mutation non-droppable sampler keeps every child + root under aggressive sampling. +- MCP tool spans: all 14 tools emit `secretenv.mcp.tool.` with `tool_name`, `client_name`, and `argument_alias_name` for the 4 alias-mutation tools (`set_alias`/`delete_alias`/`migrate_alias`/`gen_password`); `argument_reason` never emitted. +- Mutation non-droppable sampler: `MutationNonDroppableSampler` forces `RecordAndSample` for the 8 mutation span names (4 MCP + 4 migrate); override-safe against `OTEL_TRACES_SAMPLER=traceidratio OTEL_TRACES_SAMPLER_ARG=0.0001`. +- Doctor OTel surfaces: `secretenv doctor --extensive` reports `OTEL_EXPORTER_OTLP_ENDPOINT` + TCP-connect reachability; `secretenv doctor --trace` renders a local-capture span table via in-process `InMemorySpanExporter`. +- `secretenv run --verbose`: per-alias resolution timing table on stderr. +- W3C TRACEPARENT propagation: inbound only; `secretenv.run` becomes child of the parent trace when `TRACEPARENT`/`TRACESTATE` set; outbound deferred to v1.0+. +- Section 36 smoke harness (`scripts/smoke-test/run-tests.sh`, ~430 LOC, Blocks A-E); Docker Jaeger collector lifecycle (`scripts/smoke-test/lib/otel-collector.sh`); fixture `scripts/smoke-test/fixtures/v0.17-otel/`; 60 assertions; soft-SKIPs when `docker`/`jq` missing. +- Compile-fail no-leak guards: trybuild gates at `crates/secretenv-telemetry/tests/ui_sec_inv_04/`, `ui_sec_inv_12/`, `ui_sec_inv_19/`; CI grep gate `scripts/check_tracing_leaks.sh` extended with 7 leak patterns. ### Changed - -- **Spec §4 span topology** — `secretenv.registry.migrate` (was `secretenv.migrate`) brings the migrate root span in line with the spec'd name. Mutation sampler whitelist updated to match. -- **`SecretEnvSpan` typed builder** — 13 new `record_*` methods totalling 38 typed setters. Every emitted ALLOW attribute has exactly one method; every DENY attribute has no method. No generic `set_attribute(k, v)` exists anywhere on the type. -- **`RunOptions` is now `#[non_exhaustive]`** so future v0.17.x additions don't silently break downstream record-update constructions. +- Span topology: `secretenv.registry.migrate` (was `secretenv.migrate`); mutation sampler whitelist updated to match. +- `SecretEnvSpan` typed builder: 13 new `record_*` methods totalling 38 typed setters; one method per ALLOW attribute, none for DENY; no generic `set_attribute(k, v)`. +- `RunOptions` is now `#[non_exhaustive]`. ### BREAKING - -- **Migrate OTel span name renamed** — `secretenv.migrate` → `secretenv.registry.migrate`. Operators with OTel-backend queries / dashboards referencing the old name need to update. The rename brings the code into line with `docs/reference/opentelemetry.md` §4.2; the old name was a leftover from the metric name being reused as the span name. -- **`RunOptions` gains `#[non_exhaustive]`** — downstream library integrators constructing `RunOptions { dry_run, verbose, redact, redact_token }` directly will need to switch to `RunOptions { dry_run, ..Default::default() }` or use the builder pattern. Within-workspace callers unaffected. +- Migrate span renamed `secretenv.migrate` to `secretenv.registry.migrate`; update OTel queries/dashboards referencing the old name. +- `RunOptions` gains `#[non_exhaustive]`; downstream direct-struct constructors must switch to `..Default::default()` or the builder; within-workspace callers unaffected. ### Security - -- **SEC-INV-04 holds** across the v0.17 surface expansion — every new ALLOW attribute went through a typed `record_*` setter; every new DENY attribute (5 added: `run.command_argv`, `run.env_var_value`, `registry.source_uri`, `backend.namespace`, `gen.password.{value,entropy_bits}`) has no setter. Structural enforcement via the typed builder + 4 compile-fail guards + CI grep gate. -- **SEC-INV-12 / -19 / -22 all green.** `mcp.argument_reason`, `redact.alias_name`, and the 8 mutation span names are all verified by live smoke assertions in section 36 plus integration tests in `crates/secretenv-telemetry/tests/`. -- **Phase 9b Sec F-1 fix** — `secretenv.run.command_name` is the basename of argv[0] only; absolute and relative path prefixes are stripped before emission to prevent host filesystem layout leaks to OTel collectors. +- The typed-builder no-leak guarantee holds across v0.17: 5 new DENY attributes (`run.command_argv`, `run.env_var_value`, `registry.source_uri`, `backend.namespace`, `gen.password.{value,entropy_bits}`) have no setter; enforced via typed builder + 4 compile-fail guards + CI grep gate. +- Three no-leak invariants verified: `mcp.argument_reason`, `redact.alias_name`, and the 8 mutation span names, checked by live smoke + integration tests. +- `secretenv.run.command_name` is basename of argv[0] only; path prefixes stripped before emission. ### Known limitations +- `--otel-include-error-detail` flag not yet shipped; `backend.error.message` ships as unconditionally DENY (no `record_error_message` setter); lands in v0.18. +- Logs signal not installed: v0.17 installs `TracerProvider` + `MeterProvider`; `LoggerProvider` reserved for v0.17.x/v0.18; `tracing::*!` events surface as span events via the bridge. +- 11 spec-listed spans schema-reserved, not emitted: `secretenv.manifest.load`, `secretenv.registry.load`, `secretenv.backend.probe`, `secretenv.exec.prepare`, `secretenv.exec.flush`, `secretenv.doctor` root, `secretenv.doctor.registry`, `secretenv.mcp.policy.evaluate`, `secretenv.mcp.confirm`, `secretenv.registry.transaction`, `secretenv.audit.append`; `execve` handoff covered by explicit `flush_before_exec`; lands in a later v0.17.x release; no security-invariant impact. +- Span parent-child relationships are flat: each span started independently via `SecretEnvSpan::start(...)` without context attach; tree-shape lift deferred to v0.17.x; mutation non-droppability is unaffected. -- **`--otel-include-error-detail` flag is not yet shipped.** Spec §3 promises this per-run opt-in for `backend.error.message` emission (gated through the SEC-INV-20 scrubber). v0.17 ships the schema-reserved attribute as unconditionally DENY (structurally enforced by absence of `SecretEnvSpan::record_error_message` setter). Tracked at `kb/wiki/v0.17-deferred-items.md`; lands in v0.18. -- **Logs signal not installed.** `docs/reference/opentelemetry.md` §1 mentions "traces + metrics + logs". v0.17 installs `TracerProvider` + `MeterProvider`; `LoggerProvider` is reserved for a v0.17.x or v0.18 cycle. `tracing::*!` events still surface via the tracing-opentelemetry bridge as span events, just not as OTel `LogRecord` instances. -- **11 spec-listed spans are schema-reserved, not emitted.** Run subtree (§4.1): `secretenv.manifest.load`, `secretenv.registry.load`, `secretenv.backend.probe` (as child under resolution), `secretenv.exec.prepare`, `secretenv.exec.flush`. Doctor subtree (§4.3): `secretenv.doctor` root, `secretenv.doctor.registry`. MCP subtree (§4.4): `secretenv.mcp.policy.evaluate`, `secretenv.mcp.confirm`, `secretenv.registry.transaction`, `secretenv.audit.append`. The `execve` handoff is covered by an explicit `flush_before_exec` call rather than an `exec.flush` span; the MCP policy/confirm/audit events are captured in `audit_log.rs` as structured records but not as OTel spans. These will land as a v0.17.x hygiene chip; their absence does not affect any SEC-INV invariant. See spec §4 callout box. -- **Span parent-child relationships are flat** in v0.17 — each span is started independently via `SecretEnvSpan::start(...)` without `Context::current_with_span(...).attach()`. Operators inspecting trace UIs will see all spans at the root level rather than nested. The mutation non-droppable sampler operates per span name, not via trace ancestry, so SEC-INV-22 is unaffected. Tree-shape lift deferred to v0.17.x hygiene. +### v0.16.2 - Refactor sprint (merged-not-tagged) - in progress -### v0.16.2 — Refactor sprint (merged-not-tagged) — in progress +Carries the three substantive refactors v0.16.1 deferred (each adds new public crate/subcommand surface needing an audit) plus the Copilot empty-schema fixture. -Carries the three substantive refactors that v0.16.1's hygiene cycle deferred mid-cycle because each introduces new public crate/subcommand surface that needs a real Phase 7 audit. Plus F-11 (Copilot empty-schema A/B test) operator-led fixture. +- `run_mutation` combinator (module in `secretenv-mcp`) collapses ~120 LOC of policy-gate + audit-log boilerplate across `set_alias`/`delete_alias`/`init_project`; `redact_file`/`gen_password`/`migrate_alias` kept as-is; retired `should_audit` helper. +- New `secretenv-registry-mutate` crate extracting the `list + edit + serialize + set` transaction body duplicated by `secretenv-cli` (`registry_set`/`registry_unset`) and `secretenv-mcp` (`registry_writer`); CLI + MCP keep their own selection helper but share the writer; workspace + release.yml publish-list update. +- `audit_log.rs` `flock(LOCK_EX)` around every append, size rotation at `[mcp].audit_log_max_bytes` (default 10 MiB; 0 disables) with `[mcp].audit_log_max_rotations` cap (default 5; 0 truncates), new `secretenv mcp audit tail [--lines N] [--path PATH]` subcommand. +- New `secretenv-mcp-config` crate lifting the typed `[mcp]` schema (`AllowMutations`, `ConfirmVia`, `McpConfig`, `PolicyOverrides`) out of `secretenv-mcp`; backward-compat re-exports preserve `secretenv_mcp::config::*`; workspace + release.yml publish-list update. +- Copilot empty-schema A/B test prep: operator-led fixture at `scripts/smoke-test/fixtures/vscode-mcp-copilot/`; no source-level change to `MutationApproval` in v0.16.2. -- **D.2a — `run_mutation` combinator** (module in `secretenv-mcp`). Per Phase 7 code-review High-1: collapses ~120 LOC of policy-gate + audit-log boilerplate across the `set_alias` / `delete_alias` / `init_project` handlers into one combinator call. `redact_file` / `gen_password` / `migrate_alias` kept as-is (multi-stage validation / orphan-state precision that the clean combinator shape would lose). Retired the misleading `should_audit` helper. -- **D.2b — new `secretenv-registry-mutate` crate.** Per Phase 7 architecture C-2 + code-review Medium: extracts the `list + edit + serialize + set` transaction body that `secretenv-cli` (`registry_set` / `registry_unset`) and `secretenv-mcp` (`registry_writer`) previously duplicated line-for-line. CLI + MCP each keep their own selection helper (env-aware vs named-only) but share the writer. Workspace + release.yml publish-list update. -- **D.3 — `audit_log.rs` fcntl-lock + size rotation + `secretenv mcp audit tail` subcommand.** `flock(LOCK_EX)` around every append (cross-process serialization for multi-IDE deployments). Size-based rotation at `[mcp].audit_log_max_bytes` (default 10 MiB; 0 disables) with `[mcp].audit_log_max_rotations` cap (default 5; 0 truncates). New operator subcommand `secretenv mcp audit tail [--lines N] [--path PATH]` for read-only chronological inspection of recent entries. -- **D.5 — new `secretenv-mcp-config` crate.** Per Phase 7 architecture C-1: lifts the typed `[mcp]` config schema (`AllowMutations`, `ConfirmVia`, `McpConfig`, `PolicyOverrides`) out of `secretenv-mcp` into a slim sibling crate so future consumers (e.g. a slimmer `secretenv doctor` that validates `[mcp]` without spinning up rmcp) can depend on the config types alone. Backward-compat re-exports preserve every `secretenv_mcp::config::*` path. Workspace + release.yml publish-list update. -- **F-11 Copilot empty-schema A/B test prep.** Operator-led fixture at `scripts/smoke-test/fixtures/vscode-mcp-copilot/` + runbook at `kb/wiki/runbooks/copilot-elicitation-validation.md`. No source-level change to `MutationApproval` in v0.16.2 — the runbook walks the operator through the A/B and the decision tree gates whether option (a) (`confirm: bool` no-op field) ships as default or F-11 closes as upstream-fix-only. - -Pending in v0.16.2 (this cycle): Phase 7 audit trio, Phase 8 live-backend smoke, Phase 10 PR + squash-merge. - -> Carried over to a future cycle: v0.17 OpenTelemetry (design-locked); Item 12 operator-led upstream issues with Gemini / Cline / Codex / OpenCode for `elicitation: {}` capability declaration; F-3 `[mcp].allow_cli_overrides`; F-7 `mcp_client_id` threading; M-9 / M-12 migrate dual-control reconciliation + `dry_run` audit entry; R-3 `secretenv mcp setup --check-overrides` detector. +Pending in v0.16.2: audit trio, live-backend smoke, PR + squash-merge. ## [0.16.0] - 2026-05-24 -**Headline:** `secretenv mcp serve` lands — the stdio-only Model Context Protocol server giving AI agents (Claude Code, Cursor, Cline, Gemini CLI / Code Assist, Codex, OpenCode, VS Code Copilot, Continue) structured access to the SecretEnv registry **without ever exposing a resolved secret value**. 14 MCP tools cover read, mutation, generation, and migration paths. Cross-IDE day-one support for 8 IDEs via `secretenv mcp setup --ide `. Backend total stays at **15**. - -v0.15.0 → v0.16.0: workspace unit tests **1018 → 1043** (+25 from MCP + elicitation + XDG-path + per-IDE-override + SEC-INV-20 regression coverage). Three new crates published to crates.io for the first time: `secretenv-mcp`, `secretenv-backends-init`, `secretenv-migrate`. +`secretenv mcp serve` lands: a stdio-only Model Context Protocol server giving AI agents structured registry access without exposing resolved secret values; 14 MCP tools across read/mutation/generation/migration; day-one support for 8 IDEs via `secretenv mcp setup --ide `; backend total stays at 15; workspace unit tests 1018 to 1043; three new crates published to crates.io: `secretenv-mcp`, `secretenv-backends-init`, `secretenv-migrate`. ### BREAKING - -- **(Phase 7c) `[mcp].confirm_via` default flipped from `Tty` → `Auto`.** v0.16-pre-Phase-7c default (`Tty`) deadlocked inside TUI host IDEs that own the controlling terminal in raw mode (Claude Code FINDING-4; predicted by Phase 7 security audit M-15). The new `Auto` default resolves at runtime per request: if the client declared MCP elicitation capability at the initialize handshake, use `Elicitation`; else if `stdin` is a TTY (standalone shell), use `Tty`; else refuse with a clear error pointing at remediation. The `Tty` variant remains valid as an explicit opt-in for standalone shell use. Per `kb/wiki/feedback_prelaunch_breaking_changes.md` (one bundled BREAKING in a `0.x.y` cycle is acceptable pre-public-announcement; install base for the v0.16 MCP surface is zero). -- **(Phase 7h R-4) `ConfirmVia` + `MigrateReportOutcome` enums marked `#[non_exhaustive]`.** Locks freedom to add variants in v0.16.x patches without breaking downstream `match` arms. Downstream consumers using `match` on these enums must add a `_` arm. The MCP boundary's `MigrateOutcomeEcho` mirror enum in `secretenv_mcp::boundary` is NOT `#[non_exhaustive]` (it's an output type, semver-stable for response consumers). +- `[mcp].confirm_via` default flipped `Tty` to `Auto`; `Auto` resolves per request (elicitation if declared, else TTY for standalone shell, else refuse); `Tty` remains valid as explicit opt-in. +- `ConfirmVia` + `MigrateReportOutcome` enums marked `#[non_exhaustive]`; downstream `match` must add a `_` arm; the `MigrateOutcomeEcho` mirror enum in `secretenv_mcp::boundary` is NOT `#[non_exhaustive]`. ### Added - -- **`secretenv mcp serve`** — the headline v0.16 feature. Stdio-only MCP server built on the official `rmcp` 1.7 Rust SDK. 14 tools: `getting_started`, `version_info`, `list_tools`, `redact_status`, `list_backends`, `detect_password_managers`, `doctor`, `resolve_status`, `list_aliases`, `set_alias`, `delete_alias`, `init_project`, `redact_file`, `gen_password`, `migrate_alias`. Per-tool JSON Schema; structured response shapes; per-tool description budget ≤ ~200 tokens. Operator-side knobs via `[mcp]` config: `allow_mutations`, `confirm_via`, `disabled_tools`, `mutation_log`. `disabled_tools` filters BOTH `tools/list` AND dispatch at runtime via `ToolRouter::remove_route`. Full design + per-tool spec: `docs/reference/mcp.md` + `kb/wiki/build-plan-v0.16-mcp.md`. -- **Structural no-leak surface (SEC-INV-02)** — the `secretenv-mcp` crate **structurally cannot** construct, deserialize, or serialize a `secretenv_core::Secret`. Enforcement stack: (a) `clippy.toml` bans `disallowed-types` of `Secret`/`ResolvedValue` across the crate; (b) `tests/boundary_test.rs` compile-time assertions (`Secret: !Serialize` via trybuild + per-tool response-struct exhaustiveness checks banning the field names `value`, `secret`, `password`, `token`, `raw`); (c) Phase 8 live-smoke value-grep against the section-35 fixture sentinel. The Cargo feature `value-access` (additive — landed v0.15) is documentation, not the structural guarantee: workspace feature unification means `secretenv-cli`'s opt-in flips the bit for the in-workspace build; the three CI gates above are what hold the line. Two documented escape-hatch modules — `internal/gen_engine.rs` (gen_password CSPRNG path) + `internal/redact_file.rs` (redact_file tainted-set build) — name `Secret` under `#[allow(clippy::disallowed_types)]` with operator-facing justification. -- **MCP elicitation primitive (Phase 7c)** — `ConfirmVia::Elicitation` + `ConfirmVia::Auto` variants. The elicitation surface uses MCP's native server→client elicit RPC; the IDE renders a confirmation modal in its own UI. Replaces the prior `/dev/tty` mechanism which deadlocked TUI host IDEs (FINDING-4). Empty-schema `MutationApproval{}` with hand-written `JsonSchema` impl emitting explicit `{"type":"object","properties":{},"additionalProperties":false}` (schemars 1.0's derive omits `properties` for fieldless structs; the MCP elicitation validator requires it present even when empty). Single-click decision per mutation — no checkbox to tick. -- **`secretenv mcp serve --allow-mutations ` + `--confirm-via ` CLI flags (Phase 7f)** — per-launch policy overrides on top of `[mcp]` config. Operators scope the override to a specific IDE's `mcpServers` args block rather than weakening their global config. Public `PolicyOverrides` struct + `serve_with_overrides()` entry point exposed from `secretenv_mcp` for embedders. Override application is logged via `tracing::info!` for observability. -- **`secretenv mcp setup --ide `** — per-IDE config-snippet helper covering 8 IDEs (Claude Code, Cursor, Codex, VS Code Copilot, Continue, Cline, Gemini Code Assist + Gemini CLI, OpenCode) plus a `generic` profile for any IDE adopting the de-facto Claude `mcpServers` shape. Print-by-default (paste-ready); `--write` mode (refuses if target exists unless `--force`). Profiles for IDEs that don't fully advertise MCP elicitation (Gemini, OpenCode, Cursor speculative, Continue speculative) ship with `--allow-mutations=always` baked into the args[] per Phase 7f override pattern — operators can remove the flag if their IDE adds elicitation support. Claude Code profile emits `claude mcp add` shell command rather than overwriting `~/.claude.json` directly (the file contains unrelated Claude Code state). -- **`secretenv mcp disable` / `secretenv mcp enable`** — toggle the persistent disable sentinel at `$XDG_CONFIG_HOME/secretenv/mcp-disabled` (or the platform-native equivalent). `disable --duration ` for time-limited disables; sentinel honored by every `mcp serve` invocation. -- **Mutation audit log** — JSON-Lines append-only log at `$XDG_STATE_HOME/secretenv/mcp-mutations.log` (or platform equivalent) capturing `(ts, tool_name, alias_name, backend_instance, agent_reason, operator_decision, mcp_client_id)` for every mutation tool call regardless of decision. `agent_reason` is recorded verbatim per SEC-INV-12 (never echoed back in JSON-RPC response, never set as OTel span attribute; operator-facing surfaces — TTY prompt, elicitation modal body — MAY render it). -- **XDG config path support on macOS (Phase 7d)** — `secretenv` now honors `$XDG_CONFIG_HOME` AND `~/.config/secretenv/config.toml` on macOS, not just `~/Library/Application Support/secretenv/`. Precedence: `$XDG_CONFIG_HOME` → `~/.config/secretenv/` → platform-native. Unblocks dotfiles workflows (stow / chezmoi / yadm). Phase 9 audit FINDING-F-1 added a stderr warning when `XDG_CONFIG_HOME` redirects config away from platform default, defending against hostile `.envrc` silent-redirect threats. -- **`secretenv-mcp`, `secretenv-backends-init`, `secretenv-migrate` published to crates.io for the first time.** External embedders can now `cargo add secretenv-mcp` to spawn an MCP server in their own binary; `secretenv-migrate` exposes the migrate engine library lifted out of the CLI binary (v0.15 carry-forward closed). Stable public surface documented at crate-level rustdoc; internal-but-`pub` modules marked `#[doc(hidden)]` for clarity. +- `secretenv mcp serve`: stdio-only MCP server on `rmcp` 1.7; 14 tools (`getting_started`, `version_info`, `list_tools`, `redact_status`, `list_backends`, `detect_password_managers`, `doctor`, `resolve_status`, `list_aliases`, `set_alias`, `delete_alias`, `init_project`, `redact_file`, `gen_password`, `migrate_alias`); per-tool JSON Schema; per-tool description budget <=~200 tokens; `[mcp]` config knobs `allow_mutations`, `confirm_via`, `disabled_tools`, `mutation_log`; `disabled_tools` filters both `tools/list` and dispatch via `ToolRouter::remove_route`. +- Structural no-leak surface: `secretenv-mcp` cannot construct/serialize/deserialize `secretenv_core::Secret`; enforced by `clippy.toml` `disallowed-types` ban, `tests/boundary_test.rs` compile-time assertions (trybuild `Secret: !Serialize` + response-struct field-name bans on `value`/`secret`/`password`/`token`/`raw`), and live-smoke value-grep; two escape-hatch modules `internal/gen_engine.rs` + `internal/redact_file.rs` named under `#[allow(clippy::disallowed_types)]`. +- MCP elicitation primitive: `ConfirmVia::Elicitation` + `ConfirmVia::Auto` using MCP's native server-to-client elicit RPC; empty-schema `MutationApproval{}` with hand-written `JsonSchema` impl emitting `{"type":"object","properties":{},"additionalProperties":false}`; single-click decision per mutation. +- `secretenv mcp serve --allow-mutations ` + `--confirm-via ` flags: per-launch policy overrides; public `PolicyOverrides` struct + `serve_with_overrides()` entry point; override application logged via `tracing::info!`. +- `secretenv mcp setup --ide `: per-IDE config-snippet helper for 8 IDEs (Claude Code, Cursor, Codex, VS Code Copilot, Continue, Cline, Gemini Code Assist + Gemini CLI, OpenCode) plus a `generic` profile; print-by-default, `--write` mode (refuses if target exists unless `--force`); IDEs lacking elicitation ship `--allow-mutations=always` baked into args[]; Claude Code profile emits `claude mcp add` rather than overwriting `~/.claude.json`. +- `secretenv mcp disable` / `secretenv mcp enable`: toggle persistent disable sentinel at `$XDG_CONFIG_HOME/secretenv/mcp-disabled`; `disable --duration ` for time-limited disables. +- Mutation audit log: JSON-Lines append-only at `$XDG_STATE_HOME/secretenv/mcp-mutations.log` capturing `(ts, tool_name, alias_name, backend_instance, agent_reason, operator_decision, mcp_client_id)` for every mutation regardless of decision; `agent_reason` recorded verbatim, never in JSON-RPC response or OTel. +- XDG config path support on macOS: honors `$XDG_CONFIG_HOME` and `~/.config/secretenv/config.toml`; precedence `$XDG_CONFIG_HOME` to `~/.config/secretenv/` to platform-native; added a stderr warning when `XDG_CONFIG_HOME` redirects config away from platform default. +- `secretenv-mcp`, `secretenv-backends-init`, `secretenv-migrate` published to crates.io for the first time; internal-but-`pub` modules marked `#[doc(hidden)]`. ### Changed - -- **Migrate engine extracted to `secretenv-migrate` library crate (Phase 1a)** — was previously a private module in the `secretenv-cli` binary. Both the CLI's `secretenv registry migrate` subcommand and the new MCP `migrate_alias` tool consume the library crate. v0.15 audit B1 carry-forward closed. -- **`secretenv-backends-init` crate extracted (Phase 3f)** — shared factory wiring for `BackendRegistry::load_from_config()`. Consumed by both `secretenv-cli` and `secretenv-mcp` so the binary and the MCP server build registries via the same code path. -- **CLI `--config` help text rewritten** to reflect the new XDG-aware precedence on every platform (FINDING-2 fix). +- Migrate engine extracted to `secretenv-migrate` library crate; consumed by CLI `secretenv registry migrate` and MCP `migrate_alias`. +- `secretenv-backends-init` crate extracted: shared `BackendRegistry::load_from_config()` factory wiring consumed by CLI + MCP. +- CLI `--config` help text rewritten for XDG-aware precedence. ### Fixed - -- **(SEC-INV-20, Phase 7b) Backend URIs no longer appear in `Err::Display` paths** that flow into MCP `error_message` response fields. Source-side cleanup of every `with_context(|| format!(... `{uri}`))` callsite in `secretenv-mcp::tools::registry_writer` + `secretenv-mcp::internal::redact_file` (registry-name + alias-name only — URI bodies dropped). New `secretenv_mcp::error::safe_error_message(&anyhow::Error) -> String` scrubber as defense-in-depth: walks the chain, rewrites `scheme://body` → `scheme://[redacted]`. All 18 (later 25 after Phase 7c additions; Phase 7h closed the remaining 7 sites) `format!("{e:#}")` callsites feeding `error_message` swapped to `safe_error_message(&e)`. Static `no_raw_anyhow_format_in_tool_module` regression guard catches future re-introduction at compile time. 12 end-to-end JSON-serialization regression tests in `tests/uri_not_in_error_message.rs` lock in the SEC-INV-20 fix. -- **(Phase 7b) `secretenv_mcp::policy::sanitize_for_tty()` strips C0/C1/DEL control characters** from agent-controlled fragments before they reach the operator-facing confirmation surface (TTY prompt OR elicitation modal body). Closes the terminal-injection / fake-approval spoofing attack (hostile alias = `"OK\n\r[secretenv mcp] Approve? [Y/n] "`). Bidi-control sanitization (RTL override, ZWJ, etc.) deferred to v0.16.1 per Phase 9 F-2. -- **(Phase 7d) `default_config_path()` Linux CI dedup bug** — on Ubuntu CI `XDG_CONFIG_HOME=/home/runner/.config` and `$HOME/.config/secretenv/...` resolve to the same path; the original dedup only checked `native` against the candidate list, not the XDG/HOME pair. `8d493ca` introduced symmetric dedup via a `push_unique` closure. -- **(Phase 7e + 7e-fix) Single-click elicitation UX** — initial empty-schema attempt (`a7add8f`) shipped broken because schemars 1.0 derive emits `{"type":"object"}` with no `properties` key, which the MCP elicitation validator rejects. Hand-written `JsonSchema` impl for `MutationApproval{}` emits explicit `{"type":"object","properties":{},"additionalProperties":false}`. Operator gets Accept/Decline/Cancel buttons with no extra form field to tick. +- Backend URIs no longer in `Err::Display` paths feeding MCP `error_message`; source-side cleanup of `with_context` callsites in `secretenv-mcp::tools::registry_writer` + `internal::redact_file`; new `secretenv_mcp::error::safe_error_message(&anyhow::Error) -> String` scrubber rewriting `scheme://body` to `scheme://[redacted]`; all 25 `format!("{e:#}")` callsites swapped; `no_raw_anyhow_format_in_tool_module` compile-time guard; 12 regression tests in `tests/uri_not_in_error_message.rs`. +- `secretenv_mcp::policy::sanitize_for_tty()` strips C0/C1/DEL control characters from agent-controlled fragments before the operator-facing confirmation surface; bidi-control sanitization deferred to v0.16.1. +- `default_config_path()` Linux CI dedup bug where `XDG_CONFIG_HOME` and `$HOME/.config/secretenv/...` resolved to the same path; `8d493ca` added symmetric dedup via a `push_unique` closure. +- Single-click elicitation UX: initial empty-schema attempt (`a7add8f`) rejected by the validator; hand-written `JsonSchema` impl for `MutationApproval{}` yields Accept/Decline/Cancel with no form field. ### Security - -- **SEC-INV-02 (no `Secret` in `secretenv-mcp`):** structurally enforced. See "Added → Structural no-leak surface" above for the three-gate stack. -- **SEC-INV-12 (agent_reason audit-only):** wording refined in Phase 7h to acknowledge operator-facing surfaces (TTY prompt body, elicitation modal body) MAY render `agent_reason`. The protocol-level boundary (server → agent's tool-result payload, OTel span attributes) is unchanged: `agent_reason` is NEVER in either. -- **SEC-INV-15 (no value bytes in `gen_password` response):** `GenPasswordResponse` field set has no value-bearing field; rejection-sampling correctness verified for non-power-of-two charsets; `Zeroizing>` for raw entropy; explicit `drop(value)` after `Backend::set`. -- **SEC-INV-20 (URIs not in `Err::Display`):** see "Fixed → Phase 7b" above. Two-layer defense (source-side + scrubber) + compile-time regression guard. -- **`policy::resolve_confirm_via()` resolution order is LOAD-BEARING.** The priority chain (elicitation → tty → refuse) is documented as immutable without a new security audit; reordering would re-introduce Phase 7 FINDING-4 deadlocks. Rustdoc on the function lists the security justification per step. -- **macOS code-signing in release.yml (FINDING-5):** the release pipeline now ad-hoc-signs the macOS binary (`codesign --sign -`) between `strip` and tarball-package. Before this fix every Homebrew + manual-tarball installer on macOS would hit SIGKILL on the first `secretenv --version` invocation. Full Apple Developer ID notarization remains a post-v0.16 release-engineering upgrade. -- **`#[non_exhaustive]` on `ConfirmVia` + `MigrateReportOutcome`** (BREAKING, see above). Semver-freedom for future variants AND a defensive forcing-function for downstream consumers to think about unknown variants in `match` arms. -- **Public-API hygiene:** `secretenv-mcp` crate-level rustdoc lists the stable public surface (`serve`, `serve_with_overrides`, `disable`/`enable`/`disable_sentinel_path`, `PolicyOverrides`, `AllowMutations`, `ConfirmVia`); internal-but-`pub` modules (`tools`, `boundary`, `policy`, `internal`, `setup`, `audit_log`, `config`, `error`) are `#[doc(hidden)]` — their shapes are NOT covered by semver guarantees for external embedders. +- No `Secret` in `secretenv-mcp`: structurally enforced via the three-gate stack. +- `agent_reason` is audit-only: wording refined to allow operator-facing surfaces (TTY prompt, elicitation modal) to render it; never in tool-result payload or OTel. +- No value bytes in `gen_password` response: `GenPasswordResponse` has no value-bearing field; rejection-sampling verified for non-power-of-two charsets; `Zeroizing>` for raw entropy; explicit `drop(value)` after `Backend::set`. +- Backend URIs not in `Err::Display`: two-layer defense plus compile-time regression guard. +- `policy::resolve_confirm_via()` resolution order (elicitation, tty, refuse) is load-bearing and immutable without a new audit; rustdoc lists the justification. +- macOS code-signing in release.yml: pipeline now ad-hoc-signs the macOS binary (`codesign --sign -`) between strip and tarball-package, fixing SIGKILL on first invocation; full Developer ID notarization is post-v0.16. +- `#[non_exhaustive]` on `ConfirmVia` + `MigrateReportOutcome` (BREAKING, above). +- Public-API hygiene: `secretenv-mcp` rustdoc lists the stable surface (`serve`, `serve_with_overrides`, `disable`/`enable`/`disable_sentinel_path`, `PolicyOverrides`, `AllowMutations`, `ConfirmVia`); internal `pub` modules `#[doc(hidden)]`. ### Known limitations - -- **(Phase 8b F-9/F-12/F-13/F-16) Only Claude Code has working MCP elicitation in v0.16.** Verified empirically against 6 mainstream IDEs: Gemini CLI, Cline, Codex, OpenCode all fail to advertise the MCP elicitation capability at the initialize handshake. VS Code Copilot advertises it but doesn't render our empty-schema requests (F-11 hypothesis: Copilot may only render schemas with form fields). Per-IDE `--allow-mutations=always` argv override (Phase 7f) is the v0.16 mitigation — surfaced in `secretenv mcp setup --ide ` helper output for affected IDEs by default. The override skips the per-mutation confirmation gate but the audit log still captures every mutation with `autoapproved` decision. Upstream PRs to Gemini / Cline / Codex / OpenCode requesting they declare `elicitation: {}` in their `registerCapabilities` calls are queued for the v0.16.1 hygiene cycle. F-11 (VS Code Copilot empty-schema) investigation also queued — a `ConfirmVia::ElicitationWithDummy` variant adding a no-op form field could potentially unlock Copilot specifically. -- **(Phase 9 F-3) Per-IDE `--allow-mutations=always` argv override has no user-scope opt-out.** A workspace-trusted `.mcp.json` (e.g. checked into a hostile repo) can silently weaken the operator's global `[mcp].allow_mutations = "confirm"` setting. Three mitigating factors: IDE-side workspace-trust prompts (VS Code Copilot, Claude Code's `.mcp.json`) catch most cases at registration time; the audit log captures every mutation regardless; the override surface is ONLY for `[mcp].allow_mutations` + `[mcp].confirm_via` (not backend instances or registries). v0.17 carry-forward will add `[mcp].allow_cli_overrides = false` config knob (opt-in to honor argv overrides). -- **(Phase 7 audit + Phase 9 R-1) `tools/mod.rs` is ~1700 LOC with ~400 LOC of duplicated 4-arm policy-gate match boilerplate across 6 mutation handlers.** Phase 7 audit's `run_mutation` combinator refactor (consolidating the gate + audit-log boilerplate into a single helper) carries forward to v0.16.1 hygiene cycle. The duplication is mechanical to factor out; deferred per `kb/wiki/feedback_pr_scoping_hygiene_carrier.md` to keep the feature-cycle PR scoped. -- **(Phase 7 audit + Phase 9 F-7) `mcp_client_id` hardcoded `"unknown"` in audit log entries.** The rmcp `initialize` handshake's `clientInfo` is available but not yet threaded through to `MutationLogEntry`. Audit log loses ability to attribute mutations to Claude Code vs Cursor vs Codex etc. Mitigated by: every mutation logs the launching command-line argv (in `tracing::info!`) so post-incident reconstruction is possible. v0.17 fix. -- **(Phase 7 audit M-7, M-9, M-12) Additional security carry-forwards:** TTY TOCTOU between prompt-write and response-read (M-7, queued for v0.16.1); migrate dual-control collapsed to single confirmation (M-9, design change for v0.17); migrate `dry_run=true` skips policy gate entirely (M-12, can be used as topology reconnaissance — v0.17 add per-call audit-log entry even for dry-run). -- **(Phase 8b F-8) `secretenv mcp setup --ide --write` has no merge logic.** For IDEs with existing settings.json content (Gemini, Cline, Continue), operator must use `jq '. * {...}'` to merge manually. `--merge` flag queued for v0.16.1 hygiene. -- **(Phase 8b F-15) Schemars 1.0 `"format": "uint"` validation noise.** OpenCode + likely other strict MCP clients emit non-blocking validation warnings for our response schemas. Cosmetic noise only — no functional impact. v0.16.1 cleanup. -- **(Phase 7c F-4 — operator focus default)** Claude Code elicitation modal defaults focus on the "Accept" button. Spacebar/Enter accepts. Easy mis-approval — operator must visually confirm before pressing. v0.16.1 carry-forward: investigate `confirm_via = "elicitation-default-deny"` variant that requests the client surface the deny option as default. +- Only Claude Code has working MCP elicitation in v0.16; Gemini CLI, Cline, Codex, OpenCode fail to advertise the capability, VS Code Copilot advertises but does not render empty-schema requests; per-IDE `--allow-mutations=always` argv override is the mitigation (audit log still records `autoapproved`); upstream PRs + Copilot empty-schema investigation queued for v0.16.1. +- Per-IDE `--allow-mutations=always` override has no user-scope opt-out; a hostile `.mcp.json` can weaken `[mcp].allow_mutations = "confirm"`; mitigated by IDE workspace-trust prompts, audit log, and override scope limited to `allow_mutations`/`confirm_via`; v0.17 adds `[mcp].allow_cli_overrides = false`. +- `tools/mod.rs` is ~1700 LOC with ~400 LOC of duplicated policy-gate boilerplate across 6 mutation handlers; `run_mutation` combinator refactor deferred to v0.16.1. +- `mcp_client_id` hardcoded `"unknown"` in audit entries; `clientInfo` not yet threaded to `MutationLogEntry`; mitigated by logging launch argv; v0.17 fix. +- TTY TOCTOU between prompt-write and response-read (v0.16.1); migrate dual-control collapsed to single confirmation (v0.17); migrate `dry_run=true` skips policy gate (v0.17 adds per-call audit entry for dry-run). +- `secretenv mcp setup --ide --write` has no merge logic; operator must `jq` merge manually; `--merge` flag queued for v0.16.1. +- Schemars 1.0 `"format": "uint"` validation noise on strict MCP clients; cosmetic only; v0.16.1 cleanup. +- Claude Code elicitation modal defaults focus on "Accept"; easy mis-approval; v0.16.1 investigates a `confirm_via = "elicitation-default-deny"` variant. ## [0.15.0] - 2026-05-20 -> v0.14.x hygiene cycle (merged-not-tagged) absorbed into v0.15.0 on tag. +The headline `secretenv registry migrate` command plus 5 additive `Backend` trait methods land; the v0.14.x hygiene cycle (merged-not-tagged) was absorbed into v0.15.0 on tag. ### BREAKING - -v0.15.0 Phase 0 lands a bundled BREAKING block — three architectural follow-ups from the v0.14 Phase 9b architect-reviewer audit ([[v0.14-issues/04-v0.15-architectural-followups]] arch-H1/H2/H3). Per [[feedback_prelaunch_breaking_changes]], one bundled BREAKING in a `0.x.y` cycle is acceptable pre-public-announcement; v0.15 honors this by bundling all three into one CHANGELOG block (mirroring v0.14's Q-O1 a/b/c bundle). - -- **(arch-H1) `Backend::serialize_registry_doc` + `Backend::deserialize_registry_doc` move off the trait** to free functions over a new `RegistryFormat::{Json, Toml}` enum. Backends now declare their wire format via the new `Backend::registry_format(&self) -> RegistryFormat` trait method (default `Json`); `local` and `1password` override to `Toml`. Wire-format encode/decode is the responsibility of the format enum, not the backend — v0.14's "default-with-override" trait method was an over-fit since the format selection is purely about the wire representation. External backend plugins must (a) remove their `serialize_registry_doc`/`deserialize_registry_doc` overrides and (b) override `registry_format()` if they don't use the JSON default. -- **(arch-H2) `mcp-safe` Cargo feature polarity flipped to additive `value-access`.** v0.14's subtractive `mcp-safe` was a Cargo anti-pattern (feature unification across the dep graph). v0.15 inverts: default features are now `[]` (the safe surface — what `mcp-safe` enabled at v0.14), and the new `value-access` feature gates `expose_secret`, the `Backend` re-export, the `runner::*` re-exports, and `EnvEntry::value()`. The workspace-level `[workspace.dependencies]` for `secretenv-core` enables `features = ["value-access"]` so every workspace consumer keeps today's behaviour without per-crate Cargo.toml changes. External consumers must: - - `default-features = false` on their `secretenv-core` dep to get the safe (no-value-access) surface — formerly: `features = ["mcp-safe"]`. - - `features = ["value-access"]` to keep value-producing APIs — formerly: omitting `mcp-safe`. -- **(arch-H3) `pub mod runner` is now cfg-gated under the `value-access` feature.** v0.14 left the module unconditionally `pub`, so downstream crates could `use secretenv_core::runner::{...}` to bypass the re-export gate. v0.15 closes the bypass at the module declaration site; reaching `runner::*` from a no-`value-access` consumer is now a compile error, not a doc-only convention. +- `Backend::serialize_registry_doc` + `Backend::deserialize_registry_doc` move off the trait to free functions over a new `RegistryFormat::{Json, Toml}` enum; backends declare wire format via `Backend::registry_format(&self) -> RegistryFormat` (default `Json`; `local` and `1password` override to `Toml`); external plugins must remove their overrides and override `registry_format()` if not JSON. +- `mcp-safe` Cargo feature polarity flipped to additive `value-access`; default features now `[]` (the safe surface), `value-access` gates `expose_secret`, the `Backend` re-export, `runner::*` re-exports, and `EnvEntry::value()`; workspace deps enable `value-access` so workspace consumers are unchanged; external consumers set `default-features = false` for the safe surface or `features = ["value-access"]` for value APIs. +- `pub mod runner` is now cfg-gated under `value-access`; reaching `runner::*` from a no-`value-access` consumer is a compile error. ### Added - -- **`secretenv registry migrate `** — the headline v0.15 feature. Migrates an alias's secret value from one backend to another in a single operation: read from the source, write to the destination, then atomically flip the registry pointer. No consuming repo touches a backend URI, so a migration never requires a code change. Flags: `--dry-run` (probe + plan, zero mutation), `--yes` (skip the top-level prompt), `--from ` (override the inferred source for recovery flows), `--delete-source` (opt-in source cleanup, separately confirmed even under `--yes`), `--json` (machine-readable `MigrateReport` for CI), `--registry ` (registry selection). The source value is **kept by default**; partial failures **never auto-roll-back by deletion** — the operator is given the manual recovery commands. Full reference: `docs/reference/migrate.md`. -- **Five new `Backend` trait methods**, all additive with default impls so existing backends and external plugins compile unchanged: - - `write_secret(&self, &BackendUri, &Secret)` — the migrate destination-write path; takes the value by `&Secret` reference (borrow-not-clone). Default returns the typed `BackendError::WriteNotSupported`. 12 `Native` backends override with a passthrough; 3 `Gated` backends (`1password`, `keeper`, `bitwarden-sm`) refuse unless their `*_unsafe_set` config flag is set. - - `delete_secret(&self, &BackendUri)` — the opt-in `--delete-source` cleanup leg. Default returns `BackendError::DeleteNotSupported`. Same Native/Gated split as `write_secret`. - - `probe_write(&self, &BackendUri)` + `has_probe_write(&self) -> bool` — the `--dry-run` write-permission probe. Default is a no-op (`Ok(())`) with `has_probe_write() == false`; HashiCorp Vault overrides with a real `vault token capabilities` probe. - - `delete_hint(&self, &BackendUri) -> String` — a backend-native copy-paste cleanup command surfaced in the migrate success message. Terminal-only; never crosses the JSON / MCP / OTel boundaries. -- **`secretenv_core::BackendError`** — a new typed error enum (`#[non_exhaustive]`) with variants `WriteNotSupported` and `DeleteNotSupported`, letting the migrate handler dispatch structurally instead of string-matching `anyhow::Error` context. -- **Migrate telemetry surface** in `secretenv-telemetry::SecretEnvSpan` — six new `record_migrate_*` typed-attribute methods (`record_migrate_phase`, `record_migrate_outcome`, `record_migrate_source_backend_type`, `record_migrate_dest_backend_type`, `record_migrate_delete_source`, `record_migrate_transaction_id`) plus two new closed enums (`MigratePhase`, `MigrateOutcome`). The `RedactionPolicy` canonical matrix gains 11 new rows for migrate attributes — 7 ALLOW, 4 DENY. The migrated value, alias name, source/dest URIs, and source/dest backend instance names are all explicit DENY rows; only the backend TYPE strings, the phase/outcome enums, the `--delete-source` flag value, and the transaction id are ALLOW. - -> **Backward-compatibility note:** every trait method above ships with a default implementation that preserves v0.14 behaviour. A backend that does not override `write_secret`/`delete_secret`/`probe_write`/`delete_hint` simply cannot be a migrate destination/source — it is not a compile break. `BackendError` is `#[non_exhaustive]`, so future variants land additively. The v0.15 BREAKING surface is confined entirely to the Phase 0 block above (arch-H1/H2/H3); the migrate feature itself is purely additive. +- `secretenv registry migrate `: reads from source, writes to destination, atomically flips the registry pointer; no consuming repo touches a backend URI; flags `--dry-run`, `--yes`, `--from `, `--delete-source` (separately confirmed even under `--yes`), `--json` (`MigrateReport`), `--registry `; source kept by default; partial failures never auto-roll-back by deletion (manual recovery commands given). +- Five new additive `Backend` trait methods (default impls preserve v0.14 behavior): + - `write_secret(&self, &BackendUri, &Secret)` (borrow-not-clone); default `BackendError::WriteNotSupported`; 12 Native backends override with passthrough, 3 Gated (`1password`, `keeper`, `bitwarden-sm`) refuse unless their `*_unsafe_set` flag is set. + - `delete_secret(&self, &BackendUri)` for `--delete-source`; default `BackendError::DeleteNotSupported`; same Native/Gated split. + - `probe_write(&self, &BackendUri)` + `has_probe_write(&self) -> bool` for the `--dry-run` write-permission probe; default no-op with `has_probe_write() == false`; HashiCorp Vault overrides with a real `vault token capabilities` probe. + - `delete_hint(&self, &BackendUri) -> String`: backend-native cleanup command in the success message; terminal-only, never crosses JSON/MCP/OTel. +- `secretenv_core::BackendError`: new `#[non_exhaustive]` error enum with `WriteNotSupported` and `DeleteNotSupported` for structural dispatch. +- Migrate telemetry surface in `secretenv-telemetry::SecretEnvSpan`: six new `record_migrate_*` methods (`record_migrate_phase`, `record_migrate_outcome`, `record_migrate_source_backend_type`, `record_migrate_dest_backend_type`, `record_migrate_delete_source`, `record_migrate_transaction_id`) plus closed enums `MigratePhase`, `MigrateOutcome`; `RedactionPolicy` matrix gains 11 migrate rows (7 ALLOW, 4 DENY); migrated value, alias name, source/dest URIs, and source/dest instance names are DENY. ### Changed - -- CI: the trybuild compile-fail harness renamed from `mcp_safe_trybuild` to `value_access_trybuild`; ui fixtures dir from `mcp_safe_ui/` to `value_access_ui/`. The job now invokes `cargo test -p secretenv-core --no-default-features --test value_access_trybuild` (was `--features mcp-safe --test mcp_safe_trybuild`). Same load-bearing assertion: value-producing APIs do not compile on the SAFE surface. -- **Code-hygiene polish** absorbing Phase 7/9/9b code-reviewer LOW chips: - - `refuse_special_paths` now scans the first `Normal` path component, catching relative `proc/foo` / `./proc/foo` inputs (was bounded to `components[1]` which only matched absolute paths). (Code-hygiene chip.) - - `Scrubber::pattern_len` documents the Aho-Corasick `pat_id ∈ [0, num_patterns)` invariant + the `pub(crate)` scope that upholds it. +- CI: trybuild harness renamed `mcp_safe_trybuild` to `value_access_trybuild`; ui fixtures `mcp_safe_ui/` to `value_access_ui/`; job now runs `cargo test -p secretenv-core --no-default-features --test value_access_trybuild`. +- Code hygiene polish: + - `refuse_special_paths` now scans the first `Normal` path component, catching relative `proc/foo` / `./proc/foo` (was bounded to `components[1]`). + - `Scrubber::pattern_len` documents the Aho-Corasick `pat_id in [0, num_patterns)` invariant + `pub(crate)` scope. - `aggregate_errors` documents the non-empty input precondition. - - `SpanGuard._private: ()` documented as the sealed-construction marker (kept, not removed). - - `RedactionPolicy` derives `Copy` (was `Clone` only); the type wraps a `&'static` slice and is trivially `Copy`. - - Stale `v0.3 TODO` block in `secretenv-backend-aws-secrets/src/lib.rs` rewritten as the current "open follow-ups" view. - - Off-by-one regression test added: `streaming_accepts_pattern_at_exact_tail_window` covers `pattern_len == MODE_A_TAIL_WINDOW` (the previous suite only covered `>`). - - `tracing` dep in `secretenv-telemetry/Cargo.toml` documented as the anchor for v0.17's planned `tracing::Subscriber` impl (avoiding a remove-then-readd churn). - - `runner.rs::inject_env_entries` helper extracts the three identical env-injection loops (tokio pipe-redact, unix `exec()`, non-unix `spawn()`). - - `CHANGELOG.md` header documents the project-specific `Known limitations` subsection convention introduced in v0.14.0. + - `SpanGuard._private: ()` documented as the sealed-construction marker. + - `RedactionPolicy` derives `Copy` (was `Clone` only). + - Stale `v0.3 TODO` in `secretenv-backend-aws-secrets/src/lib.rs` rewritten as current open follow-ups. + - Off-by-one regression test `streaming_accepts_pattern_at_exact_tail_window` covers `pattern_len == MODE_A_TAIL_WINDOW`. + - `tracing` dep in `secretenv-telemetry/Cargo.toml` documented as the v0.17 `tracing::Subscriber` anchor. + - `runner.rs::inject_env_entries` extracts the three identical env-injection loops (tokio pipe-redact, unix `exec()`, non-unix `spawn()`). + - `CHANGELOG.md` header documents the `Known limitations` subsection convention. ### Security - -- **Defense-in-depth: `TaintedValue.bytes` now `Zeroizing>`** (`crates/secretenv-core/src/redact/mod.rs`). End-of-run drop scrubs plaintext bytes from the heap rather than leaving them dangling until the allocator reuses the slot. Aho-Corasick's own automaton still retains the patterns for its lifetime — Zeroizing here is strictly the operator-controlled half. (v0.14.x DiD chip M1.) -- **Alias-name skip notice moved from `tracing::warn!` to `eprintln!`** (`crates/secretenv-core/src/redact/mod.rs`). SEC-INV-19 carve-out: alias names are DENY for OTel because they fingerprint resolved values; this notice MUST stay operator-local stderr so a future `tracing::Subscriber → OTel` adapter cannot route it to a shared trace surface. (DiD chip M2.) -- **`scripts/check_tracing_leaks.sh` extended.** Adds coverage for `event!(Level::..)`, `Span::current().record("value", ...)`, and bare `warn!`/`info!`/`error!` macros after `use tracing::*;`; tightens the `value = ...` structured-field check by requiring `?`/`%` sigils, eliminating false positives on unrelated fields named `value`. (DiD chip M3.) -- **`secretenv run --help` now documents Mode A limits** including the `/dev/tty` escape, `syslog`/`journald`, `mmap`, core dumps, and SDK re-fetch — parity with `docs/security.md`. (DiD chip M4.) -- **`forward_signals_to` adds `SIGQUIT` + `SIGUSR1` + `SIGUSR2`** (`crates/secretenv-core/src/runner.rs`). The child's own quit handler runs on Ctrl-\, and children that use SIGUSR1/2 for runtime control (logrotate, nginx reload) receive them when the parent does. (DiD chip L2.) -- **`RedactionEvent::for_otel()` projection** (`crates/secretenv-telemetry/src/event.rs`). Strips `alias_name` (DENY per SEC-INV-19) before emission to any non-operator-terminal destination; OTel sinks at v0.17 MUST use this projection. (DiD chip L4.) -- **`SECRETENV_*` prefix wildcard env scrub** (`crates/secretenv-core/src/runner.rs::scrub_secretenv_env`). The explicit `RESERVED_ENV_VARS` denylist is retained as belt-and-braces; the prefix scrub closes the regression window where a future `SECRETENV_TOKEN`-style const is added to the codebase without being added to the explicit list. (DiD chip L5.) -- **Backup-path setuid mask documented** (`crates/secretenv-core/src/redact/mod.rs::write_backup_secure`). The existing `& 0o777` mask is correct — it drops setuid / setgid / sticky bits from the source — but the invariant was undocumented; a future maintainer might widen the mask without knowing the security commitment. Added an inline comment naming the chip. (DiD chip L6.) -- **`EnvEntry.alias_name` doc tightened with SEC-INV-19 reference** (`crates/secretenv-core/src/runner.rs`). Field stays `Option` (not `Secret`) per L1 chip's own recommendation; future leak vectors must project away the alias via `RedactionEvent::for_otel`. +- `TaintedValue.bytes` now `Zeroizing>` (`crates/secretenv-core/src/redact/mod.rs`); end-of-run drop scrubs plaintext heap bytes. +- Alias-name skip notice moved from `tracing::warn!` to `eprintln!` (`redact/mod.rs`); keeps it operator-local stderr. +- `scripts/check_tracing_leaks.sh` extended for `event!(Level::..)`, `Span::current().record("value", ...)`, and bare `warn!`/`info!`/`error!` after `use tracing::*;`; tightens the `value = ...` check to require `?`/`%` sigils. +- `secretenv run --help` documents Mode A limits: `/dev/tty` escape, `syslog`/`journald`, `mmap`, core dumps, SDK re-fetch. +- `forward_signals_to` adds `SIGQUIT` + `SIGUSR1` + `SIGUSR2` (`runner.rs`). +- `RedactionEvent::for_otel()` projection (`crates/secretenv-telemetry/src/event.rs`) strips `alias_name` before non-terminal emission; OTel sinks at v0.17 must use it. +- `SECRETENV_*` prefix wildcard env scrub (`runner.rs::scrub_secretenv_env`); explicit `RESERVED_ENV_VARS` denylist retained as belt-and-braces. +- Backup-path setuid mask documented (`redact/mod.rs::write_backup_secure`); existing `& 0o777` mask drops setuid/setgid/sticky bits. +- `EnvEntry.alias_name` doc tightened (`runner.rs`); field stays `Option`; future leak vectors must project away via `RedactionEvent::for_otel`. ### CI - -- **`rust-toolchain.toml` pinned to `1.95.0`** (was floating `stable`). Symmetric with CI's `dtolnay/rust-toolchain@stable` (which honors the project pin), eliminating red CI on every rust point-release for new clippy lints + trybuild fixture text drift. Bump is its own chore per the new runbook at `kb/wiki/runbooks/rust-toolchain-bump.md`; `CONTRIBUTING.md` references the runbook. (Issue #03.) +- `rust-toolchain.toml` pinned to `1.95.0` (was floating `stable`); symmetric with CI's `dtolnay/rust-toolchain@stable`; eliminates red CI on rust point-releases; bump is its own chore (Issue #03). ### Known limitations - -- **(SEC-INV-23) Registry-document read-modify-write is not atomic in v0.15.** Both `secretenv registry set` and the new `secretenv registry migrate` pointer-flip phase implement document mutation as `Backend::list(...)` → mutate the in-memory `BTreeMap` → `Backend::set(...)`. None of the 15 backends carry CAS / If-Match / version-stamp plumbing today, so concurrent registry mutations on the same instance can clobber each other (classic lost-update race). The window is short (one round-trip) and the surface area is operator-driven (registry mutations are rare events), so this is shipping as a documented limitation. Mitigation: operators must serialize their own registry mutations against a single instance. v0.17 will introduce `Backend::cas_set(uri, expected_etag, new)` — backends with native ETag/version semantics (AWS S3, GCS, etcd-backed Vault) will implement it; backends without (local file, 1Password, keychain) will continue to degrade to current behavior under explicit acknowledgment. Phase 7 audit (architect-reviewer H2, code-reviewer B2) flagged this; both agreed v0.15 ships honestly with the limitation documented rather than blocking on the larger v0.17 surface. +- Registry-document read-modify-write is not atomic in v0.15; both `secretenv registry set` and the `migrate` pointer-flip do `Backend::list(...)` to in-memory `BTreeMap` mutate to `Backend::set(...)`; no backend carries CAS/If-Match/version-stamp, so concurrent mutations on the same instance can clobber each other; mitigation: operators serialize their own mutations; v0.17 introduces `Backend::cas_set(uri, expected_etag, new)` (ETag/version backends implement it, others degrade under explicit acknowledgment). ## [0.14.0] - 2026-05-15 -**Headline:** `secretenv redact` lands in two modes, plus the foundation machinery three downstream cycles (v0.15 migrate, v0.16 MCP, v0.17 OTel) depend on. Backend total stays at **15**. - -v0.13.0 → v0.14.0: workspace unit tests **893 → 918** (+25 from redact unit/integration + telemetry + Secret + McpSafe trybuild coverage). +`secretenv redact` lands in two modes plus the foundation machinery v0.15 migrate, v0.16 MCP, and v0.17 OTel depend on; backend total stays 15; workspace unit tests 893 to 918 (+25). ### BREAKING - -Three deliberate breaking changes, bundled per the v0.14+ Q-O1 resolution (one CHANGELOG block instead of three separate patch tags). Pre-launch install-base is zero; the window for one bundled break before public announcement is honored per the [[feedback_prelaunch_breaking_changes]] policy. - -- **`Backend::get(&self, uri: &BackendUri) -> Result>`** (was `Result`). Cascades across all 15 backends, `secretenv-testing` mocks, the CLI's `get` handler, the resolver, and the runner's `EnvEntry`. External backend plugins must update their `get()` return type and wrap their fetched value with `Secret::new(...)`. Internal consumers (`build_env` → child `exec`) extract via the crate-internal `as_str_internal()`; CLI callers use `value.expose_secret()`. **Q-O1.a.** -- **`Backend::serialize_registry_doc` + `Backend::deserialize_registry_doc`** moved from `secretenv-cli`'s match-arm helper to trait methods on `Backend`. Default impl is JSON; `local` and `1password` override to TOML. Removes the v0.13-era silent "not supported" failure mode where a new backend without a CLI dispatch update produced a runtime error. **Q-O1.b.** -- **`pub use backend::Backend`** is cfg-gated to `not(feature = "mcp-safe")` on `secretenv-core`. Crates linking with the new `mcp-safe` feature (the v0.16 MCP server) must reach the trait via the module path `secretenv_core::backend::Backend`. The CLI never enables `mcp-safe`. **Q-O1.c.** +- `Backend::get(&self, uri: &BackendUri) -> Result>` (was `Result`). Cascades across all 15 backends, `secretenv-testing` mocks, the CLI `get` handler, the resolver, and the runner's `EnvEntry`. External plugins update `get()` return type and wrap with `Secret::new(...)`; internal consumers use `as_str_internal()`, CLI callers use `value.expose_secret()`. +- `Backend::serialize_registry_doc` + `Backend::deserialize_registry_doc` moved from a `secretenv-cli` match-arm helper to trait methods on `Backend`. Default impl JSON; `local` and `1password` override to TOML. Removes the v0.13-era silent "not supported" runtime failure. +- `pub use backend::Backend` is cfg-gated to `not(feature = "mcp-safe")` on `secretenv-core`. Crates linking with `mcp-safe` reach the trait via `secretenv_core::backend::Backend`; the CLI never enables `mcp-safe`. ### Added - -- **`secretenv redact `** — Mode B post-hoc file scrubber. Aho-Corasick byte scanner over the resolved-value set; substitutes with `[redacted:]` (or `--redact-token `). `--in-place` rewrites atomically through a sibling tempfile + `rename(2)` with mode preservation; `--backup ` keeps a copy; `--dry-run` counts without writing. -- **Runtime redaction** in `secretenv run` (Mode A) — on by default. Pipes stdout/stderr through a streaming Aho-Corasick scrubber with a `max(pattern_len) - 1`-byte carry-over window so matches across read-chunk boundaries fire correctly. `--redact` forces pipe-based mode on a TTY; `--no-redact --i-know` opts out entirely. Default (`Auto`) falls back to `exec()` when stdin is a TTY and emits a one-line stderr advisory. -- **Signal forwarding** in mode A — `SIGINT`, `SIGTERM`, `SIGHUP` to the parent are forwarded to the child via `rustix::process::kill_process`. -- **`secretenv-core::Secret`** — generic newtype wrapping `Zeroizing`. Custom `Debug` redacts; no `Display`, `Clone`, `Serialize`, `Deserialize`, `From`, or `Into`. `expose_secret()` is cfg-gated behind `not(feature = "mcp-safe")`. -- **`secretenv-core::McpSafe`** — sealed marker trait. v0.14 seals `HistoryEntry`; v0.16 adds `AliasList`, `ResolveStatus`, `DoctorReport` when those types crystallize. Critically, `Secret` is **not** sealed — the MCP server's tool signatures will be typed against `T: McpSafe`, so a missing impl is a compile-time refusal to expose values. -- **`mcp-safe` Cargo feature** on `secretenv-core` — subtractive: removes `expose_secret` and the crate-root `Backend` re-export. CI gate: `cargo test -p secretenv-core --features mcp-safe --test mcp_safe_trybuild` runs as a dedicated job and verifies the compile-fail surface. -- **`secretenv-telemetry` crate** — new workspace member. Ships `SecretEnvSpan` typed attribute builder (one method per ALLOW attribute in the v0.14+ §6 matrix; no `set_attribute(&str, &str)` escape hatch), `SecretEnvErrorKind` closed enum, `RedactionEvent` / `RedactionStream` / `RedactionSource`, `RedactionPolicy` (declarative ALLOW/DENY classification of every span attribute), and `RedactionSink` trait + `NoopRedactionSink`. **No `opentelemetry` dependency at v0.14** — the load-bearing v0.14 deliverable is the typed surface; v0.17 wires the OTLP exporter through the same trait without restructuring any call site. -- **`Backend::supports_native_gen()`** — default `false`. Reserved for v0.16's MCP `gen_password` tool routing. -- **Typed per-handler reports** (`crates/secretenv-cli/src/reports.rs`) — `RunReport`, `RedactReport`, `RegistryReport`, `ResolveReport`, `GetReport`, `SetupReport`, `ProfileReport`, `CompletionsReport`, plus `CommandOutcome` and `RedactMode` enums. v0.14 discards them via `let _ = handler.await?;` in the dispatcher; v0.17 wires the report's `Drop` to OTel span emission without touching the handlers again. +- `secretenv redact `: Mode B post-hoc file scrubber. Aho-Corasick byte scanner over resolved values; substitutes `[redacted:]` or `--redact-token `. `--in-place` rewrites atomically via sibling tempfile + `rename(2)` with mode preservation; `--backup ` keeps a copy; `--dry-run` counts without writing. +- Runtime redaction in `secretenv run` (Mode A), on by default. Streaming Aho-Corasick scrubber on stdout/stderr with a `max(pattern_len) - 1`-byte carry-over window for cross-chunk matches. `--redact` forces pipe mode on a TTY; `--no-redact --i-know` opts out. Default `Auto` falls back to `exec()` when stdin is a TTY with a stderr advisory. +- Signal forwarding in mode A: `SIGINT`, `SIGTERM`, `SIGHUP` forwarded to the child via `rustix::process::kill_process`. +- `secretenv-core::Secret`: generic newtype wrapping `Zeroizing`. Custom `Debug` redacts; no `Display`, `Clone`, `Serialize`, `Deserialize`, `From`, or `Into`. `expose_secret()` cfg-gated behind `not(feature = "mcp-safe")`. +- `secretenv-core::McpSafe`: sealed marker trait. v0.14 seals `HistoryEntry`; v0.16 adds `AliasList`, `ResolveStatus`, `DoctorReport`. `Secret` is not sealed, so a missing impl is a compile-time refusal to expose values. +- `mcp-safe` Cargo feature on `secretenv-core`: subtractive, removes `expose_secret` and the crate-root `Backend` re-export. CI gate `cargo test -p secretenv-core --features mcp-safe --test mcp_safe_trybuild` runs as a dedicated job. +- `secretenv-telemetry` crate: ships `SecretEnvSpan` typed attribute builder (one method per ALLOW attribute, no `set_attribute(&str, &str)` escape hatch), `SecretEnvErrorKind` closed enum, `RedactionEvent` / `RedactionStream` / `RedactionSource`, `RedactionPolicy`, and `RedactionSink` trait + `NoopRedactionSink`. No `opentelemetry` dependency at v0.14. +- `Backend::supports_native_gen()`: default `false`, reserved for v0.16's MCP `gen_password` tool routing. +- Typed per-handler reports (`crates/secretenv-cli/src/reports.rs`): `RunReport`, `RedactReport`, `RegistryReport`, `ResolveReport`, `GetReport`, `SetupReport`, `ProfileReport`, `CompletionsReport`, plus `CommandOutcome` and `RedactMode` enums. v0.14 discards them via `let _ = handler.await?;`. ### Changed - -- `secretenv run` defaults to redacted output. Non-TTY parents (CI, scripts) get pipe-based redaction; TTY parents get the auto-fallback advisory. -- Internal `serialize_registry(backend_type, &map)` helper removed from `secretenv-cli`; dispatch is now `backend.serialize_registry_doc(&map)`. The four CLI-layer unit tests covering the helper are removed; equivalent round-trip tests live in each backend's own crate. -- Workspace deps: `aho-corasick = "1"`, `rustix = { version = "1", features = ["fs", "process"] }` added. `tokio` gains the `"signal"` feature. `tempfile` promoted from dev-dep to runtime dep on `secretenv-core` (consumed by `redact::scrub_file_in_place`). -- `EnvEntry.value` switched from `Zeroizing` to `Secret` (preserves the zero-on-drop property via `Zeroizing`'s presence inside `Secret`). -- CI: workspace `cargo test` no longer passes `--all-features`. `mcp-safe` is subtractive and feature-unification under `--all-features` would cascade it across the 15 backends and break injection. The dedicated `mcp-safe-trybuild` CI job covers that surface. A `secret-no-leak-grep` CI job fails the build on a `Display` impl for `Secret` or a forbidden derive. +- `secretenv run` defaults to redacted output: non-TTY parents get pipe-based redaction, TTY parents get the auto-fallback advisory. +- Internal `serialize_registry(backend_type, &map)` helper removed from `secretenv-cli`; dispatch is now `backend.serialize_registry_doc(&map)`. The four CLI-layer helper unit tests removed; round-trip tests live in each backend crate. +- Workspace deps: `aho-corasick = "1"`, `rustix = { version = "1", features = ["fs", "process"] }` added; `tokio` gains `"signal"`; `tempfile` promoted from dev-dep to runtime dep on `secretenv-core`. +- `EnvEntry.value` switched from `Zeroizing` to `Secret`. +- CI: workspace `cargo test` no longer passes `--all-features` (mcp-safe is subtractive and would cascade under feature unification). Dedicated `mcp-safe-trybuild` job covers it; a `secret-no-leak-grep` job fails on a `Display` impl for `Secret` or a forbidden derive. ### Security - -- New `docs/security.md#redaction-v014` section covers the redact threat model and the **Limits matrix**: writes to `/dev/tty`, `syslog(3)` / `journald`, `mmap`'d output, core dumps + post-mortem analysis, and PTY-bound interactive children are **not** covered by redaction. Operators are explicitly told this. -- `O_NOFOLLOW` on every redact file open; symlink-swap-between-stat-and-open is rejected. -- Foreign-owner refusal: redact refuses files owned by a UID other than the caller's EUID unless `--allow-foreign-owner` opts in. -- `/proc`, `/sys`, `/dev` are refused outright — "scrubbing" a kernel pseudofile is meaningless. -- Minimum tainted-value length of 8 bytes. Shorter values are dropped from the tainted set with a `tracing::warn!` that carries the alias name but never the value or its length. -- 64 KiB max tainted-value length for mode A. Larger patterns refuse mode-A startup with a clear error (matches cannot reliably fire across the stream's chunk boundaries). -- Foundation work for v0.16's MCP-server boundary (`Secret`, `McpSafe`, `mcp-safe` feature) and v0.17's OTel attribute discipline (`SecretEnvSpan`, `SecretEnvErrorKind`, `RedactionPolicy`) lands in this cycle so neither downstream cycle has to retrofit instrumentation across the codebase. +- New `docs/security.md#redaction-v014` covers the redact threat model and Limits matrix: `/dev/tty`, `syslog(3)` / `journald`, `mmap`'d output, core dumps, and PTY-bound interactive children are not covered. +- `O_NOFOLLOW` on every redact file open; symlink-swap-between-stat-and-open rejected. +- Foreign-owner refusal: redact refuses files owned by a UID other than the caller's EUID unless `--allow-foreign-owner`. +- `/proc`, `/sys`, `/dev` refused outright. +- Minimum tainted-value length 8 bytes; shorter values dropped with a `tracing::warn!` carrying the alias name but never the value or length. +- 64 KiB max tainted-value length for mode A; larger patterns refuse mode-A startup. +- Foundation work for v0.16's MCP boundary (`Secret`, `McpSafe`, `mcp-safe`) and v0.17's OTel discipline (`SecretEnvSpan`, `SecretEnvErrorKind`, `RedactionPolicy`). ### Known limitations - -- **Typed-report `Drop` does not fire on `secretenv run`'s exec/exit happy paths.** The per-handler typed reports (`RunReport` et al.) reach their `Drop` impl on early-return error paths but not when `run` reaches the end of `cmd_run` and the process exits with the child's status. Surfaced by the Phase 9b architecture review (arch-H5). v0.14 ships this honestly: the report types are the load-bearing surface for v0.16+v0.17 consumers, and the v0.14 dispatcher already discards them via `let _ = handler.await?`. v0.17 adds a pre-exec hook (~30 LOC in the runner) that forces the report's emission before the `exec`/exit. Until then, OTel emission for `secretenv run` will use the `RunOptions`-resident hook path; reports remain authoritative for all other handlers. -- **Three v0.15 architectural follow-ups identified during Phase 9b review** (none blocking v0.14 ship): polarity-flip the `mcp-safe` feature to additive `value-access` before v0.16's MCP crate locks in the subtractive surface; relocate `serialize_registry_doc`/`deserialize_registry_doc` from the `Backend` trait to a free function + `RegistryFormat` enum; lift `crates/secretenv-cli/src/reports.rs` down into `secretenv-core` once v0.16 MCP becomes the second consumer. +- Typed-report `Drop` does not fire on `secretenv run`'s exec/exit happy paths; it reaches `Drop` on early-return error paths but not when `cmd_run` ends and the process exits with the child's status. v0.17 adds a pre-exec hook (~30 LOC) to force emission; until then `secretenv run` OTel uses the `RunOptions`-resident hook path. +- Three v0.15 architectural follow-ups identified (non-blocking): polarity-flip `mcp-safe` to additive `value-access` before v0.16; relocate `serialize_registry_doc`/`deserialize_registry_doc` to a free function + `RegistryFormat` enum; lift `reports.rs` into `secretenv-core` once v0.16 MCP is the second consumer. ## [0.13.0] - 2026-05-06 -**Headline:** **hygiene + docs release** absorbing both v0.12.x carry-forward queues. Originally queued as Delinea Secret Server (per [[roadmap]]); Delinea remains blocked on invite-only trial access (vendor-side), so v0.13 fills the slot with the merged-not-tagged hygiene work that would otherwise have rolled forward to the next backend cycle. **No new backend, no new platform, no schema change.** Backend total stays at **15**. - -v0.12.0 → v0.13.0: workspace unit tests **876 → 893** (+17 from new bitwarden-sm regression coverage). Live full-matrix smoke against operator's real backends: **508/508 PASS** (was 454/508 at the v0.12.0 baseline; +54 cleared by the GCP env-debt fixes — Section 15 SKIP-on-precondition guard, env-driven `GCP_PROJECT`, and `CLOUDSDK_CORE_DISABLE_PROMPTS=1` global). Phase 9 release-prep audit trio (security-auditor + code-reviewer + deployment-engineer) ran clean: 0 BLOCKING + 0 HIGH from security; 0 BLOCKING + 1 HIGH (doc-comment LIMITATION note for `parse_version_token` — landed inline) from code-reviewer; 1 BLOCKING from deployment (workspace version bump — landed inline as part of release prep) — **Phase 9 paid for itself a third consecutive cycle** per [[feedback_audit_after_release_prep]]. +Hygiene and docs release absorbing both v0.12.x carry-forward queues; no new backend, platform, or schema change; backend total stays 15; workspace unit tests 876 to 893 (+17); live full-matrix smoke 508/508 PASS (was 454/508; +54 from GCP env-debt fixes); the release-prep audit trio ran clean. -### Fixed — GCP environment debt (smoke harness) - -Closes [[v0.12-issues/01-gcp-env-debt]]. Surfaced by the v0.12 Phase 8 full-matrix smoke as 51 cascading FAILs unrelated to v0.12 feature work; rooted in two distinct issues in `scripts/smoke-test/run-tests.sh`. - -- **Drop redundant `GCP_PROJECT="${SECRETENV_TEST_GCP_PROJECT:-eva-dev-490220}"` shadow** (`scripts/smoke-test/run-tests.sh`) — `lib/common.sh` already exports the env-driven value, and `require_cloud_env()` already enforces non-empty before any cloud section runs. The line was dead at best, silently retargeting a long-decommissioned project at worst. Single source of truth: `SECRETENV_TEST_GCP_PROJECT`. No fallback. -- **Test-150 assertion `project=eva-dev-490220` interpolates `${GCP_PROJECT}`** (`scripts/smoke-test/run-tests.sh:917`) — was a hardcoded literal that would FAIL even with the right env exported. -- **Section 15 wrapped in standard SKIP-on-precondition** mirroring sections 21+ (Doppler, Keeper, openbao, conjur, bitwarden-sm). Probes `gcloud secrets list --project ... --limit 1` before running the body; on failure records a single `119 v0.3 gcp section skipped — gcloud secrets list failed` SKIP with operator hint instead of cascading 12+ FAILs through doctor + cross-backend resolver. SKIP-record number `119` matches the section's first test ID per the established convention. -- **Global `export CLOUDSDK_CORE_DISABLE_PROMPTS=1`** at the top of `scripts/smoke-test/run-tests.sh` — `secretenv doctor --fix` against a NotAuth GCP backend can no longer spawn `gcloud auth login` and hang the whole matrix on a browser callback (live-observed during v0.12 Phase 8: section 19a stalled with the gcloud PID parked on stdin). With the prompt suppressed, gcloud fails fast with a clean error and the test proceeds. -- **Pre-smoke runbook step** added to `kb/wiki/parallel-backend-workflow.md` (operator-facing) — `gcloud auth list` + ADC verification + project-match check, mirroring the existing `op signin` priming pattern. - -Cross-backend cascade FAILs (sections 7 / 8 / 9 / 17 / 22 azure-reg) when one backend in the alias map is NotAuth — NOT addressed; that requires an architectural change to the cross-backend resolver's behavior on partial-readiness manifests. Out of scope for this hygiene cycle. Captured for a future polish cycle. - -### Fixed — bitwarden-sm Phase 7 deferred chips - -Closes [[v0.12-issues/02-phase-7-deferred-chips]]. Phase 7 closing-audit MEDIUM/LOW findings deferred from v0.12.0 per [[feedback_pr_scoping_hygiene_carrier]]. The Phase 7 HIGH chips landed inline before v0.12.0; this cycle absorbs the deferred residue. - -- **`bitwarden_bin` control-character validation parity** (`crates/backends/secretenv-backend-bitwarden-sm/src/lib.rs`) — `bitwarden_server_url` and `bitwarden_access_token_env` already ran through `has_forbidden_control_char`; `bitwarden_bin` did not. Inconsistent hardening; a NUL byte in the binary path produced a confusing OS-level error far from the config site instead of a typed config-site message. Now mirrors the sibling check + new `factory_rejects_control_char_in_bitwarden_bin` regression test. -- **`bitwarden_access_token_env` validation order** (`create_concrete`) — the `unwrap_or_else(|| DEFAULT_TOKEN_ENV.to_owned())` default path was running the control-char + POSIX-name checks against a `&'static str` constant (dead code on the common path). Validation now lives inside the `if let Some(env) = ...` branch; default branch returns the const directly. Existing tests confirm both paths still round-trip cleanly. -- **`parse_version_token` permissive scanner** — was anchored on the literal `bws ` prefix, so a future rebrand (`bws-cli`, `bitwarden-sm-cli`, etc.) would have failed Level 1 opaquely. New scanner finds the first `` whitespace token; tolerates rebrands and trailing build metadata; defends against pathological double-prefix input (`bws bws 0.5.0`). Doc-comment names the trust boundary (`bws --version` stdout sits inside the `bitwarden_bin` trust envelope) and the LIMITATION (first numeric triple wins; an embedded IPv4 fragment BEFORE the version would misparse). Six new regression tests cover canonical, rebrand, trailing-metadata, no-numeric-triple, two-component-rejection, and the "first triple wins" tie-break. -- **`SecretGetResponse.value` doc-comment hardened** — names the `#[serde(default)]` rationale (forward-compat with future `bws` schema drift) and the section-28 smoke assertion that catches the silent-empty-value drift. -- **`ProjectListElement.id` no longer `#[serde(default)]`** — the doc-comment claimed `id` "anchors the shape" but `default` let `[{}]` parse fine, contradicting the claim. Field is now REQUIRED; an envelope shape that omits it surfaces as a parse error at Level 2. -- **`extract_json_field` array/object rejection** — split the combined `ref v @ (Array | Object)` arm into two explicit arms (one per JSON kind). Same error wording, less indirection. -- **`set_uses_secret_edit_not_create` mock body `ok("{}")` → `ok("")`** — the `secret edit` stdout is unread by the wrapper (only `status.success()` and `stderr` are inspected); empty body is honest about that vs. a misleading-looking JSON object. -- **`set_rejects_fragment` test** — added positive `assert!(msg.contains("fragment"))` alongside the existing absence-of-`disabled by default` check, verifying the fragment-reject branch fired (not just that the unsafe-set branch didn't). -- **6 new `extract_json_field` variant tests** covering string / number / boolean / null / array-rejection / object-rejection branches — pin the exact bail wording so a future refactor surfaces unintended message changes. - -### Fixed — `infisical` backend doctor false-NotAuthenticated under `infisical` 0.43.79 - -Surfaced during the v0.13.0 hygiene-cycle live smoke (post-`infisical login`, with a valid cached JWT). `secretenv doctor` reported `infisical` as `not authenticated` even though `infisical user get token --plain` returned exit 0 with a valid token in the operator's shell. - -Root cause: `Backend::check()` configured the `infisical user get token --plain` probe with `Stdio::piped()` for stderr but waited via `.status()`, which does not drain piped streams. Once the `infisical` CLI's stderr output (upgrade-available notice + auth-state lines) exceeded the OS pipe buffer, the child blocked on stderr write and exited abnormally — surfacing as a spurious `NotAuthenticated` from a logged-in CLI. The bug was always present but only manifested as the CLI's notice payload grew with releases (last reproduced against `infisical` 0.43.79 with the `0.43.79 -> 0.43.80` upgrade banner). - -Fix: change `probe.stderr(Stdio::piped())` to `probe.stderr(Stdio::null())` (`crates/backends/secretenv-backend-infisical/src/lib.rs:454`). The probe's documented intent — per its own comment — is "we don't need its value, only exit status," so dropping the pipe matches stated behavior. Code-comment expanded to call out the pipe-buffer-vs-`.status()` interaction so a future maintainer doesn't reintroduce the pattern. - -Caught only because the v0.13 hygiene cycle re-ran the full smoke matrix end-to-end against the operator's live backends; standalone unit tests with strict mocks could not have surfaced this (the mock CLI emits no stderr). - -### Fixed — `bitwarden_server_url` documentation - -- **Security note added** (`docs/backends/bitwarden-sm.md`) on `bitwarden_server_url` token-forwarding risk — naming the typo-squat / poisoned-template threat model and three concrete operator mitigations. Includes a TLS-trust-delegation paragraph for operators on corporate networks with intercepting proxies (private CA bundles). - -### Process - -- **Phase 9 audit trio (security + code + deployment) made default cadence** for the third consecutive cycle. Security: APPROVE outright (0 BLOCK + 0 HIGH + 0 MED + 2 LOW polish). Code-reviewer: APPROVE w/ HIGH (`parse_version_token` doc-comment LIMITATION note — landed inline). Deployment: REJECT-then-APPROVE (workspace version bump pre-tag — landed inline). Every BLOCKING + HIGH + MEDIUM + LOW finding closed in this cycle (no carry-forward to v0.13.x). -- **No three-agent feature-cycle audit (Phase 7) run** — this cycle introduces no new backend / no new feature surface; the bitwarden-sm changes are direct closures of audited findings, and the smoke-harness changes are bash-script hygiene. Phase 9 is the appropriate gate for hygiene + docs cycles. +### Fixed +- GCP env-debt: drop redundant `GCP_PROJECT="${SECRETENV_TEST_GCP_PROJECT:-eva-dev-490220}"` shadow in `scripts/smoke-test/run-tests.sh`; `lib/common.sh` already exports the env-driven value and `require_cloud_env()` enforces it. Single source of truth is `SECRETENV_TEST_GCP_PROJECT`, no fallback. +- GCP env-debt: Test-150 assertion `project=eva-dev-490220` now interpolates `${GCP_PROJECT}` (`run-tests.sh:917`); was a hardcoded literal that failed even with correct env. +- GCP env-debt: Section 15 wrapped in standard SKIP-on-precondition mirroring sections 21+; probes `gcloud secrets list --project ... --limit 1` and records a single `119 v0.3 gcp section skipped` SKIP instead of cascading 12+ FAILs. +- GCP env-debt: global `export CLOUDSDK_CORE_DISABLE_PROMPTS=1` at the top of `run-tests.sh` so `secretenv doctor --fix` against a NotAuth GCP backend cannot spawn `gcloud auth login` and hang the matrix. +- GCP env-debt: pre-smoke runbook step added (operator-facing): `gcloud auth list` + ADC verification + project-match check. +- bitwarden-sm: `bitwarden_bin` control-character validation parity via `has_forbidden_control_char` (matching `bitwarden_server_url` / `bitwarden_access_token_env`) + new `factory_rejects_control_char_in_bitwarden_bin` test. +- bitwarden-sm: `bitwarden_access_token_env` validation order: control-char + POSIX-name checks now run inside the `if let Some(env)` branch; the default branch returns the const directly. +- bitwarden-sm: `parse_version_token` permissive scanner finds the first `` whitespace token instead of anchoring on literal `bws ` prefix; tolerates rebrands and trailing build metadata; six new regression tests. +- bitwarden-sm: `SecretGetResponse.value` doc-comment hardened, naming the `#[serde(default)]` rationale and the section-28 smoke assertion. +- bitwarden-sm: `ProjectListElement.id` no longer `#[serde(default)]`; field is now REQUIRED, so an omitting envelope surfaces as a parse error at Level 2. +- bitwarden-sm: `extract_json_field` array/object rejection split into two explicit arms (one per JSON kind). +- bitwarden-sm: `set_uses_secret_edit_not_create` mock body `ok("{}")` to `ok("")` (the `secret edit` stdout is unread). +- bitwarden-sm: `set_rejects_fragment` test adds positive `assert!(msg.contains("fragment"))`. +- bitwarden-sm: 6 new `extract_json_field` variant tests (string / number / boolean / null / array-rejection / object-rejection) pinning exact bail wording. +- infisical: doctor false-NotAuthenticated under infisical 0.43.79; `Backend::check()` configured the `infisical user get token --plain` probe with `Stdio::piped()` stderr but waited via `.status()`, which does not drain piped streams; once CLI stderr exceeded the pipe buffer the child blocked. Fix: `probe.stderr(Stdio::piped())` to `probe.stderr(Stdio::null())` (`secretenv-backend-infisical/src/lib.rs:454`). +- bitwarden-sm docs: security note added (`docs/backends/bitwarden-sm.md`) on `bitwarden_server_url` token-forwarding risk, naming the typo-squat / poisoned-template threat and three mitigations plus a TLS-trust-delegation paragraph for intercepting proxies. -### Deferred / declined +### Changed +- Release-prep audit trio (security + code + deployment) made default cadence for the third consecutive cycle. Security APPROVE (0 BLOCK + 0 HIGH + 0 MED + 2 LOW); code-reviewer APPROVE with HIGH (`parse_version_token` doc-comment LIMITATION, landed inline); deployment REJECT-then-APPROVE (workspace version bump, landed inline). No carry-forward to v0.13.x. +- No three-agent feature-cycle audit run; no new backend or feature surface. -- **Drop dead `backend_type: &'static str` field on `BitwardenSmBackend`** — flagged by the v0.12 Phase 7 code-reviewer as a "minor maintainability nit." Declined here for two reasons: (1) clippy `unnecessary_literal_bound` flags the resulting `fn backend_type(&self) -> &str { "bitwarden-sm" }` shape against the trait's elided-lifetime return signature; (2) every other backend in the family (14 of them) keeps the same field. Family consistency wins over the local cleanup. -- **Cross-backend cascade resolver behavior on partial-readiness manifests** (sections 7 / 8 / 9 / 17 / 22 azure-reg in the smoke matrix) — when one backend in an alias map is NotAuth, `secretenv run` aborts the whole resolution. Architectural; not a hygiene-cycle change. -- **`bws_command` / `bws_secret_command` DRY merge** — the two helpers are near-duplicates but the deduplication is taste-driven, not correctness-driven. Defer until naturally touched by a future change. -- **Four LOW style nits** carried into a future polish cycle (no v0.13.x queue opened — the hygiene/docs scope is fully closed): `unsafe_set_refused` hint phrasing, `secret_uuid` `Cow<'_, str>` allocation, `drop(lock)` style consistency, `apply_env` rename to `apply_env_with_token_required`. +### Known limitations +- Cross-backend cascade FAILs (sections 7 / 8 / 9 / 17 / 22 azure-reg) when one backend in the alias map is NotAuth not addressed; needs an architectural change to the resolver's partial-readiness behavior. Out of scope. +- Drop dead `backend_type: &'static str` field on `BitwardenSmBackend` declined: clippy `unnecessary_literal_bound` flags the resulting shape, and all 14 other family backends keep the field. +- `bws_command` / `bws_secret_command` DRY merge deferred (taste-driven). +- Four LOW style nits deferred: `unsafe_set_refused` hint phrasing, `secret_uuid` `Cow<'_, str>` allocation, `drop(lock)` style consistency, `apply_env` rename to `apply_env_with_token_required`. ## [0.12.0] - 2026-05-05 -**Headline:** seventh release of the single-backend-per-release cycle; seventh [[project_cycle_execution_model|solo-fresh-session]] release. One new backend — **Bitwarden Secrets Manager** (the developer/CI product, distinct from Bitwarden Password Manager) via the `bws` CLI v2.x — brings the total to **15**. First cycle to pull a backend forward in the queue (was v0.13 per [[roadmap]]; Delinea Secret Server slipped to v0.13 pending invite-only trial access). Tag absorbs the v0.11.x merged-not-tagged hygiene cycle below. - -v0.11.0 → v0.12.0: workspace unit tests **830 → 876** (+46 from the new bitwarden-sm crate). Live smoke matrix Section 28 (bitwarden-sm): **29/29 PASS** against operator's live cloud account. Phase 7 closing trio (security + code + rust) surfaced 3 HIGH findings (1 security, 2 code-reviewer) — all landed inline before tag; 7 MEDIUM + 10 LOW deferred to v0.12.x hygiene queue per [[feedback_pr_scoping_hygiene_carrier]] ([[v0.12-issues/02-phase-7-deferred-chips]]). Phase 8 live smoke caught two production bugs that Phase 7 audit-only would have missed (envelope-shape `deny_unknown_fields` + UUID dual-form parser) — vindicates the audit-then-smoke-then-audit-again sequence from [[feedback_audit_after_release_prep]]. - -The full-matrix smoke run additionally surfaced 51 cascading FAILs from pre-existing **GCP environment debt** ([[v0.12-issues/01-gcp-env-debt]]) — `eva-dev-490220` hardcoded in `run-tests.sh` + `gcloud` reauth needed. NOT v0.12-introduced; deferred to v0.12.x hygiene cycle. Section 28's bitwarden-sm work sits in a self-contained config and was unaffected. - -### Added — Bitwarden Secrets Manager backend (v0.12) - -- **`secretenv-backend-bitwarden-sm` crate** — `BitwardenSmFactory` registered unconditionally in `secretenv-cli/src/backends_init.rs`. URI shape `bitwarden-sm-://[#json-key=]` where `` is either the 36-char canonical hyphenated form (`8-4-4-4-12`, what `bws` emits + what users copy from the web UI) OR the 32-char simple form (no hyphens). Bitwarden Secrets Manager addresses every secret by server-generated UUID — `bws secret get` accepts UUID only, and the server allows duplicate KEY names within a project, so key-name addressing would be both ambiguous and costlier. Human-readable aliases live in the SecretEnv registry layer. **46 unit tests** via the strict-mock harness. -- **`bitwarden_unsafe_set` defense-in-depth gate** — `bws` v2.0.0 has NO stdin path for `secret create` / `secret edit` (live-probed against `bws 2.0.0` 2026-05-05); the value is on argv via `--value `. Default posture: `set` and `delete` REFUSE with a clear error pointing operators at the Bitwarden web UI. Setting `bitwarden_unsafe_set = true` opens the argv path explicitly. Same precedent as `op_unsafe_set` / `bao_unsafe_set` / `conjur_unsafe_set`. Both `set` AND `delete` gated by the same flag (the threat model is "destructive write operations from a wrapped CLI", not argv-leak risk specifically). -- **`set` updates only — never creates** — the URI is a UUID, which can only refer to a secret that already exists; `set` always invokes `bws secret edit --value ` and never `bws secret create`. Secret provisioning is an out-of-band web-UI workflow that returns a UUID; the operator then writes that UUID into the registry. -- **Token routing via env** — `BWS_ACCESS_TOKEN` is the canonical env var `bws` reads; multi-instance setups rename via `bitwarden_access_token_env` (e.g. `BWS_ACCESS_TOKEN_PROD`). The wrapper sources the token from the operator shell at command time and sets `BWS_ACCESS_TOKEN` on the child env only — never on argv, never in the registry doc, never logged. Token VALUE never echoed by `doctor`; only the env-var NAME and project COUNT appear. -- **Server URL — omit when default** — `bitwarden_server_url` is optional; when unset the wrapper actively REMOVES `BWS_SERVER_URL` from the child env (so the CLI's built-in US-cloud default applies, even if the operator's parent shell has it set globally). EU / self-hosted operators set the field explicitly. -- **`#json-key=` fragment on `get`** — extracts a top-level scalar from a JSON-encoded `value`. Mirrors `aws-secrets` / `openbao` / `conjur`. `set` / `delete` / `list` / `history` reject any fragment. -- **URI parser strict UUID validation** — 32-char `[0-9a-f]+` enforced at parse time; hyphenated UUIDs (36-char canonical form) rejected with a clear error naming the constraint, rather than letting `bws` produce a cryptic "invalid length" message later. Mixed-case hex normalized to lowercase so registry documents written either way round-trip. -- **`history` returns trait-default "not implemented"** — Bitwarden Secrets Manager surfaces secret revisions in the web UI (every `secret edit` bumps `revisionDate`) but the CLI exposes no `secret history` subcommand. Out of scope until vendor exposes versioning. -- **`bitwarden-sm` added to `serialize_registry` JSON arm** in `secretenv-cli/src/cli.rs` so `registry set` / `unset` write through the JSON-string-in-`value` shape (matching `aws-secrets` / `openbao` / `conjur`). -- **Smoke harness Section 28** — 29 records (13 `run_test` + 16 `assert_*`) covering doctor Level 1+2 (with `server=` / `token=$BWS_ACCESS_TOKEN` / `projects=` checks), scalar round-trip, `#json-key=password` fragment extraction, end-to-end `run`, set-blocked-by-default + opt-in cycle, fragment-reject on registry list, history-not-implemented surface, registry-source cross-backend chain, and URI-parser non-UUID rejection. Phase 8 live-cloud run: 29/29 PASS against the operator's live Bitwarden Secrets Manager account. Skipped when `bws` missing OR `BWS_ACCESS_TOKEN` unset OR `bws project list` fails OR fixture UUIDs missing. -- **Backend total 14 → 15.** README backend table flipped Bitwarden Secrets Manager from "Coming Soon" to "Available" and corrected the row's `type` string to `bitwarden-sm` (leaves the `bitwarden` namespace open for a future Password Manager `bw` wrapper without rename pain). -- **Roadmap swap (2026-05-05)** — v0.12 was originally Delinea Secret Server. Delinea's local-stack provisioning requires an invite-only trial (vendor-side); Bitwarden Secrets Manager is publicly available, so the cycle order swaps: **v0.12 = Bitwarden Secrets Manager**, **v0.13 = Delinea Secret Server (deferred pending invite)**. - -### v0.11.x hygiene — closing-audit deferred LOW chips (merged-not-tagged) - -Sixth merged-not-tagged hygiene cycle (v0.7.1 / v0.7.2 / v0.9.1 / v0.9.2 / v0.10.x → **v0.11.x**) per the rolling-backlog pattern: merged to `main`, workspace `version` stays at `0.11.0`, no tag pushed. Triggered by routine post-cycle slack and a desire to clean the v0.11 carry-forward queue before v0.12 (Delinea Secret Server) opens. Every chip below was already audited during v0.10's or v0.11's closing trio — the hygiene cycle just lands the deferred LOW fixes that were explicitly punted to keep the v0.11 PR scoped per [[feedback_pr_scoping_hygiene_carrier]]. - -#### Fixed +Seventh single-backend-per-release cycle adding Bitwarden Secrets Manager (the developer/CI product, via `bws` CLI v2.x), bringing the total to 15; workspace unit tests 830 to 876 (+46); live smoke Section 28 29/29 PASS; the feature-cycle trio surfaced 3 HIGH (landed inline), 7 MEDIUM + 10 LOW deferred to v0.12.x; absorbs the v0.11.x merged-not-tagged hygiene cycle. -- **`aws-secrets` `extract_json_field` `map.remove` allocation fix** (`crates/backends/secretenv-backend-aws-secrets/src/lib.rs:442-475`) — the same single-line allocation fix that landed in `openbao` during the v0.10 Phase 6 audit and in `conjur` during v0.11 Phase 7. The `String` arm now moves rather than clones (`map.remove` instead of `map.get` + `.clone()`). Carried forward from v0.10.x; deliberately deferred from v0.11 to keep the conjur PR scoped. -- **`.github/workflows/release.yml:177-194` backend-publish strict-mode** — the 14-line `cargo publish` block now starts with `set -euo pipefail` so a transient mid-list failure (crates.io 5xx, indexing race, network blip) fails the workflow step rather than being masked by bash's last-command-only exit semantics. v0.10.x deferred this; Phase 9 security audit re-flagged at v0.11; landing here. Mirrors the strict-mode discipline that landed on the Package-tarball block in v0.11.0. -- **`conjur` backend `tracing::warn` on `conjur_unsafe_set = true` runtime branch** (`crates/backends/secretenv-backend-conjur/src/lib.rs:280-294`) — when the operator opts into the `-v ` argv path, the backend now emits a per-invocation `tracing::warn!` naming the instance + URI + operation. Mirrors the 1Password / Keeper precedent. `secretenv --verbose` now surfaces the choice as a runtime breadcrumb instead of silently routing through argv. Phase 7 closing security-auditor LOW. -- **`conjur` `parse_version_token` dead-fallback cleanup** (`crates/backends/secretenv-backend-conjur/src/lib.rs:549-559`) — the prior `split('-').next().unwrap_or(token)` shape had an unreachable fallback arm (`split` always yields at least one element). Replaced with `split_once('-').map_or(token, |(prefix, _)| prefix)` for clearer intent. Phase 7 closing rust-engineer LOW. -- **`conjur` `parse_json_key_fragment` two-pass cleanup** (`crates/backends/secretenv-backend-conjur/src/lib.rs:212-242`) — the prior `contains_key` + `len()` shape made two passes over directives in the common single-key case. Refactored to single-pass `shift_remove` + leftover-emptiness check; whatever remains in the map after extracting `json-key` is by definition the unsupported set. Phase 7 closing rust-engineer LOW. - -#### Process - -- This is the **first hygiene cycle to land entirely from the v0.11.x post-cycle carry-forward queue** documented in [[roadmap]]. The `feedback_pr_scoping_hygiene_carrier.md` discipline is now an established pattern across two cycles (v0.10.x retrospective + v0.11.x carry-forward); subsequent cycles should default to it. No three-agent retrospective audit run on this commit because every chip closed a finding from v0.10 / v0.11 closing audits — re-auditing closed findings would be theatre. +### Added +- `secretenv-backend-bitwarden-sm` crate: `BitwardenSmFactory` registered unconditionally in `secretenv-cli/src/backends_init.rs`. URI shape `bitwarden-sm-://[#json-key=]` where `` is the 36-char canonical hyphenated form or 32-char simple form. Addresses every secret by server-generated UUID. 46 unit tests. +- `bitwarden_unsafe_set` defense-in-depth gate: `bws` v2.0.0 has no stdin path for `secret create` / `secret edit` (value is on argv via `--value`). Default: `set` and `delete` REFUSE; `bitwarden_unsafe_set = true` opens the argv path. Both gated by the same flag. +- `set` updates only, never creates: the URI is a UUID, so `set` always invokes `bws secret edit --value `, never `bws secret create`. +- Token routing via env: `BWS_ACCESS_TOKEN` is canonical; multi-instance renames via `bitwarden_access_token_env`. Token set on child env only, never on argv, registry, or logs. `doctor` shows only env-var NAME and project COUNT. +- Server URL omitted when default: `bitwarden_server_url` optional; when unset the wrapper actively REMOVES `BWS_SERVER_URL` from the child env. EU / self-hosted set it explicitly. +- `#json-key=` fragment on `get` extracts a top-level scalar from JSON-encoded `value`; `set` / `delete` / `list` / `history` reject any fragment. +- URI parser strict UUID validation: 32-char `[0-9a-f]+` at parse time; 36-char hyphenated rejected with a clear error; mixed-case hex normalized to lowercase. +- `history` returns trait-default "not implemented"; the CLI exposes no `secret history` subcommand. +- `bitwarden-sm` added to `serialize_registry` JSON arm in `secretenv-cli/src/cli.rs`. +- Smoke harness Section 28: 29 records covering doctor Level 1+2, scalar round-trip, `#json-key=password` extraction, end-to-end `run`, set-blocked-by-default + opt-in cycle, fragment-reject, history-not-implemented, registry-source cross-backend chain, URI-parser non-UUID rejection. Skipped when `bws` missing, `BWS_ACCESS_TOKEN` unset, `bws project list` fails, or fixture UUIDs missing. +- Backend total 14 to 15; README backend table flipped Bitwarden Secrets Manager to "Available" with `type` string `bitwarden-sm`. +- Roadmap swap (2026-05-05): v0.12 was Delinea Secret Server; Delinea needs an invite-only trial so the order swaps to v0.12 = Bitwarden Secrets Manager, v0.13 = Delinea (deferred). + +### Fixed (v0.11.x hygiene, merged-not-tagged) +- `aws-secrets` `extract_json_field` `map.remove` allocation fix (`secretenv-backend-aws-secrets/src/lib.rs:442-475`); the `String` arm now moves rather than clones. +- `.github/workflows/release.yml:177-194` backend-publish strict-mode: the 14-line `cargo publish` block now starts with `set -euo pipefail`. +- `conjur` backend `tracing::warn` on `conjur_unsafe_set = true` runtime branch (`secretenv-backend-conjur/src/lib.rs:280-294`) naming instance + URI + operation. +- `conjur` `parse_version_token` dead-fallback cleanup (`lib.rs:549-559`): replaced `split('-').next().unwrap_or(token)` with `split_once('-').map_or(token, |(prefix, _)| prefix)`. +- `conjur` `parse_json_key_fragment` two-pass cleanup (`lib.rs:212-242`): refactored to single-pass `shift_remove` + leftover-emptiness check. -#### Deferred / declined +### Changed +- First hygiene cycle to land entirely from the v0.11.x post-cycle carry-forward queue; no three-agent retrospective audit (every item closed a prior-audit finding). -- **`variable_id` inline control-char rejection** — Phase 7 security-auditor LOW. Already locked at the upstream `secretenv-core::BackendUri::parse` layer (`uri.rs:96`) with a regression-lock test (`uri_parser_rejects_control_chars_in_variable_id_path` in the conjur crate). Inline check would be defense-in-depth only; not landing. -- **`teardown.sh` shell-quoting parity** — Phase 7 security-auditor MEDIUM. Same `run "..."` wrapper pattern every prior backend uses; not a regression. Would need a workspace-wide rewrite of the smoke harness to address; out of scope for this hygiene cycle. -- **Smoke `|| true` failure-signal loss** — Phase 7 security-auditor LOW. Cosmetic; the assertion below catches the real failure regardless. Not landing. -- **Phase 9 audit-artifact wiki cross-link** — Phase 9 security-auditor LOW. No `kb/wiki/reviews/2026-04-30-v0.11-conjur-audit.md` artifact exists; nothing to link. Declined. -- **CHANGELOG history-wording cleanup** — Phase 9 code-reviewer LOW (advisory). The historical v0.10.0 block at `CHANGELOG.md` still says "history-unsupported" while v0.11.0+ uses "history-not-implemented". Don't retroactively edit history. -- **`history()` for openbao via `bao kv metadata get`** — v0.10.x carry-forward. Non-trivial (KV v1/v2 mount detection, soft-delete + destroy markers); requires real design work. Stays in v0.10.x deferred-with-trigger queue. +### Known limitations +- Full-matrix smoke surfaced 51 cascading FAILs from pre-existing GCP env-debt (`eva-dev-490220` hardcoded + gcloud reauth); not v0.12-introduced, deferred to v0.12.x. +- `variable_id` inline control-char rejection declined; already locked at `secretenv-core::BackendUri::parse` (`uri.rs:96`). +- `teardown.sh` shell-quoting parity declined; would need a workspace-wide harness rewrite. +- Smoke `|| true` failure-signal loss declined (cosmetic). +- Audit-artifact cross-link declined (no artifact exists). +- CHANGELOG history-wording cleanup declined (historical v0.10.0 says "history-unsupported" vs later "history-not-implemented"); don't edit history. +- `history()` for openbao via `bao kv metadata get` deferred (KV v1/v2 mount detection, soft-delete + destroy markers). ## [0.11.0] - 2026-04-30 -**Headline:** sixth release of the single-backend-per-release cycle; sixth [[project_cycle_execution_model|solo-fresh-session]] release. One new backend — **CyberArk Conjur** (Apache-2.0 OSS / Enterprise wire-compatible, via the Go-based `conjur` v8 CLI) — brings the total to **14**. First non-Vault-family enterprise backend; first cycle to land Phase 0 prep as a discrete pre-cycle session and the **first cycle to run the Phase 9 release-prep audit by default** per `feedback_audit_after_release_prep` — the discipline the v0.10.x retrospective surfaced. - -v0.10.0 → v0.11.0: workspace unit tests **778 → 830** (+52 from the new conjur crate, counted by `cargo test --workspace` `test result: ok.` lines summed). Live smoke matrix **452 → 479** (+27 for Section 27: doctor Level 1+2 with `account=` / `identity=` / `authn=` checks, scalar round-trip, `#json-key=password` fragment extraction, end-to-end `run`, set/list/unset cycle on `secretenv-smoke/cycle`, fragment-reject on registry list, history-not-implemented surface, registry-source cross-backend chain). Pre-tag full-matrix smoke: 27/27 on Section 27 first run after a stale `target/release/secretenv` binary blocked the conjur factory in an initial attempt; full matrix **479/479** clean after release rebuild. Closing three-agent trio audit (security + code + rust) landed 1 BLOCKING + 1 HIGH + 7 MEDIUM/LOW findings inline before tag; LOW + remaining MEDIUMs deferred to v0.11.x carry-forward. - -This is the **first tagged release after the v0.10.x merged-not-tagged hygiene cycle** — the Homebrew formula re-renders with the corrected `license "AGPL-3.0-only"` (v0.3 onward had been pushing the wrong MIT label to the tap; fixed in v0.10.x but only takes effect on the next tagged release, which is this one). - -### Added — CyberArk Conjur backend (v0.11) - -- **`secretenv-backend-conjur` crate** — `ConjurFactory` registered unconditionally in `secretenv-cli/src/backends_init.rs`. URI shape `conjur-://[#json-key=]` (no KV-mount segment — Conjur uses a resource-graph identity model where the variable ID IS the path). `CONJUR_APPLIANCE_URL` + `CONJUR_ACCOUNT` routed via per-child env (uniform across `version`, `whoami`, and every `variable` invocation; the `version_command()` helper landed in the closing audit applies env even though `--version` doesn't strictly need it, so the env-only invariant stays uniform). 52 unit tests via the strict-mock harness. -- **`-f /dev/stdin` safe-stdin path** — CV-1-equivalent to OpenBao's `value=-`. Conjur v8 has no `--value-from-stdin` flag; using the kernel `/dev/stdin` pseudo-file lets the CLI read the value bytes "as if from a file" without touching disk and without ever appearing on argv. `conjur_unsafe_set = true` is the explicit operator opt-in for the `-v ` argv path (only legitimate when `/dev/stdin` is unavailable, e.g. chrooted CI runner with stripped `/dev/`). Default-off invariant machine-checked via `ConjurFactory::create_concrete` test path; argv-path-unreachable test registers ONLY the argv-mock entry and asserts the safe branch was taken. -- **`#json-key=` fragment on `get`** — parses the variable value as a JSON object and extracts the named top-level scalar. Mirrors `aws-secrets` / `openbao`. `set` / `delete` / `list` / `history` reject any fragment. -- **`delete` as clear-via-empty-set** — Conjur has no native delete (variables are policy-defined; full removal requires policy reload, which a typical SecretEnv operator can't do). `delete()` writes the empty string via the same safe `-f /dev/stdin` path used by `set`. The variable retains its policy definition; only the value is emptied. Documented as a deliberate semantic gap; mirrors 1Password's `delete` precedent. -- **Identity line surfaces configured authn** — Conjur's `whoami` JSON returns `{account, username, client_ip, user_agent, token_issued_at}` and does NOT include the authenticator name. The doctor identity line constructs `account= identity= authn=` (default authn `"authn"`). -- **v7 (Ruby) CLI rejection** — `check()` Level 1 parses the version token from `Conjur CLI version [-]` and fails fast on v7 with a clear "v8+ required" message + Docker-image install hint. A version line that doesn't parse (no literal "version " token) also surfaces as `BackendStatus::Error` rather than silently bypassing the v7 branch — closes a defense-in-depth gap surfaced by the closing security audit. -- **`list()` returns alphabetically-sorted entries** — `HashMap::into_iter` is randomized per-process; `list()` now sorts before returning so callers and the smoke harness's `assert_contains` checks see deterministic output across runs. Lifted from the closing rust-engineer audit. -- **`conjur` addition to `serialize_registry` JSON arm** (`secretenv-cli`) — registry documents through the `conjur` backend round-trip as JSON-string values stored in the variable, matching `aws-ssm` / `aws-secrets` / `gcp` / `azure` / `vault` / `openbao`. -- **Smoke harness Section 27** (27 assertions, ids 390–416, covering doctor / get / fragment / run / cycle / fragment-reject / history-not-implemented / registry-source / cross-backend resolve). Skipped cleanly if `conjur` is missing OR the server is unreachable OR the session is expired. Provision side seeds `secretenv-smoke/{scalar,json-multi,conjur-registry,cycle}` under root policy. -- **`docs/backends/conjur.md`** — leads with the install gotcha (Docker-image canonical, PyPI is EOL v7), explains the resource-graph model + `delete` semantic gap + identity-line authn convention. -- **`.github/workflows/release.yml`** — adds `cargo publish -p secretenv-backend-conjur --locked` to the backend-publish list. -- **`secretenv-backend-conjur` AGPL-3.0-only exception** in `deny.toml`. -- **README backend table** 13 → 14 + backend-count badge bumped (and stale "Coming Soon" Conjur stub row deleted). - -#### Phase 0 corrections (2026-04-30, pre-cycle) - -The pre-cycle Phase 0 live-probe ran against `Conjur CLI version 8.1.3-879b90b` at the local Conjur OSS docker-compose harness and corrected several spec inaccuracies before the cycle opened. Captured here so the v0.11 tagged CHANGELOG block carries the rationale. - -- **CLI is Go-based v8, not Python.** PyPI `conjur` is the EOL Ruby v7 line. No Homebrew tap; no native macOS binary. Canonical install is the `cyberark/conjur-cli:8` Docker image. Spec install hint corrected; doctor v7 rejection wired. -- **`--value-from-stdin` does not exist in v8.** The CV-1-safe substitute is `-f /dev/stdin` with stdin-piped value. -- **`CONJUR_APPLIANCE_URL` + `CONJUR_ACCOUNT` env routing works without a `~/.conjurrc`.** Standard env-only contract retained; no per-instance config-file fallback shipped. -- **`whoami` JSON shape:** `{account, username, client_ip, user_agent, token_issued_at}` — no authenticator name. Identity-line `authn=` derives from configured `conjur_authn`. -- **`variable get` still appends one trailing `\n`.** Strip-one rule stands. +Sixth single-backend-per-release cycle adding CyberArk Conjur (Apache-2.0 OSS / Enterprise wire-compatible, via the Go-based `conjur` v8 CLI), bringing the total to 14; first non-Vault-family enterprise backend and first cycle to run the release-prep audit by default; workspace unit tests 778 to 830 (+52); live smoke 452 to 479 (+27 for Section 27); full matrix 479/479 clean after release rebuild; trio audit landed 1 BLOCKING + 1 HIGH + 7 MEDIUM/LOW inline. -### v0.10.x hygiene — Homebrew formula license fix + retrospective audit closeout (merged-not-tagged) - -Rolling-backlog cycle following the v0.7.1 / v0.7.2 / v0.9.1 / v0.9.2 dev-work pattern: merged to `main`, workspace `version` stays at `0.10.0`, no tag pushed. Triggered by the user catching a real Homebrew-formula license bug post-tag and the realization that the Phase 6 three-agent audit at v0.10.0 ran BEFORE the Phase 8 release-prep commit landed, so the smoke patch + version bump + CHANGELOG closeout + `release.yml` change shipped unreviewed. Retrospective three-agent audit (security + code + deployment) ran against the as-shipped state @ `368c38a`; this CHANGELOG block + commit closes the BLOCKING/HIGH findings. - -#### Fixed - -- **Homebrew formula license** (`.github/workflows/release.yml:251`) — `license "MIT"` → `license "AGPL-3.0-only"`. Workspace has been AGPL-3.0-only since the v0.3 relicense; every release since had been pushing a wrongly-licensed brew formula to `TechAlchemistX/homebrew-secretenv`. Real legal-surface bug, not just cosmetic. The next tagged release will re-render the formula correctly. Caught by the retrospective deployment-engineer audit pass. -- **CHANGELOG line 57** — folded v0.9.1 hygiene block referenced "v0.10 Bitwarden release" but v0.10 shipped as **OpenBao** (queue was reordered 2026-04-25 putting OpenBao before Bitwarden). Updated to "v0.10 OpenBao release". -- **CHANGELOG headline test counts in `[0.10.0]`** — cited `705 → 748` was wrong on both ends. Actual counts (`cargo test --workspace` summed): `735 → 778`. The `+43 from openbao` delta was correct; baselines were prediction-error. Caught by the retrospective code-reviewer audit pass. -- **Duplicate `## [0.8.0] - 2026-04-24` header** — pre-existing CHANGELOG defect at lines 106/108, removed. - -#### Process +### Added +- `secretenv-backend-conjur` crate: `ConjurFactory` registered unconditionally in `secretenv-cli/src/backends_init.rs`. URI shape `conjur-://[#json-key=]` (no KV-mount segment). `CONJUR_APPLIANCE_URL` + `CONJUR_ACCOUNT` routed via per-child env, uniform across `version`, `whoami`, and every `variable` invocation. 52 unit tests. +- `-f /dev/stdin` safe-stdin path (equivalent to OpenBao's `value=-`); Conjur v8 has no `--value-from-stdin`. `conjur_unsafe_set = true` is the explicit opt-in for the `-v ` argv path. Default-off invariant machine-checked. +- `#json-key=` fragment on `get` parses the value as JSON and extracts the named top-level scalar; `set` / `delete` / `list` / `history` reject any fragment. +- `delete` as clear-via-empty-set: Conjur has no native delete; `delete()` writes the empty string via the safe `-f /dev/stdin` path, retaining the policy definition. +- Identity line surfaces configured authn: `whoami` JSON returns `{account, username, client_ip, user_agent, token_issued_at}` with no authenticator name, so the doctor line is `account= identity= authn=` (default `"authn"`). +- v7 (Ruby) CLI rejection: `check()` Level 1 parses the token from `Conjur CLI version [-]` and fails fast on v7 with a "v8+ required" message + Docker install hint; an unparseable version line surfaces as `BackendStatus::Error`. +- `list()` returns alphabetically-sorted entries (sorts before returning, since `HashMap::into_iter` is randomized per-process). +- `conjur` added to `serialize_registry` JSON arm (`secretenv-cli`). +- Smoke harness Section 27 (27 assertions, ids 390-416) covering doctor / get / fragment / run / cycle / fragment-reject / history-not-implemented / registry-source / cross-backend resolve. Skipped if `conjur` missing, server unreachable, or session expired. Seeds `secretenv-smoke/{scalar,json-multi,conjur-registry,cycle}`. +- `docs/backends/conjur.md`: leads with the install gotcha (Docker-image canonical, PyPI EOL v7), resource-graph model + `delete` semantic gap + identity-line authn convention. +- `.github/workflows/release.yml`: adds `cargo publish -p secretenv-backend-conjur --locked`. +- `secretenv-backend-conjur` AGPL-3.0-only exception in `deny.toml`. +- README backend table 13 to 14 + backend-count badge bumped (stale "Coming Soon" Conjur stub deleted). -- New feedback memory `feedback_audit_after_release_prep.md` — the Phase 6 three-agent audit must be followed by a second targeted audit pass over the Phase 8 release-prep delta (smoke patches, version bump, CHANGELOG closeout, release.yml changes) BEFORE tag push. v0.10.0 missed this and surfaced a release-yml bug post-tag; v0.11+ cycles add it as Phase 6.5. +### Changed +- Pre-cycle live-probe ran against `Conjur CLI version 8.1.3-879b90b` and corrected spec inaccuracies: CLI is Go-based v8, not Python (canonical install `cyberark/conjur-cli:8` Docker image); `--value-from-stdin` does not exist in v8 (substitute `-f /dev/stdin`); `CONJUR_APPLIANCE_URL` + `CONJUR_ACCOUNT` env routing works without `~/.conjurrc`; `whoami` JSON shape has no authenticator name; `variable get` still appends one trailing `\n`. +- First tagged release after the v0.10.x merged-not-tagged hygiene cycle; the Homebrew formula re-renders with the corrected `license "AGPL-3.0-only"` (v0.3 onward had pushed the wrong MIT label). +- New feedback memory: the three-agent feature audit must be followed by a second targeted audit over the release-prep delta (smoke patches, version bump, CHANGELOG closeout, release.yml) before tag push; adopted as standard cadence from v0.11 on. -#### Deferred / declined +### Fixed +- Homebrew formula license (`.github/workflows/release.yml:251`): `license "MIT"` to `license "AGPL-3.0-only"`; the workspace has been AGPL-3.0-only since the v0.3 relicense. +- CHANGELOG line 57: folded v0.9.1 hygiene block referenced "v0.10 Bitwarden release"; v0.10 shipped as OpenBao. Updated to "v0.10 OpenBao release". +- CHANGELOG headline test counts in `[0.10.0]`: cited `705 to 748` corrected to actual `735 to 778`; the `+43 from openbao` delta was correct. +- Duplicate `## [0.8.0] - 2026-04-24` header at lines 106/108 removed. -- **Homebrew formula `desc` length + workspace-description-as-source-of-truth** — devops audit HIGH. Intentional Homebrew-side brevity vs longer CLI Cargo.toml description. Documenting intent in a comment is fine but not load-bearing; deferred. -- **`set -euo pipefail` in release.yml bash blocks** — devops audit MEDIUM. Existing safety loop on line 241 is sufficient; defense-in-depth strict-mode would be polish, not a real bug class. -- **`sleep 45` rationale comment** — devops audit MEDIUM. 45s has been empirically sufficient through v0.1–v0.10; document if a future release times out. -- **CHANGELOG date 2026-04-27 vs commit date 2026-04-26** — code-reviewer audit MEDIUM. CHANGELOG file's own header at line 8 declares UTC dates; tag pushed 2026-04-27T01:50:35Z UTC, so 2026-04-27 is correct per the file's convention. No change. +### Known limitations +- Homebrew formula `desc` length + workspace-description-as-source-of-truth deferred (intentional Homebrew brevity). +- `set -euo pipefail` in release.yml bash blocks deferred (existing safety loop on line 241 sufficient). +- `sleep 45` rationale comment deferred (empirically sufficient through v0.1 to v0.10). +- CHANGELOG date 2026-04-27 vs commit date 2026-04-26: no change (tag pushed 2026-04-27 UTC, file header declares UTC dates). ## [0.10.0] - 2026-04-27 -**Headline:** fifth release of the single-backend-per-release cycle; fifth [[project_cycle_execution_model|solo-fresh-session]] release. One new backend — **OpenBao** (Linux Foundation MPL-2.0 fork of HashiCorp Vault, via the `bao` CLI 2.x) — brings the total to **13**. First Vault-fork peer; near-clone of `secretenv-backend-vault` with three concrete divergences: binary name (`bao` vs `vault`), env-var prefix (`BAO_*` with `VAULT_*` CLI fallback for transition), and install path (`brew install openbao` direct from homebrew-core, no tap dance — explicit contrast with Vault's post-BSL `brew tap hashicorp/tap` form). The `#json-key=` fragment ships from day one — Vault's deferred-launch fragment work informs OpenBao's, so v0.10 lands the same JSON-extraction pattern `aws-secrets` pioneered. - -v0.9.0 → v0.10.0: workspace unit tests **735 → 778** (+43 from the new openbao crate, counted by `cargo test --workspace` `test result: ok.` lines summed). Live smoke matrix **419 → 452** (+29 for Section 26: doctor Level 1+2, scalar round-trip, `#json-key` fragment, end-to-end `run`, set/list/unset cycle, fragment-reject, history-unsupported, registry-source cross-backend chain, HTTP/HTTPS mismatch surface). Pre-tag full-matrix smoke passed 444/452 on the first run; the eight failures were all in Section 26 and all smoke-test design bugs (a redundant fragment-on-scalar test, a missing pre-seed at the unique-per-run cycle path that `registry set` reads-then-writes, and a stale "not supported" assertion vs the trait-default "not implemented" wording). Re-run after the smoke-test patch: **452/452**. Closing three-agent trio audit (security + code + rust) landed 1 HIGH + 3 MEDIUM findings inline before tag; LOW + remaining MEDIUMs deferred to v0.10.x carry-forward. - -This release also folds the **v0.9.2 hygiene cycle** (merged-not-tagged 2026-04-26 per the rolling-backlog pattern) into the tagged CHANGELOG. - -### Added — OpenBao backend (v0.10) - -- **`secretenv-backend-openbao` crate** — `OpenBaoFactory` registered unconditionally in `secretenv-cli/src/backends_init.rs`. URI shape `openbao-:///[#json-key=]`. `BAO_ADDR` / `BAO_NAMESPACE` routed via per-child env (same lesson as Vault PR #33; argv-form `-address` flags after positional path tokens are rejected by the CLI parser). -- **`bao_unsafe_set` defense-in-depth flag** — reserved opt-in for any future regression that routes the secret through argv. v0.10 always uses the safe `value=-` stdin form; the flag defaults to `false` and is observed at factory time only. Default-off invariant machine-checked via `OpenBaoFactory::create_concrete` test path. -- **`#json-key=` fragment on `get`** — parses the `value` field as a JSON object and extracts the named top-level scalar. Mirrors `aws-secrets`. `set` / `delete` / `list` / `history` reject any fragment. -- **`openbao` addition to `serialize_registry` JSON arm** (`secretenv-cli`) — registry documents through the `openbao` backend round-trip as `value=-` JSON-strings, matching `aws-ssm` / `aws-secrets` / `gcp` / `azure` / `vault`. -- **Smoke harness Section 26** (29 assertions covering doctor / get / fragment / run / cycle / fragment-reject / history-unsupported / registry-source / cross-backend resolve / HTTP/HTTPS mismatch). Skipped cleanly if `bao` is missing OR the server is sealed/unreachable. -- **`docs/backends/openbao.md`** — leads with the `BAO_ADDR` HTTP/HTTPS gotcha, contrasts the install path with Vault's tap form, explains the MPL-2.0 vs BSL governance distinction. -- **`secretenv-backend-openbao` AGPL-3.0-only exception** in `deny.toml`. -- **README backend table** 12 → 13 + backend-count badge bumped. -- **Smoke harness README inventory** + `SECTIONS` array — sections 22–26 backfilled; sections 23–25 had drifted from prior cycles. +Fifth single-backend-per-release cycle adding OpenBao (Linux Foundation MPL-2.0 Vault fork, via the `bao` CLI 2.x), bringing the total to 13; workspace unit tests 735 to 778 (+43); live smoke 419 to 452 (+29 for Section 26); full matrix 452/452 after a smoke-test patch; trio audit landed 1 HIGH + 3 MEDIUM inline; folds the v0.9.2 hygiene cycle. -### Spec divergence (intentional) - -- **`list()` storage model** — the spec at `kb/wiki/backends/openbao.md` originally described `list()` as parsing `data.data` as a multi-field alias map (Vault-style). The shipped implementation instead reads a JSON-string from the canonical `value` field (`aws-secrets`-style), driven by a single-field-per-secret writer discipline (`bao kv put value=-`). The aws-secrets shape is internally consistent with this backend's `set()` path and with the `#json-key=` fragment design that ships from day one. Documented in lib.rs crate-level docs, `docs/backends/openbao.md` "Storage model" section, and the spec was amended to match. - -### Folded — v0.9.2 hygiene (merged-not-tagged 2026-04-26) - -Fourth consecutive rolling-backlog cycle (v0.7.1 → v0.7.2 → v0.9.1 → **v0.9.2**) draining the v0.9.x carry-forward queue before the v0.10 OpenBao cycle opens. Merged to `main`, workspace `version` stays at `0.9.0`, no tag. v0.10 OpenBao release will fold these into its tagged CHANGELOG. - -#### Added - -- **`cf_kv_list_prefix` config field** (cf-kv backend) — optional key-prefix filter passed to `wrangler kv key list` as `--prefix `. Enables single-namespace scalar+registry mixing via key conventions (e.g. `cf_kv_list_prefix = "registry/"` so registry-source aliases live under `registry/` while plain scalar secrets share the namespace at the top level). Empty string is normalized to `None` at factory time. Closes the v0.9.x carry-forward "cf-kv `--prefix` flag" item. Pre-v0.9.2 alternative — two separate namespaces — still works and remains the default posture for accounts where namespace count is not a constraint. -- **17 new factory-validation unit tests** across `secretenv-backend-1password` (+5: `op_unsafe_set` accept/reject + `timeout_secs` honor/default/reject), `secretenv-backend-cf-kv` (+4: `wrangler_bin` reject + `timeout_secs` honor/reject/zero) and prefix-related (+2 above), `secretenv-backend-vault` (+4: `vault_namespace`/`vault_bin` reject + `timeout_secs` honor/default), `secretenv-backend-aws-secrets` (+4: `aws_profile`/`aws_bin` reject + `timeout_secs` honor/default). Closes the cf-kv code-reviewer M1 carry-forward "factory-helper test parity audit" — the four backends with the thinnest factory-validation coverage now match the keeper / azure / aws-ssm pattern. - -#### Audited (no findings) - -- **Workspace-wide placeholder-field audit** — methodology: `grep -rn "Reserved\|reserved" crates/backends/*/src/*.rs` plus per-backend struct-field walk for any `Option` config field never read after factory construction. Result: zero placeholder fields outside the `keeper_folder` already removed in v0.9.1. The two grep hits (`backend-doppler` "Fragments are reserved for v0.7+", `backend-infisical` "Fragments are reserved and currently rejected") are doc-comment phrases describing forward-compatibility posture, not dead config fields. Audit captured here so it doesn't get re-run unnecessarily next cycle. - -#### Deferred / declined (carry-forward closeout notes) - -- **cf-kv `bulk get`** — DEFER. Cloudflare API still in open-beta as of 2026-04-26. Trigger to revisit: GA announcement. -- **cf-kv `#metadata` fragment** — DECLINE. Speculative; zero user demand. Resurrect on demand. -- **Rust 1.87+ `env::set_var` unsafe-wrap (R-13 from v0.7.2)** — DEFER. Workspace MSRV is 1.75. Bumping to 1.87 in a hygiene patch is a user-facing breaking floor change, not hygiene. Trigger: next natural workspace MSRV bump. -- **256 KiB `spawn_blocking` measured benchmark (v0.7.2 carry-over)** — DEFER. Needs `cargo bench` / criterion infrastructure that doesn't exist in the workspace yet. Trigger: dedicated benchmark-infrastructure micro-cycle. - -### v0.9.1 hygiene (merged-not-tagged 2026-04-25) - -Rolling-backlog cycle following the v0.7.1 / v0.7.2 dev-work pattern: merged to `main`, workspace `version` stays at `0.9.0`, no tag. Closes 13 actionable items from the v0.9 trio audit deferred list ([reviews/2026-04-25-v0.9-cf-kv-audit](kb/wiki/reviews/2026-04-25-v0.9-cf-kv-audit.md)) plus the v0.8.x Keeper carry-forward backlog plus baseline smoke hygiene. v0.10 OpenBao release will fold these into its tagged CHANGELOG. - -#### Added - -- **`keeper_list_max_records`** (Keeper backend) — opt-in cap on `list()` per-record fan-out. Default unset (no cap). Bounds heap residence and outbound rate-limit pressure on large vaults; hitting the cap emits a `tracing::warn!`. Closes Keeper trio sec-H1 follow-up. - -#### Changed - -- **`keeper_config_path` validated at factory time** — file existence + POSIX mode `0o077` mask. Permissive modes (group/other-readable) now bail with a `chmod 600` hint instead of silently loading a Keeper device token from a shared file. Closes Keeper trio sec-H2. -- **cf-kv `WranglerWhoami::parse` refactored to `find_map`** — clearer style, same behavior. Closes cf-kv rust-L1. -- **cf-kv `resolve_target` allocation-free** — `split_once('/')` chain replaces the previous `Vec<&str>` collect. Closes cf-kv rust-L5. -- **cf-kv smoke namespace IDs centralized** to `scripts/smoke-test/lib/cfkv-namespace.env` — single source of truth (was 3-place duplication). Closes cf-kv code-reviewer L2. -- **cf-kv docs expanded** with Troubleshooting, `set()` opt-in posture comparison table vs other backends, and multi-namespace worked example. Closes cf-kv code-reviewer L1. -- **Backend-spec template Phase 1 checklist** explicit "new crate → `deny.toml` per-crate AGPL exception" + `cargo deny check licenses` preflight gate. Captures the v0.8 Keeper lesson so future cycles don't hit the CI-only failure. - -#### Removed - -- **`keeper_folder` config field** — declared since v0.8 but never wired up (documented as "reserved for future short-form URI scoping. Currently accepted but unused"). API-surface debt removed; if folder scoping ships in a future release it will be re-introduced under a deliberate spec. - -#### Fixed - -- **Section 17 (v0.4 history) — `seed_runtime_from_fixtures` git-init validity gate.** The previous `[ ! -d .git ]` check skipped re-init when `.git/` existed but was broken (e.g., empty from a prior failed run). Replaced with `git rev-parse --git-dir` validity probe that wipes and reinits broken state. Recovers the 8 baseline failures (assertions 185-192) that were stuck in this state. Pre-existing baseline drift surfaced by v0.9 pre-tag full-matrix smoke. -- **Section 25k cf-kv post-delete read assertion** — switched pattern from lowercase `'not found'` to literal `'404'` to match wrangler 4.85.0's actual `404: Not Found` (capital N) error string. Same evidence; immune to case-shifts. +### Added +- `secretenv-backend-openbao` crate: `OpenBaoFactory` registered unconditionally in `secretenv-cli/src/backends_init.rs`. URI shape `openbao-:///[#json-key=]`. `BAO_ADDR` / `BAO_NAMESPACE` routed via per-child env. Three divergences from Vault: binary name (`bao` vs `vault`), env-var prefix (`BAO_*` with `VAULT_*` CLI fallback), install path (`brew install openbao`, no tap). +- `bao_unsafe_set` defense-in-depth flag: reserved opt-in for any future argv-routing regression. v0.10 always uses the safe `value=-` stdin form; defaults `false`, observed at factory time. Default-off invariant machine-checked. +- `#json-key=` fragment on `get` parses the `value` field as JSON and extracts the named top-level scalar; `set` / `delete` / `list` / `history` reject any fragment. +- `openbao` added to `serialize_registry` JSON arm (`secretenv-cli`); registry docs round-trip as `value=-` JSON-strings. +- Smoke harness Section 26 (29 assertions) covering doctor / get / fragment / run / cycle / fragment-reject / history-unsupported / registry-source / cross-backend resolve / HTTP/HTTPS mismatch. Skipped if `bao` missing or server sealed/unreachable. +- `docs/backends/openbao.md`: leads with the `BAO_ADDR` HTTP/HTTPS gotcha, contrasts the install path with Vault's tap form, explains the MPL-2.0 vs BSL governance distinction. +- `secretenv-backend-openbao` AGPL-3.0-only exception in `deny.toml`. +- README backend table 12 to 13 + backend-count badge bumped. +- Smoke harness README inventory + `SECTIONS` array: sections 22-26 backfilled (23-25 had drifted). +- v0.9.2 hygiene: `cf_kv_list_prefix` config field (cf-kv) optional key-prefix filter passed to `wrangler kv key list` as `--prefix `; empty string normalized to `None` at factory time. +- v0.9.2 hygiene: 17 new factory-validation unit tests across `secretenv-backend-1password` (+5), `secretenv-backend-cf-kv` (+4 plus +2 prefix), `secretenv-backend-vault` (+4), `secretenv-backend-aws-secrets` (+4). +- v0.9.1 hygiene: `keeper_list_max_records` (Keeper) opt-in cap on `list()` per-record fan-out; default unset; hitting the cap emits a `tracing::warn!`. +- 343a registry-namespace must NOT contain scalar fixtures (negative assertion locks the two-namespace discipline). +- 352-354 wrangler-delete-actually-deletes canary: provisions a probe key, deletes via wrangler in non-TTY mode, asserts post-delete read returns `404` and `wrangler kv key list` no longer shows the probe. -#### Smoke Section 25 additions (v0.9.1 hygiene) +### Changed +- Intentional spec divergence: `list()` storage model reads a JSON-string from the canonical `value` field (`aws-secrets`-style) driven by single-field-per-secret writer discipline (`bao kv put value=-`), not the spec's original Vault-style `data.data` multi-field map; documented in lib.rs, `docs/backends/openbao.md`, and the spec amended. +- v0.9.1 hygiene: `keeper_config_path` validated at factory time (file existence + POSIX mode `0o077` mask); permissive modes bail with a `chmod 600` hint. +- v0.9.1 hygiene: cf-kv `WranglerWhoami::parse` refactored to `find_map`. +- v0.9.1 hygiene: cf-kv `resolve_target` allocation-free (`split_once('/')` chain replaces `Vec<&str>` collect). +- v0.9.1 hygiene: cf-kv smoke namespace IDs centralized to `scripts/smoke-test/lib/cfkv-namespace.env`. +- v0.9.1 hygiene: cf-kv docs expanded with Troubleshooting, `set()` opt-in posture comparison table, multi-namespace worked example. +- v0.9.1 hygiene: backend-spec template checklist gains explicit "new crate to `deny.toml` per-crate AGPL exception" + `cargo deny check licenses` preflight gate. -- **343a — registry-namespace must NOT contain scalar fixtures** (sec-L1). Negative assertion locks the two-namespace discipline so a future regression mixing scalar + URI keys in the registry namespace fails loudly. -- **352–354 — wrangler-delete-actually-deletes canary** (sec-M2). Provisions a probe key, deletes via wrangler in the same non-TTY mode the cf-kv backend uses, asserts (a) post-delete read returns `404`, (b) `wrangler kv key list` no longer shows the probe. Locks the wrangler contract our backend depends on; if wrangler ever regresses to default-no on its interactive confirmation prompt, this canary catches it. +### Removed +- v0.9.1 hygiene: `keeper_folder` config field, declared since v0.8 but never wired up. -#### Smoke matrix delta +### Fixed +- v0.9.1 hygiene: Section 17 (v0.4 history) `seed_runtime_from_fixtures` git-init validity gate; replaced `[ ! -d .git ]` with a `git rev-parse --git-dir` validity probe that wipes and reinits broken state; recovers 8 baseline failures (assertions 185-192). +- v0.9.1 hygiene: Section 25k cf-kv post-delete read assertion switched pattern from lowercase `'not found'` to literal `'404'` to match wrangler 4.85.0's `404: Not Found`. -- v0.9.0 pre-tag: 408/419 PASS (11 baseline failures: 8 history + 3 Infisical). -- v0.9.1 post-fix: **420/423 PASS** (3 remaining = Infisical session expired locally; environmental, not code). +12 net recovered + 4 new assertions. +### Known limitations +- v0.9.2 hygiene: workspace-wide placeholder-field audit found zero placeholder fields outside the already-removed `keeper_folder`; the two grep hits (backend-doppler, backend-infisical) are forward-compat doc phrases, not dead config. +- cf-kv `bulk get` deferred (Cloudflare API in open-beta; trigger: GA). +- cf-kv `#metadata` fragment declined (speculative). +- Rust 1.87+ `env::set_var` unsafe-wrap deferred (workspace MSRV is 1.75; trigger: next MSRV bump). +- 256 KiB `spawn_blocking` measured benchmark deferred (no `cargo bench` / criterion infra yet). +- Smoke matrix delta: v0.9.0 pre-tag 408/419 PASS (11 baseline failures: 8 history + 3 Infisical); v0.9.1 post-fix 420/423 PASS (3 remaining Infisical session expired locally, environmental). ## [0.9.0] - 2026-04-25 -**Headline:** fourth release of the single-backend-per-release cycle; fourth [[project_cycle_execution_model|solo-fresh-session]] release. One new backend — **Cloudflare Workers KV** (`cf-kv` via `wrangler` CLI 4.x) — brings the total to **12**. First backend whose Phase 0 CLI-shape probe materially flipped the spec: the v0.7-era spec recommended a curl-against-REST design citing 2-3 s wrangler startup, but live measurement at wrangler 4.85.0 showed 0.28 s startup (Bun runtime), so the entire curl branch was retired and the backend ships wrangler-wrapped. v0.8.0 → v0.9.0: workspace unit tests **676 → 705** (+29 from the new cf-kv crate); live smoke matrix **395 → 419** (+24 for Section 25's 13 cf-kv assertions plus a few extras). Pre-tag full-matrix smoke passed all 13 cf-kv assertions on the second run after fixing a registry-source design issue (single namespace can't mix scalar secrets with URI-valued aliases — the resolver bails on the first non-URI value; switched to a two-namespace pattern, mirroring how Doppler/Infisical use separate paths). Closing three-agent trio audit (security + code + rust) landed 8 BLOCKING/HIGH/MEDIUM findings inline before tag. The remaining 11 baseline-smoke failures (8 in v0.4 history + 3 in v0.7 Infisical) are environmental drift unrelated to v0.9 — to be addressed in a follow-up hygiene cycle. +Fourth single-backend solo-fresh-session release; adds Cloudflare Workers KV (`cf-kv` via wrangler 4.x) for 12 backends total; unit tests 676 to 705, live smoke 395 to 419. ### Added - -- **Cloudflare Workers KV backend** (`cf-kv`) wrapping the official `wrangler` CLI 4.x. Supports OAuth (`wrangler login`) and `CLOUDFLARE_API_TOKEN` env-var auth transparently. Two-segment URI shape `cf-kv-:////`, with optional single-segment form `cf-kv-:///` enabled by `cf_kv_default_namespace_id` config. `set()` writes through a mode-0600 tempfile + `--path` flag (no `_unsafe_set` opt-in needed — strictly safer than argv; matches Infisical's `--file` discipline). `list()` is Pattern A bulk-mode with sequential per-key fan-out for value hydration; `history()` is unsupported (KV has no per-key versioning — overwrites simply replace). Brings the total backend count to **12**. Spec at [docs/backends/cf-kv.md](docs/backends/cf-kv.md). -- **Smoke Section 25** (assertions 330-352, 13 total) covering cf-kv as both a secrets backend (doctor / get / run-injection / history-unsupported / fragment-reject) AND a registry source (registry list / registry get / cross-backend resolve / end-to-end run via cf-kv-backed registry → local-main file content). Two-namespace pattern (`secretenv-smoke-v09` for secrets + `secretenv-smoke-v09-registry` for URI-valued aliases) is documented in `provision.sh` since cf-kv namespaces are flat (no folders to scope mixed key types). +- Cloudflare Workers KV backend (`cf-kv`) wrapping wrangler CLI 4.x; supports OAuth (`wrangler login`) and `CLOUDFLARE_API_TOKEN` auth. Two-segment URI `cf-kv-:////`, optional single-segment `cf-kv-:///` via `cf_kv_default_namespace_id`. `set()` writes through mode-0600 tempfile + `--path` (no `_unsafe_set`). `list()` is Pattern A bulk-mode with sequential per-key value hydration; `history()` unsupported. 12th backend. +- Smoke Section 25 (assertions 330-352, 13 total) covering cf-kv as secrets backend and registry source; two-namespace pattern (`secretenv-smoke-v09` + `secretenv-smoke-v09-registry`). ### Fixed - -- **Tightened `cf-kv` "key not found" detector** from a loose `"10009"` substring match to word-boundary forms (`error 10009` / `code 10009` / `code: 10009`) — prevents false-positives on stderr containing the digit run inside request IDs or timestamps. Surfaced by Phase 6 trio audit. -- **Made `set()` tempfile flush fatal** (`with_context` instead of `.ok()`) — silently dropping a flush error could leave wrangler reading a truncated value with no surface to caller. Surfaced by Phase 6 trio audit. -- **Added `NotFound` mapping to the `whoami` arm of `check()`** — the `tokio::join!` fires both probes simultaneously; the previous code only mapped `NotFound` on the version arm. Defense-in-depth for OS-shape variation. Surfaced by Phase 6 trio audit. +- Tightened `cf-kv` key-not-found detector from loose `"10009"` substring to word-boundary forms (`error 10009` / `code 10009` / `code: 10009`). +- Made `set()` tempfile flush fatal (`with_context` instead of `.ok()`). +- Added `NotFound` mapping to the `whoami` arm of `check()`. ## [0.8.0] - 2026-04-24 -**Headline:** third release of the single-backend-per-release cycle; third [[project_cycle_execution_model|solo-fresh-session]] release. One new backend — **Keeper** (Keeper Security vault via Keeper Commander v17+) — brings the total to 11. First backend to require a **prerequisite setup step** (persistent-login device-token registration); the install UX has an extra paragraph in docs as a result. v0.7.1 → v0.8.0: workspace unit tests **645 → 676** (+31 from the new Keeper crate); live smoke matrix **383 → 395** (+12 for Section 24). Pre-tag full-matrix smoke passed 395/395 across all 11 backends on first clean run; closing three-agent trio audit (security + code + rust) landed 9 BLOCKING/HIGH/MEDIUM findings inline before tag, including a post-audit whoami text-parse fix surfaced during pre-tag smoke (CLI v17 `keeper whoami` has no `--format=json` flag — a spec/impl drift caught at the final integration gate). Note: v0.7.1 + v0.7.2 shipped as merged-not-tagged hygiene cycles; workspace version stayed at 0.7.1 until this tag bumps directly to 0.8.0. +Third single-backend solo-fresh-session release; adds Keeper (Keeper Commander v17+) for 11 backends total; unit tests 645 to 676, live smoke 383 to 395; bundles v0.7.1 + v0.7.2 merged-not-tagged hygiene work. ### Added +- Keeper backend (`keeper`) wrapping the `keeper` CLI (Keeper Commander v17+, `pip install keepercommander`); 11th backend. Requires persistent-login setup as a prerequisite (device token via `keeper shell` then `this-device register` then `this-device persistent-login on`). Enforces `--batch-mode` on every invocation. URI `keeper-:///`, optional `#field=` fragment (default password). Config: `keeper_config_path`, `keeper_unsafe_set` (default false, gates argv-based `set()`, emits `tracing::warn!`), `timeout_secs`, `keeper_bin`. `list()` Pattern A bulk model, per-record failures emit `tracing::warn!`. `history()` unsupported. +- Smoke Section 24, 12 live assertions; provisions `SMOKE_TEST_VALUE` (`kp_vault_88888`) and `SMOKE_REGISTRY_ALIAS` records; tagged `cloud=yes`. -- **Keeper backend** (`keeper`) — 11th backend. Wraps the `keeper` CLI (Keeper Commander v17+, installed via `pip install keepercommander`) to read, write (opt-in), delete, and list secrets in a user's Keeper vault. Unlike every other SecretEnv backend, **Keeper requires persistent-login setup as a prerequisite** — one-shot CLI invocations prompt for the master password unless a device token has been persisted via `keeper shell` → `this-device register` → `this-device persistent-login on`. The backend enforces `--batch-mode` on every invocation to prevent interactive prompts from hanging; a non-authenticated instance surfaces cleanly through `doctor` with a setup hint rather than blocking. URI shape: `keeper-:///` — a single path segment the CLI resolves to either a 22-char base64url UID or a record title. Optional `#field=` fragment selects a typed or custom field (default: password). Config fields: `keeper_config_path` (custom `~/.keeper/config.json` path for multi-account setups), `keeper_unsafe_set` (default `false`; opt-in gate for argv-based `set()` — the Keeper CLI has no stdin form for field values, matching 1Password's `op_unsafe_set` precedent; emits `tracing::warn!` per unsafe invocation), `timeout_secs`, `keeper_bin` (test hook). `list()` uses the Pattern A bulk model (each vault record = one alias, password field = target URI) mirroring Doppler + Infisical; per-record failures during the hydration fan-out emit `tracing::warn!` with instance + title + reason rather than silent-continue (divergence from the silent pattern was flagged + fixed inline during trio audit). `history()` is unsupported — CLI `history` is interactive-shell command history, NOT per-record version history; bails with Vault-UI pointer. Full reference at [`docs/backends/keeper.md`](docs/backends/keeper.md). -- **Smoke harness Section 24** — 12 live-backend assertions for the new Keeper backend. Provisions `SMOKE_TEST_VALUE` (scalar `kp_vault_88888`) and `SMOKE_REGISTRY_ALIAS` (URI-valued) records in the user's root folder; exercises `doctor` / `get` (round-trip via both title and registry alias) / `run` (end-to-end env injection) / `history` (unsupported bail) / unknown-fragment-reject end-to-end. Tears down both records after. Tagged `cloud=yes`; skipped when the `keeper` CLI is missing OR persistent login isn't set up. - -### Fixed (v0.7.1 + v0.7.2 hygiene work, merged as dev work 2026-04-23/24) - -The v0.7.1 (#67) and v0.7.2 (#68) cycles closed 25 DEFER items from the v0.6 Doppler and v0.7 Infisical closing audits as *merged-but-unreleased* work on `main`. Shipping now under the v0.8.0 tag so downstream users benefit. Full detail in [v0.7.1 CHANGELOG](#071---2026-04-23) and the build log. +### Changed +- Workspace version 0.7.1 to 0.8.0 (skips a 0.7.2 tag; crates.io sees v0.7.0 to v0.8.0 linearly). +- Backend count 10 to 11; README badge + supported-backends table updated. -Key items: +### Fixed - Infisical `set()` value-aware stderr scrub; fd-based chmod; non-UTF-8 `$TMPDIR` explicit bail. -- Doppler `ResolvedTarget` struct (replaces positional tuple); tightened `not found` heuristic so auth errors no longer mask as missing-secret; `tracing::debug!` on set/delete happy paths. -- Both Doppler + Infisical `list()` JSON parse hops to `spawn_blocking` ≥256 KiB (threshold PROVISIONAL pending 10K-secret benchmark). +- Doppler `ResolvedTarget` struct replaces positional tuple; tightened `not found` heuristic so auth errors no longer mask as missing-secret; `tracing::debug!` on set/delete happy paths. +- Both Doppler + Infisical `list()` JSON parse hops to `spawn_blocking` at 256 KiB and above. - Infisical env-inherit test deterministic via mutex-serialized `EnvVarGuard`. -- Live smoke coverage: Doppler + Infisical finally exercised as registry sources (sections 22g, 23g) — closed the long-deferred TODO from v0.6. -- Documentation polish: Infisical self-hosted domain trust, Doppler IAM/RBAC + chmod advisory, `docs/security.md#self-hosted-domains` cross-linked from Infisical + Vault. - -### Changed - -- **Workspace version 0.7.1 → 0.8.0.** v0.7.1 + v0.7.2 held Cargo.toml at 0.7.1 while merging hygiene work as dev-only (no tag, no crates.io publish). This v0.8.0 tag bumps directly to 0.8.0, skipping a 0.7.2 tag. Users pulling from crates.io see v0.7.0 → v0.8.0 linearly; the intermediate hygiene fixes are bundled into v0.8.0. -- **Backend count 10 → 11.** README badge + supported-backends table reflect the Keeper addition. - -### Deferred to v0.8.x+ - -- **Keeper `keeper_config_path` world-readable stat-check** at factory time (trio security H2). Feature-add, not tag-blocker. -- **Keeper Pattern A `registry set` extension.** Would let `registry set`/`unset` target Keeper (as well as Doppler + Infisical). Feature, not hygiene; target v0.9+. -- **`keeper_folder` short-form URI scoping.** Field declared + accepted; implementation deferred. -- **Rust 1.87+ `env::set_var` unsafe-wrap** (v0.7.2 carry-over R-13) — triggers when workspace rust-version floor moves. -- **256 KiB `spawn_blocking` threshold benchmark** (v0.7.2 carry-over) — distinct activity, not hygiene-shaped. +- Doppler + Infisical now exercised as registry sources in live smoke (sections 22g, 23g). +- Documentation polish: Infisical self-hosted domain trust, Doppler IAM/RBAC + chmod advisory, `docs/security.md#self-hosted-domains` cross-linked. ## [0.7.1] - 2026-04-23 -**Headline:** dedicated DEFER-closeout hygiene patch between v0.7.0 and v0.8, keeping the next backend cycle cleanly thematic. Twenty items from the v0.6 (Doppler) and v0.7 (Infisical) closing audits closed in a single patch PR: three security-hardening items on Infisical's `set()` temp-file path, six consistency items unifying Doppler and Infisical error shapes and symmetry patterns, three testing-quality items (deterministic env-isolation, list()-parse spawn_blocking on multi-MB payloads, symmetric drift-catch naming), and eight documentation polish items spanning the self-hosted-domain trust boundary, IAM/RBAC walkthroughs, and smoke-harness fixture comments. No user-facing behavior changes beyond tightened error messages. v0.7.0 → v0.7.1: workspace unit tests **637 → 642** (+5); live smoke matrix unchanged at **373 assertions** (hygiene doesn't add assertions; pre-tag smoke re-runs the v0.7.0 baseline). Closing audit by the `code-reviewer` agent landed 2 HIGH fixes (ENV_LOCK invariant documentation + spawn_blocking threshold provisional flag) inline before tag; 0 BLOCKING findings. +DEFER-closeout hygiene patch closing 20 items from the v0.6 Doppler and v0.7 Infisical audits; no user-facing behavior change beyond tightened error messages; unit tests 637 to 642, live smoke unchanged at 373. ### Changed - -- **Doppler `resolve_target` returns a struct** (`ResolvedTarget { project, config, secret }`), replacing the positional `(&str, &str, &str)` tuple. Mirrors the Infisical backend's shape so the two SaaS-bulk-model backends read the same way across four call sites (`get` / `set` / `delete` / `list`). -- **Doppler `not found` heuristic tightened.** Now requires the CLI's canonical `"Could not find requested secret"` prefix rather than the looser `"not found"` substring. Before: a stderr line like `Doppler Error: Unauthorized: token not found` (auth-error phrasing) false-positived into the friendly "secret not found" arm and hid the real failure. After: only the canonical missing-secret phrasing routes to the friendly arm; auth errors surface verbatim via `operation_failure_message`. Regression tests on both `get()` and `delete()` lock the distinction. -- **Doppler segment-count error surfaces parsed segments.** A URI like `doppler-prod:///acme/prd` (two segments) now errors with `got 2 segment(s): [acme, prd]` instead of just `got 2 segment(s)`. -- **Doppler `set()` and `delete()` emit `tracing::debug!` on happy paths.** Matches the Keychain backend's audit-symmetry precedent. No values logged — only instance, op, project, config, secret name. -- **Infisical `resolve_target` lifetimes split.** Previously `<'a>(&'a self, uri: &'a BackendUri)` over-constrained `self` and `uri` to the same lifetime. Now `<'s, 'u>(&'s self, uri: &'u BackendUri) -> Result> where 's: 'u` — callers with `self` outliving the URI (the common case) get precise borrow-checker reasoning without an artificial lifetime tie. -- **Both backends' `list()` JSON parse uses `tokio::task::spawn_blocking` above 256 KiB.** Below the threshold, the zero-overhead inline path runs (thread-pool dispatch > parse cost for a typical small registry). Above it, the parse runs on a blocking worker thread so a multi-MB payload stops stalling the tokio executor. Threshold is provisional — a measured benchmark is deferred to v0.7.2+. -- **Infisical drift-catch test renamed.** `delete_without_type_shared_flag_would_fail_strict_mock` → `delete_requires_type_shared_flag` for symmetry with the existing set() canary. Dual-purpose doc comment added to `set_value_never_appears_on_argv` flagging its paired `--type shared` drift-catch role. +- Doppler `resolve_target` returns a struct (`ResolvedTarget { project, config, secret }`) replacing the positional tuple across `get` / `set` / `delete` / `list`. +- Doppler `not found` heuristic tightened to require the canonical `"Could not find requested secret"` prefix; auth errors surface verbatim via `operation_failure_message`. Regression tests on `get()` and `delete()`. +- Doppler segment-count error surfaces parsed segments (`got 2 segment(s): [acme, prd]`). +- Doppler `set()` and `delete()` emit `tracing::debug!` on happy paths (no values logged). +- Infisical `resolve_target` lifetimes split to `<'s, 'u>(&'s self, uri: &'u BackendUri) -> Result> where 's: 'u`. +- Both backends' `list()` JSON parse uses `tokio::task::spawn_blocking` above 256 KiB; threshold provisional. +- Infisical drift-catch test renamed `delete_without_type_shared_flag_would_fail_strict_mock` to `delete_requires_type_shared_flag`; dual-purpose doc comment added to `set_value_never_appears_on_argv`. ### Fixed - -- **Infisical `set()` stderr scrubs the secret value** before folding stderr into the error chain. A CLI parse-error that echoes the `--file` contents back can surface `NAME=VALUE` in stderr; the new `set_failure_message` helper replaces the value string with `` when present (≥4 chars to avoid collision-prone short values), preserving non-value diagnostic information for debugging. Dedicated unit tests cover both the scrub path and the passthrough case. -- **Infisical env-inherit test deterministic.** `check_not_authenticated_when_probe_fails_and_no_token` previously carried a runtime skip if the parent process had `$INFISICAL_TOKEN` set. Now wrapped in an RAII `EnvVarGuard` (mutex-serialized against `ENV_LOCK`) that unsets for the test duration and restores on drop. Test runs and asserts deterministically regardless of parent-process state. +- Infisical `set()` stderr scrubs the secret value before folding into the error chain via `set_failure_message` (replaces value with `` when 4+ chars). Unit tests cover scrub + passthrough. +- Infisical env-inherit test `check_not_authenticated_when_probe_fails_and_no_token` now wrapped in RAII `EnvVarGuard` (mutex-serialized against `ENV_LOCK`), deterministic regardless of parent-process state. ### Security - -- **Infisical `set()` uses fd-based `chmod` instead of path-based.** Replaces `std::fs::set_permissions(tempfile.path(), perm)` with `tempfile.as_file().set_permissions(perm)` — closes the narrow TOCTOU window between `NamedTempFile::new()` and the redundant 0600 re-assertion. (The `NamedTempFile` crate already creates with `O_CREAT|O_EXCL` + mode 0600 on Unix; the explicit re-chmod is belt-and-braces.) -- **Infisical `set()` explicit bail on non-UTF-8 `$TMPDIR` paths.** Replaces `to_string_lossy().into_owned()` with `to_str().ok_or_else(...)?` — a non-UTF-8 temp path now surfaces a clear error instead of passing a U+FFFD-substituted garbled string to `infisical secrets set --file` (which would fail with an opaque "file not found"). +- Infisical `set()` uses fd-based `chmod` (`tempfile.as_file().set_permissions(perm)`) instead of path-based, closing the TOCTOU window. +- Infisical `set()` explicit bail on non-UTF-8 `$TMPDIR` paths (`to_str().ok_or_else(...)?` instead of `to_string_lossy()`). ### Docs - -- **Infisical self-hosted domain-trust section expanded.** New subsection under [Minting a service token](docs/backends/infisical.md) with pin-the-cert callout, verify-the-domain checklist, `openssl s_client` inspection snippet, and the "malicious domain receives every token" threat statement. Cross-linked to new `docs/security.md#self-hosted-domains`. -- **Infisical `NamedTempFile` panic-safety wording.** `set()` discipline section now spells out that `Drop` runs during unwind so panics / cancellations / runtime drops cannot orphan a secret-bearing file under `$TMPDIR`. -- **Doppler IAM/RBAC expansion** under [Service-token scope mismatch](docs/backends/doppler.md): service-token scope-mismatch vs. auth-error distinction (ties back to the tightened `not found` heuristic above), service accounts and project-level access grants, multi-workplace setup pattern. -- **Doppler `doppler_token` file-permissions advisory.** Explicit `chmod 600 config.toml` guidance matching the 1Password backend's precedent, with a pointer to also `700`-scope the parent directory. -- **Smoke harness Infisical blocks flagged "fixtures only, never use for a real secret"** on both `provision.sh` and `teardown.sh`. The provisioner uses the CLI's positional argv form (value on argv) because the value is a known-fixed fixture string and this is a fixture hook, not the code path end-users invoke. -- **`docs/security.md` new Self-hosted Domains section** cross-linked from both Infisical (`infisical_domain`) and Vault (`vault_address`). Covers the five-point discipline: verify the domain, pin HTTPS, confirm the cert chain, don't inherit a domain from an untrusted registry, rotate tokens after suspected exposure. - -### Deferred to v0.7.2+ - -- **10K-secret `list()` benchmark** for the 256 KiB `spawn_blocking` threshold. Constant is explicitly marked `PROVISIONAL` in-source; benchmark to be captured under the smoke-harness report that exercises `list()` against a large real registry. -- **Pattern A registry `set` extension** (would let `registry set`/`unset` target Doppler + Infisical). This is a feature, not hygiene. Logged in the v0.8+ roadmap row. -- **Already-correct-code-just-needs-a-comment** items from the v0.7 audit (redundant chmod rationale, `sync_all` doc, env-var check scope comment) — drive-by cleanups for any future PR touching those lines. +- Infisical self-hosted domain-trust section expanded with pin-the-cert callout, verify checklist, `openssl s_client` snippet; cross-linked to `docs/security.md#self-hosted-domains`. +- Infisical `NamedTempFile` panic-safety wording added. +- Doppler IAM/RBAC expansion: service-token scope-mismatch vs auth-error distinction, service accounts, multi-workplace pattern. +- Doppler `doppler_token` file-permissions advisory (`chmod 600 config.toml` + 700 parent dir). +- Smoke harness Infisical blocks flagged "fixtures only, never use for a real secret" on `provision.sh` and `teardown.sh`. +- `docs/security.md` new Self-hosted Domains section cross-linked from Infisical and Vault; covers five-point discipline. ## [0.7.0] - 2026-04-22 -**Headline:** third release of the single-backend-per-release cycle; second [[project_cycle_execution_model|solo-fresh-session]] release. One new backend — **Infisical** (SaaS + self-hostable) — brings the total to 10. Spec at [[backends/infisical]] shipped with three inline post-implementation corrections (no `secrets versions` subcommand at CLI v0.43.77; `--plain` deprecated on list (use `--output json`); `delete --type` defaults to `personal` and must be passed `--type shared` explicitly). v0.6.0 → v0.7.0: workspace unit tests **604 → 637** (+33); live smoke matrix **362 → 377** (+15). Pre-tag full-matrix smoke passed 377/377 across all 10 backends; closing reviewer-trio audit (security + code + rust) landed 3 BLOCKING fixes (serde `rename_all`, `list()` iterator readability, `with_context` quality) inline before tag; one post-audit `list()` semantic fix landed during pre-tag smoke (corrected to return `(name, value-as-URI)` pairs matching the Doppler-style bulk model). DEFER items consolidated into [[build-plan-v0.7.1]] for the next hygiene cycle. +Third single-backend release, second solo-fresh-session; adds Infisical (SaaS + self-hostable) for 10 backends total; unit tests 604 to 637, live smoke 362 to 377. ### Added - -- **Infisical backend** (`infisical`) — 10th backend. Wraps the `infisical` CLI (v0.43+) to read, write, delete, and list secrets in Infisical projects + environments + folders. Works against both the hosted SaaS at `app.infisical.com` and self-hosted Infisical instances — one config field (`infisical_domain`) flips between them. URI shape: full form `infisical-://///` or short form `infisical-:///` when `infisical_project_id` + `infisical_environment` defaults are set in `[backends.]`. Nested folders fold into middle segments (`////` → path = `/folder1/folder2`). Config fields (all optional): `infisical_project_id`, `infisical_environment` (both-or-neither), `infisical_secret_path` (default `/`), `infisical_token` (per-instance override for `$INFISICAL_TOKEN`), `infisical_domain` (self-hosted instance URL), `timeout_secs`, `infisical_bin` (test hook). Tokens travel via the `INFISICAL_TOKEN` subprocess env var — **never** via the `--token` argv flag (canary test `token_travels_via_env_not_argv` locks this); self-hosted domain travels via `INFISICAL_API_URL` env, never `--domain` on argv (symmetric discipline, `domain_travels_via_env_not_argv` canary). `set()` writes `NAME=VALUE` to a mode-0600 `NamedTempFile` under `$TMPDIR` and spawns `infisical secrets set --file --type shared` — the CLI has no stdin form, and using argv-positional form would expose values via `ps`; canary test `set_value_never_appears_on_argv` asserts values never reach argv. **`--type shared` is mandatory on set AND delete** — the CLI default is `personal` (user-override scope), which would silently target the wrong secret on shared project secrets; drift-catch test locks the invariant. `list()` uses `--output json` (the forward-compatible form; `--plain` is deprecated at v0.43.77) and follows the **Doppler-style bulk model** — each Infisical secret in the scoped env+path becomes one alias, the secret's value serves as the alias's target URI. `history()` is unsupported in v0.7 — the `infisical` CLI (v0.43.77) exposes no `secrets versions` subcommand; history exists in the Infisical Dashboard and REST API only. Full reference at [`docs/backends/infisical.md`](docs/backends/infisical.md). -- **Smoke harness Section 23** — live-backend assertions for the new Infisical backend. Provisions `SMOKE_TEST_VALUE=sk_test_infisical_55555` in the user's `secretenv-validation` project at `dev@/`; exercises `doctor` / `get` (full-form and short-form) / `run` / fragment-reject / `history` (unsupported bail) end-to-end. Tears down the seeded secret after. Tagged `cloud=yes`; skipped when the `infisical` CLI is missing or not authenticated. Project ID is overridable via `$SECRETENV_INFISICAL_PROJECT_ID`. Live matrix grows 362 → 373 (+11). Live-smoke of `backend.set()` is deliberately omitted — `registry set` only supports Pattern B backends (single-doc registries: local / 1password / aws-ssm / vault / aws-secrets / gcp / azure); Infisical follows the Pattern A Doppler-style bulk model, and unit-test canaries (argv + `--type shared` drift-catch) cover `set()` discipline. Extending `registry set` to Pattern A backends is deferred to v0.8+. +- Infisical backend (`infisical`) wrapping the `infisical` CLI (v0.43+); 10th backend. Works against hosted SaaS and self-hosted via `infisical_domain`. URI full form `infisical-://///` or short form `infisical-:///` with `infisical_project_id` + `infisical_environment` defaults; nested folders fold into middle segments. Config: `infisical_project_id`, `infisical_environment`, `infisical_secret_path` (default `/`), `infisical_token`, `infisical_domain`, `timeout_secs`, `infisical_bin`. Tokens travel via `INFISICAL_TOKEN` env, never `--token` argv (canary `token_travels_via_env_not_argv`); domain via `INFISICAL_API_URL`, never `--domain` (canary `domain_travels_via_env_not_argv`). `set()` writes `NAME=VALUE` to mode-0600 `NamedTempFile` and spawns `infisical secrets set --file --type shared` (canary `set_value_never_appears_on_argv`). `--type shared` mandatory on set and delete (CLI default is `personal`); drift-catch locks it. `list()` uses `--output json` (Doppler-style bulk model). `history()` unsupported. +- Smoke Section 23; provisions `SMOKE_TEST_VALUE=sk_test_infisical_55555` in `secretenv-validation` at `dev@/`; live matrix 362 to 373. `backend.set()` not live-smoked (registry set is Pattern B only); unit-test canaries cover set discipline. ## [0.6.0] - 2026-04-22 -**Headline:** second release of the single-backend-per-release cycle. One new backend (Doppler, 9th total), first **solo-fresh-session** cycle per the post-v0.5 execution model — orchestrator-solo through Phase 6, paused for sister-pane authentication at Phase 7, then tagged. Spec at [[backends/doppler]] shipped two post-implementation corrections (no `doppler secrets versions` subcommand in CLI v3.76.0; `DOPPLER_` prefix is not hard-reserved by Doppler). 9 backends supported: local, aws-ssm, aws-secrets, 1password, vault, gcp, azure, keychain, **doppler**. v0.5.0 → v0.6.0: unit tests **575 → 604** (+29); live smoke matrix **347 → 362** (+15). Pre-tag full-matrix smoke passed 362/362 across all 9 backends; closing reviewer-trio audit (security + code + rust) landed one BLOCKING fix (short-form URI + fragment-reject live assertions) plus three minor cleanups inline before tag; DEFER items captured in [[reviews/2026-04-22-v0.6-doppler-audit]]. +Second single-backend release, first solo-fresh-session cycle; adds Doppler for 9 backends total; unit tests 575 to 604, live smoke 347 to 362. ### Added - -- **Doppler backend** (`doppler`) — 9th backend. Wraps the `doppler` CLI (v3+) to read, write, delete, and list secrets in Doppler projects + configs. URI shape: full form `doppler-://///` or short form `doppler-:///` when `doppler_project` + `doppler_config` defaults are set in `[backends.]`. Config fields (all optional): `doppler_project`, `doppler_config` (both-or-neither), `doppler_token` (per-instance override for `$DOPPLER_TOKEN`), `timeout_secs`, `doppler_bin` (test hook). Tokens travel via the `DOPPLER_TOKEN` subprocess env var — **never** via the `--token` argv flag (canary test `token_travels_via_env_not_argv` locks this). `set` pipes values through child stdin with `--no-interactive` (CV-1 discipline, locked by stdin-fragment canary). `list()` uses the entire Doppler config as the alias map (each Doppler secret = one alias), with a **`DOPPLER_*`-prefix filter** that excludes the synthetic `DOPPLER_PROJECT` / `DOPPLER_CONFIG` / `DOPPLER_ENVIRONMENT` entries Doppler auto-injects into every `secrets download`. `history()` is unsupported in v0.6 — the `doppler` CLI (v3.76.0) exposes no `secrets versions` subcommand; history exists in the Doppler Dashboard and REST API only. A future CLI release adding `secrets versions` can flip this to a native implementation in a patch. Full reference at [`docs/backends/doppler.md`](docs/backends/doppler.md). -- **Smoke harness Section 22** — 15 live-backend assertions for the new Doppler backend. Provisions a `secretenv-validation` Doppler project + `dev` config with a `SMOKE_TEST_VALUE` fixture; exercises `doctor` / `get` (full-form and short-form) / `run` / fragment-reject / `history` (unsupported bail) end-to-end; tears down the test secret after. Tagged `cloud=yes`; skipped when the `doppler` CLI is missing or not authenticated. The synthetic-`DOPPLER_*`-key filter is locked by unit tests (`list_returns_filtered_map` + `list_filters_every_doppler_prefixed_key`) rather than in the live matrix, since `registry list --registry ` requires every entry's value to parse as a URI — and the live fixture's scalar `SMOKE_TEST_VALUE` exercises the `get` round-trip instead. Live matrix grows 347 → 362 (+15). +- Doppler backend (`doppler`) wrapping the `doppler` CLI (v3+); 9th backend. URI full form `doppler-://///` or short form `doppler-:///` with `doppler_project` + `doppler_config` defaults. Config: `doppler_project`, `doppler_config`, `doppler_token`, `timeout_secs`, `doppler_bin`. Tokens travel via `DOPPLER_TOKEN` env, never `--token` argv (canary `token_travels_via_env_not_argv`). `set` pipes values through child stdin with `--no-interactive`. `list()` uses the whole config as alias map with a `DOPPLER_*`-prefix filter excluding synthetic `DOPPLER_PROJECT` / `DOPPLER_CONFIG` / `DOPPLER_ENVIRONMENT`. `history()` unsupported. +- Smoke Section 22, 15 live assertions; provisions `secretenv-validation` project + `dev` config with `SMOKE_TEST_VALUE`; tagged `cloud=yes`; filter locked by unit tests `list_returns_filtered_map` + `list_filters_every_doppler_prefixed_key`; live matrix 347 to 362. ### Fixed - -- **Provision harness — Azure soft-delete recoverable state handling.** The v0.5 provision flow failed on a fresh run whenever `az keyvault secret delete` from a prior teardown had soft-deleted (rather than purged) the two `secretenv-validation-*` fixtures. The resulting `409 Conflict — ObjectIsDeletedButRecoverable` aborted the Azure block and cascaded into missing-secret failures in the v0.3 Azure smoke sections. Added `az_ensure_available()` helper in `scripts/smoke-test/provision.sh` that pre-checks `show-deleted` state per fixture, calls `az keyvault secret recover` + `sleep 8` propagation wait when soft-deleted, then proceeds to `set`. Makes provision fully idempotent across teardown/provision cycles. +- Provision harness Azure soft-delete recoverable state handling. Added `az_ensure_available()` in `scripts/smoke-test/provision.sh` that pre-checks `show-deleted` per fixture, runs `az keyvault secret recover` + `sleep 8` when soft-deleted, making provision idempotent across teardown/provision cycles. ## [0.5.0] - 2026-04-22 -**Headline:** first release of the "single backend per release" cycle — one new backend (macOS Keychain), the canonical `examples/` directory at repo root, and the five blocking fixes from the parallel three-agent closing audit. Rung-1 release of the [parallel-backend-workflow](kb/wiki/parallel-backend-workflow.md) — last "orchestrator writes the code" cycle before external agent sessions take over in v0.6. 8 backends supported: local, aws-ssm, aws-secrets, 1password, vault, gcp, azure, **keychain**. v0.4.0 → v0.5.0: unit tests **536 → 575** (+39); live smoke matrix **336 → 347** (+11). Pre-tag full-matrix smoke passed 347/347 against all 8 backends; closing reviewer-trio audit (security + code + rust) landed 5 BLOCKING fixes before tag; DEFER items captured below. +First single-backend-per-release cycle; adds macOS Keychain, canonical `examples/` directory, and 5 blocking audit fixes for 8 backends total; unit tests 536 to 575, live smoke 336 to 347. ### Added - -- **macOS Keychain backend** (`keychain`) — 8th backend. Wraps the system `security` CLI to read, write, and delete `generic-password` or `internet-password` items in any addressable keychain (the user's login keychain by default, or a custom keychain via `keychain_path`). URI shape: `keychain-:////`. Config fields: `keychain_path` (optional; default = login keychain), `kind` (optional; `"generic-password"` default or `"internet-password"`), `timeout_secs` (optional). Platform-gated: the factory bails on non-macOS at config-load time with a clear "macOS-only" error; the crate itself compiles everywhere so the workspace tests cleanly on Linux/Windows CI. Every `security` invocation uses `stdin: Stdio::null()` to prevent password-prompt hangs on locked keychains. `set` passes the secret value through child argv; on macOS's same-UID process model this exposure is same-UID-only and we accept it (no opt-in gate, unlike 1Password's `op_unsafe_set` on Linux), with a `tracing::warn!` on every `set` for audit. **`list()` is intentionally unsupported** — `security` has no safe list-by-prefix operation; host your alias registry on a different backend type (local, aws-ssm, aws-secrets, 1password, vault, gcp, or azure). `history()` and extensive-check are likewise unsupported (Keychain has no native version-history API). Full reference at [`docs/backends/keychain.md`](docs/backends/keychain.md). -- **`examples/` directory at repo root** — canonical configuration patterns. Seven subdirectories: `single-backend-local/`, `single-backend-aws-ssm/`, `single-backend-keychain/`, `cascade-local-then-vault/`, `multi-cloud-aws-and-1password/`, `ci-github-actions/`, `secretenv-toml-canonical/`. Each has `config.toml` + `secretenv.toml` + a README explaining the *why* of the pattern. Top-level [`examples/README.md`](examples/README.md) flags the "NOT Cargo examples" caveat so `cargo build --examples` is expected to no-op. -- **Smoke harness Section 21** — live-backend integration coverage for the new keychain backend. Provisions a self-contained test keychain at `$RUNTIME_DIR/test.keychain-db`, exercises `doctor` / `get` / `run` / `list` / `history` end-to-end, and tears down after. Tagged `cloud=yes` so `--local-only` (Ubuntu CI) skips it; macOS full-matrix runs include it. Live matrix grows 336 → 347 (+11). +- macOS Keychain backend (`keychain`) wrapping the system `security` CLI for `generic-password` / `internet-password` items; 8th backend. URI `keychain-:////`. Config: `keychain_path`, `kind` (`generic-password` default or `internet-password`), `timeout_secs`. Platform-gated (factory bails on non-macOS; crate compiles everywhere). Every invocation uses `stdin: Stdio::null()`. `set` passes value via child argv (same-UID exposure accepted, no opt-in gate), `tracing::warn!` on every set. `list()` intentionally unsupported; `history()` and extensive-check unsupported. +- `examples/` directory at repo root with seven subdirectories (`single-backend-local/`, `single-backend-aws-ssm/`, `single-backend-keychain/`, `cascade-local-then-vault/`, `multi-cloud-aws-and-1password/`, `ci-github-actions/`, `secretenv-toml-canonical/`), each with `config.toml` + `secretenv.toml` + README. Top-level `examples/README.md` flags the "NOT Cargo examples" caveat. +- Smoke Section 21; provisions a test keychain at `$RUNTIME_DIR/test.keychain-db`; exercises doctor/get/run/list/history; tagged `cloud=yes`; live matrix 336 to 347. ### Tests +- Workspace unit tests 536 to 575 (+39), all in `secretenv-backend-keychain`: URI parsing (incl `%2F` escape), factory validation, get/set/delete strict-mock argv, list/check_extensive/history unsupported messages, check() paths, drift-catch locks for argv invariants. +- Live smoke 336 to 347 (+11): Section 21 Keychain. -- Workspace unit tests **536 → 575** (+39): all in the new `secretenv-backend-keychain` crate. Covers URI parsing (including `%2F` slash-escape); factory validation (kind enum, timeout_secs, `keychain_path` flag-injection rejection, cross-platform cfg-gated tests); get/set/delete strict-mock argv coverage; list / check_extensive / history backend-specific unsupported-error messages; check() paths (Ok, Locked/NotAuthenticated, CliMissing, missing keychain file → Error, wrong stdout shape → Error); and drift-catch regression locks for every argv invariant (missing `-U` upsert, `-k`-vs-positional on get/delete/check). -- Live smoke matrix **336 → 347** (+11): Section 21 Keychain. - -### Internal — closing audit (2026-04-22) - -Three-agent audit (`security-engineer` + `code-reviewer` + `rust-engineer`) ran in parallel against the merged v0.5 backend + smoke + docs. Five BLOCKING findings landed in an audit-polish PR; all DEFER items captured in `kb/wiki/build-log.md`: - -- `keychain_path` values starting with `-` rejected at factory time (would be parsed as a flag by `security` once appended to argv). -- `check()` distinguishes `"No such keychain"` (missing file → `Error`, no fix available) from "locked keychain" (`NotAuthenticated`, `security unlock-keychain` hint). -- `check()` validates that `show-keychain-info` output contains a `Keychain` sentinel (in stdout or stderr — the real binary writes to stderr) before returning `Ok`, guarding against a shadowing PATH binary. -- Three drift-catch tests added — declaring `-k ` argv on get / delete / check and asserting no-match, locking the trailing-positional convention against future reintroduction. -- `history()` overridden to call `reject_any_fragment("keychain")` before bailing, so a `#version=2`-carrying URI surfaces the unsupported-directive mistake rather than silently dropping the fragment. +### Internal +- Three-agent closing audit landed 5 BLOCKING findings before tag: +- `keychain_path` values starting with `-` rejected at factory time. +- `check()` distinguishes `"No such keychain"` (missing file, Error) from locked keychain (`NotAuthenticated`, unlock hint). +- `check()` validates `show-keychain-info` output contains a `Keychain` sentinel before returning Ok. +- Three drift-catch tests added declaring `-k ` argv on get/delete/check, asserting no-match. +- `history()` overridden to call `reject_any_fragment("keychain")` before bailing. ## [0.4.0] - 2026-04-22 -**Headline:** team ergonomics + distribution profile system + tooling hygiene. Functionality-only release — **no new backends** (still 7 live: local, aws-ssm, aws-secrets, 1password, vault, gcp, azure). Aggregate of v0.4 phases 1-7: `doctor --fix/--extensive` + `registry history/invite` (operator surfaces), distribution profile system served from `https://secretenv.io/profiles/` (the headline feature), per-instance `timeout_secs` + 1Password `set` safe-by-default, smoke-harness promoted into the repo with selective-run flags, GitHub Actions Node 24 readiness, `deny.toml` AGPL tightening, and per-file SPDX headers. v0.3.0 → v0.4.0 unit tests grew **442 → 536**; live smoke matrix grew **250 → 336** assertions. Closing reviewer-trio audit (security + code + rust) ran 2026-04-21; 6 blocking findings closed. +Team ergonomics + distribution profile system + tooling hygiene; no new backends (still 7); unit tests 442 to 536, live smoke 250 to 336; 6 blocking audit findings closed. ### Added - -- **`secretenv profile install|list|update|uninstall`** — distribution profile system. A "profile" is a TOML document (`[backends.*]` + `[registries.*]` fragments only) fetched over HTTPS and auto-merged into the active `config.toml` at load time. Profiles fill gaps, never override: the user's own `config.toml` always wins where both define the same key; among profiles, alphabetical filename order decides conflicts. Profiles land in `/profiles/.toml` with a sidecar `.meta.json` (source URL + `ETag` + install timestamp) that `profile update` uses for conditional `If-None-Match` re-fetch. Default base URL is `https://secretenv.io/profiles`; overridable globally via `SECRETENV_PROFILE_URL` or per-invocation via `--url ` (supports `file://` for offline / local-staging flows). Fetching uses `curl` (subprocess) — no new HTTP client dependency, consistent with every other backend's CLI-spawn pattern. Fetched bodies are validated as `Config` fragments before being written to disk, so a malformed profile never lands. Profile names go through a strict ASCII allowlist (`[A-Za-z0-9][A-Za-z0-9_-]*`) with 64-char cap and Windows reserved-name check; `curl --max-filesize 1MiB` + `--proto =https,file` cap response size and pin scheme. `profile list --json` emits a machine-readable array; `profile update` (no name) updates every installed profile with a per-row report. Full walkthrough at [`docs/profiles.md`](docs/profiles.md). -- **`Config::load` + `Config::load_from` auto-merge profiles** from the `profiles/` directory next to the active config. Behavior for callers with no profiles installed is unchanged. New public helpers `secretenv_core::default_config_path_xdg()` and `secretenv_core::profiles_dir_for(config_path)`. The merge path enforces a 1 MiB per-file size cap so a compromised local `profiles/` dir can't OOM the load. -- **`secretenv registry history `** — show version history for the secret an alias resolves to. Output is most-recent-first across all backends. New `Backend::history()` trait method returns `Vec` (`version`, `timestamp`, `actor`, `description`); default impl reports the operation as unsupported. Native implementations: - - `local`: shells out to `git log --follow --pretty=format:%h%x09%aI%x09%an <%ae>%x09%s -- `. Surfaces "not under a git repo" / "untracked" errors verbatim from git. - - `aws-ssm`: calls `aws ssm get-parameter-history --with-decryption --name --output json` and reverses AWS's oldest-first ordering. The `Value` payload is deliberately NOT captured by the deserializer (`ParameterHistoryRecord` lacks a `value` field) so secret values can never leak into history rendering. - - `vault`: calls `vault kv metadata get -format=json ` (KV v2 only — KV v1's "Metadata not supported" error is surfaced verbatim). Versions sorted numerically descending. Soft-deleted versions surface as `[soft-deleted]`; destroyed versions as `[destroyed]`. - - `aws-secrets`, `1password`, `gcp`, `azure` report "history unsupported" via the trait default; native overrides deferred (each has a quirk). -- **`HistoryEntry`** struct exported from `secretenv_core` for backend authors and tooling outside the workspace. Fields are string-typed so backend-specific identifiers (git SHAs, AWS integer versions, Vault decimal-string versions, RFC-3339 vs raw native timestamps) all fit without forcing CLI parse. -- **`secretenv registry invite [--registry ] [--invitee ] [--json]`** — copy-pasteable onboarding payload for sharing a registry with a new collaborator. Three sections: the `config.toml` snippet the new user adds (mirrors every non-`type` field from the inviter's `[backends.]` block, sorted, with strict-mock test-only fields filtered); the per-backend IAM/RBAC grant command the inviter runs (`aws iam attach-user-policy ...`, `op vault user grant ...`, `vault policy write ...`, `gcloud secrets add-iam-policy-binding ...`, `az role assignment create ...`, etc.); and two universal verify steps the invitee runs (`secretenv doctor`, `secretenv registry list`). Local backend renders filesystem/git access guidance with no CLI grant. Unknown backend types render a generic "no canonical template" pointer. -- **`secretenv doctor --fix`** — when any backend reports `NotAuthenticated`, `--fix` shells out to the canonical remediation CLI for that backend type (`aws sso login`, `op signin`, `gcloud auth login`, `az login`, `vault login`) and inherits stdio so the user can complete the interactive flow. Re-runs `check()` once afterward and renders the post-remediation report. Audit trail (which command, exit success, any spawn-error) recorded as a `Remediation actions` section in human output and a `fix_actions` array in `--json` output. Local backend + unrecognized types are skipped without panicking. -- **`secretenv doctor --extensive`** — Level 3 depth probe. For each `Ok` backend, doctor reads every `[registries.*]` source whose scheme matches and runs `Backend::check_extensive(uri)`. Result (alias count or read-failure error) renders as an indented `depth probe` block under the backend's tree node and serializes into a `backends[*].depth` array. Source URIs are deduped across registries. Surfaces permission scope (`12 aliases readable` vs `read failed: permission denied`) so operators can verify IAM/RBAC scope without leaving doctor. -- **`--fix` and `--extensive` compose** — `secretenv doctor --fix --extensive` first remediates any auth gaps and then probes depth against the post-remediation backend set in a single invocation. -- **`DoctorOpts` struct** — flags pass to `run_doctor` via a struct rather than positional booleans so future knobs (e.g. `--quiet`, `--strict`) can be added without churning internal call sites. Implements `Default` so `setup`'s embedded post-write doctor run keeps the existing behavior unchanged. -- **Per-instance `timeout_secs` config override** (`[backends.]`) — applies to every fetch-class operation (`get`, `set`, `delete`, `list`, `history`) on that backend instance. Optional integer, must be positive seconds; default stays at `DEFAULT_GET_TIMEOUT` (30 s). The `check` (doctor) timeout deliberately does NOT consult this — `DEFAULT_CHECK_TIMEOUT` (10 s) keeps doctor parallelism predictable across instances. New `Backend::timeout()` trait method (default returns `DEFAULT_GET_TIMEOUT`); call sites in `runner::fetch_one` and `resolver::fetch_layer` wrap their backend-op futures with `with_timeout(backend.timeout(), ...)`. All 7 backends read the field at factory time. New `optional_duration_secs` and `optional_bool` helpers exported from `secretenv_core::factory_helpers` for plugin authors. -- **`scripts/smoke-test/`** — promoted live-backend integration smoke into the repo (was `/tmp/secretenv-test/` since v0.2.0). Three scripts (`provision.sh`, `run-tests.sh`, `teardown.sh`) plus `lib/common.sh` (shared bootstrap, env-driven cloud IDs, fixture seeding), `fixtures/` (templated config + local secrets), and `README.md`. `run-tests.sh` gains three filter flags: `--sections N,M,P-Q` (range syntax supported), `--local-only` (subset that needs no cloud CLI — sections 1, 12, 18 — runs in <30s without auth), `--list-sections` (inventory). Selective gating via a `SECTION_ACTIVE` short-circuit on `run_test`/`assert_contains`/`record`. Full matrix remains the maintainer-run pre-tag gate. -- **`smoke-local` CI gate** in `.github/workflows/ci.yml` — runs `bash scripts/smoke-test/run-tests.sh --local-only` on every push and PR. ~30s, no cloud auth needed. Catches CLI / completions / registry-invite / config-knobs regressions cheaply. -- **SPDX + copyright headers** on every `crates/**/*.rs` (29 files): `// Copyright (C) 2026 Mandeep Patel` + `// SPDX-License-Identifier: AGPL-3.0-only` at the top of each file. SBOM-tooling clarity + per-file provenance for fork / vendored-subtree consumers. +- `secretenv profile install|list|update|uninstall` distribution profile system. A profile is a TOML doc (`[backends.*]` + `[registries.*]` only) fetched over HTTPS and auto-merged at load time; fills gaps, never overrides (user config wins; among profiles alphabetical filename order decides). Lands in `/profiles/.toml` with sidecar `.meta.json` (source URL + ETag + timestamp) for `If-None-Match` re-fetch. Default base URL `https://secretenv.io/profiles`; overridable via `SECRETENV_PROFILE_URL` or `--url` (supports `file://`). Fetches via `curl` subprocess; bodies validated as `Config` before write. Names go through ASCII allowlist (`[A-Za-z0-9][A-Za-z0-9_-]*`, 64-char cap, Windows reserved-name check); `curl --max-filesize 1MiB` + `--proto =https,file`. `profile list --json` emits array; `profile update` with no name updates all. +- `Config::load` + `Config::load_from` auto-merge profiles from the `profiles/` directory. New public helpers `secretenv_core::default_config_path_xdg()` and `secretenv_core::profiles_dir_for(config_path)`; 1 MiB per-file size cap. +- `secretenv registry history ` shows version history, most-recent-first. New `Backend::history()` trait method returns `Vec` (`version`, `timestamp`, `actor`, `description`); default reports unsupported. Native impls: `local` (`git log --follow`), `aws-ssm` (`aws ssm get-parameter-history`, value field deliberately not deserialized), `vault` (`vault kv metadata get`, KV v2 only, soft-deleted/destroyed surfaced). `aws-secrets`, `1password`, `gcp`, `azure` report unsupported via default. +- `HistoryEntry` struct exported from `secretenv_core`; fields string-typed. +- `secretenv registry invite [--registry ] [--invitee ] [--json]` produces copy-pasteable onboarding payload: config.toml snippet, per-backend IAM/RBAC grant command, two universal verify steps. Local renders filesystem/git guidance; unknown types render generic pointer. +- `secretenv doctor --fix` shells out to canonical remediation CLI per backend type (`aws sso login`, `op signin`, `gcloud auth login`, `az login`, `vault login`) with inherited stdio when `NotAuthenticated`; re-runs check() once; records `Remediation actions` / `fix_actions`. +- `secretenv doctor --extensive` Level 3 depth probe; reads every matching `[registries.*]` source and runs `Backend::check_extensive(uri)`; renders `depth probe` block + `backends[*].depth` array; source URIs deduped. +- `--fix` and `--extensive` compose in a single invocation. +- `DoctorOpts` struct passes flags to `run_doctor`; implements `Default`. +- Per-instance `timeout_secs` config override applies to get/set/delete/list/history; positive integer, default `DEFAULT_GET_TIMEOUT` (30s); `check` uses `DEFAULT_CHECK_TIMEOUT` (10s) and ignores it. New `Backend::timeout()` trait method; call sites in `runner::fetch_one` and `resolver::fetch_layer` wrap with `with_timeout`. New `optional_duration_secs` + `optional_bool` helpers in `secretenv_core::factory_helpers`. +- `scripts/smoke-test/` promoted into the repo: `provision.sh`, `run-tests.sh`, `teardown.sh`, `lib/common.sh`, `fixtures/`, `README.md`. `run-tests.sh` adds `--sections N,M,P-Q`, `--local-only` (sections 1, 12, 18), `--list-sections`. +- `smoke-local` CI gate in `.github/workflows/ci.yml` runs `--local-only` on every push/PR. +- SPDX + copyright headers on every `crates/**/*.rs` (29 files): `// Copyright (C) 2026 Mandeep Patel` + `// SPDX-License-Identifier: AGPL-3.0-only`. ### Changed - -- **1Password `set` is now safe-by-default.** Previously the `set` path silently passed the secret value through `op item edit`'s `field=value` argv tokens (CV-1: visible via `/proc//cmdline` on multi-user Linux hosts) — a known limitation deferred from v0.2.4 because the `op` CLI still has no portable stdin-fed value form across the 1.x and 2.x generations. v0.4 closes the TODO by flipping the safe default: `set` now refuses with a clear error explaining the exposure, instructing the operator to either edit the field manually or opt in by adding `op_unsafe_set = true` to `[backends.]`. The opt-in retains the previous argv-based behavior (with the existing tracing warning). **This is a behavior change for anyone who was running `secretenv registry set` against 1Password backends**; the new error surfaces immediately on upgrade and the remediation is one config line. Documented as a pre-launch breaking change. -- **Canonical project domain renamed `secretenv.dev` → `secretenv.io`.** All `install.sh`, README, CHANGELOG, SECURITY, docs, and Cargo metadata updated. The shell-level `install.sh --profile ` flag is **removed** — it wrote fetched TOML directly into `config.toml` with zero validation, a regression vs the threat model documented in `docs/profiles.md`. Use the in-binary `secretenv profile install ` subcommand instead, which parses + validates as `Config` before writing. The shell flag now errors with a pointer to the new path. -- **`UpdateOutcome` and `UpdateReport` marked `#[non_exhaustive]`** to keep v0.5+ variant/field additions (signature verification, retry metadata) non-breaking. -- **`profile install` / `update` / `uninstall` status messages** flipped from `println!` → `eprintln!`. Stdout reserved for data-shaped output; symmetric with `registry set/unset`. -- **`deny.toml` AGPL allowance tightened.** Removed `AGPL-3.0-only` from the global `licenses.allow` list; first-party crates admitted via per-crate `[[licenses.exceptions]]`. A future transitive AGPL dep now surfaces as a `cargo deny check` failure → forced explicit review. -- **Profiles posture (v0.4):** unsigned + HTTPS only. Signing (minisign / sigstore / SHA256 manifests) + central index file are deliberate v0.5+ work — threat model + mitigations in [`docs/profiles.md`](docs/profiles.md) §"Security considerations". +- 1Password `set` now safe-by-default; refuses with a clear error explaining argv exposure, instructing manual edit or `op_unsafe_set = true` opt-in (retains argv behavior + tracing warning). Behavior change for `secretenv registry set` against 1Password; documented as a pre-launch breaking change. +- Canonical domain renamed `secretenv.dev` to `secretenv.io` across install.sh, README, CHANGELOG, SECURITY, docs, Cargo metadata. Shell-level `install.sh --profile ` flag removed (wrote unvalidated TOML); now errors pointing to `secretenv profile install`. +- `UpdateOutcome` and `UpdateReport` marked `#[non_exhaustive]`. +- `profile install` / `update` / `uninstall` status messages flipped `println!` to `eprintln!`. +- `deny.toml` AGPL allowance tightened: removed `AGPL-3.0-only` from global `licenses.allow`; first-party crates admitted via per-crate `licenses.exceptions` entries. +- Profiles posture v0.4: unsigned + HTTPS only; signing + central index deferred to v0.5+. ### Tests - -- Workspace unit tests **442 → 536** (+94): doctor (+11 for `--fix`/`--extensive`), registry history (+10 across core trait + 3 backend overrides), registry invite (+16 in new `crates/secretenv-cli/src/invite.rs`), profile (+19 across name-validation, body-validation, install/list/update/uninstall, ETag parser, RFC 3339 formatter), config merge (+5 for profile gap-fill, user-wins, alphabetical order, malformed-profile error, missing-dir noop), CLI integration (+6 for profile + doctor + registry-invite help-locks). -- Live smoke matrix **250 → 336** (+86): registry history (24), registry invite (35), doctor `--fix` + `--extensive` (15), `timeout_secs` + `op_unsafe_set` (12). +- Workspace unit tests 442 to 536 (+94): doctor (+11), registry history (+10), registry invite (+16 in `crates/secretenv-cli/src/invite.rs`), profile (+19), config merge (+5), CLI integration (+6). +- Live smoke 250 to 336 (+86): registry history (24), registry invite (35), doctor `--fix`/`--extensive` (15), `timeout_secs` + `op_unsafe_set` (12). ### Internal - -- Workspace version `0.3.0` → `0.4.0`. -- GitHub Actions bumped for Node 24 readiness: `actions/checkout` v4 → v6, `actions/upload-artifact` v4 → v7, `actions/download-artifact` v4 → v8, `softprops/action-gh-release` v2 → v3. -- Phase 6 decision: `secretenv-testing` stays `publish = false` (zero external consumers; reversible any time via single-line Cargo.toml flip + `cargo publish`). The strict-mock harness is still consumed internally as a path-dependency. -- Closing reviewer-trio audit (security-engineer + code-reviewer + rust-engineer in parallel) on 2026-04-21 surfaced 6 blocking findings; all closed before tag. Non-blocking items captured in `kb/wiki/build-log.md`. +- Workspace version 0.3.0 to 0.4.0. +- GitHub Actions bumped for Node 24: `actions/checkout` v4 to v6, `actions/upload-artifact` v4 to v7, `actions/download-artifact` v4 to v8, `softprops/action-gh-release` v2 to v3. +- `secretenv-testing` stays `publish = false`. +- Closing reviewer-trio audit 2026-04-21 surfaced 6 blocking findings, all closed before tag. ## [0.3.0] - 2026-04-19 -**Headline:** two new cloud backends (GCP Secret Manager + Azure Key Vault), the canonical `#key=value` fragment grammar locked in (v0.2.1 work), strict-mode mock test harness retrofitted across every backend (v0.2.2 → v0.2.7 + Phase 0), shared factory helpers, parallel `check()` probes via `tokio::join!`, and a **relicensing from MIT → AGPL-3.0-only + Contributor License Agreement**. The entire v0.2.1 → v0.2.7 internal-dev arc ships as one cohesive `v0.3.0` release (the last public version was v0.2.0 on 2026-04-18; v0.2.x patches were dev-merged without separate publishes per the aggregate-release posture locked during the cycle). 7 backends live: local, aws-ssm, aws-secrets, 1password, vault, **gcp**, **azure**. - -### Fixed - -- **Azure vault URL regex accepted 1-char names but rejected 2-char names.** The original pattern `^https://[a-zA-Z0-9]([a-zA-Z0-9-]{1,22}[a-zA-Z0-9])?\.vault\...` made the middle+last group optional, allowing a 1-char vault name while disallowing 2-char. Azure's own rule is 3-24. Flipped to `^https://[a-zA-Z0-9][a-zA-Z0-9-]{1,22}[a-zA-Z0-9]\.vault\...` (required middle+last, min 3 chars, max 24). Three new factory tests lock the boundary: `factory_rejects_one_char_vault_name`, `factory_rejects_two_char_vault_name`, `factory_accepts_three_char_vault_name`. Caught by the v0.3 closing code-review audit. - -### Added (closing-audit fixes, same unreleased window) - -- **`Response::with_stdin_fragment(impl Into)`** chainable method on `secretenv-testing::Response`. Appends to `stdin_must_contain`, allowing fluent composition: `Response::success("ok\n").with_stdin_fragment("secret")`. The existing `success_with_stdin(stdout, Vec)` constructor remains for back-compat. -- **`check_extensive_counts_registry_entries`** unit test added to both `secretenv-backend-gcp` and `secretenv-backend-azure`. Locks the trait-default `Ok(self.list(test_uri).await?.len())` behavior for both v0.3 backends. -- **`set_drift_catch_rejects_data_flag_on_argv`** unit test added to `secretenv-backend-gcp`. Positive CV-1 lock mirroring azure's `--value`-leak + `--encoding-utf-8` locks — declares the buggy argv form (`--data=`) so a regression emitting the secret on argv instead of via `--data-file=/dev/stdin` fails. -- **Fragment-error messages now link to `docs/fragment-vocabulary.md`** in both gcp and azure's `resolve_version` (matches the aws-secrets shorthand error). Tests extended with `msg.contains("fragment-vocabulary")` assertions. - -### Changed (v0.3 closing audit polish) - -- **`BackendUri::fragment_directives` return type: `HashMap` → `IndexMap`.** Insertion order now deterministic = URI-written order, removes the need for `sort_unstable` in backend error-message construction. Backend code calls `.shift_remove("")` instead of `.remove(...)` per `IndexMap`'s deprecation guidance. Pre-launch breaking change per the [[feedback_prelaunch_breaking_changes]] policy. Touched: `secretenv-core/src/uri.rs`, `secretenv-backend-{aws-secrets,gcp,azure}/src/lib.rs`. -- **`strict::Rule` refactor:** `Rule` in `secretenv-testing::strict` used to flatten every `Response` field (`argv, stdin_must_contain, env_must_contain, env_must_not_contain, stdout, stderr, exit_code`). Now `Rule { argv, response: Response }` — thin `(argv, response)` pair. `StrictMock::on` copies one struct move instead of seven field moves. Internal refactor; `Rule` is private so no API break. -- **Drift-catch assertion bodies tightened** from `msg.contains("strict-mock-no-match") || msg.contains("azure")` (always-true tautology — every azure error begins with `azure backend '…'`) to `msg.contains("strict-mock-no-match")` only. The `.unwrap_err()` at the outer layer remains the load-bearing lock; the content check now specifically confirms mock-level divergence rather than any azure-named error. Applied to gcp + azure drift-catch tests. Caught by the v0.3 closing security review. - -### Changed - -- **LICENSE: MIT → AGPL-3.0-only** (pre-launch breaking change per the policy documented in the memory system). MIT was the license of v0.1 and v0.2.0 — the latter is the only version currently published to crates.io / Homebrew / GitHub Releases. v0.3.0 and all subsequent releases ship under GNU Affero General Public License v3.0 (AGPL-3.0-only). The published MIT releases (v0.1.x, v0.2.0) remain available under their original terms; AGPLv3 applies going forward. Rationale: v0.3.0 finalizes the big-3-cloud-providers story (AWS, GCP, Azure) and the install base at the time of this change is effectively zero. AGPLv3 closes the SaaS-wrapping loophole (§13 requires source availability to network users of modified versions) while preserving user freedom for direct installs. -- **Workspace `Cargo.toml` `license`** field flipped `"MIT"` → `"AGPL-3.0-only"`. Every crate that inherits via `license.workspace = true` picks this up automatically; no per-crate change needed. -- **README** badge + License section updated to reflect AGPLv3 + the MIT-era disclosure. +Two new cloud backends (GCP Secret Manager + Azure Key Vault), canonical `#key=value` fragment grammar, strict-mode mock harness across every backend, shared factory helpers, parallel check() via `tokio::join!`, and relicensing MIT to AGPL-3.0-only + CLA; ships the v0.2.1 to v0.2.7 arc as one release for 7 backends total. ### Added +- `Response::with_stdin_fragment(impl Into)` chainable method on `secretenv-testing::Response`; appends to `stdin_must_contain`. `success_with_stdin` constructor remains. +- `check_extensive_counts_registry_entries` unit test added to `secretenv-backend-gcp` and `secretenv-backend-azure`; locks trait-default `Ok(self.list(test_uri).await?.len())`. +- `set_drift_catch_rejects_data_flag_on_argv` unit test added to `secretenv-backend-gcp`; declares buggy `--data=` form. +- Fragment-error messages link to `docs/fragment-vocabulary.md` in gcp and azure `resolve_version`; tests assert `msg.contains("fragment-vocabulary")`. +- Contributor License Agreement: new `CLA.md` (license grant, not copyright assignment), `AUTHORS.md` (signed-contributor ledger), expanded `CONTRIBUTING.md` License-and-CLA section. Signing via `git commit --signoff` + adding name to `AUTHORS.md`. No CLA = no merge. +- `secretenv-backend-azure` new crate: Azure Key Vault via the `az` CLI. URI `azure-:///[#version=<32-char-hex>]`; `#version=` accepts 32-char lowercase-hex ID or `latest` (latest/absent omits `--version`). Required `azure_vault_url` regex-validated across four sovereign-cloud domains (`.vault.azure.net`, `.vault.azure.cn`, `.vault.usgovcloudapi.net`, `.vault.microsoftazure.de`) with path-traversal + hyphen-edge rejection. Optional `azure_tenant`, `azure_subscription`, `az_bin`. `set` pipes via `--file /dev/stdin --encoding utf-8` (load-bearing; default base64 corrupts text). Fragment on set rejected before network. `check()` runs `az --version` + `az account show` via `tokio::join!`. Soft-delete documented (purge is manual). Cert-bound secrets surface a distinct error. 36 strict-mode tests. +- `secretenv setup` gains `--azure-vault-url` + `--azure-tenant` + `--azure-subscription` flags; scheme router accepts `azure` + `azure-*`; serializer adds `azure` JSON arm. +- `secretenv-backend-gcp` new crate: Google Cloud Secret Manager via the `gcloud` CLI. URI `gcp-:///[#version=]`; `#version=` supports positive integers + `latest` (latest omits flag). Required `gcp_project`. Optional `gcp_impersonate_service_account` (SA-email plausibility-validated), `gcloud_bin`. `set` pipes via `--data-file=/dev/stdin`; fragment on set rejected before network. `check()` runs `gcloud --version` + `gcloud auth print-access-token` + `gcloud config get-value account` via `tokio::join!`; token read for exit status only, stdout dropped (canary `check_level2_auth_ok_never_logs_token_body`). 32 strict-mode tests. Secret-name charset `[a-zA-Z0-9_-]{1,255}` validated locally before any gcloud call. +- `secretenv setup` gains `--gcp-project` + `--gcp-impersonate-service-account` flags; scheme router accepts `gcp` + `gcp-*`; serializer emits gcp registries as JSON. -- **Contributor License Agreement (CLA).** New [`CLA.md`](CLA.md) (license grant — NOT copyright assignment), [`AUTHORS.md`](AUTHORS.md) (signed-contributor ledger), and expanded [`CONTRIBUTING.md`](CONTRIBUTING.md) §License-and-CLA. The CLA lets the project relicense contributions (e.g., offer commercial licenses alongside AGPL) while contributors retain ownership of their own work. Signing is via `git commit --signoff` on every commit plus adding your name to `AUTHORS.md` on first contribution. "No CLA = no merge" is enforced at review. - -### Added - -- **`secretenv-backend-azure` (new crate):** second v0.3 backend — Azure Key Vault via the `az` CLI. URI shape `azure-:///[#version=<32-char-hex>]`. Canonical `#version=` directive accepts a 32-character lowercase-hex Azure version ID OR the literal `latest`; `latest` (and the absent-fragment default) normalizes to omitting the `--version` flag entirely. Required config: `azure_vault_url` — fully-qualified HTTPS URL, regex-validated at factory time across all four sovereign-cloud domains (`.vault.azure.net`, `.vault.azure.cn`, `.vault.usgovcloudapi.net`, `.vault.microsoftazure.de`) with explicit rejection of path traversal (anchored trailing `/?$`) and hyphen-edge vault names. Optional: `azure_tenant`, `azure_subscription` (each passed as `--tenant` / `--subscription` when set), `az_bin` (test hook). `set` pipes secret values via `--file /dev/stdin --encoding utf-8` — the `--encoding utf-8` flag is load-bearing: the default `base64` would corrupt stored text secrets. Fragment on `set` URI explicitly rejected before any network call. `check()` runs Level 1 (`az --version` — multi-line output, regex-extracted) + Level 2 (`az account show`) concurrently via `tokio::join!`. Identity format: `user= tenant= subscription= vault=`. Soft-delete semantics documented — `delete` soft-deletes (Azure default); operators wanting full purge must run `az keyvault secret purge` manually. Cert-bound secrets (`kid != null` in response) surface a distinct error. Strict-mode mocks from day one — 36 tests across factory URL regex (5 tests incl. sovereign-cloud accepts + hyphen-edge + path-traversal rejects), `check` probe pair (4), `get` (10 incl. cert-bound + `#version=latest` flag-omission), `set` (3), `delete` (2), `list` (2), tenant/subscription argv variants (3), drift-catch locks (4: missing `--vault-name`, CV-1 stdin, `--value`-leak lock, missing-`--encoding-utf-8` lock). -- **`secretenv setup` gains `--azure-vault-url` + `--azure-tenant` + `--azure-subscription` flags** for azure-scheme registry URIs. Scheme router accepts `azure` + `azure-*` suffix forms; registry serializer adds `azure` to the JSON arm. +### Changed +- `BackendUri::fragment_directives` return type `HashMap` to `IndexMap`; insertion order deterministic, removes `sort_unstable`; backends call `.shift_remove(...)`. Pre-launch breaking change. Touched `secretenv-core/src/uri.rs`, `secretenv-backend-{aws-secrets,gcp,azure}/src/lib.rs`. +- `strict::Rule` refactor: flattened fields collapsed to `Rule { argv, response: Response }`; `StrictMock::on` copies one struct move. `Rule` is private, no API break. +- Drift-catch assertion bodies tightened from `msg.contains("strict-mock-no-match") || msg.contains("azure")` (tautology) to `msg.contains("strict-mock-no-match")` only; applied to gcp + azure. +- LICENSE MIT to AGPL-3.0-only (pre-launch breaking change). Published MIT releases (v0.1.x, v0.2.0) remain under original terms; AGPLv3 applies going forward. AGPLv3 closes the SaaS-wrapping loophole. +- Workspace `Cargo.toml` `license` field flipped `"MIT"` to `"AGPL-3.0-only"`; inherited via `license.workspace = true`. +- README badge + License section updated to reflect AGPLv3 + MIT-era disclosure. -- **`secretenv-backend-gcp` (new crate):** first v0.3 backend — Google Cloud Secret Manager via the `gcloud` CLI. URI shape `gcp-:///[#version=]`. Canonical `#version=` directive supports positive integers + `latest` (latest = flag omitted; `gcloud` resolves to newest enabled version). Required config: `gcp_project`. Optional: `gcp_impersonate_service_account` (plausibility-validated as an SA email at factory time), `gcloud_bin` (test hook). `set` pipes the secret value through child stdin via `--data-file=/dev/stdin` (CV-1 discipline); fragment on `set` URI explicitly rejected before any network call. `check()` runs Level 1 (`gcloud --version`) + Level 2 (`gcloud auth print-access-token`) + identity enrichment (`gcloud config get-value account`) concurrently via `tokio::join!`. The OAuth2 bearer token returned by `print-access-token` is read only for exit status — `output.stdout` is dropped immediately and never interpolated into logs, errors, or identity strings. A dedicated canary test (`check_level2_auth_ok_never_logs_token_body`) locks this defense-in-depth contract with a sentinel token substring. Strict-mode mocks from day one — 32 tests across factory validation, the `check` probe triad, `get` + `set` + `delete` + `list`, fragment grammar rejection (shorthand, unsupported directive, invalid version value, invalid secret name), impersonation argv shape, and two drift-catch locks (missing `--project`, CV-1 stdin discipline). Secret-name charset `[a-zA-Z0-9_-]{1,255}` validated locally BEFORE any `gcloud` call. -- **`secretenv setup` gains `--gcp-project` + `--gcp-impersonate-service-account` flags** for gcp-scheme registry URIs. Scheme router accepts `gcp` + `gcp-*` suffix forms; registry serializer emits gcp registries as JSON (same wire shape as aws-ssm / vault / aws-secrets). +### Fixed +- Azure vault URL regex accepted 1-char names but rejected 2-char. Flipped to `^https://[a-zA-Z0-9][a-zA-Z0-9-]{1,22}[a-zA-Z0-9]\.vault\...` (required middle+last, min 3, max 24). Three new tests: `factory_rejects_one_char_vault_name`, `factory_rejects_two_char_vault_name`, `factory_accepts_three_char_vault_name`. ## [0.3.0-alpha.0] -**Headline:** v0.3 Phase 0 groundwork. Workspace version bumped to `0.3.0-alpha.0` — the aggregate release window is now open. This patch is pure internal refactoring — zero behavior change. GCP + Azure backend implementation follows in subsequent patches. - -Aggregate release posture (locked 2026-04-19): the entire v0.2.1 → v0.2.7 + v0.3 backend series will ship as ONE cohesive `v0.3.0` release on crates.io, Homebrew, and GH Releases. +v0.3 groundwork; pure internal refactoring, zero behavior change; aggregate release window opened (v0.2.1 to v0.2.7 + v0.3 ships as one v0.3.0 release). ### Changed - -- **`Backend::check_extensive`:** now has a default implementation on the trait itself (`Ok(self.list(test_uri).await?.len())`). The five backends' duplicated verbatim impls removed. A backend with a faster "count without materializing" CLI path may still override. Rust-engineer review flagged this as a 5× duplication. -- **`secretenv-core::factory_helpers`:** new public module exposing `required_string(config, field, backend_type, instance_name)` and `optional_string(config, field, backend_type, instance_name)`. The `backend_type` label is the new argument (vs. v0.2's hard-coded-per-backend strings); error shape is unchanged. aws-ssm, vault, aws-secrets factory blocks now call the shared helpers. v0.3 gcp + azure will use the same entry points, avoiding two more copies. 6 new unit tests in the helper module. -- **`Backend::check` for aws-ssm, aws-secrets, vault, 1password:** Level 1 (` --version`) and Level 2 (auth probe) now run concurrently via `tokio::join!`. The two probes are independent; serializing them doubled `secretenv doctor` latency per backend. For a config with 5 backends (current) that's ~5× the latency saving vs. v0.2; for v0.3 with 7 backends (adding gcp + azure) it becomes ~7×. No behavior change — error handling and short-circuiting semantics preserved. +- `Backend::check_extensive` now has a trait default implementation (`Ok(self.list(test_uri).await?.len())`); five backends' duplicated impls removed; faster backends may override. +- `secretenv-core::factory_helpers` new public module exposing `required_string(config, field, backend_type, instance_name)` and `optional_string(...)`; `backend_type` label is the new argument; aws-ssm, vault, aws-secrets call shared helpers; 6 new unit tests. +- `Backend::check` for aws-ssm, aws-secrets, vault, 1password runs Level 1 (` --version`) and Level 2 (auth probe) concurrently via `tokio::join!`; no behavior change. ### Internal - -- Workspace version 0.2.7 → 0.3.0-alpha.0. -- Workspace test count 359 → 365 (+6 new factory_helpers unit tests). -- CHANGELOG entries undated — per the dev-only posture, no tag pushed yet. Date fills in at `v0.3.0` tag. +- Workspace version 0.2.7 to 0.3.0-alpha.0. +- Workspace test count 359 to 365 (+6 factory_helpers tests). +- CHANGELOG entries undated per dev-only posture; date fills in at v0.3.0 tag. ## [0.2.7] -**Headline:** security hardening follow-up to the v0.2.x retrofit series — three reviewer agents (code / security / rust) audited the full v0.2.x scope; this patch lands the defense-in-depth fixes surfaced by the security review. No user-facing behavior change for valid URIs. +Security hardening follow-up to the v0.2.x retrofit series; defense-in-depth fixes from a three-reviewer audit, no user-facing behavior change for valid URIs. ### Security - -- **`secretenv-testing::StrictMock`:** env-var keys passed to `Response::with_env_var` / `with_env_absent` are now validated at call time against `^[A-Za-z_][A-Za-z0-9_]*$`. A malformed key (e.g. `"KEY}; rm -rf /; :{"`) panics immediately rather than injecting arbitrary shell into the generated mock script. Closes a test-author-side defense-in-depth gap flagged by the security review. -- **`secretenv-testing::StrictMock`:** stdin-fragment mismatch diagnostic now emits only a REDACTED fingerprint of the missing fragment (`-byte:…` for long values, `-byte:` for short). Previously, CV-1 stdin-discipline tests used canary secret values as the `stdin_must_contain` fragment; a regression that routed the secret to argv would have echoed the full secret to stderr and into CI logs. Now only the fingerprint appears. -- **`secretenv-testing::strict::escape_for_double_quoted`:** now panics on embedded `\n` / `\r` instead of silently swapping them for space. Caller contract was already "no newlines"; enforcement was lax. Fail-fast catches bugs where a diagnostic string was assumed safe but wasn't. -- **`secretenv-core::uri`:** new `BackendUri::reject_any_fragment(backend_label)` method + `FragmentError::UnsupportedForBackend` variant. Called from the top of `get` / `set` / `delete` / `list` in **aws-ssm**, **vault**, and **1password** — backends which do not accept fragment directives. Previously, a URI like `vault-prod:///secret/x#json-key=password` was silently accepted and the fragment dropped; now surfaces a clear "this backend accepts no fragment directives" error. Applied transitively to `list` paths that delegate to `get` (aws-ssm, 1password). -- **`secretenv-backend-1password::get`:** added a `debug_assert!` post-condition on `parse_path` output (no `/` in any of `(vault, item, field)`) to guard against future parse_path regressions that could leak the path structure into the `op:////` argv token. -- **Integration smoke harness:** the shared `secretenv-validation/api-key` fixture restore is now wired via `trap restore_fixture_on_exit EXIT` at the top of `/tmp/secretenv-test/scripts/run-tests.sh`. A mid-run failure, SIGINT, or interpreter error cannot leave the fixture polluted — the NEXT run's tests 30 / 39 will see the canonical `sk_test_secrets_22222` value regardless of how the prior run terminated. The v0.2.6 test 118 ("fixture restored") is retained for observability parity but is now a consequence of the trap handler, not the primary mechanism. +- `secretenv-testing::StrictMock`: env-var keys passed to `Response::with_env_var` / `with_env_absent` are validated at call time against `^[A-Za-z_][A-Za-z0-9_]*$`; a malformed key panics immediately instead of injecting shell into the generated mock script. +- `secretenv-testing::StrictMock`: stdin-fragment mismatch diagnostic now emits only a REDACTED fingerprint (`-byte:...` for long values, `-byte:` for short), never the full secret. +- `secretenv-testing::strict::escape_for_double_quoted`: panics on embedded `\n` / `\r` instead of silently swapping for space. +- `secretenv-core::uri`: new `BackendUri::reject_any_fragment(backend_label)` method + `FragmentError::UnsupportedForBackend` variant; called from `get` / `set` / `delete` / `list` in aws-ssm, vault, and 1password (which accept no fragment directives), applied transitively to `list` paths that delegate to `get`. +- `secretenv-backend-1password::get`: added `debug_assert!` post-condition on `parse_path` output (no `/` in `(vault, item, field)`) to guard against path structure leaking into the `op:////` argv token. +- Integration smoke harness: shared `secretenv-validation/api-key` fixture restore wired via `trap restore_fixture_on_exit EXIT` at the top of `run-tests.sh`; v0.2.6 test 118 retained for observability. ### Internal - -- 6 new unit tests in `secretenv-testing::strict::tests` covering the above: `stdin_fragment_redaction_fingerprint_hides_value_never_leaks_full`, four panic-tests for env-var key validation, `escape_for_double_quoted_panics_on_newline`. The existing `stdin_check_rejects_when_fragment_missing` test updated to assert the redacted-fingerprint contract AND that the full secret does NOT appear in stderr. -- Workspace test count **353 → 359**. - -### Origin - -Findings surfaced by three parallel reviewer agents (code-reviewer, security-engineer, rust-engineer) auditing the full v0.2.x scope (fragment grammar, StrictMock harness, 5 backends, v0.2.6 fragment-before-network fix). The reviewers' v0.3-spec findings (GCP `print-access-token --project` flag, Azure argv size miscounts, Azure vault-URL regex path traversal) are applied to `kb/wiki/backends/{gcp,azure}.md` alongside this patch. The `AwsCli` / `tokio::join!` / `Response`-`Rule` unification / `IndexMap` refactors flagged by the rust-engineer land as v0.3 Phase 0. +- 6 new unit tests in `secretenv-testing::strict::tests`: `stdin_fragment_redaction_fingerprint_hides_value_never_leaks_full`, four env-var key validation panic-tests, `escape_for_double_quoted_panics_on_newline`; existing `stdin_check_rejects_when_fragment_missing` updated to assert the redacted-fingerprint contract. +- Workspace test count 353 to 359. ## [0.2.6] -**Headline:** internal test-infrastructure release — aws-secrets backend's mock-CLI tests migrated to `StrictMock`, closing out the v0.2.x strict-mode retrofit series. **The first prod-code bug surfaced by the strict retrofit lands alongside the migration**: `AwsSecretsBackend::get()` was calling `aws secretsmanager get-secret-value` BEFORE validating the fragment directive, meaning a URI like `aws-secrets-prod:///myapp/cfg#password` (legacy shorthand, rejected since v0.2.1) would make a wasted AWS API call before surfacing the local grammar error. The v0.2 permissive-mock tests silently masked the extra call. Fixed: fragment validation now happens up-front, no AWS call occurs for invalid-grammar URIs. +Internal test-infrastructure release: aws-secrets backend mock-CLI tests migrated to `StrictMock`, plus the first prod-code bug surfaced by the strict retrofit. ### Fixed - -- **`secretenv-backend-aws-secrets`:** `get()` now validates the fragment directive (`#json-key=`, shorthand rejection, unsupported-directive rejection) BEFORE invoking `aws secretsmanager get-secret-value`. Pre-fix, an invalid fragment (`#password`, `#version=5`, or `#json-key=X,version=5`) caused a round-trip to AWS — wasting an IAM permission check, API latency, and potentially leaking an access pattern — before surfacing the local error. Caught by v0.2.6 strict-mode mocks with empty-rule installations (`StrictMock::new("aws").install(...)`) that reject any AWS call with exit 97; the v0.2 permissive-mock form returned success on the call, silently masking the extra round-trip. No end-user-visible behavior change for valid URIs. +- `secretenv-backend-aws-secrets`: `get()` now validates the fragment directive (`#json-key=`, shorthand rejection, unsupported-directive rejection) BEFORE invoking `aws secretsmanager get-secret-value`; pre-fix an invalid fragment caused a wasted AWS round-trip. No end-user-visible behavior change for valid URIs. ### Changed - -- **Internal:** all 25 mock-using tests in `secretenv-backend-aws-secrets` converted from the v0.2 raw `install_mock_aws(body)` API to declarative `StrictMock::new("aws").on(argv, Response).install(...)`. Every `secretsmanager get-secret-value`, `secretsmanager put-secret-value`, `secretsmanager delete-secret`, `sts get-caller-identity`, and `aws --version` argv is now asserted exactly. PR #33 BUG-2 (leading-slash on `--secret-id`) is **implicitly locked** across every migrated test — the declared argv carries the POST-STRIP secret ID; any regression would fail with a `strict-mock-no-match` diagnostic. -- **Internal:** argv-builder helpers `get_argv(secret_id)` / `put_argv(secret_id)` / `delete_argv(secret_id)` + `STS_ARGV_NO_PROFILE` const keep test bodies concise and make "what argv changed?" diffs narrow when a real shape change ships. -- **Internal:** `set_passes_secret_value_via_stdin_not_argv` rewritten using `Response::success_with_stdin` — CV-1 discipline is now a typed harness assertion rather than a log-file grep (parallel to aws-ssm v0.2.3 + vault v0.2.5). -- **Internal:** two new drift-catch regression-lock tests: - - `get_drift_catch_rejects_leading_slash_on_secret_id` — POSITIVE BUG-2 lock: declares argv with the pre-fix slash-prefixed form; post-fix code diverges, strict harness emits exit 97 surfaced to the caller. - - `set_drift_catch_rejects_secret_leaking_to_argv` — CV-1 parallel. -- **Internal:** three `command_always_passes_region` / `command_omits_profile` / `command_includes_profile` v0.2 log-file argv-shape tests collapsed: `--region us-east-1` is now implicitly asserted in every migrated test through the `get_argv` / `put_argv` / `delete_argv` helpers; profile-absent and profile-present cases retained as dedicated tests. -- **Internal:** v0.2.1 shorthand-reject and unsupported-directive-reject tests (`get_rejects_legacy_shorthand_fragment_with_migration_hint`, `get_rejects_unsupported_directive_with_enumerated_list`) now use empty-rule mocks — any AWS call exits 97 — making "this error originates in the fragment parser before any AWS call" a typed assertion. Both test bodies include an explicit `!msg.contains("strict-mock-no-match")` check to verify the error comes from the backend's grammar code, not the harness. +- Internal: all 25 mock-using tests in `secretenv-backend-aws-secrets` converted to declarative `StrictMock::new("aws").on(argv, Response).install(...)`; every `secretsmanager get-secret-value` / `put-secret-value` / `delete-secret`, `sts get-caller-identity`, and `aws --version` argv asserted exactly. The PR #33 leading-slash-on-`--secret-id` fix is implicitly locked across every migrated test. +- Internal: argv-builder helpers `get_argv(secret_id)` / `put_argv(secret_id)` / `delete_argv(secret_id)` + `STS_ARGV_NO_PROFILE` const. +- Internal: `set_passes_secret_value_via_stdin_not_argv` rewritten using `Response::success_with_stdin`. +- Internal: two new drift-catch regression-lock tests: `get_drift_catch_rejects_leading_slash_on_secret_id` and `set_drift_catch_rejects_secret_leaking_to_argv`. +- Internal: three `command_always_passes_region` / `command_omits_profile` / `command_includes_profile` log-file argv-shape tests collapsed; `--region us-east-1` now implicitly asserted via the argv helpers, profile-absent and profile-present cases retained. +- Internal: v0.2.1 shorthand-reject and unsupported-directive-reject tests (`get_rejects_legacy_shorthand_fragment_with_migration_hint`, `get_rejects_unsupported_directive_with_enumerated_list`) now use empty-rule mocks; both include a `!msg.contains("strict-mock-no-match")` check. ## [0.2.5] -**Headline:** internal test-infrastructure release — vault backend's mock-CLI tests migrated to `StrictMock`, and PR #33 BUG-1 (the flag-order fix: address/namespace routed via `VAULT_ADDR` / `VAULT_NAMESPACE` env vars rather than argv flags) is now a typed regression lock on every vault argv. No user-facing CLI changes; no prod bugs surfaced. - -### Changed - -- **Internal:** all 17 mock-using tests in `secretenv-backend-vault` converted from the v0.2 raw `install_mock` API to declarative `StrictMock::new("vault")...install()`. Every `vault kv get`, `vault kv put`, `vault kv delete`, `vault token lookup`, and `vault --version` argv is now asserted exactly. The PR #33 BUG-1 regression lock (no `-address` / `-namespace` argv flags) is implicit in the strict argv match — any regression that reintroduces those flags would fail with a `strict-mock-no-match` diagnostic. -- **Internal:** the two env-log side-channel tests (`command_omits_namespace_env_when_not_configured`, `command_includes_namespace_env_when_configured`) rewritten as declarative `with_env_var` / `with_env_absent` assertions — shorter, tighter, and checkable uniformly with argv at every invocation (not just this one test). -- **Internal:** `set_passes_secret_value_via_stdin_not_argv` rewritten using `Response::success_with_stdin` — CV-1 discipline is now a typed harness assertion rather than a log-file grep (same pattern aws-ssm adopted in v0.2.3). -- **Internal:** two new drift-catch regression-lock tests (`set_drift_catch_rejects_secret_leaking_to_argv` for CV-1, `get_drift_catch_env_check_rejects_wrong_vault_addr` for env-pathway) that prove the strict harness surfaces env drift loudly when it occurs. +Internal test-infrastructure release: vault backend mock-CLI tests migrated to `StrictMock`; the PR #33 address/namespace fix (via `VAULT_ADDR` / `VAULT_NAMESPACE` env vars, not argv flags) now a typed regression lock. No user-facing CLI changes; no prod bugs surfaced. ### Added +- `secretenv-testing`: `Response::with_env_var(key, value)` and `Response::with_env_absent(key)` chainable per-rule env-var contracts; generated POSIX shell uses `${KEY+set}` parameter expansion so values with spaces/quotes/regex metacharacters round-trip. Additive, `#[non_exhaustive]` honored. 6 new unit tests in `secretenv-testing::strict::tests`. -- **`secretenv-testing`:** `Response::with_env_var(key, value)` and `Response::with_env_absent(key)` chainable methods for declaring per-rule env-var contracts. The generated POSIX shell uses `${KEY+set}` parameter expansion rather than `grep` so values with spaces, quotes, or regex metacharacters round-trip safely. Additive; `#[non_exhaustive]` policy honored. 6 new unit tests in `secretenv-testing::strict::tests` cover match / mismatch / key-absent / absence-violated / absence-satisfied / special-char-value. +### Changed +- Internal: all 17 mock-using tests in `secretenv-backend-vault` converted to declarative `StrictMock::new("vault")...install()`; every `vault kv get` / `put` / `delete`, `vault token lookup`, and `vault --version` argv asserted exactly. The address/namespace regression lock (no `-address` / `-namespace` argv flags) is implicit in the match. +- Internal: env-log side-channel tests (`command_omits_namespace_env_when_not_configured`, `command_includes_namespace_env_when_configured`) rewritten as declarative `with_env_var` / `with_env_absent` assertions. +- Internal: `set_passes_secret_value_via_stdin_not_argv` rewritten using `Response::success_with_stdin`. +- Internal: two new drift-catch regression-lock tests: `set_drift_catch_rejects_secret_leaking_to_argv` and `get_drift_catch_env_check_rejects_wrong_vault_addr` (env-pathway). ### Fixed - -- **None.** The retrofit surfaced no prod bugs in the vault backend — the PR #33 fix is correct and now has typed regression locks preventing its accidental reversal. +- None. The retrofit surfaced no prod bugs in the vault backend. ## [0.2.4] -**Headline:** internal test-infrastructure release — 1password backend's mock-CLI tests migrated to `StrictMock`. No user-facing CLI changes; no prod bugs surfaced. +Internal test-infrastructure release: 1password backend mock-CLI tests migrated to `StrictMock`. No user-facing CLI changes; no prod bugs surfaced. ### Changed - -- **Internal:** all 13 mock-using tests in `secretenv-backend-1password` converted from the v0.2 raw `install_mock_op(body)` API to declarative `StrictMock::new("op").on(argv, Response).install(...)`. Every `op read`, `op item edit`, `op --version`, and `op whoami --format=json` argv is now asserted exactly (including the `--account ` tail when `op_account` is configured); a regression that drops `--account`, reorders `--vault`, or changes the `F=value` assignment shape will fail at test time rather than silently passing. -- **Internal:** `delete_runs_edit_with_empty_value` simplified — the args-log side-channel that verified the empty `F=` assignment is now obsolete because the declared argv token `F=` IS the assertion under strict match. -- **Internal:** two new drift-catch regression-lock tests (`get_drift_catch_rejects_missing_account_flag`, `set_drift_catch_rejects_missing_vault_flag`) that prove the strict harness surfaces drift loudly when it occurs. -- **Internal (one exception):** `get_non_utf8_response_errors_with_context` stays on the v0.2 raw `install_mock` harness because its assertion relies on a non-UTF-8 response, which the strict harness's `Response.stdout: String` cannot express. Documented inline; same carve-out as aws-ssm in v0.2.3. +- Internal: all 13 mock-using tests in `secretenv-backend-1password` converted to declarative `StrictMock::new("op").on(argv, Response).install(...)`; every `op read`, `op item edit`, `op --version`, and `op whoami --format=json` argv asserted exactly (including the `--account ` tail when configured). +- Internal: `delete_runs_edit_with_empty_value` simplified; the declared argv token `F=` is now the assertion under strict match. +- Internal: two new drift-catch regression-lock tests: `get_drift_catch_rejects_missing_account_flag`, `set_drift_catch_rejects_missing_vault_flag`. +- Internal (one exception): `get_non_utf8_response_errors_with_context` stays on the raw `install_mock` harness because its assertion relies on a non-UTF-8 response, which `Response.stdout: String` cannot express. ### Fixed - -- **None.** The retrofit surfaced no prod bugs in the 1password backend. Version-gating for the `set` stdin path (tracked in the backend's CV-1 comment) remains a v0.3 follow-up — not yet implemented, so no strict-mode coverage added for it. +- None. The retrofit surfaced no prod bugs in the 1password backend; `set` stdin-path version-gating remains a v0.3 follow-up. ## [0.2.3] -**Headline:** internal test-infrastructure release — aws-ssm backend's mock-CLI tests migrated to `StrictMock`. No user-facing CLI changes; no prod bugs surfaced. - -### Changed - -- **Internal:** all 14 mock-using tests in `secretenv-backend-aws-ssm` converted from the v0.2 raw `install_mock_aws(body)` API to declarative `StrictMock::new("aws").on(argv, Response).install(...)`. Every flag, positional, and value in every `aws` argv is now asserted exactly; a future refactor that drops `--with-decryption`, reorders `--region`/`--profile`, or reintroduces the CV-1 argv-leak regression will fail at test time rather than silently passing. -- **Internal:** `set_passes_secret_value_via_stdin_not_argv` rewritten using `Response::success_with_stdin`. CV-1 discipline is now a typed harness assertion rather than a log-file grep. -- **Internal:** two new drift-catch regression-lock tests (`get_drift_catch_rejects_missing_with_decryption_flag`, `set_drift_catch_rejects_secret_leaking_to_argv`) that prove the strict harness surfaces drift loudly when it occurs. -- **Internal (one exception):** `get_non_utf8_response_errors_with_context` stays on the v0.2 raw `install_mock` harness because its assertion relies on a non-UTF-8 response, which the strict harness's `Response.stdout: String` cannot express. Documented inline. +Internal test-infrastructure release: aws-ssm backend mock-CLI tests migrated to `StrictMock`. No user-facing CLI changes; no prod bugs surfaced. ### Added +- `secretenv-testing`: `Response::with_stderr(stderr)` chainable method for "response emits on stderr, not stdout." Additive, `#[non_exhaustive]` honored. -- **`secretenv-testing`:** `Response::with_stderr(stderr)` chainable method for expressing "response emits on stderr, not stdout." Additive; `#[non_exhaustive]` policy honored. +### Changed +- Internal: all 14 mock-using tests in `secretenv-backend-aws-ssm` converted to declarative `StrictMock::new("aws").on(argv, Response).install(...)`; every flag, positional, and value in every `aws` argv asserted exactly. +- Internal: `set_passes_secret_value_via_stdin_not_argv` rewritten using `Response::success_with_stdin`. +- Internal: two new drift-catch regression-lock tests: `get_drift_catch_rejects_missing_with_decryption_flag`, `set_drift_catch_rejects_secret_leaking_to_argv`. +- Internal (one exception): `get_non_utf8_response_errors_with_context` stays on the raw `install_mock` harness due to its non-UTF-8 response assertion. ## [0.2.2] -**Headline:** internal test-infrastructure release — strict-mode mock harness for backend crates. No user-facing CLI changes. +Internal test-infrastructure release: strict-mode mock harness for backend crates. No user-facing CLI changes. ### Added - -- **`secretenv-testing::StrictMock`** — declarative mock-CLI builder. - `StrictMock::new(bin).on(argv, Response).install(dir)` generates a - POSIX shell script that matches the full joined argv against a rule - list and exits 97 on no-match with a diagnostic naming the observed - argv and every declared shape. Closes the gap that let the v0.2.0 - vault flag-order bug and aws-secrets leading-slash bug ship through - `cargo test --workspace` green. `Response::success`, - `Response::failure`, and `Response::success_with_stdin` (for testing - the CV-1 stdin-delivery discipline) cover the response shapes - backend tests need. Types are `#[non_exhaustive]` so future - matchers (`PositionalThenFlags`, `Regex`, env-var assertions) can - land additively as concrete backend retrofits need them. -- `crates/secretenv-cli/tests/e2e.rs` — two end-to-end scenarios - proving `StrictMock` works in anger through the full secretenv call - chain: one happy-path exercising exact argv match, one drift-catch - asserting exit 97 surfaces a clear diagnostic naming the missing - flag. These will serve as the reference pattern for the per-backend - retrofits in v0.2.3 → v0.2.6. -- `secretenv-backend-local` crate-level doc note explaining why the - v0.2.2 strict-mode retrofit covers it by documentation only — the - backend does not shell out, so there is no argv surface to validate. +- `secretenv-testing::StrictMock`: declarative mock-CLI builder. `StrictMock::new(bin).on(argv, Response).install(dir)` generates a POSIX shell script matching full joined argv against a rule list, exiting 97 on no-match with a diagnostic naming the observed argv and every declared shape. `Response::success`, `Response::failure`, and `Response::success_with_stdin` cover the needed shapes. Types are `#[non_exhaustive]` so future matchers (`PositionalThenFlags`, `Regex`, env-var assertions) can land additively. +- `crates/secretenv-cli/tests/e2e.rs`: two end-to-end scenarios proving `StrictMock` through the full call chain (one happy-path exact argv match, one drift-catch asserting exit 97); reference pattern for the v0.2.3 to v0.2.6 per-backend retrofits. +- `secretenv-backend-local` crate-level doc note explaining the retrofit covers it by documentation only since the backend does not shell out. ### Changed - -- **Internal only:** strict-mode harness test infrastructure. No CLI - behavior, URI grammar, or backend semantics changed. Users upgrading - will see no difference. +- Internal only: strict-mode harness test infrastructure. No CLI behavior, URI grammar, or backend semantics changed. ## [0.2.1] -**Headline:** canonical `#key=value` fragment grammar. One deliberate pre-launch breaking change that locks the URI vocabulary before public eyes see the v0.2.0 shorthand form. - -### Changed — BREAKING (pre-launch correction window) - -- **Fragment grammar canonicalized.** URI fragments now must match - `#key=value[,key=value]*` under a single grammar enforced by - `BackendUri::fragment_directives()` in `secretenv-core`. Each backend - declares the directive keys it recognizes; unknown keys error with - the full URI and a list of recognized directives. See - [`docs/fragment-vocabulary.md`](docs/fragment-vocabulary.md) for the - grammar and the per-backend directive registry. -- **aws-secrets**: `#` shorthand (v0.2.0) → `#json-key=` - canonical (v0.2.1). The shorthand is rejected at URI-parse time with - a `ShorthandRejected` error that names the canonical replacement - literally (e.g. `aws-secrets:///db#password` fails with a hint - suggesting `aws-secrets:///db#json-key=password`). The backend - recognizes only `json-key`; any other directive — alone or alongside - `json-key` — surfaces as a single error listing every offender. - - This is the only deliberate breaking change permitted inside a 0.2.x - patch. It was taken before public launch while the install base was - zero (v0.2.0 tag was ~1 day old; Show HN not yet posted). Post-launch, - 0.x.y patches remain non-breaking per standard semver. +Canonical `#key=value` fragment grammar; one deliberate pre-launch breaking change locking the URI vocabulary. -### Added +### Changed, BREAKING (pre-launch correction window) +- Fragment grammar canonicalized. URI fragments must match `#key=value[,key=value]*` under a single grammar enforced by `BackendUri::fragment_directives()` in `secretenv-core`; each backend declares its recognized directive keys, unknown keys error with the full URI and a recognized-directive list. +- aws-secrets: `#` shorthand (v0.2.0) becomes `#json-key=` canonical (v0.2.1); the shorthand is rejected at URI-parse time with a `ShorthandRejected` error naming the canonical replacement (e.g. `aws-secrets:///db#password` suggests `aws-secrets:///db#json-key=password`). The backend recognizes only `json-key`; any other directive surfaces as a single error listing every offender. Only deliberate breaking change permitted inside a 0.2.x patch, taken pre-launch with zero install base. -- `BackendUri::fragment_directives()` — typed accessor that parses - the fragment body into a directive map per the canonical grammar. - `FragmentError` (re-exported from `secretenv_core`) reports - `ShorthandRejected`, `Malformed`, and `DuplicateKey` with enough - context for a caller to produce a helpful user-facing message. -- Canonical grammar doc at `docs/fragment-vocabulary.md` (user-facing) - and `kb/wiki/fragment-vocabulary.md` (project wiki). Both include - the directive registry and the migration table for v0.2.0 shorthand - URIs. +### Added +- `BackendUri::fragment_directives()`: typed accessor parsing the fragment body into a directive map per the canonical grammar. `FragmentError` (re-exported from `secretenv_core`) reports `ShorthandRejected`, `Malformed`, and `DuplicateKey`. +- Canonical grammar doc at `docs/fragment-vocabulary.md` including the directive registry and the v0.2.0 shorthand migration table. ### Migration @@ -1035,254 +740,100 @@ Findings surfaced by three parallel reviewer agents (code-reviewer, security-eng | `aws-secrets-prod:///db-creds#password` | `aws-secrets-prod:///db-creds#json-key=password` | | `aws-secrets-prod:///db-creds#host` | `aws-secrets-prod:///db-creds#json-key=host` | -If an error message in your logs mentions "legacy plain-string shorthand", rewrite the cited URI per the table above. No config or registry changes needed beyond the URI bodies. +If an error mentions "legacy plain-string shorthand", rewrite the cited URI per the table; no config or registry changes needed beyond the URI bodies. ## [0.2.0] - 2026-04-18 -**Headline:** 2 new backends (Vault, AWS Secrets Manager), cascading registries, parallel secret fetch, shell completions, enriched `resolve` report, per-cascade-source doctor, shared `secretenv-testing` crate, and a 7-item security preflight. 13 PRs (#22–#34) from scaffolding to tag. +2 new backends (Vault, AWS Secrets Manager), cascading registries, parallel secret fetch, shell completions, enriched `resolve` report, per-cascade-source doctor, shared `secretenv-testing` crate, and a 7-item security preflight; 13 PRs (#22 to #34). ### Added - -- **Shell completions.** New `secretenv completions ` - subcommand emits a completion script generated from clap's command - tree. Writes to `--output ` (chmod 0o644) or stdout. When - stdout is a TTY, a shell-specific install hint is printed to - stderr; when redirected (the usual `... > _secretenv` pipeline) - stderr stays silent. PowerShell/Elvish deliberately omitted from - v0.2 — no reason to carry the surface preemptively. -- **AWS Secrets Manager backend** (`type = "aws-secrets"`). Wraps the - same `aws` CLI as `aws-ssm` — auth story is identical (every - profile / SSO / IAM-role flow works unchanged). URI shape: - `aws-secrets-:///[#]`. First consumer - of the `BackendUri.fragment` field added in Phase 0.5 RE-2: - `#` extracts a top-level field from a JSON-valued secret, - coercing scalars (string/number/boolean/null) to strings and - erroring on nested objects/arrays with the available field names - listed so operators can correct the URI. `set` pipes through child - stdin via `--secret-string file:///dev/stdin` (CV-1 discipline); - `delete` is unconditionally `--force-delete-without-recovery` to - keep semantics symmetric with aws-ssm/vault. Update-only — creating - new secrets requires `aws secretsmanager create-secret` (deferred - to v0.3 alongside `SecretBinary` + nested-field extraction). 26 - mock-CLI tests cover every row of the spec's harness table. -- `secretenv setup` routes `aws-secrets(-*)` schemes to the new - backend type; `--region` + `--profile` flags apply to both AWS - backends identically. -- **HashiCorp Vault backend** (`type = "vault"`). Wraps the `vault` CLI - — every auth flow the CLI supports (`VAULT_TOKEN`, `AppRole`, OIDC, - Kubernetes, AWS IAM) works transparently with no secretenv auth - surface. URI shape: `vault-:///`. Uses the - unified `vault kv` CLI so KV v1 and KV v2 mounts work identically - (the CLI handles `data/` segment injection for v2 internally). `get` - uses `-field=value` for trim-one-newline single-value semantics; - `set` pipes the secret through child stdin via `value=-` (CV-1 - discipline — the secret never appears on argv). Level 2 doctor - check uses `vault token lookup` (vs `vault status` which succeeds - with no token). Supports Vault Enterprise namespaces via optional - `vault_namespace` config field — the `-namespace` flag is omitted - when unset because open-source Vault rejects it. 25 mock-CLI tests - cover every row of the spec's harness table. -- `secretenv setup` gains `--vault-address` and `--vault-namespace` - flags. Scheme prefixes `vault` and `vault-*` map to the vault - backend type. -- **Session-scoped registry cache.** New `secretenv_core::RegistryCache` - memoizes `backend.list(source)` results by source URI for the life of - a process. `resolve_registry` takes `&mut RegistryCache` and only - issues a backend call on cache miss; subsequent references to the - same source return a zero-I/O `Arc`. Within a single - `resolve_registry` call, the cache is warmed concurrently via - `futures::future::join_all` so Phase 1's cascade parallelism is - preserved. The cache holds alias-to-URI pointers only — secret - values are never cached. -- **Registry cascades.** `[registries.]` now accepts multiple - `sources = [...]` entries. Lookup is first-match-wins from `sources[0]` - downward; `sources[0]` remains the single write target for - `registry set/unset`. All sources are fetched concurrently via - `futures::future::join_all`; any source failure fails the whole - resolve (silent fall-through would hide environment problems). -- `secretenv_core::CascadeLayer` public type exposing per-source - `{source, map}` for future doctor/verbose reporting. -- `AliasMap::get` now returns `(target_uri, source_uri)` so callers can - tell which cascade layer an alias was resolved from. +- Shell completions: new `secretenv completions ` subcommand emits a clap-generated completion script; writes to `--output ` (chmod 0o644) or stdout, with a shell-specific install hint to stderr when stdout is a TTY. PowerShell/Elvish deliberately omitted. +- AWS Secrets Manager backend (`type = "aws-secrets"`): wraps the same `aws` CLI as `aws-ssm` (identical auth). URI shape `aws-secrets-:///[#]`; first consumer of `BackendUri.fragment`. `#` extracts a top-level field from a JSON-valued secret, coercing scalars to strings and erroring on nested objects/arrays with available field names listed. `set` pipes via `--secret-string file:///dev/stdin`; `delete` is unconditionally `--force-delete-without-recovery`. Update-only (create deferred to v0.3). 26 mock-CLI tests. +- `secretenv setup` routes `aws-secrets(-*)` schemes to the new backend type; `--region` + `--profile` apply to both AWS backends. +- HashiCorp Vault backend (`type = "vault"`): wraps the `vault` CLI (every CLI auth flow works transparently). URI shape `vault-:///`; unified `vault kv` CLI so KV v1 and v2 work identically. `get` uses `-field=value`; `set` pipes via `value=-`; Level 2 doctor uses `vault token lookup`. Supports Enterprise namespaces via optional `vault_namespace` config field (`-namespace` omitted when unset). 25 mock-CLI tests. +- `secretenv setup` gains `--vault-address` and `--vault-namespace` flags; `vault` and `vault-*` scheme prefixes map to the vault backend type. +- Session-scoped registry cache: new `secretenv_core::RegistryCache` memoizes `backend.list(source)` by source URI for the process lifetime. `resolve_registry` takes `&mut RegistryCache`, issues a backend call only on cache miss, returns a zero-I/O `Arc` on hit; cache warmed concurrently via `futures::future::join_all`. Holds alias-to-URI pointers only, never secret values. +- Registry cascades: `[registries.]` accepts multiple `sources = [...]` entries; lookup is first-match-wins from `sources[0]` down, `sources[0]` remains the single write target for `registry set/unset`; all sources fetched concurrently via `futures::future::join_all`, any source failure fails the whole resolve. +- `secretenv_core::CascadeLayer` public type exposing per-source `{source, map}`. +- `AliasMap::get` now returns `(target_uri, source_uri)` so callers can tell which cascade layer resolved an alias. - `AliasMap::primary_source`, `layers`, `sources` accessors. -- `BackendUri.fragment: Option` — parses the `#` suffix - of `scheme://path#fragment` URIs. Not yet consumed by any backend; v0.2 - Phase 6 (aws-secrets) will use it for `#json-key` extraction (RE-2). -- `secretenv_core::with_timeout` helper and `DEFAULT_GET_TIMEOUT` (30s) / - `DEFAULT_CHECK_TIMEOUT` (10s) constants. Backend ops now have deadlines; - `doctor` and `run` cannot hang indefinitely on a wedged CLI (CV-5). -- `BackendConfig.raw_fields` now preserves typed TOML values - (`HashMap` instead of `HashMap`). - Factories can read `as_str`, `as_integer`, `as_bool`, `as_array` — v0.2+ - backends get typed config fields without a later ABI break (RE-1). +- `BackendUri.fragment: Option`: parses the `#` suffix; not yet consumed by any backend. +- `secretenv_core::with_timeout` helper and `DEFAULT_GET_TIMEOUT` (30s) / `DEFAULT_CHECK_TIMEOUT` (10s) constants; backend ops now have deadlines so `doctor` and `run` cannot hang on a wedged CLI. +- `BackendConfig.raw_fields` now preserves typed TOML values (`HashMap` instead of `HashMap`); factories can read `as_str`, `as_integer`, `as_bool`, `as_array`. ### Changed - -- **`secretenv doctor`** gains a `Registries` section that reports - per-source reachability for every cascade source in `config.toml`. - Each source line shows ✓/✗ + the source URI + a one-word suffix - (`reachable` / `backend not authenticated` / `backend CLI 'x' missing` - / `backend error`); non-OK sources render an indented `→ ` - with the actionable remediation. A single backend-instance status - feeds every source that uses it (no duplicate `check()` calls). - `--json` gains a top-level `registries: [{name, sources: [{uri, - status, hint}]}]` key. `skip_serializing_if = "Vec::is_empty"` - means consumers of the v0.1 doctor JSON shape see no new key when - no registries are configured — backward-compatible. -- **`doctor` exit code still driven by backend-level summary only.** - A backend failure already propagates to every source that uses it, - so doubling the signal at the source level would double-count. - Registry section is informational. -- **`secretenv resolve `** now emits a tabular metadata report - instead of printing only the resolved URI. Rows: `alias`, `env var` - (reverse-lookup from the manifest, `(none)` if unused), - `resolved` (target backend URI), `source` (cascade layer URI + the - layer index), and `backend` (one-line Level 2 status of the target - backend instance — doubles as a lightweight pre-flight check). New - `--json` flag emits the same data as structured JSON for editor / - IDE consumers. Manifest loading is best-effort: a missing - `secretenv.toml` no longer blocks resolve, it just sets `env_var` - to `(none)`. Backend check failure does not fail resolve — the - mapping is still printed so operators can debug auth separately. -- **`resolve_registry` signature.** Added a `cache: &mut RegistryCache` - parameter. Callers must now construct a `RegistryCache::new()` - (typically per command) and pass it through. Breaking change for - anyone consuming `secretenv-core` as a library. -- **`AliasMap` internals.** Layers are now held as - `Vec>` instead of `Vec`. - `AliasMap::layers()` returns `&[Arc]`. Enables - shared-layer semantics between the cache and returned maps with - zero deep clones. -- **Backend crate directories** renamed to match their published - crate names: `crates/backends/backend-local` → - `crates/backends/secretenv-backend-local` (and same for - `aws-ssm`, `1password`). The v0.1 crates.io-prep rename deliberately - left the directories unchanged; aligning them removes the - path-vs-package-name inconsistency that was tripping readers. The - CLI directory (`crates/secretenv-cli/`) stays as-is — it publishes - as plain `secretenv` so `cargo install secretenv` lines up with the - binary name. -- **Parallel secret fetch.** `runner::build_env` dispatches every - alias-backed secret concurrently via `futures::future::join_all` - instead of awaiting them one at a time. `Default`-sourced entries - stay inline (zero I/O). Declaration order in the emitted env map is - preserved regardless of backend completion order. -- **Multi-error aggregation.** When more than one alias fetch fails, - the returned error now lists every failure in one message - (` secrets failed to resolve:` followed by one line per alias - with env-var, URI, and upstream cause). Single-failure error shape - is unchanged so operators with one broken alias see the same - message as before (RE-7). -- `ResolvedSource::Uri` is now a struct variant `{ target, source }` - instead of `Uri(BackendUri)`. The added `source` field carries the - cascade layer URI the alias resolved from, for future `--verbose` - and `doctor --extensive` surfacing. -- `registry set`/`unset` writes use `BTreeMap` internally - so alias output is alphabetically sorted and deterministic across - runs — no more spurious diffs on every write (CV-4). -- `BackendFactory::create` signature: `config: &HashMap` - (was `config: HashMap` by value). Borrowed config removes - a per-load clone and matches core's borrowed-config convention (RE-5). -- `Manifest::find_upward` stops at project-root sentinels (`.git`, `.hg`, - `.svn`, `.secretenv-root`). A hostile `secretenv.toml` dropped upstream - of the user's project can no longer hijack alias resolution (CV-6). - Falls back to v0.1 behavior when no sentinel exists anywhere. -- `--verbose` stderr output omits full URI paths — only env-var + backend - instance name. Registry topology no longer leaks into CI build logs on - `--verbose` runs (CV-7). Full URIs remain available under `--dry-run`. +- `secretenv doctor` gains a `Registries` section reporting per-source reachability for every cascade source; each line shows status + source URI + a one-word suffix (`reachable` / `backend not authenticated` / `backend CLI 'x' missing` / `backend error`), non-OK sources render an indented hint. A single backend-instance status feeds every source using it. `--json` gains a top-level `registries: [{name, sources: [{uri, status, hint}]}]` key with `skip_serializing_if = "Vec::is_empty"` for v0.1 backward-compatibility. +- `doctor` exit code still driven by backend-level summary only; the Registry section is informational. +- `secretenv resolve ` now emits a tabular metadata report instead of just the resolved URI: rows `alias`, `env var`, `resolved`, `source` (cascade layer URI + index), `backend` (one-line Level 2 status). New `--json` flag. Manifest loading is best-effort (missing `secretenv.toml` sets `env_var` to `(none)`); backend check failure does not fail resolve. +- `resolve_registry` signature: added a `cache: &mut RegistryCache` parameter; callers construct `RegistryCache::new()` and pass it through. Breaking for library consumers of `secretenv-core`. +- `AliasMap` internals: layers held as `Vec>` instead of `Vec`; `AliasMap::layers()` returns `&[Arc]`. +- Backend crate directories renamed to match published crate names: `crates/backends/backend-local` becomes `crates/backends/secretenv-backend-local` (same for `aws-ssm`, `1password`); the CLI directory `crates/secretenv-cli/` stays (publishes as plain `secretenv`). +- Parallel secret fetch: `runner::build_env` dispatches every alias-backed secret concurrently via `futures::future::join_all`; `Default`-sourced entries stay inline; declaration order in the emitted env map is preserved. +- Multi-error aggregation: more than one failed alias fetch returns one message listing every failure (` secrets failed to resolve:` plus one line per alias with env-var, URI, cause); single-failure shape unchanged. +- `ResolvedSource::Uri` is now a struct variant `{ target, source }` instead of `Uri(BackendUri)`; the added `source` field carries the cascade layer URI. +- `registry set`/`unset` writes use `BTreeMap` internally so alias output is alphabetically sorted and deterministic. +- `BackendFactory::create` signature: `config: &HashMap` (was `HashMap` by value), removing a per-load clone. +- `Manifest::find_upward` stops at project-root sentinels (`.git`, `.hg`, `.svn`, `.secretenv-root`) so a hostile upstream `secretenv.toml` cannot hijack alias resolution; falls back to v0.1 behavior with no sentinel. +- `--verbose` stderr output omits full URI paths (only env-var + backend instance name); full URIs remain under `--dry-run`. ### Fixed - -- **AWS SSM `set`** pipes secret values via child-process stdin using - `--value file:///dev/stdin`. The secret never appears on argv, closing - the `/proc//cmdline` local-user exposure window (CV-1 — critical). -- **1Password `set`** documents its remaining argv exposure with an - inline comment + stderr warning on every call. Full stdin fix pending - a v0.3 follow-up with `op` CLI version gating (CV-1 — partial). -- `BackendUri::parse` rejects invalid scheme characters (anything outside - `[a-zA-Z0-9][a-zA-Z0-9_-]*`), NUL bytes in path/fragment, and ASCII - control characters except tab. Warns on Unicode bidi-override codepoints - without rejecting them (CV-3). -- `SECRETENV_REGISTRY` and `SECRETENV_CONFIG` scrubbed from the child - process environment before `exec()`/`spawn`. CLI-layer config provenance - no longer leaks to the child (SEC-1). +- AWS SSM `set` pipes secret values via child-process stdin using `--value file:///dev/stdin`; secret never appears on argv, closing the `/proc//cmdline` exposure window (critical). +- 1Password `set` documents its remaining argv exposure with an inline comment + stderr warning on every call; full stdin fix pending v0.3 with `op` CLI version gating (partial). +- `BackendUri::parse` rejects invalid scheme characters (outside `[a-zA-Z0-9][a-zA-Z0-9_-]*`), NUL bytes in path/fragment, and ASCII control characters except tab; warns on Unicode bidi-override codepoints without rejecting. +- `SECRETENV_REGISTRY` and `SECRETENV_CONFIG` scrubbed from the child process environment before `exec()`/`spawn`. ### Security - -- Phase 0.5 security preflight complete. See - `kb/wiki/reviews/pre-v0.2-review.md` for the three-reviewer audit that - identified the above items, and `kb/wiki/build-plan-v0.2.md §Phase 0.5` - for the subtask breakdown. Remaining audit items are addressed in the - Phase 1+ feature work. +- Security preflight complete (three-reviewer audit); remaining audit items addressed in later feature work. ### Internal - -- **Extracted `secretenv-testing` crate** (unpublished). The `install_mock` - shell-script writer with its Linux ETXTBSY probe loop previously lived - in three separate locations (`backend-aws-ssm/src/lib.rs`, - `backend-1password/src/lib.rs`, `secretenv-cli/tests/e2e.rs`). All - three now call into the shared crate. Public surface is - `install_mock(dir, bin_name, body) -> PathBuf` plus thin - `install_mock_aws` / `install_mock_op` wrappers. `publish = false` - for v0.2 — revisit once Phase 5 (Vault) + Phase 6 (AWS Secrets - Manager) have proven the API shape. -- v0.2 development baseline: branch `feat/v0.2-prep` opened, workspace bumped - to 0.2.0, roadmap updated to reflect the Vault + AWS Secrets Manager - dual-backend release. -- `tokio` promoted from dev-dep to runtime dep on `secretenv-core` (needed - for `tokio::time::timeout` in the timeout wrapper). Workspace tokio - features gain `"time"`. -- `tracing` added as a direct runtime dep on `secretenv-core` (bidi-override - warning) and `secretenv-backend-1password` (argv-exposure warning). -- `toml` added as a direct runtime dep on `secretenv-backend-aws-ssm` (now - references `toml::Value` in the factory signature). +- Extracted `secretenv-testing` crate (unpublished): the `install_mock` shell-script writer with its Linux ETXTBSY probe loop, previously duplicated in `backend-aws-ssm`, `backend-1password`, and `secretenv-cli/tests/e2e.rs`, now shared. Public surface `install_mock(dir, bin_name, body) -> PathBuf` plus `install_mock_aws` / `install_mock_op` wrappers; `publish = false` for v0.2. +- v0.2 dev baseline: branch `feat/v0.2-prep`, workspace bumped to 0.2.0, roadmap updated. +- `tokio` promoted from dev-dep to runtime dep on `secretenv-core` (for `tokio::time::timeout`); workspace tokio features gain `"time"`. +- `tracing` added as a direct runtime dep on `secretenv-core` (bidi-override warning) and `secretenv-backend-1password` (argv-exposure warning). +- `toml` added as a direct runtime dep on `secretenv-backend-aws-ssm` (factory signature now references `toml::Value`). ## [0.1.1] - 2026-04-17 First public release of SecretEnv. ### Added - -- Core CLI surface: `run`, `registry list/get/set/unset`, `setup`, `doctor`, - `get`. -- Three backends: `local` (TOML file), `aws-ssm` (AWS Systems Manager - Parameter Store via `aws` CLI), `1password` (1Password via `op` CLI). -- `secretenv.toml` manifest format with alias (`from = "secretenv://..."`) - and default (`default = "..."`) secret declarations. -- `~/.config/secretenv/config.toml` machine-level configuration with - `[registries.] sources = [...]` and `[backends.]` blocks. -- `secretenv://` URI scheme for aliases resolved against the - registry; direct-scheme URIs (e.g. `aws-ssm-prod:///path`) for concrete - backend references inside registry documents. -- `BackendRegistry` + `Backend` + `BackendFactory` plugin system; all - backends compiled into a single binary (no compile-time feature flags). -- Level 1 (CLI present) + Level 2 (authenticated) doctor checks, with - `--json` output for CI integration. -- `secretenv setup ` bootstrap with `--force`, `--skip-doctor`, - and backend-specific flags (`--region`, `--profile`, `--account`). -- `install.sh` POSIX installer with `--profile ` distribution-profile - support (downloads config from `https://secretenv.io/profiles/.toml` - by default; override via `SECRETENV_PROFILE_URL`). +- Core CLI surface: `run`, `registry list/get/set/unset`, `setup`, `doctor`, `get`. +- Three backends: `local` (TOML file), `aws-ssm` (AWS Systems Manager Parameter Store via `aws` CLI), `1password` (via `op` CLI). +- `secretenv.toml` manifest format with alias (`from = "secretenv://..."`) and default (`default = "..."`) secret declarations. +- `~/.config/secretenv/config.toml` machine-level config with `[registries.] sources = [...]` and `[backends.]` blocks. +- `secretenv://` URI scheme for registry-resolved aliases; direct-scheme URIs (e.g. `aws-ssm-prod:///path`) for concrete backend references. +- `BackendRegistry` + `Backend` + `BackendFactory` plugin system; all backends compiled into a single binary (no compile-time feature flags). +- Level 1 (CLI present) + Level 2 (authenticated) doctor checks, with `--json` output. +- `secretenv setup ` bootstrap with `--force`, `--skip-doctor`, and backend-specific flags (`--region`, `--profile`, `--account`). +- `install.sh` POSIX installer with `--profile ` distribution-profile support (downloads config from `https://secretenv.io/profiles/.toml` by default; override via `SECRETENV_PROFILE_URL`). - Homebrew tap at `TechAlchemistX/homebrew-secretenv`. -- Release workflow builds and publishes for `x86_64-unknown-linux-gnu`, - `aarch64-unknown-linux-gnu`, `x86_64-apple-darwin`, `aarch64-apple-darwin`. +- Release workflow builds and publishes for `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`, `x86_64-apple-darwin`, `aarch64-apple-darwin`. ### Security - - Workspace-wide `unsafe_code = "forbid"`. - Clippy `unwrap_used` / `expect_used` set to warn; CI denies warnings. -- Secret values wrapped in `zeroize::Zeroizing` in the runner; - `exec()` replaces the parent process, zeroing automatically on drop. -- No shell interpolation — every backend uses - `tokio::process::Command::args([...])` with separate argv strings. +- Secret values wrapped in `zeroize::Zeroizing` in the runner; `exec()` replaces the parent process, zeroing automatically on drop. +- No shell interpolation; every backend uses `tokio::process::Command::args([...])` with separate argv strings. - `cargo deny check` + `cargo audit` gate every PR. -- Errors include alias + URI + instance name + trimmed backend stderr, - never the secret value. - -[Unreleased]: https://github.com/TechAlchemistX/secretenv/compare/v0.12.0...HEAD +- Errors include alias + URI + instance name + trimmed backend stderr, never the secret value. + + +[0.19.0]: https://github.com/TechAlchemistX/secretenv/releases/tag/v0.19.0 +[0.18.0]: https://github.com/TechAlchemistX/secretenv/releases/tag/v0.18.0 +[0.17.0]: https://github.com/TechAlchemistX/secretenv/releases/tag/v0.17.0 +[0.16.0]: https://github.com/TechAlchemistX/secretenv/releases/tag/v0.16.0 +[0.15.0]: https://github.com/TechAlchemistX/secretenv/releases/tag/v0.15.0 +[0.14.0]: https://github.com/TechAlchemistX/secretenv/releases/tag/v0.14.0 +[0.13.0]: https://github.com/TechAlchemistX/secretenv/releases/tag/v0.13.0 [0.12.0]: https://github.com/TechAlchemistX/secretenv/releases/tag/v0.12.0 -[0.2.3]: https://github.com/TechAlchemistX/secretenv/releases/tag/v0.2.3 -[0.2.2]: https://github.com/TechAlchemistX/secretenv/releases/tag/v0.2.2 -[0.2.1]: https://github.com/TechAlchemistX/secretenv/releases/tag/v0.2.1 +[0.11.0]: https://github.com/TechAlchemistX/secretenv/releases/tag/v0.11.0 +[0.10.0]: https://github.com/TechAlchemistX/secretenv/releases/tag/v0.10.0 +[0.9.0]: https://github.com/TechAlchemistX/secretenv/releases/tag/v0.9.0 +[0.8.0]: https://github.com/TechAlchemistX/secretenv/releases/tag/v0.8.0 +[0.7.0]: https://github.com/TechAlchemistX/secretenv/releases/tag/v0.7.0 +[0.6.0]: https://github.com/TechAlchemistX/secretenv/releases/tag/v0.6.0 +[0.5.0]: https://github.com/TechAlchemistX/secretenv/releases/tag/v0.5.0 +[0.4.0]: https://github.com/TechAlchemistX/secretenv/releases/tag/v0.4.0 +[0.3.0]: https://github.com/TechAlchemistX/secretenv/releases/tag/v0.3.0 [0.2.0]: https://github.com/TechAlchemistX/secretenv/releases/tag/v0.2.0 [0.1.1]: https://github.com/TechAlchemistX/secretenv/releases/tag/v0.1.1 diff --git a/CLA.md b/CLA.md index 576edc9..703d84d 100644 --- a/CLA.md +++ b/CLA.md @@ -1,14 +1,14 @@ # SecretEnv Contributor License Agreement (CLA) -**Version 1.0 — effective 2026-04-19.** +**Version 1.0, effective 2026-04-19.** This Contributor License Agreement ("**Agreement**") is entered into between **TechAlchemistX** (the "**Project**", operated by Mandeep Patel) and **You** (the contributor). -By submitting a Contribution (defined below) to the Project — whether as a Pull Request, patch, comment, or any other form accepted by the Project — You agree to the terms below. This Agreement applies to all Contributions You have ever submitted and all Contributions You will submit in the future, unless superseded in writing. +By submitting a Contribution (defined below) to the Project, whether as a Pull Request, patch, comment, or any other form accepted by the Project, You agree to the terms below. This Agreement applies to all Contributions You have ever submitted and all Contributions You will submit in the future, unless superseded in writing. ## 1. Definitions -- **"Contribution"** means any original work of authorship — source code, documentation, configuration, test, asset, or other creative material — that You intentionally submit to the Project for inclusion in, or documentation of, any of the Project's products or artifacts. +- **"Contribution"** means any original work of authorship (source code, documentation, configuration, test, asset, or other creative material) that You intentionally submit to the Project for inclusion in, or documentation of, any of the Project's products or artifacts. - **"Submit"** means any form of electronic, verbal, or written communication sent to the Project, including code pushed to the Project's repositories, Pull Requests opened against those repositories, issue comments containing code, email, and communication on Project-operated communication channels, but excluding communication conspicuously marked as "Not a Contribution." - **"You"** (or "Your") means the individual or legal entity entering into this Agreement. If You are entering into this Agreement on behalf of a legal entity, You represent that You are authorized to bind that entity. @@ -26,7 +26,7 @@ If any entity institutes patent litigation against You or any other entity (incl You acknowledge that the Project is currently licensed under the **GNU Affero General Public License v3.0 (AGPL-3.0-only)** to downstream users. -You further grant the Project the right to **relicense Your Contribution** under any other license of the Project's choosing — including but not limited to permissive licenses (MIT, Apache-2.0), other copyleft licenses (GPLv3, LGPLv3), or proprietary/commercial licenses — and to sublicense under any such license. This enables the Project to offer dual-licensed commercial terms to organizations that require them, while continuing to distribute under AGPL-3.0-only to the community at large. +You further grant the Project the right to **relicense Your Contribution** under any other license of the Project's choosing, including but not limited to permissive licenses (MIT, Apache-2.0), other copyleft licenses (GPLv3, LGPLv3), or proprietary/commercial licenses, and to sublicense under any such license. This enables the Project to offer dual-licensed commercial terms to organizations that require them, while continuing to distribute under AGPL-3.0-only to the community at large. **You retain full ownership of Your Contribution.** This Agreement is a license grant, not a copyright assignment. You may continue to use, modify, and relicense Your own Contribution outside the Project under any terms You choose. @@ -50,7 +50,7 @@ You signify agreement to this CLA by doing all of the following when submitting 1. **Sign off every commit in Your contribution** with the `Signed-off-by: Your Name ` trailer. This trailer attests that You have the right to submit the Contribution and that You agree to this CLA. Use `git commit --signoff` (or `-s`) to add it automatically. 2. **On Your first Contribution to the Project**, add Your name + email to the bottom of [`AUTHORS.md`](AUTHORS.md) in the same Pull Request, formatted as: ``` - - Your Name — first contributed YYYY-MM-DD + - Your Name , first contributed YYYY-MM-DD ``` This serves as an explicit, timestamped record of assent. 3. **Continue signing off every subsequent Contribution.** A missing `Signed-off-by` trailer is treated as an absent CLA and blocks merge. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5c2dcb4..95049d7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,21 +1,21 @@ # Contributing to SecretEnv -Thanks for considering a contribution. This document covers the mechanics. For the why — the architecture, the plugin model, the security posture — read [`README.md`](README.md) and [`docs/`](docs/) first. +Thanks for considering a contribution. This document covers the mechanics. For the why (the architecture, the plugin model, the security posture), read [`README.md`](README.md) and [`docs/`](docs/) first. ## Development setup -You need a recent Rust stable toolchain. The repo pins one via `rust-toolchain.toml`, so `rustup` will pick it up automatically on first build. The pin is deliberate: it keeps local and CI on the same compiler so new clippy lints + trybuild fixture text drift do not surface as red CI on push. Bumping the pin is its own chore — see `kb/wiki/runbooks/rust-toolchain-bump.md`. +Install Rust via rustup. The repo pins a toolchain in `rust-toolchain.toml`. Rustup picks it up automatically. The pin keeps local and CI on the same compiler. -Supporting tools used by the pre-commit suite: +Install supporting tools: ```sh cargo install cargo-deny --locked cargo install cargo-audit --locked ``` -## Pre-commit suite +## Pre-commit -Every commit on `main` must pass the following locally before opening a PR. CI re-runs the same checks: +Every commit must pass locally before opening a PR: ```sh cargo fmt --all -- --check @@ -25,74 +25,68 @@ cargo deny check cargo audit ``` -If any of these fails, fix it at the source. Do **not** weaken the workspace-level lint configuration to paper over a failure; prefer a narrowly scoped `#[allow(...)]` with a comment explaining why. +Fix failures at the source, not by weakening lints. Use narrowly-scoped `#[allow(...)]` with a comment if needed. -## Branching & commit workflow +## Branching & commits -- Branch off `main` with a prefix: `feat/`, `fix/`, or `chore/`. -- One PR per logical change. Squash-merge. -- Commits follow [Conventional Commits](https://www.conventionalcommits.org/): - - `feat(core): ...`, `fix(backend-aws-ssm): ...`, `chore: ...`, `docs: ...`, `test: ...` -- Commits must be signed. See [`docs/signing.md`](docs/signing.md) if it exists, or configure `git config --global commit.gpgsign true` with an SSH signing key registered on your GitHub account. +- Branch: `feat/`, `fix/`, or `chore/` off `main` +- One PR per logical change; squash-merge +- Commits follow [Conventional Commits](https://www.conventionalcommits.org/): `feat(core):`, `fix(backend-aws-ssm):`, `chore:`, etc. +- Sign commits with SSH key: `git config --global commit.gpgsign true` -## Adding a new backend +## New backends -Follow the step-by-step walkthrough at [`docs/adding-a-backend.md`](docs/adding-a-backend.md). The architectural constraints — shell-out to native CLIs only, `Backend` + `BackendFactory` traits, no cloud-SDK imports — are load-bearing, not stylistic. +Follow [`docs/reference/adding-a-backend.md`](docs/reference/adding-a-backend.md). Architectural constraints (shell-out to native CLIs, `Backend` + `BackendFactory` traits, no cloud SDKs) are load-bearing. -## Adding a telemetry attribute (typed setter) +## Telemetry setters -`SecretEnvSpan` has **no `set_attribute(key, value)` escape hatch** — every emitted OTel attribute goes through a typed `record_*` setter, and there is no string-keyed path. When the value is a closed set (a backend type, a command name, an outcome), the setter takes a **closed enum, not a `&str`**, so a typo or a leaky raw string cannot reach the wire. +`SecretEnvSpan` has no `set_attribute(key, value)` escape hatch. All OTel attributes go through typed `record_*` setters. Closed-set values use enums, not `&str`. -The rule that trips contributors: **those closed enums live in `secretenv-telemetry::span`, not in the crate that owns the domain concept.** The dependency direction is `secretenv-core → secretenv-telemetry` (and `secretenv-migrate`/`secretenv-mcp` likewise depend *on* telemetry), so telemetry cannot depend back on core to reuse, say, a backend-type enum defined there. Adding a typed setter is therefore a three-part change, all in `secretenv-telemetry`: +**Closed enums live in `secretenv-telemetry::span`, not the domain crate.** Dependency direction: `secretenv-core → secretenv-telemetry`. Adding a typed setter requires three changes in `secretenv-telemetry`: -1. the closed enum (e.g. `BackendType`, `SecretEnvCommand`, `MigrateOutcome`) — with an `into_attribute_value`/`as_attribute_value` method — in `span.rs`; -2. the `record_(&mut self, value: TheEnum)` setter on `SecretEnvSpan`; -3. the consuming caller in `secretenv-core`/`-migrate`/`-mcp`, which converts a runtime string at the boundary via the enum's `from_runtime_str` (or constructs the variant directly). +1. Closed enum (e.g. `BackendType`, `SecretEnvCommand`, `MigrateOutcome`) with `into_attribute_value`/`as_attribute_value` in `span.rs` +2. `record_(&mut self, value: TheEnum)` setter on `SecretEnvSpan` +3. Consuming caller in `secretenv-core`/`-migrate`/`-mcp` converting runtime strings -A setter that takes `&str` for a value that is really a closed set is a half-closure — see the v0.18→v0.19 history of `record_migrate_*_backend_type` (Arch-M4 / Arch-W-4). Do not add new `&str` setters for closed-set values. +Do not add `&str` setters for closed-set values. -## Panics in production code +## Panics in production -Production code (everything outside `#[cfg(test)]`) must not `panic!`/`unreachable!`/`unwrap`/`expect` on any input an operator or agent can influence. Two narrow exceptions, each requiring a comment: +Production code must not panic on operator or agent input. Two exceptions (both require comments): -- **Statically-valid `expect`** — e.g. compiling a hand-audited `regex` literal. Carry `#[allow(clippy::expect_used)]` and a one-line justification that the failure is impossible by construction and would surface at first use, not in a user path. -- **By-construction `panic!`** — when an internal invariant makes a branch unreachable, prefer making it unrepresentable in the type system (the `ResolvedConfirmVia` / `MutationOperatorDecision` marker-type pattern — see `secretenv-mcp`) over a runtime guard. If a type-level lift is disproportionate for a single internal caller, document the precondition with a `// by construction:` block at the panic site (see `aggregate_errors` in `secretenv-core::runner`). +- **Hand-audited `expect`**: e.g. compiling regex literals. Add `#[allow(clippy::expect_used)]` with a one-line justification that the failure is impossible by construction. +- **By-construction panic**: internal invariants making branches unreachable. Prefer making it unrepresentable in the type system (marker-type pattern in `secretenv-mcp`). If unproportionate, add a `// by construction:` comment at the panic site (see `aggregate_errors` in `secretenv-core::runner`). -Prefer the type-system lift. A `tracing::warn!` + soft-fail on a "can't happen" branch is a smell, not a safety net. +Prefer the type-system lift. A `tracing::warn!` + soft-fail is a smell, not a safety net. -## Marker types and the `Decision` trait +## Marker types -When an enum variant is unreachable in a specific context, prefer a context-specific marker type that structurally omits the variant over a runtime guard or `unreachable!()` arm. The impossible state then cannot be witnessed in that context; the compiler enforces the invariant rather than a human reader. Convert to and from the shared on-disk or serde type at the context boundary via a small trait. This pattern has three instances in the codebase: `ResolvedConfirmVia` (produced by `resolve_confirm_via` in the policy gate; omits `Auto` once resolution has run, eliminating the v0.17 `unreachable!()`), `MutationSpanName` (the closed enum drives both the span-name constructor and the sampler whitelist so adding a variant covers both), and the v0.19 `OperatorDecision` family (`MutationOperatorDecision` omits `DryRun` — mutation tools structurally cannot receive a dry-run decision; `MigrateOperatorDecision` retains it; the `Decision` trait's `to_audit() -> OperatorDecision` is the single projection point so echo and audit-write cannot diverge). The on-disk/serde union (`OperatorDecision`) remains one type shared across all contexts; only the in-memory marker types are split. See also `## Panics in production code` above — the marker-type lift is the preferred alternative to the `// by construction:` documented panic. +Use context-specific marker types that omit unreachable variants instead of runtime guards or `unreachable!()`. The compiler enforces the invariant. Convert at context boundaries via a trait. -## Security - -Security-relevant changes get extra care. See [`SECURITY.md`](SECURITY.md) for the disclosure policy and [`docs/security.md`](docs/security.md) for the threat model. Report vulnerabilities privately — do not open a public issue. - -## License and CLA +Examples: `ResolvedConfirmVia` (omits `Auto` post-resolution), `MutationSpanName` (closed enum drives span-name + sampler whitelist), `OperatorDecision` family (`MutationOperatorDecision` omits `DryRun`; `MigrateOperatorDecision` retains it). -SecretEnv is licensed under **[GNU AGPL v3.0 (AGPL-3.0-only)](LICENSE)** starting with v0.3.0. v0.1 and v0.2.0 shipped under MIT; the published MIT releases remain available under their original terms. +The on-disk/serde union stays shared; only in-memory marker types split. Prefer this over the `// by construction:` documented panic. -All Contributions are accepted under the project's [Contributor License Agreement (CLA)](CLA.md). The CLA is a **license grant** (not a copyright assignment — You retain ownership of Your work) that lets the project relicense contributions under alternate terms, enabling dual-licensing (e.g., commercial licenses alongside the AGPL community license). +## Security -### Signing the CLA +Security-relevant changes get extra care. See [`SECURITY.md`](SECURITY.md) for the disclosure policy and [`docs/security.md`](docs/security.md) for the threat model. Report vulnerabilities privately. Do not open a public issue. -Two things are required on every Contribution: +## License and CLA -1. **Every commit must carry a `Signed-off-by` trailer** attesting to the CLA. Use `git commit -s` (or `--signoff`) — git appends the trailer from your configured `user.name` + `user.email` automatically: - ``` - Signed-off-by: Jane Doe - ``` - Missing trailer = no merge. This is enforced at review. +SecretEnv is licensed under **[GNU AGPL v3.0](LICENSE)** from v0.3.0 forward. v0.1 to 0.2.0 shipped under MIT. -2. **First-time contributors:** add your name to [`AUTHORS.md`](AUTHORS.md) in the same PR as your first Contribution, using the format documented in that file. Subsequent Contributions only need the sign-off trailer. +All contributions are accepted under the [Contributor License Agreement](CLA.md), a license grant (not copyright assignment) enabling dual-licensing. -Corporate contributors whose employer claims IP rights over their Contributions must additionally execute a Corporate CLA — contact the project maintainer to arrange this before submitting. +### CLA requirements -### Commit signing vs. CLA sign-off +1. **Sign-off on every commit:** Use `git commit -s`. Adds `Signed-off-by` trailer from your configured `user.name` + `user.email`. Missing trailer = no merge. +2. **First-time contributors:** Add your name to [`AUTHORS.md`](AUTHORS.md) in the same PR. Subsequent contributions only need the trailer. +3. **Corporate contributors:** Your employer must execute a Corporate CLA. Contact the maintainer first. -These are two different things: +### Commit signing vs. sign-off -- **`git commit --signoff` (`-s`)** adds the `Signed-off-by` line (CLA attestation). **Required.** -- **`git commit --gpg-sign` (`-S`) / SSH signing (`commit.gpgsign = true`)** cryptographically signs the commit. **Also required** per the Branching & Commit Workflow section above. +Two distinct things: +- **`git commit -s` (sign-off)**: adds `Signed-off-by` (CLA attestation). Required. +- **`git commit -S` (cryptographic signature)**: signs the commit with SSH key. Required. -Configure both; they compose. A typical commit on this repo carries both a cryptographic signature (verifies identity) and a `Signed-off-by` trailer (CLA assent). +Configure both. A typical commit carries cryptographic signature + `Signed-off-by` trailer. diff --git a/README.md b/README.md index 2817278..8b5108b 100644 --- a/README.md +++ b/README.md @@ -5,734 +5,284 @@ -[![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](LICENSE) -[![Crates.io](https://img.shields.io/crates/v/secretenv.svg)](https://crates.io/crates/secretenv) -[![Build](https://img.shields.io/github/actions/workflow/status/TechAlchemistX/secretenv/ci.yml?branch=main)](https://github.com/TechAlchemistX/secretenv/actions) -[![Backends](https://img.shields.io/badge/backends-15-green)](#supported-backends) -[![Smoke](https://img.shields.io/badge/live--smoke-796_assertions-brightgreen)](#stability-proof) +[![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg?style=for-the-badge)](LICENSE) +[![Crates.io](https://img.shields.io/crates/v/secretenv.svg?style=for-the-badge)](https://crates.io/crates/secretenv) +[![Build](https://img.shields.io/github/actions/workflow/status/TechAlchemistX/secretenv/ci.yml?branch=main&&style=for-the-badge)](https://github.com/TechAlchemistX/secretenv/actions) +[![Backends](https://img.shields.io/badge/backends-15-green?style=for-the-badge)](#supported-backends) +[![Smoke](https://img.shields.io/badge/live--smoke-779_assertions-brightgreen?style=for-the-badge)](#stability-proof) -### One registry. Every repo. Every backend. Migrate without touching a single repo. +### Multi-backend secrets orchestration using alias registry -*No SaaS. No re-encryption. No lock-in. No .env files.* +**One registry. Every repo. Every backend. Migrate without touching a single repo.** -**Multi-backend secrets orchestration via an alias registry that lives in your own backend.** +*No SaaS. No re-encryption. No lock-in. No .env files.* -[Quick Start](#quick-start) · [How It Works](#the-three-file-model) · [Workflows](#concrete-workflows) · [Backends](#supported-backends) · [CLI Reference](#cli-reference) · [Compare](#how-secretenv-compares) · [Security](#security) · [Docs](docs/) +[Quick Start](#quick-start) · [How It Works](#the-three-file-model) · [Workflows](#workflows) · [Backends](#supported-backends) · [CLI](#cli) · [Compare](#how-it-compares) · [Security](#security) · [Docs](docs/README.md) --- -## The Problem - -Your org uses AWS SSM for infra credentials, 1Password for team secrets, and Vault for service tokens. Every developer has a slightly different `.env` file assembled from manual fetches across all three. New engineers spend their first day asking where things live. Offboarding is a manual checklist nobody fully trusts. Migrating from one backend to another means touching every repo. +## The problem -Every existing tool assumes it is your only secrets backend. You have three. Or four. Or five. +Your org uses AWS SSM for infra credentials, 1Password for team secrets, and Vault for service tokens. Every developer hand-assembles a `.env` from all three. New hires spend day one asking where things live; offboarding is a checklist nobody fully trusts; migrating one backend to another means touching every repo. ---- +Every other tool assumes it's your *only* secrets backend. You have three. Or four. Or five. -## What SecretEnv Does +## What it does -SecretEnv runs any command with secrets injected as environment variables, sourced from whatever combination of backends your team already uses — without storing, encrypting, or managing any secrets itself. Instead, it orchestrates your existing backends through an **alias registry that lives in your own backend**: name your secrets once in the project manifest, change where the alias points to update every repo on its next run. No PRs. No re-encryption. No coordination. +`secretenv run` injects secrets as environment variables into any command, sourced from whatever mix of backends your team already uses, without storing, encrypting, or managing any secret itself. It orchestrates your existing backends through an **alias registry that lives in your own backend**: name a secret once in the repo manifest, and change where the alias points to update every repo on its next run. No PRs, no re-encryption, no coordination. ```bash secretenv run -- npm start -secretenv run --registry dev -- python manage.py runserver -secretenv run --registry staging -- docker compose up +secretenv run --registry staging -- ./deploy.sh ``` -Secrets are fetched at runtime, injected into the child process, and gone when it exits. **No secret values written to disk.** - -> **SecretEnv is a coat of paint. If the walls aren't strong, the paint is useless. The walls are your backends.** SecretEnv is not a security product — it's a workflow product that removes the most common vectors for secrets-in-git and secrets-on-disk. Auth, encryption, and storage stay where they already are. +Secrets are fetched at runtime, injected into the child process, and gone when it exits. **No secret values are written to disk.** +**SecretEnv is a workflow product, not a security product.** It removes the most common vectors for secrets-in-git and secrets-on-disk; auth, encryption, and storage stay exactly where they already are. --- -## The Three-File Model +## The three-file model -SecretEnv separates three things every other tool conflates. **Three files, three owners, three lifecycles.** +SecretEnv separates three things every other tool conflates: **three files, three owners, three lifecycles.** Reading a repo teaches you nothing about backend topology, because topology never enters the repo. -| File | Lives where | Who owns it | What it contains | Committed to git? | -|---|---|---|---|---| -| `secretenv.toml` | Repo root | Developer | Alias names + static defaults | **Yes** — contains nothing sensitive | -| `~/.config/secretenv/config.toml` | Machine XDG dir | Each developer (or platform team via profiles) | Backend instances + registry sources | **No** — machine-specific | -| Alias registry document | Inside a backend you already control | Platform / security team | `alias-name → backend-URI` map | **No** — lives in your backend | - -The manifest tells SecretEnv **what** is needed. The registry tells SecretEnv **where** things live. The machine config tells SecretEnv **which backends** exist on this machine. Reading the repo teaches you nothing about backend topology, because topology never enters the repo. - -For the file-by-file breakdown with full schemas, see [docs/reference/three-file-model-deep.md](docs/reference/three-file-model-deep.md). +

+ The three-file model: the manifest (secretenv.toml) names an alias, the registry maps it to a backend URI, the machine config declares which backends exist, and the value is fetched at runtime and injected into the child process +

-### File 1 — The Project Manifest (`secretenv.toml`) - -Every repo commits a `secretenv.toml` declaring which secrets it needs — using alias names, not backend paths. Zero infrastructure information. +Every repo commits only alias names. Here's an example manifest: ```toml -# secretenv.toml — committed to git +# secretenv.toml: committed to git. Alias names only; backend paths are a hard error. [secrets] -STRIPE_KEY = { from = "secretenv://stripe-key" } -DATABASE_URL = { from = "secretenv://db-url" } -DATADOG_API_KEY = { from = "secretenv://datadog-api-key" } -LOG_LEVEL = { default = "info" } -``` - -Two value shapes only — `secretenv://` aliases or static defaults. Direct backend URIs are a hard error. - -### File 2 — The Alias Registry - -A document stored in any backend your team already controls, mapping alias names to fully-qualified backend URIs: - -```toml -# stored in aws-ssm-platform:///secretenv/registry -# managed via: secretenv registry set/unset/list - -stripe-key = "1password-work://payments/stripe/api_key" -db-url = "aws-ssm-dev:///myapp/dev/db_url" -datadog-api-key = "1password-work://engineering/datadog/api_key" -redis-url = "aws-ssm-dev:///myapp/dev/redis_url" -``` - -Change a backend? Update one line in the registry. Every repo picks it up automatically on the next run. The registry lives in a backend **you already control** so you can manage it with the access controls, audit trails, and versioning you already trust — no new tool, no new infrastructure. - -### File 3 — The Machine Config - -Each developer's machine holds the credential topology — which named backend instances exist, where each registry lives. This file never touches a repo. - -```toml -# ~/.config/secretenv/config.toml - -[registries.default] -sources = ["aws-ssm-platform:///secretenv/org-registry"] - -[registries.dev] -sources = [ - "aws-ssm-dev:///secretenv/dev-registry", # team aliases, checked first - "aws-ssm-platform:///secretenv/org-registry", # org-wide fallback -] - -[registries.prod] -sources = ["aws-ssm-prod:///secretenv/prod-registry"] - -[backends.aws-ssm-platform] -type = "aws-ssm" -aws_profile = "platform" -aws_region = "us-east-2" - -[backends.aws-ssm-dev] -type = "aws-ssm" -aws_profile = "dev" -aws_region = "us-east-1" - -[backends.aws-ssm-prod] -type = "aws-ssm" -aws_profile = "prod" -aws_region = "us-east-1" - -[backends.1password-work] -type = "1password" -op_account = "company.1password.com" +STRIPE_KEY = { from = "secretenv://stripe-key" } +DATABASE_URL = { from = "secretenv://db-url" } +LOG_LEVEL = { default = "info" } ``` -> **This is Workflow 2's wiring.** Same alias names across all three registries. `--registry dev` vs `--registry prod` routes the same `secretenv.toml` to env-specific backend instances. The manifest never sees env-specific paths. - -Platform teams distribute this file across an org with [profiles](#profiles) — one HTTPS-hosted TOML, one install command per developer. +Change a backend? Update one line in the registry; every repo picks it up on its next run. Because the registry lives in a backend you control, you manage it with the access controls, audit trails, and versioning you already trust. -### Resolution Flow - -``` -secretenv run --registry dev -- npm start - - secretenv.toml alias registry backends - ────────────── ────────────── ──────── - STRIPE_KEY stripe-key 1Password (work account) - └─ secretenv:// ────► └─ 1password-work:// ──► op read ... - DATABASE_URL db-url AWS SSM (dev account) - └─ secretenv:// ────► └─ aws-ssm-dev:// ──► aws ssm get-parameter --profile dev - LOG_LEVEL (static default) - └─ "info" ──────────────────────────────────────► injected directly - - All resolved → fetched in parallel → injected into process env → npm start -``` - -Resolution is **all-or-nothing per invocation**: if any required alias fails to resolve, the child process never starts. Partial environments are never injected. +> Full schemas, validation rules, and the 5-phase resolution flow: **[The three-file model, deep](docs/reference/three-file-model-deep.md)**. --- -## Quick Start - -### Install +## Quick start ```bash -# macOS +# 1. Install (macOS / universal / cargo) brew install secretenv - -# Linux / macOS (universal) curl -sfS https://secretenv.io/install.sh | sh - -# Cargo cargo install secretenv -``` -### Configure Your Machine - -```bash +# 2. Point your machine at a registry secretenv setup aws-ssm:///secretenv/registry --region us-east-1 -# ✓ Registry configured as [registries.default] -# ✓ Registry reachable: 12 aliases found -# ✓ AWS credentials detected (profile: default) -``` - -### Check Everything Is Ready - -```bash +# 3. Confirm every backend is installed + authenticated secretenv doctor -# ── Registries ─────────────────────────────────────────────────────── -# default -# ✓ aws-ssm:///secretenv/registry reachable via aws-ssm -# -# ── Backends ───────────────────────────────────────────────────────── -# aws-ssm (aws-ssm) -# ✓ aws CLI v2.34.35 -# ✓ authenticated profile=default account=123456789 region=us-east-1 -``` - -### Add a `secretenv.toml` to Your Project - -```toml -[secrets] -STRIPE_KEY = { from = "secretenv://stripe-key" } -DATABASE_URL = { from = "secretenv://db-url" } -LOG_LEVEL = { default = "info" } -``` - -### Run - -```bash +# 4. Add a secretenv.toml (see above), then run secretenv run -- npm start ``` ---- +New to the model? Browse the **[documentation](docs/README.md)**. -## Concrete Workflows +--- -These are the day-to-day workflows SecretEnv was built for. +## Workflows -### Workflow 1 — Day 1: a new engineer joins the team +The four workflows SecretEnv was built for. -The platform team published `acme-corp.toml` once to an internal HTTPS host. Onboarding is now two commands: +**1 · Onboard a new engineer.** The platform team publishes one profile; onboarding is two commands. ```bash -# 1. Install the binary -brew install secretenv # or: curl -sfS https://secretenv.io/install.sh | sh - -# 2. Install your team's profile (your platform team owns + hosts this) -secretenv profile install acme-corp \ - --url https://internal.acme.com/secretenv/acme-corp.toml - -# 3. Verify +secretenv profile install acme-corp --url https://internal.acme.com/secretenv/acme-corp.toml secretenv doctor ``` -Clone any repo, run `secretenv run -- npm start`. Done. The profile carries every backend instance and registry source the team has converged on — no copy-paste from a wiki page, no Slack thread asking where Stripe lives. **Publish the profile once; every dev gets it via one command.** Updating org-wide credential topology later is `secretenv profile update` on the developer's machine — the platform team never logs into anyone else's laptop. +The profile carries every backend instance and registry source the team has converged on. No copy-paste from a wiki, no Slack thread asking where Stripe lives. → [Profiles](docs/reference/profiles.md) - -### Workflow 2 — Multi-environment deployment - -Same `secretenv.toml` works across dev, staging, and prod. The registry cascade routes the same alias names to different backend instances per environment. +**2 · Deploy across environments.** The same `secretenv.toml` runs in dev, staging, and prod; only the registry selection changes. ```bash -# Same project, same manifest, same code path. -# Only the registry selection changes. - -secretenv run --registry dev -- ./deploy.sh -secretenv run --registry staging -- ./deploy.sh -secretenv run --registry prod -- ./deploy.sh +secretenv run --registry prod -- ./deploy.sh ``` -Each registry maps `db-url`, `stripe-key`, `api-key` to env-specific backends. Your repo never knows which AWS account or which Vault namespace it's running against. - +Each registry maps the same alias names to env-specific backends. The repo never knows which AWS account or Vault namespace it's running against. → [Registries & cascading](docs/reference/registry.md) -### Workflow 3 — Backend migration without touching repos - -Stripe key needs to move from 1Password to Vault. One command: +**3 · Migrate a backend without touching repos.** Move a secret from 1Password to Vault in one command. ```bash -# Dry-run first — probe both ends, print the plan, mutate nothing. secretenv registry migrate stripe-key "vault-prod://secret/payments/stripe_key" --dry-run - -# Execute: read from 1Password, write to Vault, flip the registry pointer atomically. secretenv registry migrate stripe-key "vault-prod://secret/payments/stripe_key" - -# Every repo picks this up on the next secretenv run. -# No PRs. No re-encryption. No coordination. ``` -`secretenv registry migrate` folds the move into one operation — read from the -current backend, write to the destination (where you have write permission), -flip the registry pointer atomically. The source value is **kept by default**; -add `--delete-source` to remove it after a separately-confirmed prompt. Partial -failures never auto-roll-back by deletion — you're given the manual recovery -commands and you decide. Full reference: [docs/reference/migrate.md](docs/reference/migrate.md). - -The older two-step flow — move the value with your own tooling, then -`secretenv registry set` to repoint the alias — still works and remains the -fallback when you don't have write access to the destination from the machine -running `secretenv`. - - -### Workflow 4 — Offboarding an engineer +Read from the source, write to the destination, flip the registry pointer atomically. The source value is kept by default (`--delete-source` removes it after a separate prompt). Every repo picks up the change on its next run. → [Migrate](docs/reference/migrate.md) -Revoke the departing engineer's access to the registry backend. **One operation in IAM, Vault, or 1Password. Done.** +**4 · Offboard an engineer.** Revoke their access to the registry backend. One operation in IAM, Vault, or 1Password covers every repo at once. Immediate, no re-encryption, no manual checklist. -They can no longer resolve any alias. They can no longer fetch any secret via SecretEnv. The revocation is immediate and covers every repo simultaneously — no re-encryption, no manual checklist, no "did we get all of them." +> Rolling SecretEnv out org-wide is its own playbook: **[Adoption guide](docs/guides/rollout.md)**. --- -## Multiple Accounts and Backends +## Supported backends -This is where SecretEnv earns its keep. Real organizations have multiple AWS accounts, multiple credential sets, and multiple backend tools. Named backend instances handle this without new plugins or new concepts — just configuration. +SecretEnv delegates all authentication to each backend's native CLI, so it inherits your MFA, SSO, and biometric unlock and **adds no new auth surface to audit**. All 15 backends compile into one binary: no plugins, no feature flags. The presence of `[backends.]` in your config decides which are active. -```toml -# Three AWS accounts — one plugin, three named instances -[backends.aws-ssm-platform] # type = "aws-ssm", aws_profile = "platform", aws_region = "us-east-2" -[backends.aws-ssm-dev] # type = "aws-ssm", aws_profile = "dev", aws_region = "us-east-1" -[backends.aws-ssm-prod] # type = "aws-ssm", aws_profile = "prod", aws_region = "us-east-1" - -# Two 1Password accounts -[backends.1password-work] # type = "1password", op_account = "company.1password.com" -[backends.1password-personal] # type = "1password", op_account = "personal.1password.com" -``` - -Registry entries reference named instances as their URI scheme: - -```toml -# dev registry — aliases point to dev account -db-url = "aws-ssm-dev:///myapp/dev/db_url" -stripe-key = "1password-work://payments/stripe/dev_key" - -# prod registry — same alias names, different backends -db-url = "aws-ssm-prod:///myapp/prod/db_url" -stripe-key = "1password-work://payments/stripe/prod_key" -``` - -Alias names stay identical across environments. The registry routing handles the rest. Same pattern scales to all 15 backends. +| Backend | Type | Tested CLI | +|---|---|---| +| [Local file](docs/backends/local.md) | `local` | `std::fs` | +| [AWS SSM Parameter Store](docs/backends/aws-ssm.md) | `aws-ssm` | aws-cli v2.34.35+ | +| [AWS Secrets Manager](docs/backends/aws-secrets.md) | `aws-secrets` | aws-cli v2.34.35+ | +| [1Password](docs/backends/1password.md) | `1password` | op v2.34.0+ | +| [HashiCorp Vault](docs/backends/vault.md) | `vault` | vault v2.0.0+ | +| [GCP Secret Manager](docs/backends/gcp.md) | `gcp` | gcloud v560.0.0+ | +| [Azure Key Vault](docs/backends/azure.md) | `azure` | azure-cli v2.85.0+ | +| [macOS Keychain](docs/backends/keychain.md) | `keychain` | `security` (macOS only) | +| [Doppler](docs/backends/doppler.md) | `doppler` | doppler v3.76.0+ | +| [Infisical](docs/backends/infisical.md) | `infisical` | infisical v0.43.79+ | +| [Keeper](docs/backends/keeper.md) | `keeper` | Commander v17.2.13+ | +| [Cloudflare Workers KV](docs/backends/cf-kv.md) | `cf-kv` | wrangler v4.85.0+ | +| [OpenBao](docs/backends/openbao.md) | `openbao` | bao v2.5.3+ | +| [CyberArk Conjur](docs/backends/conjur.md) | `conjur` | conjur v8.1.3+ (Go) | +| [Bitwarden Secrets Manager](docs/backends/bitwarden-sm.md) | `bitwarden-sm` | bws v2.0.0+ | +| Delinea Secret Server | `delinea` | *coming soon* | + +Each page covers the URI scheme, config fields, authentication, `doctor` output, and examples. URI fragment directives (`#json-key`, `#version`) are documented in the [fragment vocabulary](docs/reference/fragment-vocabulary.md). Start at the **[backend index](docs/backends/README.md)** for selection guidance. + +> **SecretEnv never calls cloud APIs directly.** Every fetch is a shell-out to the native CLI, so it inherits whatever auth your backend already enforces, with no new authentication surface. --- -## Selecting and Cascading Registries +## CLI ```bash -# Use [registries.default] from config -secretenv run -- npm start - -# Use a named registry from config -secretenv run --registry dev -- npm start - -# Use a direct URI — single source, no cascade -secretenv run --registry aws-ssm-dev:///secretenv/registry -- npm start -``` - -The `--registry` flag accepts either a name (looks up `[registries.]` in config) or a direct URI (uses that document, no cascade). The same disambiguation applies to the `SECRETENV_REGISTRY` environment variable — the canonical mechanism for CI. - -**Registry selection precedence:** - -``` -1. --registry ← explicit per-invocation -2. SECRETENV_REGISTRY= ← CI / shell-session override -3. [registries.default] in config ← machine default -4. hard error ← no assumption made +secretenv run [--registry ] [--dry-run] [--verbose] [--redact] -- +secretenv registry list | get | set | unset | migrate | history | invite +secretenv profile install | list | update | uninstall +secretenv doctor [--json] [--fix] [--extensive] [--trace] +secretenv setup [--region R] [--profile P] [--vault-address …] +secretenv redact [--in-place] [--backup ] [--dry-run] +secretenv mcp serve | disable | enable | setup | audit +secretenv resolve | get | completions ``` -### Cascading - -A named registry can cascade across multiple sources. **First match wins.** Use this for team-specific aliases that shadow org-wide defaults. - -```toml -[registries.dev] -sources = [ - "aws-ssm-dev:///secretenv/dev-registry", # team aliases — checked first - "aws-ssm-platform:///secretenv/org-registry", # org-wide fallback -] -``` +Key environment variables: +- `SECRETENV_REGISTRY` (registry override, the primary CI mechanism) +- `SECRETENV_PROFILE_URL` +- `RUST_LOG=secretenv=debug` -`stripe-key` in the dev registry shadows `stripe-key` in the org registry. Org-wide entries that exist only in the fallback resolve transparently. Writes always go to `sources[0]`. +> Every command, every flag, every exit code: **[CLI reference](docs/reference/cli-reference-full.md)**. --- -## CI/CD Integration +## CI/CD -SecretEnv works in CI via the `SECRETENV_REGISTRY` environment variable. Set it once at the org or repo level — no config file needed on the runner. +In CI you authenticate the backend CLI, not SecretEnv. That works exactly as it would if you were calling the CLI directly, and SecretEnv adds no auth layer. Set the registry once via an env var: ```yaml -# GitHub Actions -jobs: - deploy: - runs-on: ubuntu-latest - permissions: { id-token: write } - steps: - - uses: aws-actions/configure-aws-credentials@v4 - with: - role-to-assume: arn:aws:iam::123456789012:role/github-actions-role - aws-region: us-east-1 - - - name: Install secretenv - run: curl -sfS https://secretenv.io/install.sh | sh - - - name: Run with secrets - env: - SECRETENV_REGISTRY: aws-ssm:///secretenv/registry - run: secretenv run -- ./deploy.sh -``` - -**In CI you're not authenticating SecretEnv** — you're authenticating the backend CLI. Set up the backend CLI's service account credentials the same way you would if you were calling it directly. SecretEnv adds no auth layer. - -| CI platform | Runner model | Pattern | Reference | -|---|---|---|---| -| GitHub Actions | Ephemeral | OIDC + `SECRETENV_REGISTRY` | inline above | -| GitLab CI | Ephemeral / persistent | Native Vault JWT or CI variables | [docs/ci-cd.md](docs/ci-cd.md) | -| Jenkins | Persistent | Agent-baked CLIs + global env | [docs/ci-cd.md](docs/ci-cd.md) | -| BuildKite | Persistent | Agent-baked CLIs + hooks | [docs/ci-cd.md](docs/ci-cd.md) | -| CircleCI | Ephemeral | Context vars + OIDC | [docs/ci-cd.md](docs/ci-cd.md) | - - ---- - -## Rolling SecretEnv Out Across Your Org - -Adopting SecretEnv at scale follows a predictable rollout sequence. Each stage is independently reversible. - -1. **Discovery.** Inventory existing secret backends. Identify a registry-host candidate (any backend the platform team controls and every engineer can reach — AWS SSM, Vault, 1Password, Cloudflare KV). -2. **Pilot.** One team, one registry, hand-written `config.toml`. Validate end-to-end with `secretenv doctor --extensive`. -3. **Author the org profile.** Publish a single `acme-defaults.toml` to an HTTPS-reachable host (CDN, internal artifact store, or `secretenv.io/profiles`). Profiles are TOML fragments containing `[registries.*]` and `[backends.*]` blocks. -4. **Org-wide install.** Distribute the one-line installer; new joiners get the correct config from minute one. Local config always wins where keys overlap, so a profile can never silently break a developer's workflow. -5. **CI integration.** Set `SECRETENV_REGISTRY` at org-level CI variable scope. Add `secretenv doctor --json` as a pre-deploy gate. -6. **Offboarding playbook.** Codify "revoke registry-backend access" in your IAM runbook. One operation; covers every repo. - -**Profile rollback** is a profile re-publish + `secretenv profile update` on the fleet — there is no server-pushed update channel; the developer pulls. - -### Profiles - -Profiles are how a platform team converges every dev machine to the team's intent without touching individual machines. - -```bash -# Install or re-install with updated metadata -secretenv profile install acme-corp --url https://internal.acme.com/secretenv/acme-corp.toml - -# Update — ETag-conditional re-fetch; reports up-to-date or refreshed -secretenv profile update - -# List + uninstall -secretenv profile list -secretenv profile uninstall acme-corp -``` - -Profiles are **additive merges, never overrides** — local `config.toml` always wins. Self-hosted / air-gapped orgs override the canonical base via `SECRETENV_PROFILE_URL`. Hard size cap of 1 MiB per profile guards against compromised distributions. Full guide: [docs/reference/profiles.md](docs/reference/profiles.md). - ---- - -## Operational Health: `secretenv doctor` - -`secretenv doctor` is the front door for everything operational — onboarding validation, CI pre-deploy gates, on-call diagnostics. It runs three levels of checks, all in parallel. - -| Level | Probe | Default | -|---|---|---| -| **L1** | Is the backend's CLI installed? (`aws --version`, `op --version`, ...) | always | -| **L2** | Is the backend authenticated? (`aws sts get-caller-identity`, `op whoami`, ...) | always | -| **L3** | Can we read each registry source? (counts aliases, reports permission scope) | `--extensive` | - -```bash -secretenv doctor # default (L1 + L2) -secretenv doctor --json # machine-readable for CI / monitoring -secretenv doctor --fix # interactive remediation (aws sso login, op signin, vault login, ...) -secretenv doctor --extensive # deep probe — exercises registry reads -``` - -Exit code is non-zero if any backend reports anything other than `Ok`. `doctor --json` is designed to run in under 2s wall-clock for a 10-backend topology and is suitable as a CI pre-deploy gate or a per-minute monitoring probe. - -### Failure semantics - -- **Resolution is all-or-nothing per invocation.** If any required alias fails to resolve, the child process never starts. Partial environments are never injected. -- **No on-disk cache.** Every run hits live backends. Deliberate — no stale-cache class of bug, no key material between runs. -- **Failure modes report cleanly.** `BackendUnauthenticated`, `AliasNotFound`, `RegistryUnreachable`, `BackendCliMissing` are the four operationally interesting failure shapes; each carries enough context to triage without re-running. -- **Logging.** `RUST_LOG=secretenv=debug` emits structured logs to stderr. `--verbose` on `run` emits per-secret fetch progress. - ---- - -## Observability - -SecretEnv emits OpenTelemetry traces and metrics for every resolution, backend probe, MCP tool call, and registry mutation. Telemetry is **opt-in** — set `OTEL_EXPORTER_OTLP_ENDPOINT` to point at any OTLP-compatible collector (Jaeger, Tempo, Honeycomb, Datadog, the OTel collector). With no endpoint configured, SecretEnv installs no exporter and has zero startup overhead. - -**Production onramp** — two env vars and you're done: - -```bash -export OTEL_EXPORTER_OTLP_ENDPOINT=http://collector.internal:4317 -export OTEL_SERVICE_NAME=payments-secretenv # optional; defaults to "secretenv" -secretenv run -- ./deploy.sh +- run: curl -sfS https://secretenv.io/install.sh | sh +- env: + SECRETENV_REGISTRY: aws-ssm:///secretenv/registry + run: secretenv run -- ./deploy.sh ``` -The first var turns telemetry on; the second overrides the default `secretenv` service name (useful when multiple teams ship SecretEnv-wrapped CI jobs to a shared collector and want per-project tagging). Standard `OTEL_*` env vars work for endpoint, timeout, sampler, resource attributes, and W3C `TRACEPARENT`/`TRACESTATE` propagation. The exporter transport is OTLP/gRPC only; `OTEL_EXPORTER_OTLP_PROTOCOL` is not consulted. Full details: [`docs/reference/opentelemetry.md`](docs/reference/opentelemetry.md) §7. - -The full attribute schema, span topology, metric inventory, and the audit-facing redaction taxonomy are documented in [`docs/reference/opentelemetry.md`](docs/reference/opentelemetry.md). Every emitted attribute is enumerated with an explicit ALLOW/DENY classification, enforced at compile time by the typed `SecretEnvSpan` builder — there is no `set_attribute(key, value)` escape hatch. - -For observability without a collector: `secretenv run --verbose` shows per-alias timing, and `secretenv doctor --trace` renders an in-process span table from the doctor backend probe pass — no OTLP collector required. +Per-platform patterns (GitHub Actions OIDC, GitLab Vault JWT, Jenkins/BuildKite agent-baked CLIs, CircleCI contexts): **[CI/CD guide](docs/guides/ci-cd.md)**. --- -## Stability Proof - -Every backend tool claims stability. SecretEnv proves it. - -The smoke harness exercises the **real binary** against **real backend CLIs** in **real shells** — not mocks, not contract tests. Every assertion validates: spawn the CLI, route input via tempfile or stdin, parse stdout, handle stderr, observe exit code. **796 assertions across 15 backends as of v0.18.0.** - -In the v0.13 cycle, this harness caught a latent pipe-deadlock in the Infisical backend that had survived **15 days and 6 release cycles** since Infisical shipped in v0.7.0. CI was green every release. Unit tests passed. Three-agent audits passed. Only the live-backend smoke — running the real binary against the real CLI in a real shell — surfaced it. The fix was one line; the lesson was the harness. - -| Release | Date | Backends | Live-smoke assertions | Notable addition | -|---|---|---|---|---| -| v0.2.0 | 2026-04-18 | 5 | ~90 | First integration smoke | -| v0.3.0 | 2026-04-19 | 7 | 250 | +GCP, +Azure | -| v0.4.0 | 2026-04-21 | 7 | 336 | Functionality cycle (+86) | -| v0.5.0 | 2026-04-22 | 8 | 347 | +Keychain | -| v0.6.0 | 2026-04-22 | 9 | 362 | +Doppler | -| v0.7.0 | 2026-04-22 | 10 | 377 | +Infisical | -| v0.8.0 | 2026-04-23 | 11 | 395 | +Keeper | -| v0.9.0 | 2026-04-24 | 12 | 419 | +Cloudflare KV | -| v0.10.0 | 2026-04-26 | 13 | 452 | +OpenBao | -| v0.11.0 | 2026-04-30 | 14 | 479 | +CyberArk Conjur | -| v0.12.0 | 2026-05-05 | 15 | 508 | +Bitwarden Secrets Manager | -| v0.13.0 | 2026-05-07 | 15 | 508 | Hygiene cycle — caught the v0.7-era pipe-deadlock | -| v0.15.0 | 2026-05-20 | 15 | 690 | +registry migrate | -| v0.16.0 | 2026-05-24 | 15 | 712 | +MCP server (v0.16) | -| v0.17.0 | 2026-05-28 | 15 | 767 | +OpenTelemetry (v0.17) | -| v0.18.0 | 2026-06-04 | 15 | 796 | Hardening cycle (non-backend) | - -Test surface grew with feature surface across ~3 weeks of single-backend-per-minor-release cadence. - ---- - -## Supported Backends - -SecretEnv delegates all authentication to each backend's native CLI. The version column below is what the v0.18.0 release smoke ran against. - - -| Backend | Type | URI Scheme | Tested CLI version | Status | -|---|---|---|---|---| -| [Local file](docs/backends/local.md) | `local` | `local:///path/to/file.toml` | (uses `std::fs`) | Available | -| [AWS SSM Parameter Store](docs/backends/aws-ssm.md) | `aws-ssm` | `aws-ssm-:///path` | `aws-cli/2.34.35` | Available | -| [AWS Secrets Manager](docs/backends/aws-secrets.md) | `aws-secrets` | `aws-secrets-:///[#json-key=]` | `aws-cli/2.34.35` | Available | -| [1Password](docs/backends/1password.md) | `1password` | `1password-://vault/item/field` | `op 2.34.0` | Available | -| [HashiCorp Vault](docs/backends/vault.md) | `vault` | `vault-:////` | `vault 2.0.0` | Available | -| [GCP Secret Manager](docs/backends/gcp.md) | `gcp` | `gcp-:///[#version=]` | `gcloud 560.0.0` | Available | -| [Azure Key Vault](docs/backends/azure.md) | `azure` | `azure-:///[#version=<32-hex>]` | `azure-cli 2.85.0` | Available | -| [macOS Keychain](docs/backends/keychain.md) | `keychain` | `keychain-:///service/account` | macOS `security` (Darwin 25.4) | Available (macOS only) | -| [Doppler](docs/backends/doppler.md) | `doppler` | `doppler-://///` | `doppler v3.76.0` | Available | -| [Infisical](docs/backends/infisical.md) | `infisical` | `infisical-://///` | `infisical 0.43.79` | Available | -| [Keeper](docs/backends/keeper.md) | `keeper` | `keeper-:///[#field=]` | `keeper Commander 17.2.13` | Available | -| [Cloudflare Workers KV](docs/backends/cf-kv.md) | `cf-kv` | `cf-kv-:////` | `wrangler 4.85.0` | Available | -| [OpenBao](docs/backends/openbao.md) | `openbao` | `openbao-://mount/path[#json-key=]` | `bao v2.5.3` | Available | -| [CyberArk Conjur](docs/backends/conjur.md) | `conjur` | `conjur-://[#json-key=]` | `conjur v8.1.3` (Go) | Available | -| [Bitwarden Secrets Manager](docs/backends/bitwarden-sm.md) | `bitwarden-sm` | `bitwarden-sm-://[#json-key=]` | `bws 2.0.0` | Available | -| Delinea Secret Server | `delinea` | `delinea-://folder/secret` | `tss` | Coming Soon | - -**Click any backend** for its per-backend doc page — configuration, URI format, authentication, doctor output, examples. Or browse the [backend index](docs/backends/README.md) for the full table with selection guidance. - -The URI scheme is your named instance. Multiple instances of the same backend type — for multiple accounts, multiple vaults, or multiple credential sets — are configured in `config.toml` and referenced by their instance name. - -
-URI syntax notes — triple slashes, fragments, json-key extraction - -**Why some URIs have three slashes.** Standard URI grammar is `:///`. SecretEnv URIs have no authority component (the "host" position is empty — the instance is encoded in the scheme), so a leading-slash path produces the triple-slash form `:///`. AWS SSM REQUIRES the leading `/` because Parameter Store names begin with `/`. The 1Password row uses the double-slash form because its path tokens are `vault/item/field` segments. - -**Fragment directives.** URIs optionally carry a `#key=value[,key=value]*` fragment that each backend interprets per its own registered directives — `#json-key=` to pick a value out of a JSON-shaped secret, `#version=` to pin a version. See [docs/reference/fragment-vocabulary.md](docs/reference/fragment-vocabulary.md) for the full grammar. +## Health & observability -
+**`secretenv doctor`** is the front door for onboarding validation, CI pre-deploy gates, and on-call triage. It runs three levels of checks in parallel: -> **SecretEnv never calls cloud APIs directly.** Every fetch is a shell-out to the native CLI. This means SecretEnv inherits your MFA, SSO, biometric unlock, and any other auth your backend requires — with **no new authentication surface to audit.** +- **L1**: is the backend's CLI installed? +- **L2**: is the backend authenticated? +- **L3**: is each registry source readable? (`--extensive` only) ---- - -## CLI Reference +`--json` runs under 2s for a 10-backend topology; `--fix` walks interactive remediation. → [doctor reference](docs/reference/cli-reference-full.md#secretenv-doctor) -```bash -# run — primary use case -secretenv run [--registry ] [--dry-run] [--verbose] [--redact] [--no-redact --i-know] [--redact-token ] -- - -# registry — alias CRUD + history + onboarding helpers -secretenv registry list [--registry ] -secretenv registry get [--registry ] -secretenv registry set [--registry ] -secretenv registry unset [--registry ] -secretenv registry migrate [--dry-run] [--yes] [--from ] [--delete-source] [--json] [--registry ] -secretenv registry history [--registry ] [--json] -secretenv registry invite [--registry ] [--invitee ] [--json] - -# profile — distribute team config fragments via HTTPS -secretenv profile install [--url ] -secretenv profile list [--json] -secretenv profile update [] -secretenv profile uninstall - -# doctor — three-level health checks (L1 CLI + L2 auth + L3 read) -secretenv doctor [--json] [--fix] [--extensive] [--trace] - -# setup — bootstrap config wizard -secretenv setup [--region R] [--profile P] [--account A] [--vault-address ...] [--force] [--skip-doctor] - -# redact — post-hoc file scrubber (v0.14+) -secretenv redact [--registry ] [--in-place] [--backup ] [--dry-run] [--redact-token ] - -# mcp — Model Context Protocol server (v0.16+) -secretenv mcp serve [--allow-mutations ] [--confirm-via ] -secretenv mcp disable [--duration <30m|2h|1d>] -secretenv mcp enable -secretenv mcp setup [--ide ] [--list-ides] [--write] [--force] [--merge] [--check-overrides] -secretenv mcp audit tail [--lines ] [--path ] - -# completions, resolve, get -secretenv completions [--output ] -secretenv resolve [--registry ] [--json] -secretenv get [--registry ] [--yes] -``` +**OpenTelemetry**: opt-in traces and metrics for every resolution, backend probe, MCP tool call, and registry mutation: -**Global flag:** `--config ` (defaults to `$XDG_CONFIG_HOME/secretenv/config.toml`) +- One-line setup: point `OTEL_EXPORTER_OTLP_ENDPOINT` at any OTLP/gRPC collector. +- No endpoint? No exporter is installed, with zero startup overhead. +- Every attribute is ALLOW/DENY-classified at compile time; there's no `set_attribute` escape hatch. -**Environment variables:** +→ [OpenTelemetry reference](docs/reference/opentelemetry.md) -```bash -SECRETENV_REGISTRY= # registry override — primary CI mechanism -SECRETENV_PROFILE_URL= # override the default profile fetch base -RUST_LOG=secretenv=debug # structured logging (default: secretenv=warn) -``` +**Two guarantees worth stating plainly:** -Full per-flag reference + exit codes: [docs/reference/cli-reference-full.md](docs/reference/cli-reference-full.md). +- **All-or-nothing per invocation.** If any required alias fails to resolve, the child process never starts. Partial environments are never injected. +- **No on-disk cache.** Every run hits live backends, so rotation is transparent on the next run. --- -## How SecretEnv Compares +## How it compares | Property | **SecretEnv** | `.env` | fnox¹ | direnv | |---|---|---|---|---| -| Multi-backend in one invocation | **✓** | — | ✓ | manual per-project | +| Multi-backend in one invocation | **✓** | n/a | ✓ | manual per-project | | Backend migration without editing repos | **✓** (one `registry set`) | n/a | ✗ (edit every `fnox.toml`) | n/a | | Infrastructure topology hidden from repos | **✓** (aliases only) | ✗ | ✓ (ciphertext or refs) | ✗ (paths in `.envrc`) | -| Centrally-shared mutable alias registry | **✓** (lives in your backend) | — | — | — | -| One-line offboarding (single revoke covers all repos) | **✓** | ✗ | ✗ (age) / ✓ (KMS — IAM revoke) | ✗ | +| Centrally-shared mutable alias registry | **✓** (in your backend) | n/a | n/a | n/a | +| One-line offboarding (single revoke covers all repos) | **✓** | ✗ | ✗ (age) / ✓ (KMS) | ✗ | | Stores no secret material on disk | **✓** | ✗ | depends¹ | **✓** | | No SaaS dependency | **✓** | ✓ | ✓ | ✓ | -| Inherits backend MFA / SSO / biometric | **✓** (native CLI) | — | partial | — | +| Inherits backend MFA / SSO / biometric | **✓** (native CLI) | n/a | partial | n/a | -¹ **fnox** is multi-mode. Age mode keeps an age private key on disk; KMS modes (aws-kms / azure-kms / gcp-kms) gate decryption on IAM and have no persistent disk key. SecretEnv's distinction is orthogonal to encryption: the alias-registry layer removes backend topology from every repo. See [docs/comparisons/vs-fnox.md](docs/comparisons/vs-fnox.md) for the mode-by-mode breakdown. - -### Why not... - -- **`.env` files** — Manual, error-prone, accumulate stale values, get committed accidentally, sit on disk in plaintext, make offboarding a checklist nobody fully trusts. This is the workflow SecretEnv replaces. Full: [docs/comparisons/vs-dotenv.md](docs/comparisons/vs-dotenv.md). -- **fnox** — A thoughtful multi-mode tool covering age-encryption, KMS-gated decryption, and cloud references. **In KMS modes, fnox closes the persistent-key + offboarding concerns at the KMS-key level.** SecretEnv's distinction is orthogonal: an alias-registry layer above the backend that decouples repos from backend URIs entirely. Migrating a secret in fnox (any mode) means editing every `fnox.toml`; in SecretEnv it's one `registry set`. Full: [docs/comparisons/vs-fnox.md](docs/comparisons/vs-fnox.md). -- **`direnv`** — Shell-hook model requires writing custom glue per project. Backend integration is manual scripting. Paths live in `.envrc` files. No standard for what a project needs or where things live. Full: [docs/comparisons/vs-direnv.md](docs/comparisons/vs-direnv.md). - -**Per-tool deep dives** for op run / doppler run / Pulumi ESC / External Secrets Operator / sops / Vault Enterprise + Conjur as identity platforms: see the [docs/comparisons/](docs/comparisons/) directory. - ---- +¹ **fnox** is multi-mode. Age mode keeps an age private key on disk; KMS modes gate decryption on IAM with no persistent disk key. SecretEnv's distinction is orthogonal to encryption: the alias-registry layer removes backend topology from every repo, so a migration is one `registry set` instead of editing every config. -## Right For You / Not Right For You +**Pick SecretEnv if** you run 2+ backends and want one onboarding/offboarding story, want infrastructure topology hidden from your repos, or operate across local dev + CI + persistent runners and want one tool for all three. -**Pick SecretEnv if:** -- Your team uses **2+ secret backends** and wants a unified onboarding/offboarding story -- You want **infrastructure topology hidden** from your repos (no AWS account IDs, no Vault namespaces in code) -- You've experienced backend migrations and want to avoid touching N microservices next time -- You want to avoid SaaS gates for a core workflow tool -- You operate across local dev + ephemeral CI + persistent CI, and want **one tool** for all three +**Pick something else if** you're committed to a single backend (`op run` / `doppler run` are simpler and more deeply integrated), you're all-Kubernetes (External Secrets Operator), you need a hosted policy/audit/rotation service (Pulumi ESC, Vault Enterprise, CyberArk Conjur), or you need at-rest file encryption for gitops (sops, or fnox in KMS mode). -**Pick something else if:** -- You're committed to a single backend long-term — the single-backend wrappers (`op run`, `doppler run`, `infisical run`) are simpler and more deeply integrated with their UIs -- Your entire deployment is Kubernetes — **External Secrets Operator** has tighter in-cluster integration -- You need a hosted policy engine, hosted audit trail, or rotation orchestration as a service — **Pulumi ESC**, **Vault Enterprise**, or **CyberArk Conjur** are purpose-built for that -- You need at-rest file encryption for gitops workflows — **sops** owns that category -- You want client-side encryption with KMS-gated decryption stored in committed config — **fnox** in KMS mode is purpose-built for that +> Per-tool deep dives for `.env`, fnox, direnv, op run, doppler run, Pulumi ESC, External Secrets Operator, sops, and Vault/Conjur: **[comparisons](docs/comparisons/README.md)**. --- ## Security -> **SecretEnv is not a security product. It is a workflow product that removes the most common vectors for secrets-in-git and secrets-on-disk — the failures that happen at scale.** +> **SecretEnv is not a security product. It's a workflow product that removes the most common vectors for secrets-in-git and secrets-on-disk, the failures that happen at scale.** -The model is simple: SecretEnv has no credential storage, no login command, and no auth surface of its own. If your backend is authenticated, SecretEnv works. If it isn't, SecretEnv fails with the same error the native CLI would give you. Fix it there. +The model is simple: SecretEnv has no credential storage, no login command, and no auth surface of its own. If your backend is authenticated, SecretEnv works. If it isn't, SecretEnv fails with the same error the native CLI would give you. -### What SecretEnv eliminates +**What it eliminates**: secrets committed to git (nothing to commit), secret values on disk (nothing written), infrastructure paths in repos (aliases only), manual offboarding gaps (one revoke covers everything), and backend lock-in (a registry update migrates every repo at once). -- **Secrets committed to git accidentally** — eliminated; nothing to commit. -- **Secret values sitting on disk in plaintext** — eliminated; nothing written. -- **Infrastructure paths exposed in repos** — eliminated; aliases only. -- **Manual offboarding gaps** — one backend access revocation covers everything. -- **Backend lock-in** — registry update migrates every repo simultaneously. -- **Stale secrets** — fetched on every invocation; rotation is transparent on next run (subject to backend caching). +**What it deliberately does not do**: defend a compromised machine, protect secrets after injection (a property of the env-var model), replace platform-native runtime injection for ECS/Lambda/Kubernetes, provide encryption-at-rest (that belongs to your backend), or replace policy engines, audit services, and rotation orchestration (it routes to them). -### What SecretEnv deliberately does NOT do +**Redaction (v0.14+)** scrubs resolved values from child-process stdout/stderr by default, falling back to `exec()` for interactive TTYs; `secretenv redact ` cleans existing files post-hoc. -- **Machine compromise** — if the machine is owned, active backend sessions are inherited. This is true for every secrets tool. The real defense is credential scoping at the backend level. -- **Post-injection process exposure** — once secrets are injected as env vars, they are readable by any process running as the same user. This is a property of the env-var model, not a SecretEnv issue. -- **Runtime production injection** — for ECS / Lambda / Kubernetes, the platform-native injection mechanisms are the right answer. SecretEnv is for local dev + CI + general-purpose runtime. -- **Encryption at rest.** SecretEnv stores no secret values, so it provides no encryption-at-rest property. Encryption at rest is the responsibility of whichever backend holds the value (Vault's storage encryption, SSM SecureString + KMS, 1Password's E2E vault). Tools like fnox (KMS modes) and sops *do* provide ciphertext-in-repo and are the right answer if that's the property you need. -- **Policy engines, audit log services, rotation orchestration** — these belong to your backend (or a dedicated identity platform). SecretEnv routes to them; it does not replace them. +> Full 14-category threat model vs `.env`, direnv, op run, doppler run, and fnox: **[threat model](docs/security.md)**. Responsible disclosure: **[SECURITY.md](SECURITY.md)**. -### Defensive engineering - -- **No decryption surface at all.** SecretEnv neither holds nor needs a decryption key — it has nothing encrypted to decrypt. This is a structural property of being a router, not a security feature we engineered. Tools that gate decryption on cloud IAM (fnox-KMS, op-run, doppler-run) achieve comparable containment a different way. -- **No secret values written to disk.** In-memory secrets use `zeroize::Zeroizing` and are zeroed on drop. -- **No secrets in CLI argv.** Every backend uses safe stdin / tempfile forms (`-f /dev/stdin`, `value=-`, mode-0600 tempfiles). Unsafe argv forms are gated behind opt-in `_unsafe_set` flags and emit warnings on every use. -- **Control-character validation on URIs.** NUL and ASCII <0x20 (except tab) are rejected at parse time; stops a class of injection-via-registry attacks. -- **Manifest VCS-sentinel boundary.** The upward walk for `secretenv.toml` stops at `.git`/`.hg`/`.svn`/`.secretenv-root`. Prevents a hostile parent directory from hijacking resolution. -- **Registry-write determinism.** Writes use `BTreeMap` ordering — clean diffs make registry tampering reviewable in PRs. -- **CLI version pinning.** Backends validate the wrapped CLI's version (e.g., Conjur v7 is rejected; only v8 accepted) — incompatible CLIs surface as clear errors, not silent corruption. - -### Comparison summary (security-relevant axes) - -| Property | **SecretEnv** | `.env` | fnox (age) | fnox (KMS / aws-sm / vault) | -|---|---|---|---|---| -| Backend migration without editing repos | **✓** registry update | n/a | ✗ edit every `fnox.toml`; re-encrypt | ✗ edit every `fnox.toml` (no re-encrypt for ref modes) | -| One-revoke offboarding across all repos | **✓** revoke registry-backend access | manual | ✗ re-encrypt without ex-member's recipient | ✓ IAM revoke on the KMS key | -| No persistent decryption key on disk | **✓** no decryption surface | n/a | ✗ age private key on disk | ✓ IAM-gated KMS calls | -| Machine-compromise containment after re-image | **✓** active sessions only; bounded by backend scope | plaintext breach permanent | ✗ age key theft = offline decryption survives re-image | ✓ active sessions only; bounded by KMS key policy | -| Repo contains backend topology | **✓** no — aliases only | ✗ yes (paths) | ✗ yes (provider + path, encrypted) | ✗ yes (provider + path, ciphertext or reference) | - -The honest line: **encryption posture is comparable across SecretEnv and fnox-KMS-mode.** The differentiator is alias indirection — SecretEnv's registry decouples the alias from the backend URI so a migration is one `registry set` instead of editing every config. That property is orthogonal to encryption. - -### Redaction (v0.14) - -SecretEnv redacts resolved values from child-process stdout/stderr **by default**. `secretenv run` pipes the child's stdio through a streaming scrubber that substitutes resolved values with `[redacted:]`. For post-hoc cleanup of existing files, `secretenv redact ` performs the same substitution against a tainted set built from the active registry. +--- -- **Default on** for non-TTY parents (CI, pipelines, scripts). The default invocation requires no flag changes. -- **Auto-fallback to `exec()`** for interactive TTY parents — preserves `psql`, `vim`, `ssh`, and any other PTY-bound child. One-line stderr advisory tells you when fallback fires. -- **`--redact`** forces pipe-based redaction on a TTY (PTY-bound children may misbehave). -- **`--no-redact --i-know`** opts out entirely. The two-flag dance prevents CI typos from accidentally printing values. -- **`secretenv redact `** scrubs an existing file post-hoc. `--in-place` rewrites atomically (sibling tempfile + `rename(2)`); `--backup .bak` keeps the original; `--dry-run` counts without writing. +## Stability proof -Defense-in-depth, not a complete protection. The full Limits matrix (writes to `/dev/tty`, `syslog`, `mmap`, core dumps, etc.) lives in [docs/security.md](docs/security.md#redaction-v014); the operator reference for both modes is at [docs/reference/redact.md](docs/reference/redact.md). +Every backend tool claims stability. The smoke harness *proves* it: it exercises the **real binary** against **real backend CLIs** in **real shells**, not mocks, not contract tests. **779 assertions across 15 backends as of v0.19.0.** -### Full threat model + responsible disclosure +

+ Live-backend smoke assertions per release, v0.2.0 to v0.19.0 +

-A 14-category threat-model comparison across `.env`, `direnv`, `op run`, `doppler run`, `fnox`, and SecretEnv lives in [docs/security.md](docs/security.md). For responsible disclosure, see [SECURITY.md](SECURITY.md). +> The assertion count for **every** release since v0.2.0, plus methodology: **[Stability & smoke-test history](docs/stability.md)**. --- -## Plugin Architecture +## Architecture -The core is an SDK. It parses `secretenv.toml`, resolves `--registry` to a source list, fetches registry documents, resolves aliases, fetches secret values in parallel, and injects them into the child process. The core never knows about AWS, Vault, 1Password, or any specific backend — it only speaks the trait interface. +The core is an SDK. It parses `secretenv.toml`, resolves `--registry` to a source list, fetches registry documents, resolves aliases, fetches secret values in parallel, and injects them into the child process. It never knows about AWS, Vault, 1Password, or any specific backend; it only speaks the trait interface. Each backend is an independent crate in `crates/backends/`, and adding one is a new crate plus one line of factory registration, never a change to core. -Every backend is an independent Rust crate in `crates/backends/` implementing two traits defined in `secretenv-core`. Adding a new backend is a new crate plus one line of factory registration — never a change to core. All 15 backends compile into the single binary; no feature flags. The presence (or absence) of `[backends.]` in `config.toml` determines which backends are active at runtime. - -For the trait interface and step-by-step backend-development guide: [docs/reference/adding-a-backend.md](docs/reference/adding-a-backend.md). +> Trait interface and step-by-step guide: **[Adding a backend](docs/reference/adding-a-backend.md)**. --- ## Contributing -SecretEnv is built in Rust and welcomes contributions. The easiest entry point is adding a new backend — each one is a self-contained crate with a focused, well-defined interface. +SecretEnv is built in Rust. The easiest entry point is adding a new backend, a self-contained crate with a focused, well-defined interface. ```bash -git clone https://github.com/TechAlchemistX/secretenv -cd secretenv -cargo build -cargo test +git clone https://github.com/TechAlchemistX/secretenv && cd secretenv +cargo build && cargo test ``` See [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide. Issues tagged [`good first issue`](https://github.com/TechAlchemistX/secretenv/issues?q=label%3A%22good+first+issue%22) are scoped for first-time contributors. @@ -741,13 +291,11 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide. Issues tagged [`good ## License -**GNU Affero General Public License v3.0 (AGPL-3.0-only)** — see [LICENSE](LICENSE). - -SecretEnv is free to use, modify, and redistribute under AGPLv3. **§13 of the AGPL requires that any network-deployed modified version make its corresponding source code available to its users.** If you fork SecretEnv and operate the fork as a service, you must offer its source to your users. **Using SecretEnv as a CLI inside your organization — even commercially — is unaffected by §13; it triggers only on network-service deployment of a modified fork.** +**GNU Affero General Public License v3.0 (AGPL-3.0-only)**. See [LICENSE](LICENSE). Free to use, modify, and redistribute. -MIT was the license for v0.1 and v0.2.0. **v0.3.0 onward is AGPL-3.0-only.** +**§13 triggers only when you deploy a *modified fork* as a network service**: you must then offer its source to that service's users. Using SecretEnv as a CLI inside your organization, even commercially, is unaffected. (v0.1 and v0.2.0 were MIT; v0.3.0 onward is AGPL-3.0-only.) -Contributions are welcome under the project's [Contributor License Agreement](CLA.md). The CLA lets the project relicense contributions while keeping your own rights intact. **No CLA = no merge.** +Contributions are welcome under the project's [Contributor License Agreement](CLA.md). **No CLA, no merge.** --- diff --git a/SECURITY.md b/SECURITY.md index 5defbee..dc800da 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,7 +8,7 @@ We aim to acknowledge reports within 48 hours and provide an initial assessment ## Scope and Threat Model -secretenv is a workflow product that eliminates a class of workflow-driven security failures. It is not a cryptographic or access-control product. For the full scope — including what we protect against, what we explicitly do not, and how the threat model compares to adjacent tools — see [`docs/security.md`](docs/security.md). +secretenv is a workflow product that eliminates a class of workflow-driven security failures. It is not a cryptographic or access-control product. For the full scope (including what we protect against, what we explicitly do not, and how the threat model compares to adjacent tools), see [`docs/security.md`](docs/security.md). ## Supported Versions diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 1a32c5b..35f096d 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -18,7 +18,7 @@ export default defineConfig({ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500;600&family=Instrument+Serif:ital@0;1&display=swap', }], - ['meta', { property: 'og:title', content: 'secretenv — multi-backend secrets orchestration' }], + ['meta', { property: 'og:title', content: 'secretenv: multi-backend secrets orchestration' }], ['meta', { property: 'og:description', content: 'One registry. Every repo. Every backend. Migrate without touching a single repo.' }], ['meta', { property: 'og:type', content: 'website' }], ['meta', { property: 'og:url', content: 'https://docs.secretenv.io' }], @@ -40,7 +40,7 @@ export default defineConfig({ ignoreDeadLinks: [ // Cross-links to repo-root files (work in GitHub repo browsing, not in the docs site). - // VitePress normalises ./../../README, ../../README, and ../README — match all variants. + // VitePress normalises ./../../README, ../../README, and ../README: match all variants. /README(\.md)?(#|$)/, /(CONTRIBUTING|SECURITY|LICENSE|CLA)(\.md)?(#|$)/, ], @@ -65,7 +65,7 @@ export default defineConfig({ { text: 'Operations', items: [ - { text: 'CI/CD Integration', link: '/ci-cd' }, + { text: 'CI/CD Integration', link: '/guides/ci-cd' }, { text: 'Security & Threat Model', link: '/security' }, { text: 'Comparisons', link: '/comparisons/' }, ], @@ -138,8 +138,9 @@ export default defineConfig({ { text: 'Operations', items: [ - { text: 'CI/CD Integration', link: '/ci-cd' }, + { text: 'CI/CD Integration', link: '/guides/ci-cd' }, { text: 'Security & Threat Model', link: '/security' }, + { text: 'Stability & Smoke History', link: '/stability' }, ], }, ], diff --git a/docs/README.md b/docs/README.md index aae6917..8dd9ccb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,31 +1,39 @@ # SecretEnv Documentation -The main [README](../README.md) covers the basics — install, configure, and run your first command. This directory holds the deep references that don't belong in the main README. +The main [README](../README.md) covers install, configure, and your first command. This directory holds the deep references. --- -## Getting Started -- **[Quick Start](../README.md#quick-start)** — install, configure, and run your first command -- **[Configuration Reference](reference/configuration.md)** — schema for `secretenv.toml` and machine config -- **[CLI Reference](reference/cli-reference-full.md)** — every command, every flag, every exit code +## Getting started +- **[Quick Start](../README.md#quick-start)**: install, configure, run +- **[Configuration Reference](reference/configuration.md)**: schema for `secretenv.toml` and machine config +- **[CLI Reference](reference/cli-reference-full.md)**: every command, every flag, every exit code -## Core Concepts -- **[The Three-File Model (Deep)](reference/three-file-model-deep.md)** — full schemas, validation rules, 5-phase resolution flow -- **[Registry Management](reference/registry.md)** — alias registry concepts and CLI commands -- **[Profiles & Distribution](reference/profiles.md)** — how to share org-wide config -- **[Fragment Vocabulary](reference/fragment-vocabulary.md)** — URI `#directive` grammar +## Core concepts +- **[The Three-File Model](reference/three-file-model-deep.md)**: full schemas, validation rules, 5-phase resolution flow +- **[Registry Management](reference/registry.md)**: alias registry concepts, cascading, and CLI commands +- **[Migrating a Secret](reference/migrate.md)**: `registry migrate` mechanics and recovery +- **[Profiles & Distribution](reference/profiles.md)**: share org-wide config over HTTPS +- **[Fragment Vocabulary](reference/fragment-vocabulary.md)**: URI `#directive` grammar ## Backends -- **[Supported Backends](backends/README.md)** — 15 backend pages with config, URI format, examples, tested CLI versions -- **[Adding a Backend](reference/adding-a-backend.md)** — trait interface and step-by-step development guide +- **[Supported Backends](backends/README.md)**: 15 backend pages with config, URI format, examples, tested CLI versions +- **[Adding a Backend](reference/adding-a-backend.md)**: trait interface and step-by-step development guide -## How SecretEnv Compares -- **[Tool Comparisons](comparisons/README.md)** — `.env` vs fnox vs direnv vs op-run vs Pulumi ESC vs ESO vs sops vs Vault/Conjur +## Operations +- **[CI/CD Integration](guides/ci-cd.md)**: GitHub Actions, GitLab, Jenkins, BuildKite, CircleCI patterns +- **[Org Rollout](guides/rollout.md)**: the six-stage adoption playbook +- **[OpenTelemetry](reference/opentelemetry.md)**: opt-in traces and metrics; the redaction attribute taxonomy +- **[Redaction](reference/redact.md)**: scrubbing values from output and existing files +- **[MCP Server](reference/mcp.md)**: the Model Context Protocol server and its tools -## Security & Operations -- **[Threat Model & Security](security.md)** — 14-category honest comparison with alternatives -- **[CI/CD Integration](ci-cd.md)** — GitHub Actions, GitLab, Jenkins, BuildKite, CircleCI patterns +## Security & comparisons +- **[Threat Model & Security](security.md)**: 14-category honest comparison with alternatives +- **[Tool Comparisons](comparisons/README.md)**: `.env`, fnox, direnv, op run, doppler run, Pulumi ESC, ESO, sops, Vault/Conjur + +## Project +- **[Stability & Smoke History](stability.md)**: live-backend assertion count for every release, since v0.2.0 --- -**Try it now:** `secretenv doctor` — the front door for validating your config against any backend. See [Operational Health](../README.md#operational-health-secretenv-doctor) in the main README. +**Try it now:** `secretenv doctor` is the front door for validating your config against any backend. See [Health & observability](../README.md#health--observability) in the main README. diff --git a/docs/backends/1password.md b/docs/backends/1password.md index 4d2de70..90b0084 100644 --- a/docs/backends/1password.md +++ b/docs/backends/1password.md @@ -1,28 +1,29 @@ # 1Password -**Type:** `1password` -**CLI required:** [`op`](https://developer.1password.com/docs/cli/get-started/) (1Password CLI v2) -**URI scheme:** `://vault/item/field` -**Platform:** all (macOS, Linux, Windows) -**Tested:** `op 2.34.0` on macOS Darwin 25.4 (SecretEnv v0.13.0, 2026-05-07) +- **Type:** `1password` +- **CLI required:** [`op`](https://developer.1password.com/docs/cli/get-started/) +- **CLI version:** 1Password CLI v2 +- **URI scheme:** `://vault/item/field` +- **Platform:** all (macOS, Linux, Windows) +- **Tested:** `op 2.34.0` on macOS Darwin 25.4 (SecretEnv v0.19.0) -> SecretEnv injects secrets from any backend as environment variables. This page covers the `1password` backend. New here? See the [overview](/). +> SecretEnv injects secrets as environment variables. This page covers the `1password` backend. New here? See the [overview](/). -1Password is a password manager and secret store used by engineering teams for credential centralization, audit logging, and fine-grained access control. The `op` CLI is 1Password's official integration surface, supporting both interactive authentication via the desktop app and non-interactive service accounts for CI/CD environments. +1Password is a team password manager with audit logging and access control. The `op` CLI supports both interactive (desktop app) and non-interactive (service accounts) authentication. ## When to pick this -- **Team credential sharing:** 1Password vaults scale across teams; fine-grained access control per vault -- **Audit trails:** Every access is logged; compliance audits see who accessed what -- **Desktop integration:** Interactive biometric authentication via the 1Password app (no token management) -- **CI/CD service accounts:** 1Password supports scoped service accounts for non-interactive automation +- **Team sharing**, fine-grained access control per vault +- **Audit trails**, every access logged for compliance +- **Desktop integration**, biometric auth via 1Password app (no token management) +- **CI/CD automation**, scoped service accounts ## Configuration ```toml [backends.1password-work] type = "1password" -op_account = "company.1password.com" # optional — omit for single-account setups +op_account = "company.1password.com" # optional, omit for single-account setups ``` ### Fields @@ -54,21 +55,21 @@ op_account = "personal.1password.com" instance name vault item field ``` -URIs have exactly three path segments: vault name, item name, and field label. Common fields for Login items: `username`, `password`. For API Credential items: use the field label configured in 1Password. +URIs require exactly three segments: vault, item, and field. Common fields for Login items: `username`, `password`. API Credential items: use the configured field label. -**Verify your setup with:** `secretenv doctor` — green output means you're ready to run `secretenv run -- `. +**Verify:** `secretenv doctor`. Green output means ready to run. ## Authentication -**Local development:** Interactive biometric authentication via the 1Password desktop app. The `op` CLI communicates with the app over a local socket. No token management required — open the app and authenticate once. +**Local development:** Biometric via the 1Password desktop app. The `op` CLI communicates over a local socket. Open the app and authenticate once; no token management needed. -**CI/CD:** Use a service account token. This is 1Password's official mechanism for non-interactive environments: +**CI/CD:** Use a service account token (1Password's official non-interactive method): ```bash export OP_SERVICE_ACCOUNT_TOKEN="ops_..." ``` -Service accounts are created in the 1Password admin console. Scope them to specific vaults — never grant access to all vaults for CI credentials. +Create service accounts in the 1Password admin console. Scope to specific vaults only, never all vaults. ## doctor Output @@ -95,13 +96,13 @@ No fragment directives. Any `#...` fragment is rejected at URI-parse time. ## History API support -Not implemented. The 1Password CLI does not expose a per-item version-history subcommand, so historical revisions cannot be retrieved programmatically. +Not implemented. The `op` CLI lacks a per-item history subcommand for programmatic retrieval. ## Limitations -- **Set operation requires opt-in.** `op item edit` passes the field value through subprocess argv (visible in `/proc//cmdline` on multi-user Linux hosts). This is a 1Password CLI limitation (no stdin-fed form for field edits). The backend refuses `set` by default; operators acknowledge the exposure by setting `op_unsafe_set = true` under `[backends.]`. -- **No auto-create:** `secretenv registry set` modifies a single field within an existing item; it does not create new items. Create the item manually in the 1Password app or via `op item create` first. -- **Sections and nested fields:** v0.13 supports flat three-segment URIs only (`vault/item/field`). Nested sections are not supported. +- **Set requires opt-in.** `op item edit` passes values via argv (visible in `/proc//cmdline` on multi-user Linux); this is a 1Password CLI limitation. Disabled by default; enable with `op_unsafe_set = true` +- **No auto-create.** `registry set` edits existing items only; create items manually first via 1Password app or `op item create` +- **No nested fields.** v0.13 supports flat three-segment URIs only (`vault/item/field`) ## Examples @@ -160,8 +161,8 @@ Enable `op_unsafe_set = true` in `[backends.1password-]` if you need t ## See Also -- [`secretenv doctor`](/reference/cli-reference-full#secretenv-doctor) — health checks for all backends -- [Alias registry concepts](../reference/registry.md) — how registry sources resolve aliases -- [1Password CLI documentation](https://developer.1password.com/docs/cli) — `op` command reference -- [All backends](README.md) — pick a different backend -- [Overview](/) — overview + workflows +- [`secretenv doctor`](/reference/cli-reference-full#secretenv-doctor), health checks for all backends +- [Alias registry concepts](../reference/registry.md), how registry sources resolve aliases +- [1Password CLI documentation](https://developer.1password.com/docs/cli), `op` command reference +- [All backends](README.md), pick a different backend +- [Overview](/), overview + workflows diff --git a/docs/backends/README.md b/docs/backends/README.md index 924d1b1..3bb5899 100644 --- a/docs/backends/README.md +++ b/docs/backends/README.md @@ -25,9 +25,9 @@ SecretEnv supports 15 secret backends. Every backend page follows the same struc | OpenBao | `openbao` | all | bao v2.5.3 | Available | [openbao.md](openbao.md) | | CyberArk Conjur | `conjur` | all | conjur v8.1.3 (Go) | Available | [conjur.md](conjur.md) | | Bitwarden Secrets Manager | `bitwarden-sm` | all | bws 2.0.0 | Available | [bitwarden-sm.md](bitwarden-sm.md) | -| Delinea Secret Server | `delinea` | all | `tss` | Coming Soon | — | +| Delinea Secret Server | `delinea` | all | `tss` | Coming Soon | n/a | -All "tested CLI version" entries reflect the operator's local matrix at v0.13.0 release time (2026-05-07). The minimum-supported versions are noted on each backend page. +All "tested CLI version" entries reflect the v0.19.0 release smoke. Minimum-supported versions are noted on each backend page. --- @@ -47,16 +47,16 @@ For deeper trade-off analysis: [Tool Comparisons](../comparisons/README.md). ## Documentation Structure -Every backend page is shaped the same way so you can switch between them without re-orienting: +Every backend page follows the same shape: -1. **Header block** — type slug, CLI, URI scheme, platform, tested CLI version -2. **Configuration** — the `[backends.]` block + field table -3. **URI Format** — canonical example with labeled parts -4. **Authentication** — credential mechanisms in precedence order -5. **doctor Output** — what success and common-failure states look like -6. **Limitations / Known Issues** — what the backend can't do (set, history, fragments) -7. **Examples** — real config snippets for typical patterns -8. **See Also** — cross-links to related backends, registry concepts, fragment grammar +1. **Header**: type, CLI, URI scheme, platform, tested version +2. **Configuration**: `[backends.]` + field table +3. **URI Format**: canonical example with labeled parts +4. **Authentication**: credential mechanisms in precedence order +5. **doctor Output**: success and failure states +6. **Limitations**: what the backend can't do (set, history, fragments) +7. **Examples**: real config snippets +8. **See Also**: cross-links and related concepts --- @@ -68,7 +68,7 @@ Backends are independent Rust crates implementing two traits. See [reference/add ## See Also -- [Overview](/) — overview + workflows -- [Tool comparisons](../comparisons/README.md) — SecretEnv vs `.env` / fnox / direnv / op-run / etc. -- [Threat model](../security.md) — 14-category honest security comparison -- [CI/CD patterns](../ci-cd.md) — using SecretEnv in CI pipelines +- [Overview](/), overview + workflows +- [Tool comparisons](../comparisons/README.md), SecretEnv vs `.env` / fnox / direnv / op-run / etc. +- [Threat model](../security.md), 14-category honest security comparison +- [CI/CD patterns](../guides/ci-cd.md), using SecretEnv in CI pipelines diff --git a/docs/backends/aws-secrets.md b/docs/backends/aws-secrets.md index 3ddb3ae..399a9cb 100644 --- a/docs/backends/aws-secrets.md +++ b/docs/backends/aws-secrets.md @@ -1,21 +1,22 @@ # AWS Secrets Manager -**Type:** `aws-secrets` -**CLI required:** [`aws`](https://aws.amazon.com/cli/) (AWS CLI v2) -**URI scheme:** `://secret-name[#json-key=]` -**Platform:** all (macOS, Linux, Windows) -**Tested:** `aws-cli/2.34.35` on macOS Darwin 25.4 (SecretEnv v0.13.0, 2026-05-07) +- **Type:** `aws-secrets` +- **CLI required:** [`aws`](https://aws.amazon.com/cli/) +- **CLI version:** AWS CLI v2 +- **URI scheme:** `://secret-name[#json-key=]` +- **Platform:** all (macOS, Linux, Windows) +- **Tested:** `aws-cli/2.34.35` on macOS Darwin 25.4 (SecretEnv v0.19.0) -> SecretEnv injects secrets from any backend as environment variables. This page covers the `aws-secrets` backend. New here? See the [overview](/). +> SecretEnv injects secrets as environment variables. This page covers the `aws-secrets` backend. New here? See the [overview](/). -AWS Secrets Manager is AWS's native secrets store for cross-service replication and automatic rotation. Unlike Parameter Store (which stores values as-is), Secrets Manager offers structured secret support and fine-grained permission control. Pick Secrets Manager when you need multi-region replication, automatic rotation orchestration, or fine-grained RDS/database credential management. The `aws` CLI wraps the Secrets Manager API with full credential chain support. +AWS Secrets Manager is AWS's secrets store for replication and rotation. Unlike Parameter Store, it offers structured secrets and fine-grained permissions. Pick for multi-region replication, auto-rotation orchestration, or RDS/database credential management. ## When to pick this -- **Multi-region replication:** Secrets Manager replicates across regions; Parameter Store is region-scoped -- **Automatic rotation:** Rotate database passwords and API credentials via Lambda orchestration -- **Fine-grained permissions:** IAM policy granularity per secret (not just per operation) -- **Team workflows:** Named profiles let multiple accounts/contexts live in one config +- **Multi-region replication**, unlike Parameter Store +- **Automatic rotation**, Lambda-orchestrated rotation for passwords and API credentials +- **Fine-grained IAM**, per-secret policies, not just per-operation +- **Team workflows**, named profiles for multiple accounts ## Configuration @@ -23,7 +24,7 @@ AWS Secrets Manager is AWS's native secrets store for cross-service replication [backends.aws-secrets-prod] type = "aws-secrets" aws_region = "us-east-1" -aws_profile = "prod" # optional — omit to use ambient credentials +aws_profile = "prod" # optional, omit to use ambient credentials ``` ### Fields @@ -59,26 +60,26 @@ aws-secrets-prod://myapp/prod/stripe_key instance name secret name ``` -Secret names are passed verbatim to `aws secretsmanager get-secret-value --secret-id`. Both friendly names and ARNs are supported. The leading `/` is **not** part of the secret name — double-slash form `aws-secrets-prod://` omits it, while triple-slash form `aws-secrets-prod:///` strips any leading `/` from the URI path. +Secret names passed verbatim to `aws secretsmanager get-secret-value --secret-id`. Both friendly names and ARNs supported. Leading `/` omitted automatically. -For secrets with JSON value (multiple key-value pairs), append the `#json-key` fragment directive: +For JSON secrets, append `#json-key=`: ``` aws-secrets-prod://myapp/prod/db_credentials#json-key=password ``` -**Verify your setup with:** `secretenv doctor` — green output means you're ready to run `secretenv run -- `. +**Verify:** `secretenv doctor`. Green output means ready to run. ## Authentication -SecretEnv delegates authentication entirely to the `aws` CLI. Any credential mechanism the CLI supports works automatically: +Delegates entirely to the `aws` CLI. All standard credential mechanisms work: - Named profiles (via `aws_profile` field) - Environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`) - IAM instance/task roles (EC2, ECS, Lambda, AppRunner) - AWS SSO / IAM Identity Center - `credential_process` custom providers -- Cross-account role assumption via `role_arn` in profile config +- Cross-account role assumption via `role_arn` in profile ## IAM Permissions @@ -124,24 +125,22 @@ aws-secrets-prod (aws-secrets) ## Fragment directives -`#json-key=` extracts a single field from a JSON-shaped secret: - | Directive | Effect | Example | |---|---|---| -| `#json-key=password` | Extract the `password` field as a string | `aws-secrets-prod://myapp/db#json-key=password` | -| (no fragment) | Return the secret value verbatim (string or whole JSON) | `aws-secrets-prod://myapp/db` | +| `#json-key=password` | Extract `password` field from JSON | `aws-secrets-prod://myapp/db#json-key=password` | +| (no fragment) | Return value verbatim (string or JSON) | `aws-secrets-prod://myapp/db` | -Shorthand fragments (`#password` with no `=`) and unsupported directives (`#version=5`) are rejected at URI-parse time with a migration hint. +Shorthand fragments (`#password`) and invalid directives (`#version=5`) rejected at parse time with migration hint. ## History API support -Not implemented. The backend returns the trait-default "not implemented" error. AWS Secrets Manager exposes version IDs via `aws secretsmanager list-secret-version-ids`, but this backend does not yet call that surface. Version history is available via the AWS Console or `aws secretsmanager list-secret-version-ids` directly. +Not implemented. AWS Secrets Manager exposes version IDs via `aws secretsmanager list-secret-version-ids`, but this backend does not yet use that. Check the AWS Console or call the CLI directly. ## Limitations -- **Auto-create on first set:** `registry set` adds a new version to an existing secret. The secret itself must exist first (`aws secretsmanager create-secret`); this is a one-time setup per secret. -- **No nested JSON field extraction:** `#json-key` selects top-level fields only. Nested paths like `#json-key=db.password` are not supported. -- **No automatic rotation orchestration:** Secrets Manager supports rotation policies in the console; this backend rotates the version but does not invoke the rotation Lambda. Use the AWS console or CloudFormation for rotation setup. +- **No auto-create.** Secret must exist first (`aws secretsmanager create-secret`); `registry set` adds new versions only +- **No nested JSON extraction.** `#json-key` selects top-level fields only; nested paths like `db.password` not supported +- **No rotation orchestration.** `registry set` adds versions; rotation Lambda invocation via AWS Console/CloudFormation ## Examples @@ -203,9 +202,9 @@ Secret names cannot start with `/`. Use `aws secretsmanager list-secrets --regio ## See Also -- [`secretenv doctor`](/reference/cli-reference-full#secretenv-doctor) — health checks for all backends -- [Alias registry concepts](../reference/registry.md) — how registry sources resolve aliases -- [Fragment vocabulary](../reference/fragment-vocabulary.md) — `#json-key` directive reference -- [AWS Systems Manager Parameter Store](aws-ssm.md) — alternative for simpler use cases -- [All backends](README.md) — pick a different backend -- [Overview](/) — overview + workflows +- [`secretenv doctor`](/reference/cli-reference-full#secretenv-doctor), health checks for all backends +- [Alias registry concepts](../reference/registry.md), how registry sources resolve aliases +- [Fragment vocabulary](../reference/fragment-vocabulary.md), `#json-key` directive reference +- [AWS Systems Manager Parameter Store](aws-ssm.md), alternative for simpler use cases +- [All backends](README.md), pick a different backend +- [Overview](/), overview + workflows diff --git a/docs/backends/aws-ssm.md b/docs/backends/aws-ssm.md index 0229004..422f3b0 100644 --- a/docs/backends/aws-ssm.md +++ b/docs/backends/aws-ssm.md @@ -1,21 +1,22 @@ # AWS SSM Parameter Store -**Type:** `aws-ssm` -**CLI required:** [`aws`](https://aws.amazon.com/cli/) (AWS CLI v2) -**URI scheme:** `:///path/to/parameter` -**Platform:** all (macOS, Linux, Windows) -**Tested:** `aws-cli/2.34.35` on macOS Darwin 25.4 (SecretEnv v0.13.0, 2026-05-07) +- **Type:** `aws-ssm` +- **CLI required:** [`aws`](https://aws.amazon.com/cli/) +- **CLI version:** AWS CLI v2 +- **URI scheme:** `:///path/to/parameter` +- **Platform:** all (macOS, Linux, Windows) +- **Tested:** `aws-cli/2.34.35` on macOS Darwin 25.4 (SecretEnv v0.19.0) -> SecretEnv injects secrets from any backend as environment variables. This page covers the `aws-ssm` backend. New here? See the [overview](/). +> SecretEnv injects secrets as environment variables. This page covers the `aws-ssm` backend. New here? See the [overview](/). -AWS Systems Manager (SSM) Parameter Store is AWS's native secrets store — built-in, region-scoped, and integrated with KMS for SecureString encryption. Pick SSM when you're already on AWS and want the simplest path: no new service account, no new CLI, no API tokens. The `aws` CLI wraps the Parameter Store API and resolves credentials from your ambient AWS configuration (profiles, IAM roles, SSO, environment variables). +AWS Systems Manager Parameter Store is AWS's native secrets store. Built-in, region-scoped, KMS-integrated for SecureString encryption. Pick when already on AWS and wanting the simplest path: no new service account, CLI, or tokens. ## When to pick this -- **You're on AWS:** native integration, no new authentication layer -- **Short-term secrets:** Parameter Store suits dev/test values well; consider AWS Secrets Manager if you need automatic rotation or cross-service replication -- **Team workflows:** named profiles let multiple accounts/contexts live in one config -- **Air-gapped automation:** IAM roles on EC2/ECS/Lambda require zero external secrets +- **On AWS**, native integration, no new auth layer +- **Short-term secrets**, good for dev/test; use Secrets Manager for auto-rotation +- **Team workflows**, named profiles handle multiple accounts +- **Air-gapped automation**, EC2/ECS/Lambda IAM roles work directly ## Configuration @@ -23,7 +24,7 @@ AWS Systems Manager (SSM) Parameter Store is AWS's native secrets store — buil [backends.aws-ssm-dev] type = "aws-ssm" aws_region = "us-east-1" -aws_profile = "dev" # optional — omit to use ambient credentials +aws_profile = "dev" # optional, omit to use ambient credentials ``` ### Fields @@ -64,20 +65,20 @@ aws-ssm-dev:///myapp/prod/stripe_key instance name SSM parameter path ``` -Parameter names must exist as `SecureString` type. `String` and `StringList` types are supported but **not recommended for secrets** — they lack envelope encryption. The leading `/` is automatic; both `aws-ssm-dev:///myapp/key` and `aws-ssm-dev://myapp/key` resolve to the parameter `/myapp/key`. +Parameters must be `SecureString` type. `String` and `StringList` work but lack envelope encryption (not recommended). Leading `/` is automatic; both `aws-ssm-dev:///myapp/key` and `aws-ssm-dev://myapp/key` resolve to `/myapp/key`. -**Verify your setup with:** `secretenv doctor` — green output means you're ready to run `secretenv run -- `. +**Verify:** `secretenv doctor`. Green output means ready to run. ## Authentication -SecretEnv delegates authentication entirely to the `aws` CLI. Any credential mechanism the CLI supports works automatically: +Delegates entirely to the `aws` CLI. All standard credential mechanisms work: - Named profiles (via `aws_profile` field) - Environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`) - IAM instance/task roles (EC2, ECS, Lambda, AppRunner) - AWS SSO / IAM Identity Center - `credential_process` custom providers -- Cross-account role assumption via `role_arn` in profile config +- Cross-account role assumption via `role_arn` in profile ## IAM Permissions @@ -136,9 +137,9 @@ Full support via `aws ssm get-parameter-history`. `secretenv registry history :///[#version=]` -**Platform:** all (macOS, Linux, Windows) -**Tested:** `azure-cli 2.85.0` on macOS Darwin 25.4 (SecretEnv v0.13.0, 2026-05-07) +- **Type:** `azure` +- **CLI required:** [`az`](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) +- **CLI version:** Azure CLI 2.55+ +- **URI scheme:** `:///[#version=]` +- **Platform:** all (macOS, Linux, Windows) +- **Tested:** `azure-cli 2.85.0` on macOS Darwin 25.4 (SecretEnv v0.19.0) > SecretEnv injects secrets from any backend as environment variables. This page covers the `azure` backend. New here? See the [overview](/). -Azure Key Vault is Microsoft Azure's native secrets store, offering centralized credential management with fine-grained RBAC and comprehensive audit logging. Key Vault integrates seamlessly with Azure Managed Identities, allowing pods and VMs to authenticate without managing credentials. Pick Key Vault when you're on Azure or need a multi-cloud strategy with an Azure anchor. +Azure Key Vault is Microsoft Azure's native secrets store with fine-grained RBAC, audit logging, and Managed Identity integration for zero-credential pod/VM authentication. ## When to pick this -- **You're on Azure:** native integration, Managed Identity automatic credential discovery -- **RBAC / audit compliance:** Fine-grained roles per secret; full audit trail of access -- **Sovereign clouds:** Support for Azure Commercial, China, US Government, and legacy Germany clouds -- **Soft-delete recovery:** Secrets are recoverable for 90 days (customizable) after deletion +- **On Azure:** native integration, Managed Identity automatic credential discovery +- **RBAC and audit:** Fine-grained roles; full access trail +- **Sovereign clouds:** Azure Commercial, China, US Government, Germany +- **Soft-delete recovery:** 90-day recovery window (customizable) ## Configuration @@ -52,7 +53,7 @@ azure-prod:///stripe-api-key instance secret name ``` -Use triple-slash (`azure-prod:///secret-name`) — the vault URL is in config, never the URI. Secret names follow Azure rules: `[a-zA-Z0-9-]{1,127}` (alphanumerics and hyphens only, no underscores). +Use triple-slash (`azure-prod:///secret-name`). Secret names follow Azure rules: `[a-zA-Z0-9-]{1,127}` (alphanumerics and hyphens only). For version pinning: @@ -61,20 +62,18 @@ azure-prod:///stripe-api-key#version=abc123deadbeef0123456789abcdef01 azure-prod:///stripe-api-key # Default: latest enabled version ``` -**Verify your setup with:** `secretenv doctor` — green output means you're ready to run `secretenv run -- `. +**Verify your setup with:** `secretenv doctor`. Green output means you're ready to run `secretenv run -- `. ## Authentication -SecretEnv delegates to the `az` CLI. All of these work: +SecretEnv delegates to the `az` CLI. Any credential method the CLI supports works automatically: -- **User interactive:** `az login` (device code or browser) -- **Service principal (password):** `az login --service-principal --tenant --username --password ` -- **Service principal (certificate):** `az login --service-principal --username --tenant --password ` -- **Managed Identity:** `az login --identity` (on Azure compute: VMs, App Service, AKS, Functions) -- **Federated credentials:** GitHub Actions `azure/login@v1` + Workload Identity Federation -- **Azure Cloud Shell:** Pre-authenticated - -If `az ` works in your shell, the backend will too. +- **User interactive**: `az login` (device code or browser) +- **Service principal (password)**: `az login --service-principal --tenant --username --password ` +- **Service principal (certificate)**: `az login --service-principal --username --tenant --password ` +- **Managed Identity**: `az login --identity` (VMs, App Service, AKS, Functions) +- **Federated credentials**: GitHub Actions `azure/login@v1` + Workload Identity Federation +- **Azure Cloud Shell**: Pre-authenticated ## IAM Permissions @@ -137,34 +136,27 @@ azure-prod (azure) ## Fragment directives -`#version=` pins a specific secret version. Version IDs are 32-character lowercase hex strings generated by Azure: +`#version=` pins specific versions (32-character hex strings): | Directive | Effect | Example | |---|---|---| -| `#version=0123456789abcdef...` | Fetch that specific version | `azure-prod:///stripe-key#version=0123456789abcdef0123456789abcdef` | -| `#version=latest` | Same as omitting (latest version) | `azure-prod:///stripe-key#version=latest` | -| (no fragment) | Fetch latest enabled version | `azure-prod:///stripe-key` | - -To find a version ID: +| `#version=0123...` | Specific version | `azure-prod:///stripe-key#version=0123456789abcdef0123456789abcdef` | +| `#version=latest` | Latest (same as omitting) | `azure-prod:///stripe-key#version=latest` | +| (no fragment) | Latest enabled version | `azure-prod:///stripe-key` | -```bash -az keyvault secret list-versions \ - --vault-name my-kv-prod \ - --name stripe-api-key \ - --query '[].id' -o tsv -``` +Find version IDs with: `az keyvault secret list-versions --vault-name my-kv-prod --name stripe-api-key --query '[].id' -o tsv` ## History API support -Not implemented. The `az` CLI does not expose per-secret version-history metadata, so historical revisions cannot be retrieved programmatically. +Not implemented. The `az` CLI does not expose per-secret version-history metadata. ## Limitations -- **Soft-delete, not purge.** `secretenv registry delete` soft-deletes the secret (recoverable for 90 days default). Fully remove it with `az keyvault secret purge --name --vault-name `. This differs from aws-secrets and gcp (which delete immediately) — it's an Azure platform default. -- **Text secrets only.** Key Vault supports `certificates` as a distinct resource type. v0.13 targets `secrets` only. A secret bound to a certificate returns with a `kid` field and the backend surfaces an error. -- **Version IDs are opaque hex.** Unlike GCP's integer versions, Azure version IDs are 32-char hex strings — not human-memorable. Operators typically copy-paste from the portal or `az keyvault secret list-versions`. -- **Auto-create on `set`.** Unlike gcp (update-only), `secretenv registry set` auto-creates the secret if it doesn't exist. This matches `az keyvault secret set` behavior. -- **No Managed HSM support.** Azure's FIPS 140-3 Level 3 HSM uses a different CLI surface (`--hsm-name` not `--vault-name`). Not in v0.13. +- **Soft-delete only.** `registry delete` soft-deletes (recoverable for 90 days). Use `az keyvault secret purge` for immediate deletion. Differs from AWS/GCP default. +- **Text secrets only.** v0.13 targets `secrets`; certificates not supported. +- **Opaque version IDs.** Azure uses 32-char hex (not human-memorable, unlike GCP). Copy-paste from portal or `az keyvault secret list-versions`. +- **Auto-create on `set`.** Unlike GCP, `registry set` creates the secret if it doesn't exist. +- **No Managed HSM.** FIPS 140-3 Level 3 HSM requires `--hsm-name` (not `--vault-name`). Not in v0.13. ## Examples @@ -212,22 +204,22 @@ secretenv run --registry azure-prod:///myapp-registry -- ./deploy.sh ## Troubleshooting **"SecretNotFound"** -Verify the secret exists in the vault. Use `az keyvault secret list --vault-name my-kv-prod` to list all secrets. +Verify the secret exists with `az keyvault secret list --vault-name my-kv-prod`. **"Forbidden"** -Check your RBAC role or access policy. Run `az keyvault show --name my-kv-prod --query properties.enableRbacAuthorization` to see which permission model is in use, then grant the appropriate role or policy. +Check RBAC role with `az keyvault show --name my-kv-prod --query properties.enableRbacAuthorization`. Grant appropriate role or policy. **"Could not find a vault with name"** -Verify `azure_vault_url` points to an existing vault. Use `az keyvault list` to list vaults in your subscription. +Verify `azure_vault_url` exists with `az keyvault list`. **"secret has been deleted but not purged"** -The secret is soft-deleted and recoverable. Either recover with `az keyvault secret recover --name --vault-name ` or wait for the recovery window to expire. +Recover with `az keyvault secret recover --name --vault-name ` or wait for recovery window to expire. ## See Also -- [`secretenv doctor`](/reference/cli-reference-full#secretenv-doctor) — health checks for all backends -- [Alias registry concepts](../reference/registry.md) — how registry sources resolve aliases -- [Fragment vocabulary](../reference/fragment-vocabulary.md) — `#version` directive reference -- [Azure Key Vault documentation](https://learn.microsoft.com/en-us/azure/key-vault/) — permissions, soft-delete, RBAC -- [All backends](README.md) — pick a different backend -- [Overview](/) — overview + workflows +- [`secretenv doctor`](/reference/cli-reference-full#secretenv-doctor), health checks for all backends +- [Alias registry concepts](../reference/registry.md), how registry sources resolve aliases +- [Fragment vocabulary](../reference/fragment-vocabulary.md), `#version` directive reference +- [Azure Key Vault documentation](https://learn.microsoft.com/en-us/azure/key-vault/), permissions, soft-delete, RBAC +- [All backends](README.md), pick a different backend +- [Overview](/), overview + workflows diff --git a/docs/backends/bitwarden-sm.md b/docs/backends/bitwarden-sm.md index 54d734e..ff52710 100644 --- a/docs/backends/bitwarden-sm.md +++ b/docs/backends/bitwarden-sm.md @@ -1,21 +1,21 @@ # Bitwarden Secrets Manager -**Type:** `bitwarden-sm` -**CLI required:** [`bws`](https://bitwarden.com/help/secrets-manager-cli/) (Bitwarden Secrets Manager CLI v2+) -**URI scheme:** `://[#json-key=]` -**Platform:** all (macOS, Linux, Windows) -**Tested:** `bws 2.0.0` on macOS Darwin 25.4 (SecretEnv v0.13.0, 2026-05-07) +- **Type:** `bitwarden-sm` +- **CLI required:** [`bws`](https://bitwarden.com/help/secrets-manager-cli/) +- **CLI version:** Bitwarden Secrets Manager CLI v2+ +- **URI scheme:** `://[#json-key=]` +- **Platform:** all (macOS, Linux, Windows) +- **Tested:** `bws 2.0.0` on macOS Darwin 25.4 (SecretEnv v0.19.0) > SecretEnv injects secrets from any backend as environment variables. This page covers the `bitwarden-sm` backend. New here? See the [overview](/). -[Bitwarden Secrets Manager](https://bitwarden.com/products/secrets-manager/) is Bitwarden's developer/CI secrets product — machine-account access tokens, project-scoped secrets keyed by UUID. It is **a distinct product from Bitwarden Password Manager** (`bw` CLI). The backend wraps `bws` v2+ only; human-readable aliases live in the SecretEnv registry layer because `bws secret get` accepts UUIDs only (no key-name lookup), and Bitwarden allows duplicate key names within a project. +Bitwarden Secrets Manager is Bitwarden's developer/CI secrets product (distinct from Password Manager). Machine-account tokens grant project-scoped access. This backend wraps `bws` v2+ only; `bws secret get` accepts UUIDs only (no key-name lookup), so human-readable aliases live in the SecretEnv registry layer. ## When to pick this -- **You use Bitwarden:** native integration, no new service account type -- **Multi-environment CI:** machine accounts scoped per project suit dev/staging/prod separation -- **Zero-infrastructure local:** machine accounts with no internal PKI required -- **EU/self-hosted Bitwarden:** set `bitwarden_server_url` to your deployment +- **Bitwarden already in use:** native integration, no new service account type +- **Multi-environment CI:** per-project machine accounts for env separation +- **EU or self-hosted:** configurable server URL ## Configuration @@ -55,11 +55,11 @@ bitwarden_access_token_env = "BWS_ACCESS_TOKEN_STAGING" | Field | Required | Description | |---|---|---| | `type` | Yes | Must be `"bitwarden-sm"` | -| `bitwarden_project_id` | Yes | Project UUID this instance scopes to. Find via the web UI or `bws project list`. | -| `bitwarden_server_url` | No | Override the Bitwarden server URL. Defaults to US cloud (`https://vault.bitwarden.com`). Set for EU cloud or self-hosted. | -| `bitwarden_access_token_env` | No | Name of the env var holding the machine-account access token. Defaults to `BWS_ACCESS_TOKEN`. Use to keep multiple instances scoped to different machine accounts without collision. | -| `bitwarden_bin` | No | Override the `bws` binary path. Defaults to `"bws"` (PATH lookup). | -| `bitwarden_unsafe_set` | No | Defense-in-depth opt-in for argv-based `set` / `delete`. Defaults to `false` (both refused). | +| `bitwarden_project_id` | Yes | Project UUID (from web UI or `bws project list`). | +| `bitwarden_server_url` | No | Override server URL. Default: US cloud. Set for EU or self-hosted. | +| `bitwarden_access_token_env` | No | Env var name for machine-account token. Default: `BWS_ACCESS_TOKEN`. | +| `bitwarden_bin` | No | Override `bws` binary path. Default: `"bws"` (PATH lookup). | +| `bitwarden_unsafe_set` | No | Opt into argv-based `set` / `delete`. Default `false` (both gated). | | `timeout_secs` | No | Per-instance fetch timeout. Default: 30s. | ## URI Format @@ -70,13 +70,12 @@ bws-prod://abcdef0123456789abcdef0123456789 instance UUID (36-char hyphenated or 32-char simple) ``` -The path is the secret's UUID. `bws` accepts both the canonical hyphenated form (`8-4-4-4-12`, 36 chars) and the 32-char simple form (no hyphens). The wrapper normalizes both to lowercase. +The path is the secret's UUID (36-char hyphenated or 32-char simple form, normalized to lowercase). -### Why UUID, not key-name +### Why UUID only -- `bws secret get` accepts UUID only; there is no `--key` lookup mode. -- Bitwarden allows duplicate key names within a project, making key-name URIs ambiguous. -- Human-readable aliases live in the SecretEnv registry: `stripe-live → bws-prod://abcdef...`. +- No key-name lookup mode in `bws secret get`. +- Bitwarden allows duplicate key names; aliases via SecretEnv registry (`stripe-live → bws-prod://abcdef...`). ### `#json-key=` fragment @@ -89,7 +88,7 @@ db_username = "bws-prod://abcdef0123456789abcdef0123456789#json-key=username" The fragment is recognized on `get` only. `set`, `delete`, `list`, and `history` reject any fragment. -**Verify your setup with:** `secretenv doctor` — green output means you're ready to run `secretenv run -- `. +**Verify your setup with:** `secretenv doctor`. Green output means you're ready to run `secretenv run -- `. ## Authentication @@ -98,21 +97,20 @@ Bitwarden Secrets Manager uses **machine accounts** (not user logins). Issue an 1. Open the [Bitwarden web vault](https://vault.bitwarden.com/) → **Secrets Manager → Machine Accounts → New machine account**. 2. Grant `read` (or `read-write`) on the projects this instance will access. 3. Generate an **Access Token** (shape: `0..:`). -4. Export it (no surrounding quotes — see below): +4. Export it (no surrounding quotes, see below): ```bash export BWS_ACCESS_TOKEN=0.abc...:xyz... # NO QUOTES ``` -There is **no interactive `bws login` flow** — `bws` is purely env-var-driven. The wrapper sources the token from your shell at command time and sets `BWS_ACCESS_TOKEN` on the child process env only; it is never logged or written to argv. +No interactive login flow exists. `bws` is purely env-var-driven. The token is set on the child process env only; never logged or on argv. ### Critical: no surrounding quotes -`bws` v2 doesn't strip surrounding double-quotes from `BWS_ACCESS_TOKEN`. If you export `BWS_ACCESS_TOKEN="..."` (literal quotes), the quote characters become part of the token bytes, decryption fails, and you get the misleading "Cipher MAC doesn't match". Re-export bare. Verify with: +`bws` v2 doesn't strip surrounding quotes. If you export `BWS_ACCESS_TOKEN="..."`, the quotes become part of the token, causing "Cipher MAC doesn't match". Export bare. Verify: ```bash -echo "len=${#BWS_ACCESS_TOKEN}" -# A clean token is 94 chars; quoted reads 96. +echo "len=${#BWS_ACCESS_TOKEN}" # Clean: 94 chars; quoted: 96 ``` ## RBAC and project scoping @@ -153,16 +151,16 @@ Other fragments are rejected with an enumerated error. ## History API support -Not implemented. Bitwarden Secrets Manager surfaces revision timestamps in the web UI (`revisionDate` field), but the CLI exposes no `secret history` subcommand. `secretenv registry history ` returns the trait-default "not implemented" until the vendor exposes version metadata via CLI. +Not implemented. Revision timestamps are in the web UI, but the CLI has no `secret history` subcommand. ## Limitations -- **`set` disabled by default.** `bws secret edit --value ` passes the value through argv. Set `bitwarden_unsafe_set = true` to enable; only do so after reading the threat model. Recommended alternative: provision via the web UI. -- **`delete` is gated alongside `set`.** Both are destructive write operations; the gate flag applies to both. -- **`set` updates only, never creates.** A UUID can only refer to an existing secret. Provision secrets via the web UI (or `bws secret create` outside SecretEnv), obtain the UUID, then add it to the registry. -- **UUID addressing only.** Alias via registry. -- **No secret history via CLI.** History is available in the web UI only. -- **Free-tier limits apply.** Free tier caps at 2 projects and 2 machine accounts per organization. +- **`set` gated by default:** argv-based (`bws secret edit --value `). Set `bitwarden_unsafe_set = true` only after reading the threat model; prefer web UI. +- **`delete` gated with `set`:** both destructive; same flag controls both. +- **`set` updates only:** provision new secrets via web UI, obtain UUID, then add to registry. +- **UUID-only addressing:** human-readable names via registry. +- **No CLI history:** web UI only. +- **Free-tier limits:** 2 projects + 2 machine accounts per organization. ## Examples @@ -222,7 +220,7 @@ bitwarden_access_token_env = "BWS_INTERNAL_TOKEN" ## Troubleshooting **"Cipher MAC doesn't match"** -Your `BWS_ACCESS_TOKEN` contains surrounding quotes. Export bare: `export BWS_ACCESS_TOKEN=0.uuid...` (no `"..."` wrapper). Verify with `echo "len=${#BWS_ACCESS_TOKEN}"` — should be 94 chars, not 96. +Your `BWS_ACCESS_TOKEN` contains surrounding quotes. Export bare: `export BWS_ACCESS_TOKEN=0.uuid...` (no `"..."` wrapper). Verify with `echo "len=${#BWS_ACCESS_TOKEN}"`. Should be 94 chars, not 96. **"set is disabled by default"** You hit the defense-in-depth gate. Either provision the secret via the Bitwarden web UI (preferred) or set `bitwarden_unsafe_set = true` and review the threat model. Per-invocation warnings appear in `secretenv --verbose` output. @@ -235,10 +233,10 @@ Your token is authenticated but scoped to zero projects. Grant the machine accou ## See Also -- [`secretenv doctor`](/reference/cli-reference-full#secretenv-doctor) — health checks for all backends -- [Alias registry concepts](../reference/registry.md) — how registry sources resolve aliases -- [Fragment vocabulary](../reference/fragment-vocabulary.md) — `#json-key`, `#version`, etc. -- [1Password backend](1password.md) — alternative: personal vault + team vaults -- [Vault backend](vault.md) — alternative: HashiCorp's general-purpose secrets engine -- [All backends](README.md) — pick a different backend -- [Overview](/) — overview + workflows +- [`secretenv doctor`](/reference/cli-reference-full#secretenv-doctor), health checks for all backends +- [Alias registry concepts](../reference/registry.md), how registry sources resolve aliases +- [Fragment vocabulary](../reference/fragment-vocabulary.md), `#json-key`, `#version`, etc. +- [1Password backend](1password.md), alternative: personal vault + team vaults +- [Vault backend](vault.md), alternative: HashiCorp's general-purpose secrets engine +- [All backends](README.md), pick a different backend +- [Overview](/), overview + workflows diff --git a/docs/backends/cf-kv.md b/docs/backends/cf-kv.md index bda1c08..ceb3deb 100644 --- a/docs/backends/cf-kv.md +++ b/docs/backends/cf-kv.md @@ -1,21 +1,22 @@ # Cloudflare Workers KV -**Type:** `cf-kv` -**CLI required:** [`wrangler`](https://developers.cloudflare.com/workers/wrangler/install-and-update/) 4.x — `npm install -g wrangler` OR `brew install cloudflare/cloudflare/wrangler` -**URI scheme:** `:////` (or `:///` when `cf_kv_default_namespace_id` is configured) -**Platform:** all (macOS, Linux, Windows) -**Tested:** `wrangler 4.85.0` on macOS Darwin 25.4 (SecretEnv v0.13.0, 2026-05-07) +- **Type:** `cf-kv` +- **CLI required:** [`wrangler`](https://developers.cloudflare.com/workers/wrangler/install-and-update/) +- **CLI version:** 4.x +- **URI scheme:** `:////` (or `:///` when `cf_kv_default_namespace_id` is configured) +- **Platform:** all (macOS, Linux, Windows) +- **Tested:** `wrangler 4.85.0` on macOS Darwin 25.4 (SecretEnv v0.19.0) > SecretEnv injects secrets from any backend as environment variables. This page covers the `cf-kv` backend. New here? See the [overview](/). -Cloudflare Workers KV is a globally-distributed, eventually-consistent key-value store designed for low-latency reads from Cloudflare Workers. This backend wraps the `wrangler` CLI for read, write, list, and delete operations. Auth flows through `wrangler login` (OAuth) or `CLOUDFLARE_API_TOKEN`. +Cloudflare Workers KV is a globally-distributed, eventually-consistent key-value store for low-latency edge reads. This backend wraps the `wrangler` CLI (read, write, list, delete) with OAuth or API-token auth. ## When to pick this -- **Cloudflare Workers already deployed:** native integration, no new service -- **Global edge caching:** KV is replicated to Cloudflare's edge; reads are fast from anywhere -- **Flat key-value model:** simple key namespaces, no folder scoping (use naming conventions or multiple namespaces) -- **Low-cost, pay-as-you-go:** no upfront secrets infrastructure to manage +- **Cloudflare Workers in use:** native integration, low latency +- **Edge caching:** globally replicated, reads fast from anywhere +- **Simple flat model:** no folder hierarchy (use naming conventions or namespaces) +- **Pay-as-you-go:** minimal infrastructure overhead ## Configuration @@ -31,8 +32,8 @@ cf_kv_list_prefix = "registry/" # optional | Field | Required | Description | |---|---|---| | `type` | Yes | Must be `"cf-kv"` | -| `cf_kv_default_namespace_id` | No | Namespace UUID for single-segment URIs. When unset, two-segment form required. Find via `wrangler kv namespace list`. | -| `cf_kv_list_prefix` | No | Prefix filter for `list()` (e.g., `registry/` to enumerate only keys starting with that prefix). Enables single-namespace scalar + registry mixing. | +| `cf_kv_default_namespace_id` | No | Namespace UUID (from `wrangler kv namespace list`) for one-segment URIs. Unset = two-segment URIs required. | +| `cf_kv_list_prefix` | No | Prefix filter for `list()` (e.g., `registry/` to enumerate subset). Enables scalar + registry coexistence. | | `timeout_secs` | No | Per-instance fetch timeout. Default: 30s. | ### Single-namespace mixing via `cf_kv_list_prefix` @@ -56,9 +57,9 @@ cf-kv-prod:///c554de8d89644f3d85f21933e7aea910/STRIPE_KEY instance name namespace ID (or default) key name ``` -Two-segment form: `/`. Single-segment form (one segment) requires `cf_kv_default_namespace_id` in config. The namespace ID is the stable UUID-shaped identifier from `wrangler kv namespace list` — **not** the Worker-local binding name. +Two-segment form: `/`. Single-segment requires `cf_kv_default_namespace_id` config. Use the UUID from `wrangler kv namespace list`, not the Worker binding name. -**Verify your setup with:** `secretenv doctor` — green output means you're ready to run `secretenv run -- `. +**Verify your setup with:** `secretenv doctor`. Green output means you're ready to run `secretenv run -- `. ## Authentication @@ -80,7 +81,7 @@ Mint a token at `dashboard.cloudflare.com → My Profile → API Tokens` with th export CLOUDFLARE_API_TOKEN= ``` -Wrangler picks up the env var transparently — no further config required. +Wrangler picks up the env var transparently; no further config required. ## doctor Output @@ -107,14 +108,14 @@ No fragment directives. Any `#...` fragment is rejected at URI-parse time. ## History API support -Not implemented. Workers KV has no per-key version history — overwrites simply replace the previous value. If you need versioning, encode it in the key name (e.g., `STRIPE_KEY/v3`). +Not implemented. KV has no per-key versioning. Encode version in the key name if needed (e.g., `STRIPE_KEY/v3`). ## Limitations -- **Flat namespace.** KV stores all keys at the same level; no folder scoping. Use `cf_kv_list_prefix` to separate scalar + registry keys via naming convention. -- **Eventually consistent.** KV replicates globally with a slight delay. Immediate strong consistency is not available. -- **Rate limits.** Cloudflare KV enforces per-account rate limits (~1200 req / 5 min default). `list()` with large namespaces can throttle; request a limit increase if needed. -- **Safe set form (no `_unsafe_set` flag).** `set` writes a mode-0600 tempfile and passes `--path `; the value never appears on argv. +- **Flat namespace:** no folder scoping. Use `cf_kv_list_prefix` to separate scalar + registry keys by name. +- **Eventually consistent:** global replication has slight delay; no strong consistency. +- **Rate limits:** per-account limits (~1200 req / 5 min). Large `list()` may throttle; request increase if needed. +- **Safe `set`:** value written to temp file, never on argv. ## Examples @@ -171,9 +172,9 @@ OAuth token expired or `CLOUDFLARE_API_TOKEN` not set. Quickest fix: `wrangler l ## See Also -- [`secretenv doctor`](/reference/cli-reference-full#secretenv-doctor) — health checks for all backends -- [Alias registry concepts](../reference/registry.md) — how registry sources resolve aliases -- [Fragment vocabulary](../reference/fragment-vocabulary.md) — `#json-key`, `#version`, etc. on other backends -- [Cloudflare Workers KV docs](https://developers.cloudflare.com/workers/wrangler/commands/#kv) — authoritative KV reference -- [All backends](README.md) — pick a different backend -- [Overview](/) — overview + workflows +- [`secretenv doctor`](/reference/cli-reference-full#secretenv-doctor), health checks for all backends +- [Alias registry concepts](../reference/registry.md), how registry sources resolve aliases +- [Fragment vocabulary](../reference/fragment-vocabulary.md), `#json-key`, `#version`, etc. on other backends +- [Cloudflare Workers KV docs](https://developers.cloudflare.com/workers/wrangler/commands/#kv), authoritative KV reference +- [All backends](README.md), pick a different backend +- [Overview](/), overview + workflows diff --git a/docs/backends/conjur.md b/docs/backends/conjur.md index 6a55b7b..ee3681a 100644 --- a/docs/backends/conjur.md +++ b/docs/backends/conjur.md @@ -1,21 +1,21 @@ # CyberArk Conjur -**Type:** `conjur` -**CLI required:** [`conjur`](https://github.com/cyberark/conjur-cli-go) (Go-based v8+; v7 Ruby line is rejected at startup) -**URI scheme:** `://[#json-key=]` -**Platform:** all (macOS, Linux, Windows) -**Tested:** `Conjur CLI v8.1.3-879b90b` on macOS Darwin 25.4 (SecretEnv v0.13.0, 2026-05-07) +- **Type:** `conjur` +- **CLI required:** [`conjur`](https://github.com/cyberark/conjur-cli-go) +- **CLI version:** Go-based v8+ (v7 Ruby line is rejected at startup) +- **URI scheme:** `://[#json-key=]` +- **Platform:** all (macOS, Linux, Windows) +- **Tested:** `Conjur CLI v8.1.3-879b90b` on macOS Darwin 25.4 (SecretEnv v0.19.0) > SecretEnv injects secrets from any backend as environment variables. This page covers the `conjur` backend. New here? See the [overview](/). -[CyberArk Conjur](https://www.conjur.org/) is the open-source PAM secrets store — Apache-2.0 (OSS) and Enterprise deployments share the same wire protocol. Unlike Vault's KV-mount-and-path model, Conjur uses a **resource-graph identity model**: every secret is a `variable` resource with access mediated by per-resource policies. SecretEnv treats the variable ID as the URI path. The v8 CLI is Go-based and currently distributed as the `cyberark/conjur-cli:8` Docker image (the PyPI `conjur` package is EOL Ruby v7 and is rejected by `secretenv doctor`). +CyberArk Conjur is the open-source PAM secrets store (Apache-2.0, same protocol across OSS and Enterprise). Unlike Vault's mount-and-path model, Conjur uses **resource-graph identity**. Every secret is a `variable` with policy-mediated access. SecretEnv treats the variable ID as the URI path. The v8 CLI is Go-based (`cyberark/conjur-cli:8` Docker image); the PyPI `conjur` package (Ruby v7) is rejected. ## When to pick this -- **You're using Conjur Enterprise or OSS:** native integration, shared policy/audit infrastructure -- **Policy-scoped access:** Conjur's resource-graph model suits complex permission hierarchies -- **Team workflows:** machine accounts and role-based access control built-in -- **Docker-friendly CI:** the official CLI image works in containerized pipelines +- **Conjur Enterprise or OSS:** native integration, shared policy/audit +- **Complex permissions:** resource-graph model for fine-grained access +- **Containerized CI:** official v8 Docker image available ## Configuration @@ -32,10 +32,10 @@ conjur_authn = "authn" # optional, default "authn" | Field | Required | Description | |---|---|---| | `type` | Yes | Must be `"conjur"` | -| `conjur_appliance_url` | Yes | Full URL (HTTP or HTTPS) of the Conjur server. Local dev typically uses `http://localhost:8083` (HTTP only). | -| `conjur_account` | Yes | Top-level account namespace. Conjur is multi-tenant; every variable lives under exactly one account. | -| `conjur_authn` | No | Authenticator name. Defaults to `"authn"` (API-key). Other values: `authn-jwt`, `authn-oidc`, `authn-iam`, `authn-k8s`, `authn-azure`, `authn-gcp`. Surfaced in the doctor identity line. The CLI's pre-established session controls actual auth. | -| `conjur_unsafe_set` | No | Defense-in-depth opt-in for the `-v ` argv path. Defaults to `false`; use the safe `-f /dev/stdin` path by default. Set `true` only if `/dev/stdin` is unavailable. | +| `conjur_appliance_url` | Yes | Conjur server URL (HTTP or HTTPS). Local dev: `http://localhost:8083`. | +| `conjur_account` | Yes | Top-level account namespace (multi-tenant). Every variable under one account. | +| `conjur_authn` | No | Authenticator name. Default: `"authn"` (API-key). Other: `authn-jwt`, `authn-oidc`, `authn-iam`, `authn-k8s`, `authn-azure`, `authn-gcp`. Surfaced in `doctor` output. | +| `conjur_unsafe_set` | No | Opt into argv-based `-v `. Default `false`; safe `/dev/stdin` used always. | | `timeout_secs` | No | Per-instance fetch timeout. Default: 30s. | ### Multiple Conjur instances @@ -60,7 +60,7 @@ conjur-prod://prod/db/password instance variable ID (the entire path is the variable ID) ``` -Conjur variables have **no KV-mount concept** — the entire URI path IS the variable ID. SecretEnv strips a single leading `/` and passes the rest to `conjur variable get -i `. +Conjur variables have no mount concept. The entire URI path IS the variable ID. SecretEnv strips a leading `/` and passes the rest to `conjur variable get -i `. ### `#json-key=` fragment @@ -73,24 +73,24 @@ db_username = "conjur-prod://prod/db/credentials#json-key=username" The fragment is recognized on `get` only. `set`, `delete`, `list`, and `history` reject any fragment. -**Verify your setup with:** `secretenv doctor` — green output means you're ready to run `secretenv run -- `. +**Verify your setup with:** `secretenv doctor`. Green output means you're ready to run `secretenv run -- `. ## Authentication -SecretEnv delegates to the `conjur` CLI. The CLI's pre-established session controls actual auth — SecretEnv does not take credentials directly. Set `conjur_authn` to surface the configured authenticator in `secretenv doctor`: +SecretEnv delegates to the `conjur` CLI. The CLI's pre-established session controls actual auth. SecretEnv does not take credentials directly. Set `conjur_authn` to surface the configured authenticator in `secretenv doctor`: -- **API key** (`authn`, default) — `conjur login -i `. Session persisted in OS keystore (Keychain / Secret Service / Credential Manager) by default. -- **JWT** (`authn-jwt`) — for CI / Kubernetes. Operator pre-establishes via `conjur login --jwt-from-file `. -- **OIDC** (`authn-oidc`) — browser / device-code flow. -- **Cloud-native** (`authn-iam`, `authn-azure`, `authn-gcp`, `authn-k8s`) — workload identity flows. SecretEnv trusts the CLI's session. +- **API key** (`authn`, default), `conjur login -i `. Session persisted in OS keystore (Keychain / Secret Service / Credential Manager) by default. +- **JWT** (`authn-jwt`), for CI / Kubernetes. Operator pre-establishes via `conjur login --jwt-from-file `. +- **OIDC** (`authn-oidc`), browser / device-code flow. +- **Cloud-native** (`authn-iam`, `authn-azure`, `authn-gcp`, `authn-k8s`), workload identity flows. SecretEnv trusts the CLI's session. ### Set safety (no argv) -`set` uses `-f /dev/stdin` with the value piped through stdin — the kernel pseudo-file lets the CLI read bytes without touching disk or argv. This is CV-1 safe. `conjur_unsafe_set = true` switches to `-v ` argv path (expose only if `/dev/stdin` is unavailable — rare). +`set` uses `-f /dev/stdin` to pipe the value (safe; bytes never touch disk or argv). Switching to `-v ` argv requires `conjur_unsafe_set = true` (rare; only if `/dev/stdin` unavailable). ### Delete semantics -Conjur has **no `conjur variable delete` command** — variables are policy-defined. SecretEnv's `delete()` implements **clear** semantics: it sets the value to the empty string via the safe stdin path. The variable retains its policy definition; only the value is emptied. +Conjur has **no `conjur variable delete` command**. Variables are policy-defined. SecretEnv's `delete()` implements **clear** semantics: it sets the value to the empty string via the safe stdin path. The variable retains its policy definition; only the value is emptied. ## Minimum policy @@ -122,7 +122,7 @@ Not authenticated (session expired): ``` conjur-prod (conjur) ✓ conjur CLI Conjur CLI version 8.1.3-879b90b - ✗ not authenticated — session expired + ✗ not authenticated. session expired → run: conjur login (or 'conjur init' then 'conjur login' if first-time) ``` @@ -132,7 +132,7 @@ CLI not found (v7 Ruby or not installed): conjur-prod (conjur) ✗ conjur CLI not found → install: docker pull cyberark/conjur-cli:8 (alias `conjur` to a docker-run wrapper) - — see https://github.com/cyberark/conjur-cli-go for native builds + see https://github.com/cyberark/conjur-cli-go for native builds ``` ## Fragment directives @@ -145,13 +145,13 @@ Other fragments are rejected with an enumerated error listing supported directiv ## History API support -Not implemented. Conjur's CLI exposes no per-variable version-history subcommand. The server maintains audit logs; `secretenv registry history ` returns the trait-default "not implemented" until the CLI surfaces revision metadata. +Not implemented. The CLI has no per-variable version-history subcommand. Server maintains audit logs; CLI support pending. ## Limitations -- **No variable deletion.** `delete()` clears the value (empty string); the variable remains defined. Remove via policy reload only. -- **Go v8 CLI only.** The PyPI `conjur` package (Ruby v7) is EOL and rejected at startup. Use `cyberark/conjur-cli:8` Docker image or native v8 builds. -- **HTTP/HTTPS choice is yours.** Set `conjur_appliance_url` with the correct scheme; local dev uses HTTP, production uses HTTPS. +- **No variable deletion:** `delete()` clears the value (empty string); variable persists. Remove via policy reload only. +- **Go v8 CLI required:** PyPI `conjur` (Ruby v7) is EOL and rejected. Use `cyberark/conjur-cli:8` Docker image. +- **Scheme selection manual:** set correct scheme in `conjur_appliance_url` (HTTP dev, HTTPS prod). ## Examples @@ -210,7 +210,7 @@ Variable `prod/secretenv/registry` holds: ## Troubleshooting -**"not authenticated — session expired"** +**"not authenticated, session expired"** Run `conjur login` (or `conjur init` then `conjur login` on first setup). The CLI persists sessions in your OS keystore automatically. **"conjur: 'variable' is not a conjur command"** @@ -221,10 +221,10 @@ Check your policy grants `read` (or `read, execute`) privilege on the target var ## See Also -- [`secretenv doctor`](/reference/cli-reference-full#secretenv-doctor) — health checks for all backends -- [Alias registry concepts](../reference/registry.md) — how registry sources resolve aliases -- [Fragment vocabulary](../reference/fragment-vocabulary.md) — `#json-key`, `#version`, etc. -- [Vault backend](vault.md) — alternative: KV-mount-based secrets management -- [OpenBao backend](openbao.md) — alternative: Vault-compatible, open-source -- [All backends](README.md) — pick a different backend -- [Overview](/) — overview + workflows +- [`secretenv doctor`](/reference/cli-reference-full#secretenv-doctor), health checks for all backends +- [Alias registry concepts](../reference/registry.md), how registry sources resolve aliases +- [Fragment vocabulary](../reference/fragment-vocabulary.md), `#json-key`, `#version`, etc. +- [Vault backend](vault.md), alternative: KV-mount-based secrets management +- [OpenBao backend](openbao.md), alternative: Vault-compatible, open-source +- [All backends](README.md), pick a different backend +- [Overview](/), overview + workflows diff --git a/docs/backends/doppler.md b/docs/backends/doppler.md index da00d8e..f66bbd7 100644 --- a/docs/backends/doppler.md +++ b/docs/backends/doppler.md @@ -1,30 +1,31 @@ # Doppler -**Type:** `doppler` -**CLI required:** [`doppler`](https://docs.doppler.com/docs/install-cli) v3+ -**URI scheme:** `://///` (full) or `:///` (short, when config supplies defaults). No fragment directives supported. -**Platform:** all (macOS, Linux, Windows) -**Tested:** `doppler v3.76.0` on macOS Darwin 25.4 (SecretEnv v0.13.0, 2026-05-07) +- **Type:** `doppler` +- **CLI required:** [`doppler`](https://docs.doppler.com/docs/install-cli) +- **CLI version:** v3+ +- **URI scheme:** `://///` (full) or `:///` (short, when config supplies defaults). No fragment directives supported. +- **Platform:** all (macOS, Linux, Windows) +- **Tested:** `doppler v3.76.0` on macOS Darwin 25.4 (SecretEnv v0.19.0) > SecretEnv injects secrets from any backend as environment variables. This page covers the `doppler` backend. New here? See the [overview](/). -Doppler is a SaaS secrets manager with a clean CLI surface. Pick Doppler when you want multi-project secret scoping, team-friendly dashboards, and integrated audit logs. The `doppler` CLI resolves auth from three sources: an explicit `DOPPLER_TOKEN` env var, the local keychain entry from `doppler login`, or an instance-scoped token via config. +Doppler is a SaaS secrets manager with a clean CLI. The `doppler` CLI resolves auth from `DOPPLER_TOKEN` env var, `doppler login` keychain, or config-scoped token. ## When to pick this -- **Multi-project workflows:** named projects + environments (configs) let one machine scope to different teams/products -- **Developer-friendly dashboard:** interactive exploration via the browser UI -- **Audit + rotation ready:** Doppler tracks changes and supports scheduled secret rotation -- **CI/CD with service tokens:** mint a scoped token per pipeline, no keychain needed +- **Multi-project workflows:** named projects + environments scope one machine to different teams +- **Developer-friendly dashboard:** interactive UI exploration +- **Audit + rotation:** tracks changes, supports scheduled rotation +- **CI/CD:** mint scoped service tokens per pipeline, no keychain needed ## Configuration ```toml [backends.doppler-prod] type = "doppler" -doppler_project = "acme" # optional — short-form default -doppler_config = "prd" # optional — short-form default (both-or-neither) -doppler_token = "dp.st.prd.…" # optional — override $DOPPLER_TOKEN +doppler_project = "acme" # optional, short-form default +doppler_config = "prd" # optional, short-form default (both-or-neither) +doppler_token = "dp.st.prd.…" # optional, override $DOPPLER_TOKEN ``` ### Fields @@ -58,19 +59,19 @@ doppler-prod:///acme/prd/STRIPE_API_KEY instance name proj cfg secret name ``` -Full form requires three non-empty segments: `//`. Short form (one segment) requires both `doppler_project` and `doppler_config` in config. Doppler secret names follow `[A-Z_][A-Z0-9_]*` (all-caps, underscores). +Full form requires three segments: `//`. Short form (one segment) requires both `doppler_project` and `doppler_config` in config. Secret names follow `[A-Z_][A-Z0-9_]*` (all-caps, underscores). -**Verify your setup with:** `secretenv doctor` — green output means you're ready to run `secretenv run -- `. +**Verify your setup with:** `secretenv doctor`. Green output means you're ready to run `secretenv run -- `. ## Authentication Precedence (highest wins): -1. **`doppler_token` config field** — instance-scoped, via `DOPPLER_TOKEN` env -2. **`$DOPPLER_TOKEN` env var** — from parent shell -3. **`doppler login` keychain** — cached login token +1. **`doppler_token` config field**, via `DOPPLER_TOKEN` env +2. **`$DOPPLER_TOKEN` env var**, from parent shell +3. **`doppler login` keychain**, cached login token -Service tokens (`dp.st..*`) are scoped to a project + config at mint time. Ensure your token's scope matches the URI you're reading. +Service tokens (`dp.st..*`) are scoped to a project + config at mint. Verify token scope matches the URI. ## doctor Output @@ -97,13 +98,13 @@ No fragment directives. Any `#...` fragment is rejected at URI-parse time. ## History API support -Not implemented. The `doppler` CLI (v3.76.0) has no per-secret version-history subcommand; version history is available in the Doppler Dashboard and REST API. Open the secret in the dashboard to view its version history. +Not implemented. The `doppler` CLI has no per-secret version-history subcommand. Open the secret in the dashboard to view history. ## Limitations -- **No native stdin form for set.** `doppler secrets set` reads the value through argv. The backend gates writes behind `doppler_unsafe_set = true` to signal this risk. -- **`list()` synthetic keys filtered.** `doppler secrets download` injects `DOPPLER_PROJECT`, `DOPPLER_CONFIG`, `DOPPLER_ENVIRONMENT` keys. These are filtered out of registry-source enumerations automatically. -- **No folder scoping.** Doppler organizes by project + config only. Secrets don't nest in folders; use naming conventions (underscores) if needed. +- **No stdin set.** `doppler secrets set` reads the value through argv. Gated behind `doppler_unsafe_set = true`. +- **`list()` synthetic keys filtered.** `doppler secrets download` injects `DOPPLER_PROJECT`, `DOPPLER_CONFIG`, `DOPPLER_ENVIRONMENT` keys. Automatically filtered from registry-source enumerations. +- **No folder scoping.** Doppler organizes by project + config only. Use naming conventions (underscores) for nested structure. ## Examples @@ -162,19 +163,19 @@ Then: `secretenv run --registry doppler-prod:///acme/prd/REGISTRY -- npm start` ## Troubleshooting **"Doppler Error: Unauthorized: token not found"** -The token is malformed, revoked, or the workspace changed. Run `doppler me --json` to verify the token. Check `doppler_token` and `$DOPPLER_TOKEN` match a valid token. +Token is malformed, revoked, or workspace changed. Run `doppler me --json` to verify. **"Could not find requested secret: "** -The secret doesn't exist in the scoped project + config. Verify the project and config are correct via `secretenv doctor`. +Secret doesn't exist in the scoped project + config. Verify via `secretenv doctor`. **"Unexpected HTTP response 401 Unauthorized"** -Token scope mismatch. Service tokens are locked to a project + config at mint. Verify your token's scope with `doppler me --json`. +Token scope mismatch. Service tokens lock to a project + config at mint. Verify scope with `doppler me --json`. ## See Also -- [`secretenv doctor`](/reference/cli-reference-full#secretenv-doctor) — health checks for all backends -- [Alias registry concepts](../reference/registry.md) — how registry sources resolve aliases -- [Fragment vocabulary](../reference/fragment-vocabulary.md) — `#json-key`, `#version`, etc. on other backends -- [Doppler CLI reference](https://docs.doppler.com/docs/cli) — authoritative Doppler docs -- [All backends](README.md) — pick a different backend -- [Overview](/) — overview + workflows +- [`secretenv doctor`](/reference/cli-reference-full#secretenv-doctor), health checks for all backends +- [Alias registry concepts](../reference/registry.md), how registry sources resolve aliases +- [Fragment vocabulary](../reference/fragment-vocabulary.md), `#json-key`, `#version`, etc. on other backends +- [Doppler CLI reference](https://docs.doppler.com/docs/cli), authoritative Doppler docs +- [All backends](README.md), pick a different backend +- [Overview](/), overview + workflows diff --git a/docs/backends/gcp.md b/docs/backends/gcp.md index c6a6513..f357bd8 100644 --- a/docs/backends/gcp.md +++ b/docs/backends/gcp.md @@ -1,21 +1,22 @@ # GCP Secret Manager -**Type:** `gcp` -**CLI required:** [`gcloud`](https://cloud.google.com/sdk/docs/install) (Google Cloud SDK 380+) -**URI scheme:** `:///[#version=]` -**Platform:** all (macOS, Linux, Windows) -**Tested:** `Google Cloud SDK 560.0.0` on macOS Darwin 25.4 (SecretEnv v0.13.0, 2026-05-07) +- **Type:** `gcp` +- **CLI required:** [`gcloud`](https://cloud.google.com/sdk/docs/install) +- **CLI version:** Google Cloud SDK 380+ +- **URI scheme:** `:///[#version=]` +- **Platform:** all (macOS, Linux, Windows) +- **Tested:** `Google Cloud SDK 560.0.0` on macOS Darwin 25.4 (SecretEnv v0.19.0) > SecretEnv injects secrets from any backend as environment variables. This page covers the `gcp` backend. New here? See the [overview](/). -GCP Secret Manager is Google Cloud's native secrets store, deeply integrated with GCP identity and access management. It offers fine-grained IAM roles, version management, and seamless authentication on GCP compute (Workload Identity on GKE, instance metadata on Compute Engine). Pick Secret Manager when you're on Google Cloud and want the simplest, tightest integration path. +GCP Secret Manager is Google Cloud's native secrets store with fine-grained IAM, version management, and seamless authentication on GCP compute (Workload Identity on GKE, instance metadata on Compute Engine). ## When to pick this -- **You're on Google Cloud:** native integration, automatic credential discovery via ADC +- **On Google Cloud:** native integration, automatic credential discovery via ADC - **Workload Identity (GKE):** Pod-mounted service account tokens; zero config -- **Service account impersonation:** Optional per-operation impersonation for privilege escalation -- **Version management:** Pin to specific secret versions for canary testing or rollbacks +- **Service account impersonation:** for privilege escalation +- **Version pinning:** canary testing or rollbacks ## Configuration @@ -23,7 +24,7 @@ GCP Secret Manager is Google Cloud's native secrets store, deeply integrated wit [backends.gcp-prod] type = "gcp" gcp_project = "my-project-prod" -# Optional — impersonate a service account on every call: +# Optional, impersonate a service account on every call: # gcp_impersonate_service_account = "secretenv-reader@my-project-prod.iam.gserviceaccount.com" ``` @@ -44,7 +45,7 @@ gcp-prod:///stripe_api_key instance secret name ``` -Use triple-slash (`gcp-prod:///secret-name`) — the project is always in config, never in the URI. Secret names follow GCP rules: `[a-zA-Z0-9_-]{1,255}`. +Use triple-slash (`gcp-prod:///secret-name`). Secret names follow GCP rules: `[a-zA-Z0-9_-]{1,255}`. For version pinning: @@ -54,20 +55,18 @@ gcp-prod:///stripe_api_key#version=latest # Explicit latest (same as omittin gcp-prod:///stripe_api_key # Default: latest enabled version ``` -**Verify your setup with:** `secretenv doctor` — green output means you're ready to run `secretenv run -- `. +**Verify your setup with:** `secretenv doctor`. Green output means you're ready to run `secretenv run -- `. ## Authentication -SecretEnv delegates entirely to the `gcloud` CLI. All of these work: +SecretEnv delegates to the `gcloud` CLI. Any credential method the CLI supports works automatically: -- **Application Default Credentials (ADC):** `gcloud auth application-default login` or automatic on GCP compute -- **User account:** `gcloud auth login` (browser OAuth) -- **Service account key file:** `gcloud auth activate-service-account --key-file /path/to/key.json` -- **Workload Identity (GKE):** Pod-mounted service account tokens; zero config -- **Compute Engine metadata:** VM-attached service account; zero config -- **Cloud Shell:** Pre-authenticated - -If `gcloud ` works in your shell, the backend will too. +- **Application Default Credentials (ADC)**, `gcloud auth application-default login` or automatic on GCP compute +- **User account**, `gcloud auth login` (browser OAuth) +- **Service account key**, `gcloud auth activate-service-account --key-file /path/to/key.json` +- **Workload Identity (GKE)**, Pod-mounted tokens; zero config +- **Compute Engine metadata**, VM-attached service account; zero config +- **Cloud Shell**, Pre-authenticated ## IAM Permissions @@ -123,15 +122,13 @@ gcp-prod (gcp) ## Fragment directives -`#version=` pins a specific version. Version IDs are positive integers: +`#version=` pins specific versions (positive integers): | Directive | Effect | Example | |---|---|---| -| `#version=5` | Fetch version 5 explicitly | `gcp-prod:///stripe_key#version=5` | -| `#version=latest` | Explicit latest (same as omitting) | `gcp-prod:///stripe_key#version=latest` | -| (no fragment) | Fetch the latest enabled version | `gcp-prod:///stripe_key` | - -Shorthand fragments and non-integer versions are rejected with a migration hint. +| `#version=5` | Version 5 | `gcp-prod:///stripe_key#version=5` | +| `#version=latest` | Latest (same as omitting) | `gcp-prod:///stripe_key#version=latest` | +| (no fragment) | Latest enabled version | `gcp-prod:///stripe_key` | ## History API support @@ -139,11 +136,11 @@ Not implemented. The backend returns the trait-default "not implemented" error. ## Limitations -- **Update-only `set`.** The secret must exist before `registry set` can add a version. Create with `gcloud secrets create ` first. -- **Delete removes entire secret.** `secretenv registry delete` removes all versions. Use `gcloud secrets versions destroy` for per-version destruction. -- **Secret names are case-sensitive.** `stripe-key` ≠ `stripe-Key`. -- **No regional secrets.** Regional secrets (launched late 2024) require `--location `; v0.13 always uses multi-region (the default). -- **No binary secret support.** GCP Secret Manager stores bytes; the `gcloud` CLI decodes on read. Strings only for v0.13. +- **Update-only `set`.** Secret must exist before `registry set` can add versions. Create with `gcloud secrets create ` first. +- **Delete removes entire secret.** `registry delete` removes all versions. Use `gcloud secrets versions destroy` per-version. +- **Secret names are case-sensitive:** `stripe-key` ≠ `stripe-Key`. +- **No regional secrets.** Regional secrets require `--location `; v0.13 uses multi-region (default). +- **Strings only.** v0.13 doesn't support binary secrets. ## Examples @@ -185,19 +182,19 @@ secretenv registry set db-pass "gcp-prod:///db_password#version=3" ## Troubleshooting **"NOT_FOUND: Secret [...]"** -Verify the secret exists and is in the correct project. Use `gcloud secrets list --project ` to list all secrets. +Secret doesn't exist in the project. Use `gcloud secrets list --project ` to verify. **"PERMISSION_DENIED"** -Check your IAM role. Run `gcloud projects get-iam-policy ` to see your current roles, and grant `roles/secretmanager.secretAccessor` if needed. +Check IAM role with `gcloud projects get-iam-policy `. Grant `roles/secretmanager.secretAccessor` if needed. -**"NotAuthenticated" on `secretenv doctor`** -Run `gcloud auth login` or `gcloud auth activate-service-account --key-file ` to set up credentials. +**"NotAuthenticated"** +Run `gcloud auth login` or `gcloud auth activate-service-account --key-file `. ## See Also -- [`secretenv doctor`](/reference/cli-reference-full#secretenv-doctor) — health checks for all backends -- [Alias registry concepts](../reference/registry.md) — how registry sources resolve aliases -- [Fragment vocabulary](../reference/fragment-vocabulary.md) — `#version` directive reference -- [GCP Secret Manager documentation](https://cloud.google.com/secret-manager/docs) — permissions, versions, replication -- [All backends](README.md) — pick a different backend -- [Overview](/) — overview + workflows +- [`secretenv doctor`](/reference/cli-reference-full#secretenv-doctor), health checks for all backends +- [Alias registry concepts](../reference/registry.md), how registry sources resolve aliases +- [Fragment vocabulary](../reference/fragment-vocabulary.md), `#version` directive reference +- [GCP Secret Manager documentation](https://cloud.google.com/secret-manager/docs), permissions, versions, replication +- [All backends](README.md), pick a different backend +- [Overview](/), overview + workflows diff --git a/docs/backends/infisical.md b/docs/backends/infisical.md index f33f2a2..be1d0ba 100644 --- a/docs/backends/infisical.md +++ b/docs/backends/infisical.md @@ -1,31 +1,32 @@ # Infisical -**Type:** `infisical` -**CLI required:** [`infisical`](https://infisical.com/docs/cli/overview) v0.43+ -**URI scheme:** `://///` (full) or `:///` (short, when config supplies defaults). Nested folders fold into middle segments: `////`. -**Platform:** all (macOS, Linux, Windows) -**Tested:** `infisical 0.43.79` on macOS Darwin 25.4 (SecretEnv v0.13.0, 2026-05-07) +- **Type:** `infisical` +- **CLI required:** [`infisical`](https://infisical.com/docs/cli/overview) +- **CLI version:** v0.43+ +- **URI scheme:** `://///` (full) or `:///` (short, when config supplies defaults). Nested folders fold into middle segments: `////`. +- **Platform:** all (macOS, Linux, Windows) +- **Tested:** `infisical 0.43.79` on macOS Darwin 25.4 (SecretEnv v0.19.0) > SecretEnv injects secrets from any backend as environment variables. This page covers the `infisical` backend. New here? See the [overview](/). -Infisical is an open-source secrets manager available as SaaS or self-hosted. Pick Infisical when you want a Doppler-like experience without vendor lock-in, with support for nested folder scoping and self-hosting. The `infisical` CLI wraps the API with a clean `secrets get / set / delete` surface. +Infisical is an open-source secrets manager available as SaaS or self-hosted, with nested folder scoping and a clean CLI (`secrets get / set / delete`). ## When to pick this -- **Self-hosted option:** run your own instance; Infisical Cloud (`app.infisical.com`) is also available -- **Folder-scoped secrets:** organize secrets in nested paths (`/api/stripe`, `/database/replica`) -- **Open source first:** MPL-2.0 license, community-driven governance -- **Team collaboration:** built-in role-based access, audit logging, secret rotation +- **Self-hosted option:** run your own instance or use Infisical Cloud (`app.infisical.com`) +- **Folder-scoped secrets:** organize in nested paths (`/api/stripe`, `/database/replica`) +- **Open source:** MPL-2.0 license, community-driven +- **Team collaboration:** role-based access, audit logging, secret rotation ## Configuration ```toml [backends.infisical-prod] type = "infisical" -infisical_project_id = "abc-123-xyz" # optional — short-form default -infisical_environment = "prod" # optional — short-form default (both-or-neither) -infisical_secret_path = "/api" # optional — default folder path (default: /) -infisical_domain = "https://infisical.acme.com" # optional — self-hosted domain +infisical_project_id = "abc-123-xyz" # optional, short-form default +infisical_environment = "prod" # optional, short-form default (both-or-neither) +infisical_secret_path = "/api" # optional, default folder path (default: /) +infisical_domain = "https://infisical.acme.com" # optional, self-hosted domain ``` ### Fields @@ -43,12 +44,11 @@ infisical_domain = "https://infisical.acme.com" # optional — self-hosted ### Self-hosted domain trust -`infisical_domain` accepts any URL. A typo or lookalike domain routes credentials to an attacker. Before committing a self-hosted domain: +`infisical_domain` accepts any URL. A typo or lookalike routes credentials to an attacker. Before committing: - Verify it matches your org's canonical Infisical install -- Pin HTTPS with a trusted cert (avoid `http://` except loopback) -- Confirm the TLS cert belongs to your org (inspect with `openssl s_client -connect ...`) -- Avoid registries pointing at domains you don't control +- Pin HTTPS with trusted cert (avoid `http://` except loopback) +- Confirm TLS cert belongs to your org (`openssl s_client -connect ...`) See [security.md#self-hosted-domains](../security.md#self-hosted-domains) for the full disclosure discipline. @@ -72,19 +72,19 @@ infisical-prod:///abc-123/prod/STRIPE_API_KEY instance name project env secret name ``` -Full form: `//`. Nested folders fold into middle segments: `abc-123/prod/api/stripe/KEY` → project=`abc-123`, env=`prod`, path=`/api/stripe`, secret=`KEY`. Short form (one segment) requires both project-id and environment in config. +Full form: `//`. Nested folders fold into middle segments: `abc-123/prod/api/stripe/KEY` → project=`abc-123`, env=`prod`, path=`/api/stripe`, secret=`KEY`. Short form (one segment) requires both in config. -**Verify your setup with:** `secretenv doctor` — green output means you're ready to run `secretenv run -- `. +**Verify your setup with:** `secretenv doctor`. Green output means you're ready to run `secretenv run -- `. ## Authentication Precedence (highest wins): -1. **`infisical_token` config field** — instance-scoped, via `INFISICAL_TOKEN` env -2. **`$INFISICAL_TOKEN` env var** — from parent shell -3. **`infisical login` local cache** — browser-based login, cached locally +1. **`infisical_token` config field**, via `INFISICAL_TOKEN` env +2. **`$INFISICAL_TOKEN` env var**, from parent shell +3. **`infisical login` local cache**, browser-based login -Service tokens (`st.*`) are scoped to a project + environment + path at mint time. Ensure your token's scope matches the URI you're reading. Machine identities (via identity exchange) are also supported. +Service tokens (`st.*`) are scoped to project + environment + path at mint. Verify token scope matches the URI. Machine identities (via identity exchange) also supported. ## doctor Output @@ -119,13 +119,13 @@ No fragment directives. Any `#...` fragment is rejected at URI-parse time. ## History API support -Not implemented. The `infisical` CLI (v0.43.79) has no per-secret version-history subcommand; version history is available in the Infisical Dashboard and REST API. Open the secret in the dashboard to view its version history. +Not implemented. The `infisical` CLI has no per-secret version-history subcommand. Open the secret in the dashboard to view history. ## Limitations -- **`doctor` pipe-deadlock fix in v0.13.** Earlier releases occasionally false-reported "not authenticated" because `Stdio::piped()` + `.status()` left stderr undrained when the upgrade-banner payload exceeded the OS pipe buffer. v0.13 uses `Stdio::null()`. No action needed when upgrading. -- **No stdin set form.** `infisical secrets set` requires `--file `. The backend writes to a mode-0600 tempfile and passes `--type shared` to avoid personal-override scope corruption. -- **Self-hosted domain validation.** Domain trust is on the operator — a typo or lookalike routes all credentials to an attacker. +- **`doctor` pipe-deadlock fix in v0.13.** Earlier releases false-reported "not authenticated" due to undrained stderr. v0.13 uses `Stdio::null()`. No action needed upgrading. +- **No stdin set.** `infisical secrets set` requires `--file `. Backend writes to mode-0600 tempfile + `--type shared` to avoid personal-override corruption. +- **Self-hosted domain validation.** Domain trust is on the operator; typos or lookalikes route credentials to attackers. ## Examples @@ -177,20 +177,20 @@ Then: `secretenv run --registry infisical-prod:///abc-123/prod/registry/REGISTRY ## Troubleshooting **"error reading secret: invalid request body"** -Check that `infisical_secret_path` matches the folder where the secret exists. Use `infisical secrets list --path /...` to verify. +Check that `infisical_secret_path` matches the folder. Use `infisical secrets list --path /...` to verify. **"401 Unauthorized"** -Token is invalid, expired, or scoped to a different project/env. Run `infisical export` to test the token; it should succeed. +Token is invalid, expired, or scoped to a different project/env. Run `infisical export` to test. **"secret not found"** -The secret doesn't exist in the scoped project + env + path. Verify all three with `secretenv doctor` and `infisical secrets list`. +Secret doesn't exist in the scoped project + env + path. Verify with `secretenv doctor` and `infisical secrets list`. ## See Also -- [`secretenv doctor`](/reference/cli-reference-full#secretenv-doctor) — health checks for all backends -- [Alias registry concepts](../reference/registry.md) — how registry sources resolve aliases -- [Fragment vocabulary](../reference/fragment-vocabulary.md) — `#json-key`, `#version`, etc. on other backends -- [Self-hosted domain trust](../security.md#self-hosted-domains) — `infisical_domain` disclosure discipline -- [Infisical CLI reference](https://infisical.com/docs/cli/overview) — authoritative Infisical docs -- [All backends](README.md) — pick a different backend -- [Overview](/) — overview + workflows +- [`secretenv doctor`](/reference/cli-reference-full#secretenv-doctor), health checks for all backends +- [Alias registry concepts](../reference/registry.md), how registry sources resolve aliases +- [Fragment vocabulary](../reference/fragment-vocabulary.md), `#json-key`, `#version`, etc. on other backends +- [Self-hosted domain trust](../security.md#self-hosted-domains), `infisical_domain` disclosure discipline +- [Infisical CLI reference](https://infisical.com/docs/cli/overview), authoritative Infisical docs +- [All backends](README.md), pick a different backend +- [Overview](/), overview + workflows diff --git a/docs/backends/keeper.md b/docs/backends/keeper.md index 7817552..228a1c1 100644 --- a/docs/backends/keeper.md +++ b/docs/backends/keeper.md @@ -1,23 +1,23 @@ # Keeper -**Type:** `keeper` -**CLI required:** [`keeper` (Commander)](https://docs.keeper.io/en/keeperpam/commander-cli) v17+ — `pip install keepercommander` -**URI scheme:** `:///[#field=]` -**Platform:** all (macOS, Linux, Windows) -**Tested:** `keeper Commander 17.2.13` on macOS Darwin 25.4 (SecretEnv v0.13.0, 2026-05-07) +- **Type:** `keeper` +- **CLI required:** [`keeper` (Commander)](https://docs.keeper.io/en/keeperpam/commander-cli) +- **CLI version:** v17+ +- **URI scheme:** `:///[#field=]` +- **Platform:** all (macOS, Linux, Windows) +- **Tested:** `keeper Commander 17.2.13` on macOS Darwin 25.4 (SecretEnv v0.19.0) > SecretEnv injects secrets from any backend as environment variables. This page covers the `keeper` backend. New here? See the [overview](/). -Keeper is an enterprise password manager and secrets vault. Unlike other backends, Keeper requires a one-time persistent-login setup before non-interactive use. After setup, the `keeper` CLI provides interactive shell access and one-shot commands. This backend wraps the one-shot path. +Keeper is an enterprise password manager and secrets vault requiring a one-time persistent-login setup for non-interactive use. This backend wraps the `keeper` CLI one-shot command path. ## When to pick this -- **Enterprise password vault:** team password sharing, fine-grained record permissions -- **Persistent-login workflows:** machine-bound device tokens for non-interactive use -- **Compliance auditing:** Keeper Vault tracks access via the web UI -- **Existing Keeper deployments:** native integration without a new credential type +- **Enterprise password vault:** fine-grained record permissions, team sharing, audit trails +- **Non-interactive CI:** persistent-login device tokens enable automation +- **Existing Keeper deployments:** native integration -## Important — persistent-login setup required +## Important: persistent-login setup required **Before adding a Keeper instance to `config.toml`, set up persistent login:** @@ -40,9 +40,9 @@ This writes a device token to `~/.keeper/config.json`. The backend reads it auto ```toml [backends.keeper-prod] type = "keeper" -# keeper_config_path = "~/.keeper/config.json" # optional — override default -# keeper_unsafe_set = false # optional — opt-in for set() -# keeper_list_max_records = 500 # optional — cap for list() +# keeper_config_path = "~/.keeper/config.json" # optional, override default +# keeper_unsafe_set = false # optional, opt-in for set() +# keeper_list_max_records = 500 # optional, cap for list() ``` ### Fields @@ -50,9 +50,9 @@ type = "keeper" | Field | Required | Description | |---|---|---| | `type` | Yes | Must be `"keeper"` | -| `keeper_config_path` | No | Path to `config.json` holding the device token. Defaults to `~/.keeper/config.json`. Must be mode 0600 or stricter; permissive modes are rejected. | -| `keeper_unsafe_set` | No | Default `false`. When `false`, `set()` bails with a UI pointer. When `true`, opts into argv-based `set()` — **the Keeper CLI has no stdin form**, so argv exposure via `ps -ww` is unavoidable. | -| `keeper_list_max_records` | No | Optional cap on `list()` per-record enumeration. Default unset (no cap). When set, stops after N records, bounding heap + rate-limit pressure on large vaults. | +| `keeper_config_path` | No | Path to device-token file. Defaults to `~/.keeper/config.json` (mode 0600+ required). | +| `keeper_unsafe_set` | No | Opt into argv-based `set()` (unavoidable `ps -ww` exposure). Default `false`. | +| `keeper_list_max_records` | No | Cap on `list()` enumeration per vault size. Default: no limit. | | `timeout_secs` | No | Per-instance fetch timeout. Default: 30s. | ### Multi-account setups @@ -77,7 +77,7 @@ keeper-prod:///STRIPE_API_KEY instance name record UID or title ``` -The path segment is the record identifier — either a 22-character base64url UID or a record title. The Keeper CLI resolves both. +The path is the record identifier: 22-character UID or title (Keeper CLI resolves both). ### Field-selection fragment @@ -89,7 +89,7 @@ keeper-prod:///STRIPE_API_KEY#field=api_key `#field=` selects a custom field or typed field. Matching is case-insensitive. Priority: custom-field label → typed-field label → typed-field type name. -**Verify your setup with:** `secretenv doctor` — green output means you're ready to run `secretenv run -- `. +**Verify your setup with:** `secretenv doctor`. Green output means you're ready to run `secretenv run -- `. ## Authentication @@ -137,13 +137,13 @@ Other fragments are rejected with a specific error. ## History API support -Not implemented. Keeper records have per-version history in the Vault UI (Vault → record → `...` → "Record History") and the REST API, but the `keeper` CLI (v17.2.13) has no per-record history subcommand. Use the Vault UI to view record history. +Not implemented. The `keeper` CLI (v17.2.13) has no per-record history subcommand. View history via the Vault UI (Vault → record → `...` → "Record History"). ## Limitations -- **Set is gated by default.** `keeper_unsafe_set = true` is required because the Keeper CLI has no stdin form for field values. argv exposure via `ps -ww` is unavoidable when opted in. -- **Device token is a trust boundary.** `~/.keeper/config.json` contains long-lived auth. Protect it (`chmod 600` is the default) and the parent directory (`chmod 700 ~/.keeper`). -- **Large vault enumeration is sequential.** `list()` runs one `keeper get` per record. 1000+ records can take several seconds; use `keeper_list_max_records` to cap. +- **`set` gated by default:** no stdin form available; argv exposure unavoidable. Set `keeper_unsafe_set = true` only if needed. +- **Device token is sensitive:** protect `~/.keeper/config.json` (mode 0600) and its parent directory (mode 0700). +- **Slow enumeration:** `list()` is sequential (one CLI call per record). Cap with `keeper_list_max_records` for large vaults. ## Examples @@ -185,8 +185,8 @@ Then: `secretenv run --registry keeper-prod:///REGISTRY -- npm start` ## Troubleshooting -**"not authenticated — invalid master password"** -Persistent login is not set up. Follow the [setup section](#important--persistent-login-setup-required) above. +**"not authenticated, invalid master password"** +Persistent login is not set up. Follow the [setup section](#important-persistent-login-setup-required) above. **"record not found"** The record UID or title doesn't exist in the vault. Verify with `keeper --batch-mode list` or `keeper shell → search`. @@ -196,9 +196,9 @@ The Keeper CLI has no stdin form for field values. Either opt in via config (`ke ## See Also -- [`secretenv doctor`](/reference/cli-reference-full#secretenv-doctor) — health checks for all backends -- [Alias registry concepts](../reference/registry.md) — how registry sources resolve aliases -- [Fragment vocabulary](../reference/fragment-vocabulary.md) — `#json-key`, `#version`, etc. on other backends -- [Keeper CLI reference](https://docs.keeper.io/en/keeperpam/commander-cli) — authoritative Keeper docs -- [All backends](README.md) — pick a different backend -- [Overview](/) — overview + workflows +- [`secretenv doctor`](/reference/cli-reference-full#secretenv-doctor), health checks for all backends +- [Alias registry concepts](../reference/registry.md), how registry sources resolve aliases +- [Fragment vocabulary](../reference/fragment-vocabulary.md), `#json-key`, `#version`, etc. on other backends +- [Keeper CLI reference](https://docs.keeper.io/en/keeperpam/commander-cli), authoritative Keeper docs +- [All backends](README.md), pick a different backend +- [Overview](/), overview + workflows diff --git a/docs/backends/keychain.md b/docs/backends/keychain.md index 3b5c03e..e64a20a 100644 --- a/docs/backends/keychain.md +++ b/docs/backends/keychain.md @@ -1,19 +1,19 @@ # macOS Keychain -**Type:** `keychain` -**CLI required:** `security` (ships with every macOS) -**URI scheme:** `:////` -**Platform:** macOS only — `secretenv doctor` reports `keychain-` as CliMissing on Linux/Windows -**Tested:** macOS Darwin 25.4 (SecretEnv v0.13.0, 2026-05-07) +- **Type:** `keychain` +- **CLI required:** `security` (ships with every macOS) +- **URI scheme:** `:////` +- **Platform:** macOS only. `secretenv doctor` reports `keychain-` as CliMissing on Linux/Windows +- **Tested:** macOS Darwin 25.4 (SecretEnv v0.19.0) > SecretEnv injects secrets from any backend as environment variables. This page covers the `keychain` backend. New here? See the [overview](/). -The macOS Keychain is the OS's native credential store, accessed via the built-in `security` CLI. It requires no external service account or API token. Use it for solo developer machines, paired with the `local` backend as a registry document pointing at keychain entries. +The macOS Keychain is the OS's native credential store via the built-in `security` CLI. Requires no external service account or API token. ## When to pick this -- **Solo developer machines:** zero-infrastructure local workflow, passwords encrypted at rest by the OS -- **App development:** store API keys, credentials, certificates in the system keychain +- **Solo dev machines:** zero-infrastructure, passwords encrypted at rest by the OS +- **App development:** store API keys, credentials, certificates locally - **Paired with `local` registry:** `local://…/aliases.toml` points at `keychain://…` entries - **macOS only:** non-macOS hosts cannot use this backend @@ -68,18 +68,18 @@ If a service name contains a literal `/` (e.g., `com.acme/subteam`), percent-enc keychain-default:///com.acme%2Fsubteam/stripe-key ``` -Only `%2F` is decoded — no general percent-escape handling. +Only `%2F` is decoded, no general percent-escape handling. ### `kind`: generic-password vs internet-password | `kind` | Use case | |---|---| -| `generic-password` (default) | Generic API keys, tokens, shared secrets. Keychain Access UI calls these "application passwords". | -| `internet-password` | Protocol+host-scoped credentials (HTTP basic-auth, SMB shares). Ships extra attributes (protocol, port) not surfaced in v0.13. | +| `generic-password` (default) | API keys, tokens, shared secrets. Keychain Access calls these "application passwords". | +| `internet-password` | Protocol+host-scoped credentials (HTTP basic-auth, SMB shares). Extra attributes (protocol, port) not surfaced in v0.13. | -Most use cases want `generic-password`. Pick `internet-password` only if your items were created through Keychain Access → Add Password or by an app (browser, VPN client) that writes to the internet-password realm. +Most use cases want `generic-password`. Pick `internet-password` only if items were created through Keychain Access → Add Password or by an app. -**Verify your setup with:** `secretenv doctor` — green output means you're ready to run `secretenv run -- `. +**Verify your setup with:** `secretenv doctor`. Green output means you're ready to run `secretenv run -- `. ## Authentication @@ -90,19 +90,16 @@ The Keychain has no user-level auth surface like cloud backends. Access is gated ### Locked-keychain remediation -If `secretenv doctor` reports `NotAuthenticated` with a `security unlock-keychain` hint, your keychain is locked: +If `secretenv doctor` reports `NotAuthenticated`, unlock with: ```bash security unlock-keychain ~/Library/Keychains/login.keychain-db -# prompts for your keychain password interactively -secretenv doctor # verify it's unlocked +# prompts for keychain password ``` ### ACL setup for automation -When you create an item through `security add-generic-password`, macOS sets an ACL tied to the creating process. Items created through Keychain Access.app default to "Confirm before allowing" — which works interactively but breaks automation (triggers a system dialog per fetch). - -For automation-friendly items, set "Always allow" ACL at creation via the `-T` flag: +Items created through Keychain Access.app default to "Confirm before allowing", which breaks automation. For automation-friendly items, set "Always allow" via `-T`: ```bash security add-generic-password \ @@ -111,23 +108,18 @@ security add-generic-password \ -T /usr/local/bin/secretenv \ -T /bin/zsh \ ~/Library/Keychains/login.keychain-db - -# Or "Always allow for ANY process" (less secure, simplest for solo dev): -security add-generic-password \ - -s myapp -a stripe-key \ - -w "sk_test_…" \ - -A \ - ~/Library/Keychains/login.keychain-db ``` -## The `list()` limitation — registry patterns +Or allow any process (simpler for solo dev): add `-A` instead of `-T` flags. + +## The `list()` limitation: registry patterns -**The keychain backend does not implement `list()` as a registry source.** The `security` CLI offers no safe list-by-prefix operation. `security dump-keychain` requires per-item confirmation, prompts for the keychain password, and dumps every credential — a security footgun. +**The keychain backend does not implement `list()` as a registry source.** The `security` CLI offers no safe list-by-prefix operation. `security dump-keychain` requires per-item confirmation and dumps every credential, a security footgun. ### What this means -- **Keychain cannot host an alias registry.** `secretenv.toml` aliases cannot point `sources = [...]` at a keychain URI. -- **Keychain URIs are alias TARGETS only.** Put keychain entries on the right-hand side of a registry entry; host the registry itself on `local`, `aws-ssm`, `aws-secrets`, `vault`, or another backend. +- **Keychain cannot host an alias registry.** Aliases cannot point `sources = [...]` at a keychain URI. +- **Keychain URIs are alias TARGETS only.** Put keychain entries on the right-hand side; host the registry on `local`, `aws-ssm`, `aws-secrets`, `vault`, or another backend. Recommended shape: @@ -145,7 +137,7 @@ type = "keychain" ``` ```toml -# /Users/you/.config/secretenv/aliases.toml — the registry +# /Users/you/.config/secretenv/aliases.toml, the registry stripe-key = "keychain-default:///com.acme.prod/stripe-key" db-url = "keychain-default:///com.acme.prod/db-url" @@ -167,7 +159,7 @@ Locked: ``` keychain-default (keychain) ✓ security (macOS system) - ✗ not authenticated — keychain is locked + ✗ not authenticated, keychain is locked → run: security unlock-keychain ~/Library/Keychains/login.keychain-db ``` @@ -176,7 +168,7 @@ Non-macOS: ``` keychain-default (keychain) ✗ CLI 'security' not found - → the 'security' tool ships with macOS — non-macOS hosts cannot use this backend + → the 'security' tool ships with macOS; non-macOS hosts cannot use this backend ``` ## Fragment directives @@ -189,9 +181,9 @@ Not implemented. The Keychain has no version-history API. Items are overwritten ## Limitations -- **Stdin discipline.** Every `security` invocation sets `stdin: Stdio::null()`. Without this, a locked keychain hangs on a TTY password prompt instead of failing fast. The wrapper converts the hang case into a fast "keychain is locked" error. -- **`set` argv exposure.** `security add-*-password -w ` passes the value through child argv. On macOS's single-UID process model, `ps -ww` output is visible only to the same UID — structurally safer than Linux's world-readable `/proc//cmdline`. We accept this exposure on single-user machines and do **not** gate behind an opt-in flag (unlike 1Password's `op_unsafe_set`). A `tracing::warn!` at `set` time records the exposure for audit. -- **Multi-user macOS.** Machines with multiple active UIDs (university Macs, shared dev workstations) don't get additional `set` protection. Avoid `secretenv registry set` against keychain targets on shared machines. +- **Stdin discipline.** Every `security` invocation sets `stdin: Stdio::null()` to prevent hangs on locked keychains. +- **`set` argv exposure.** `security add-*-password -w ` passes the value through argv. On macOS's single-UID model, `ps -ww` is visible only to the same UID. We accept this and don't gate behind an opt-in flag (unlike `op_unsafe_set`). `tracing::warn!` at `set` time records the exposure for audit. +- **Multi-user macOS.** Machines with multiple active UIDs don't get additional `set` protection. Avoid `registry set` against keychain on shared machines. ## Examples @@ -237,19 +229,19 @@ work-api-key = "keychain-work:///company.internal/api-key" ## Troubleshooting **"keychain is locked"** -Run `security unlock-keychain ~/Library/Keychains/login.keychain-db` and enter your keychain password. +Run `security unlock-keychain ~/Library/Keychains/login.keychain-db`. **"Item not found" on a custom keychain** -Verify the keychain file exists at the path in `keychain_path`. Use `ls -la` to check. If the file is missing, recreate it via `security create-keychain` or use the login keychain (omit `keychain_path`). +Verify the keychain file exists at `keychain_path`. If missing, recreate with `security create-keychain` or use the login keychain (omit `keychain_path`). **"Permission denied" or "ACL mismatch"** -The keychain item's ACL is set to "Confirm before allowing". Either edit the item in Keychain Access.app to always allow, or recreate it with `-T /usr/local/bin/secretenv` or `-A` (always allow) at creation time. +The item's ACL is set to "Confirm before allowing". Edit in Keychain Access.app to always allow, or recreate with `-T /usr/local/bin/secretenv` or `-A` at creation. ## See Also -- [`secretenv doctor`](/reference/cli-reference-full#secretenv-doctor) — health checks for all backends -- [Alias registry concepts](../reference/registry.md) — how registry sources resolve aliases -- [Local file backend](local.md) — recommended registry host when keychain is the secret target -- [macOS Keychain documentation](https://www.unix.com/man-page/osx/1/security/) — the authoritative CLI reference -- [All backends](README.md) — pick a different backend -- [Overview](/) — overview + workflows +- [`secretenv doctor`](/reference/cli-reference-full#secretenv-doctor), health checks for all backends +- [Alias registry concepts](../reference/registry.md), how registry sources resolve aliases +- [Local file backend](local.md), recommended registry host when keychain is the secret target +- [macOS Keychain documentation](https://www.unix.com/man-page/osx/1/security/), the authoritative CLI reference +- [All backends](README.md), pick a different backend +- [Overview](/), overview + workflows diff --git a/docs/backends/local.md b/docs/backends/local.md index 252ad10..35d4668 100644 --- a/docs/backends/local.md +++ b/docs/backends/local.md @@ -1,21 +1,21 @@ # Local File -**Type:** `local` -**CLI required:** None (filesystem only) -**URI scheme:** `:///path/to/file.toml` -**Platform:** all (macOS, Linux, Windows) -**Tested:** SecretEnv v0.13.0 (2026-05-07) +- **Type:** `local` +- **CLI required:** None (filesystem only) +- **URI scheme:** `:///path/to/file.toml` +- **Platform:** all (macOS, Linux, Windows) +- **Tested:** SecretEnv v0.19.0 -> SecretEnv injects secrets from any backend as environment variables. This page covers the `local` backend — the only backend that needs no CLI. New here? See the [overview](/). +> SecretEnv injects secrets as environment variables. This page covers the `local` backend, the only one needing no CLI. New here? See the [overview](/). -The local backend reads secret values directly from a flat TOML file on disk. Use it for solo developers who want zero-infrastructure local workflow, or as a **registry document** pointing at other backends (most commonly the macOS Keychain). +Read secrets from a TOML file on disk. Best for solo development (zero infrastructure) or as a registry document pointing at other backends. ## When to pick this -- **Solo development:** zero external service, no credentials, filesystem only -- **Local registry document:** point aliases at Keychain / Vault / other backends -- **Air-gapped environments:** all secrets on disk, no network required -- **Testing SecretEnv:** quick setup without infrastructure +- **Solo development**, no external service, no credentials +- **Local registry**, alias to Keychain, Vault, or other backends +- **Air-gapped setups**, all secrets on disk, no network +- **Quick testing**, minimal setup ## Configuration @@ -34,7 +34,7 @@ No credential fields, no remote service, no authentication. Just `std::fs`. ### Multiple instances -You can configure multiple `local` instances if you want different files referenced as separate backends: +Configure multiple `local` instances to reference different files: ```toml [backends.local-personal] @@ -44,8 +44,6 @@ type = "local" type = "local" ``` -The instance name (`local-personal`, `local-team`) becomes the URI scheme. - ## URI Format ``` @@ -54,9 +52,9 @@ local:///Users/yourname/.config/secretenv/local-registry.toml instance absolute path to TOML file ``` -The path must be absolute. The file at the path must be a flat TOML key-value document — keys at top level only, values either scalar (raw secret values) or URI strings (when the file serves as a registry document). +Path must be absolute. File must be a flat TOML key-value document (top-level keys only; values are scalars or URIs). -**Verify your setup with:** `secretenv doctor` — green output means SecretEnv can read the file. +**Verify:** `secretenv doctor`. Green output means SecretEnv can read the file. ### File Format @@ -82,13 +80,11 @@ DATABASE_URL = "postgres://localhost/mydb" API_TOKEN = "abc123…" ``` -The registry-document pattern is **strongly recommended** — it aliases secrets from other backends. Storing actual secret values in a flat file is what `.env` already does poorly. Prefer routing through Keychain or any cloud backend instead. +**Strongly prefer the registry-document pattern.** It aliases from other backends. Storing actual secrets in flat files defeats the point; use Keychain or a cloud backend instead. ## Authentication -None. The backend reads from the filesystem with the current user's permissions. - -If a file is unreadable (permissions, missing), `secretenv doctor` reports it cleanly. +None. Reads the file with the current user's permissions. Unreadable files are reported cleanly by `secretenv doctor`. ## doctor Output @@ -97,7 +93,7 @@ Healthy (file readable): ``` local (local) ✓ filesystem access - ✓ /Users/alice/.config/secretenv/local-registry.toml — readable + ✓ /Users/alice/.config/secretenv/local-registry.toml, readable ``` File missing or unreadable: @@ -105,7 +101,7 @@ File missing or unreadable: ``` local (local) ✓ filesystem access - ✗ /Users/alice/.config/secretenv/local-registry.toml — not readable + ✗ /Users/alice/.config/secretenv/local-registry.toml, not readable → check the path exists and you have read permissions ``` @@ -115,13 +111,13 @@ No fragment directives. Any `#...` fragment is rejected at URI-parse time. ## History API support -Supported via `git log --follow`. `secretenv registry history ` shells out to `git log` against the registry file and returns one entry per commit, with the short SHA as `version`, the ISO-8601 author timestamp, and the author name/email as `actor`. The file must be inside a git repository; a clear error is returned if it is not tracked. The fragment is rejected before the git call fires. +Supported via `git log --follow` on the registry file. Returns one entry per commit with short SHA (version), ISO-8601 timestamp, and author name/email. File must be git-tracked; clear error if not. ## Limitations -- **Plaintext on disk if storing raw secrets.** `chmod 600` and exclude from cloud-syncing backups. Strongly prefer the registry-document pattern. -- **No write protection.** Anyone with write access to the file can change values; no signing or verification. -- **TOML parse errors are fatal.** A malformed file fails the whole resolve, not just the affected alias. +- **Plaintext if storing raw secrets.** Use `chmod 600` and exclude from cloud backups +- **No write protection.** Writable file = writable secrets (no signing) +- **Parse errors are fatal.** Malformed TOML fails all aliases, not just one ## Examples @@ -141,7 +137,7 @@ sources = ["local-main:///Users/you/.config/secretenv/aliases.toml"] ``` ```toml -# ~/.config/secretenv/aliases.toml — the registry +# ~/.config/secretenv/aliases.toml, the registry stripe-key = "keychain-default:///myapp/stripe-key" db-url = "keychain-default:///myapp/db-url" @@ -182,9 +178,9 @@ The file is readable but the TOML syntax is invalid. Check for missing quotes on ## See Also -- [`secretenv doctor`](/reference/cli-reference-full#secretenv-doctor) — health checks for all backends -- [Alias registry concepts](../reference/registry.md) — how aliases resolve -- [macOS Keychain backend](keychain.md) — the typical pair for `local`-as-registry -- [AWS SSM backend](aws-ssm.md) — alternative: cloud-native parameter store -- [All backends](README.md) — pick a different backend -- [Overview](/) — overview + workflows +- [`secretenv doctor`](/reference/cli-reference-full#secretenv-doctor), health checks for all backends +- [Alias registry concepts](../reference/registry.md), how aliases resolve +- [macOS Keychain backend](keychain.md), the typical pair for `local`-as-registry +- [AWS SSM backend](aws-ssm.md), alternative: cloud-native parameter store +- [All backends](README.md), pick a different backend +- [Overview](/), overview + workflows diff --git a/docs/backends/openbao.md b/docs/backends/openbao.md index 36b24c7..737445d 100644 --- a/docs/backends/openbao.md +++ b/docs/backends/openbao.md @@ -1,21 +1,21 @@ # OpenBao -**Type:** `openbao` -**CLI required:** [`bao`](https://openbao.org/docs/install/) v2+ -**URI scheme:** `://mount/path/to/secret[#json-key=]` -**Platform:** all (macOS, Linux, Windows) -**Tested:** `bao v2.5.3` (build 2026-04-20) on macOS Darwin 25.4 (SecretEnv v0.13.0, 2026-05-07) +- **Type:** `openbao` +- **CLI required:** [`bao`](https://openbao.org/docs/install/) +- **CLI version:** v2+ +- **URI scheme:** `://mount/path/to/secret[#json-key=]` +- **Platform:** all (macOS, Linux, Windows) +- **Tested:** `bao v2.5.3` (build 2026-04-20) on macOS Darwin 25.4 (SecretEnv v0.19.0) > SecretEnv injects secrets from any backend as environment variables. This page covers the `openbao` backend. New here? See the [overview](/). -OpenBao is the Linux Foundation MPL-2.0 fork of HashiCorp Vault — same wire protocol, KV semantics, and auth methods. Pick OpenBao if you're already running a Vault-compatible instance and want the open-source governance model. Migration from Vault is a one-line config swap. +OpenBao is the Linux Foundation MPL-2.0 fork of Vault with identical wire protocol and KV semantics. Migration from Vault is a one-line config swap. ## When to pick this -- **Vault-compatible instances:** OpenBao understands the Vault API; use it with Vault servers -- **Open source governance:** MPL-2.0 licensed, Linux Foundation governed -- **Self-hosted or cloud:** run your own instance or use a managed provider -- **Enterprise features via OSS:** namespaces are free in OpenBao 2.x (Enterprise-gated in Vault) +- **Vault compatibility:** works with Vault and OpenBao servers +- **Open-source governance:** MPL-2.0, Linux Foundation directed +- **Free namespaces:** open in OpenBao 2.x (Enterprise-only in Vault) ## Configuration @@ -31,9 +31,9 @@ openbao_address = "http://127.0.0.1:8300" # required | Field | Required | Description | |---|---|---| | `type` | Yes | Must be `"openbao"` | -| `openbao_address` | Yes | Full URL of the OpenBao instance (include scheme). Dev mode listens on **HTTP** (`http://127.0.0.1:8300`), not HTTPS. Set explicitly to keep registry portable. | -| `openbao_namespace` | No | OpenBao namespace (free OSS feature in 2.x). Omit if not in use. | -| `bao_unsafe_set` | No | Defense-in-depth opt-in. Defaults to `false`; the safe `value=-` stdin form is used regardless. Reserved for forward-compatibility. | +| `openbao_address` | Yes | Full URL (with scheme). Dev mode: `http://127.0.0.1:8300` (HTTP, not HTTPS). Set explicitly for portable registries. | +| `openbao_namespace` | No | OpenBao namespace (2.x OSS feature). Omit if unused. | +| `bao_unsafe_set` | No | Defense-in-depth opt-in (reserved for forward-compatibility). Default `false`; safe `value=-` stdin always used. | | `timeout_secs` | No | Per-instance fetch timeout. Default: 30s. | ### Multiple instances or namespaces @@ -58,7 +58,7 @@ openbao-dev://secret/prod/db_password instance mount path within mount ``` -The unified `bao kv` CLI handles KV v1 and v2 transparently. The mount is the KV backend's name (typically `secret`); the path is the secret's location within it. +The `bao kv` CLI handles KV v1 and v2 transparently. The mount is the KV backend's name (typically `secret`). ### `#json-key=` fragment @@ -78,7 +78,7 @@ echo -n '{"username":"app","password":"sk_live_abc"}' \ The fragment is recognized only on `get`. `set`, `delete`, `list`, and `history` reject any fragment. -**Verify your setup with:** `secretenv doctor` — green output means you're ready to run `secretenv run -- `. +**Verify your setup with:** `secretenv doctor`. Green output means you're ready to run `secretenv run -- `. ## Authentication @@ -127,13 +127,13 @@ Other fragments are rejected with a specific error. ## History API support -Not implemented (planned for future). The `bao` CLI (v2.5.3) has no per-secret history subcommand. Version history is available via the REST API and the web UI; this backend will flip to a native implementation once the CLI supports it. +Not implemented. The `bao` CLI (v2.5.3) lacks a per-secret history subcommand. Available via REST API and web UI. ## Limitations -- **Storage model.** Every secret is stored in the `value` field of a KV v2 entry (`bao kv put value=-`). Multi-field secrets are not produced by this backend. -- **Scheme mismatch gotcha.** Dev mode (`bao server -dev`) listens on **HTTP**, but the CLI defaults to **HTTPS**. Always set `openbao_address` explicitly with the correct scheme. -- **KV v1 vs v2.** `bao kv` CLI is transparent, but if you're migrating from Vault, verify your mount type in OpenBao (run `bao secrets list` to see mount types). +- **Storage model:** all secrets in the `value` field of KV v2 (`bao kv put value=-`). +- **Scheme gotcha:** dev mode is HTTP, CLI defaults to HTTPS. Always set `openbao_address` explicitly. +- **KV mount verification:** when migrating from Vault, check mount types (`bao secrets list`). ## Examples @@ -202,10 +202,10 @@ The path doesn't exist. Verify with `bao kv list secret/` to enumerate existing ## See Also -- [`secretenv doctor`](/reference/cli-reference-full#secretenv-doctor) — health checks for all backends -- [Alias registry concepts](../reference/registry.md) — how registry sources resolve aliases -- [Fragment vocabulary](../reference/fragment-vocabulary.md) — `#json-key`, `#version`, etc. on other backends -- [Vault backend](vault.md) — compatible and equivalent (choose based on governance preference) -- [OpenBao CLI reference](https://openbao.org/docs/commands/) — authoritative OpenBao docs -- [All backends](README.md) — pick a different backend -- [Overview](/) — overview + workflows +- [`secretenv doctor`](/reference/cli-reference-full#secretenv-doctor), health checks for all backends +- [Alias registry concepts](../reference/registry.md), how registry sources resolve aliases +- [Fragment vocabulary](../reference/fragment-vocabulary.md), `#json-key`, `#version`, etc. on other backends +- [Vault backend](vault.md), compatible and equivalent (choose based on governance preference) +- [OpenBao CLI reference](https://openbao.org/docs/commands/), authoritative OpenBao docs +- [All backends](README.md), pick a different backend +- [Overview](/), overview + workflows diff --git a/docs/backends/vault.md b/docs/backends/vault.md index f54dd90..adbb4c0 100644 --- a/docs/backends/vault.md +++ b/docs/backends/vault.md @@ -1,10 +1,10 @@ # HashiCorp Vault -**Type:** `vault` -**CLI required:** [`vault`](https://developer.hashicorp.com/vault/docs/install) -**URI scheme:** `://mount/path/to/secret` -**Platform:** all (macOS, Linux, Windows) -**Tested:** `vault v2.0.0` (build 2026-04-13) on macOS Darwin 25.4 (SecretEnv v0.13.0, 2026-05-07) +- **Type:** `vault` +- **CLI required:** [`vault`](https://developer.hashicorp.com/vault/docs/install) +- **URI scheme:** `://mount/path/to/secret` +- **Platform:** all (macOS, Linux, Windows) +- **Tested:** `vault v2.0.0` (build 2026-04-13) on macOS Darwin 25.4 (SecretEnv v0.19.0) > SecretEnv injects secrets from any backend as environment variables. This page covers the `vault` backend. New here? See the [overview](/). @@ -23,7 +23,7 @@ HashiCorp Vault is a centralized, multi-tenant secrets store designed for enterp [backends.vault-eng] type = "vault" vault_address = "https://vault.company.com" # required -vault_namespace = "engineering" # optional — Vault Enterprise only +vault_namespace = "engineering" # optional, Vault Enterprise only ``` ### Fields @@ -57,9 +57,9 @@ vault-eng://secret/myapp/db_password instance mount path within mount ``` -For KV v2 mounts, the `vault` CLI automatically injects the `data/` segment — you do **not** include it in the URI. Example: `vault-eng://secret/myapp/db` correctly maps to the KV v2 path `secret/data/myapp/db`. +For KV v2 mounts, the `vault` CLI automatically injects the `data/` segment; you do **not** include it in the URI. Example: `vault-eng://secret/myapp/db` correctly maps to the KV v2 path `secret/data/myapp/db`. -**Verify your setup with:** `secretenv doctor` — green output means you're ready to run `secretenv run -- `. +**Verify your setup with:** `secretenv doctor`. Green output means you're ready to run `secretenv run -- `. ## Authentication @@ -104,7 +104,7 @@ Full support via `vault kv metadata get -format=json`. `secretenv registry histo - **KV v2 only for history.** `vault kv metadata get` only works on KV v2 mounts. KV v1 mounts have no version history available. - **Soft-delete vs destroy.** `vault kv delete` soft-deletes (metadata + prior versions survive). Full destruction requires `vault kv destroy` explicitly. This is an operational safeguard. -- **Self-hosted domain trust.** `vault_address` is the trust boundary — see [security.md#self-hosted-domains](../security.md#self-hosted-domains) for the full disclosure discipline. +- **Self-hosted domain trust.** `vault_address` is the trust boundary; see [security.md#self-hosted-domains](../security.md#self-hosted-domains) for the full disclosure discipline. - **Namespace paths must exist.** Vault Enterprise namespace paths are hierarchical. Parent namespaces must exist before child namespaces can be referenced. ## Examples @@ -169,9 +169,9 @@ History API only works on KV v2 mounts. Check your mount type with `vault secret ## See Also -- [`secretenv doctor`](/reference/cli-reference-full#secretenv-doctor) — health checks for all backends -- [Alias registry concepts](../reference/registry.md) — how registry sources resolve aliases -- [Self-hosted domain trust](../security.md#self-hosted-domains) — `vault_address` disclosure discipline -- [OpenBao](openbao.md) — LF MPL fork; near-identical wire protocol and KV semantics -- [All backends](README.md) — pick a different backend -- [Overview](/) — overview + workflows +- [`secretenv doctor`](/reference/cli-reference-full#secretenv-doctor), health checks for all backends +- [Alias registry concepts](../reference/registry.md), how registry sources resolve aliases +- [Self-hosted domain trust](../security.md#self-hosted-domains), `vault_address` disclosure discipline +- [OpenBao](openbao.md), LF MPL fork; near-identical wire protocol and KV semantics +- [All backends](README.md), pick a different backend +- [Overview](/), overview + workflows diff --git a/docs/ci-cd.md b/docs/ci-cd.md deleted file mode 100644 index 2ce2d85..0000000 --- a/docs/ci-cd.md +++ /dev/null @@ -1,225 +0,0 @@ -# CI/CD Integration - -SecretEnv works in CI via the `SECRETENV_REGISTRY` environment variable. No config file is needed on the runner — set the variable once at the org or repo level and every `secretenv run` in every pipeline resolves from the right registry. - -The [CI/CD Integration overview](/ci-cd) has the headline GitHub Actions example. This page is the deeper playbook for Jenkins, GitLab, BuildKite, and CircleCI patterns — plus the per-CI-platform runner-lifecycle reasoning that doesn't fit inline in the README. - ---- - -## The Core Pattern - -```bash -SECRETENV_REGISTRY=aws-ssm:///secretenv/registry secretenv run -- ./deploy.sh -``` - -`SECRETENV_REGISTRY` accepts either a registry name (if a `config.toml` exists on the runner) or a direct URI (no config file needed). **For ephemeral CI runners, always use a direct URI.** For persistent agents, you can stage a `config.toml` once and reference it by name. - -**You're not authenticating SecretEnv** — you're authenticating the backend CLI. Set up the backend CLI's service account credentials the same way you would if you were calling it directly. SecretEnv adds no auth layer. - ---- - -## GitHub Actions - -Ephemeral runners. Each workflow run starts a fresh container. Use OIDC federation for AWS and similar — no static credentials stored in GitHub. - -The full inline example lives in the [CI/CD Integration](/ci-cd). Patterns covered there: - -- AWS OIDC + `SECRETENV_REGISTRY` env-var pattern -- Org-level `SECRETENV_REGISTRY` variable for cross-repo consistency - -### 1Password backend in GitHub Actions - -```yaml -- name: Run with 1Password secrets - env: - OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} - SECRETENV_REGISTRY: 1password-work://secretenv/registry - run: secretenv run -- ./deploy.sh -``` - -`OP_SERVICE_ACCOUNT_TOKEN` is the official 1Password mechanism for non-interactive environments. Service accounts are created in the 1Password admin console — scope them to specific vaults. - -### Vault backend via `hashicorp/vault-action` - -```yaml -- name: Authenticate to Vault - uses: hashicorp/vault-action@v3 - with: - url: https://vault.company.com - method: jwt - role: github-actions - secrets: | - secret/data/ci/runner-token token | VAULT_TOKEN - -- name: Run with Vault secrets - env: - SECRETENV_REGISTRY: vault://secret/secretenv/registry - run: secretenv run -- ./deploy.sh -``` - -The `vault-action` step exchanges GitHub's OIDC token for a short-lived `VAULT_TOKEN` that `secretenv` then inherits. - ---- - -## Jenkins - -Persistent agents — install the backend CLIs and `secretenv` once on the agent image. Set `SECRETENV_REGISTRY` as a global environment variable in **Manage Jenkins → System → Global properties**, scoped per environment if needed. - -### Agent provisioning - -```bash -# One-time on each Jenkins agent image -brew install secretenv awscli # macOS agents -# or: -curl -sfS https://secretenv.io/install.sh | sh # Linux agents - -# Validate -secretenv doctor --json | jq -r '.summary' -``` - -### Pipeline gate via doctor - -```groovy -pipeline { - agent any - environment { - SECRETENV_REGISTRY = 'aws-ssm:///secretenv/registry' - } - stages { - stage('Pre-deploy health check') { - steps { - sh 'secretenv doctor --json' - } - } - stage('Deploy') { - steps { - sh 'secretenv run -- ./deploy.sh' - } - } - } -} -``` - -`secretenv doctor` exits non-zero on any backend-Not-Ok state, failing the build before deploy. Use `--json` to feed monitoring stacks if you have a centralized log aggregator. - -### AWS auth on Jenkins - -Two clean options: - -- **EC2 instance role** — the agent assumes an IAM role; no static credentials needed. -- **Credentials Plugin** — store an `AWS Credentials` entry in Jenkins; reference via `withAWS(credentials: 'my-aws-creds') { ... }`. - -Avoid static `AWS_ACCESS_KEY_ID` env vars in Jenkins — they're long-lived and a frequent leak source. - ---- - -## GitLab CI - -Both ephemeral runner and persistent agent patterns work. GitLab's native Vault integration (via JWT) pairs cleanly with `vault://...` registries. - -```yaml -deploy: - image: ubuntu:24.04 - before_script: - - apt-get update && apt-get install -y curl jq - - curl -sfS https://secretenv.io/install.sh | sh - - curl -sfS https://aws-cli-install-url | sh # or use a runner image with aws preinstalled - script: - - export VAULT_TOKEN=$(vault write -field=token auth/jwt/login role=ci-runner jwt=$CI_JOB_JWT_V2) - - SECRETENV_REGISTRY="vault://secret/secretenv/registry" secretenv run -- ./deploy.sh -``` - -`$CI_JOB_JWT_V2` is GitLab's per-job JWT, exchanged for a short-lived Vault token at job start. - ---- - -## BuildKite - -Persistent agents. Install once on the agent image; gate via hooks. - -```bash -# /etc/buildkite-agent/hooks/pre-command -#!/usr/bin/env bash -set -e -secretenv doctor --json >/dev/null # fail fast if any backend is broken -export SECRETENV_REGISTRY="aws-ssm:///secretenv/registry" -``` - -Then in pipeline steps: - -```yaml -steps: - - command: "secretenv run -- ./deploy.sh" - label: ":rocket: Deploy" -``` - -The pre-command hook runs `doctor` for every job — cheap (sub-2s) and pre-empts the "fails 30 minutes into deploy" failure mode. - ---- - -## CircleCI - -Ephemeral runners. CircleCI's Contexts + OIDC pattern matches GitHub's flow. - -```yaml -version: 2.1 -jobs: - deploy: - docker: - - image: cimg/base:current - steps: - - checkout - - run: - name: Install SecretEnv - command: curl -sfS https://secretenv.io/install.sh | sh - - run: - name: Configure AWS via OIDC - command: | - aws configure set web_identity_token_file "$CIRCLE_OIDC_TOKEN_FILE" - aws configure set role_arn "arn:aws:iam::123456789012:role/circleci-role" - - run: - name: Deploy with secrets - environment: - SECRETENV_REGISTRY: aws-ssm:///secretenv/registry - command: secretenv run -- ./deploy.sh - -workflows: - deploy-workflow: - jobs: - - deploy: - context: aws-prod # contexts hold long-lived config -``` - ---- - -## Platform Comparison - -| Platform | Runner model | Auth pattern | Recommended | -|---|---|---|---| -| GitHub Actions | Ephemeral | OIDC federation | Set `SECRETENV_REGISTRY` at org or repo level | -| GitLab CI | Ephemeral / persistent | Native Vault JWT (`CI_JOB_JWT_V2`) or CI variables | Use Vault JWT for production deploys | -| Jenkins | Persistent | Agent IAM role / Credentials Plugin | Bake CLIs into agent images; use `doctor --json` as a pre-deploy gate | -| BuildKite | Persistent | Agent IAM role / pre-command hooks | Run `doctor` in pre-command hook | -| CircleCI | Ephemeral | Contexts + OIDC | Same shape as GitHub Actions | - ---- - -## Pre-flight: `secretenv doctor` as a Pipeline Gate - -Run `secretenv doctor --json` before any deploy step. It catches: - -- Backend CLI not installed on the runner -- Backend not authenticated (expired tokens, wrong profile) -- Registry document unreachable (network, IAM, missing path) -- Manifest references an alias that doesn't exist in the registry - -Exit code is non-zero on any not-`Ok` state. Average runtime: under 2 seconds for a 10-backend topology. Suitable as a per-job pre-step or a per-minute scheduled probe. - ---- - -## See Also - -- [CI/CD overview](/ci-cd) — headline GitHub Actions example -- [`secretenv doctor`](/reference/cli-reference-full#secretenv-doctor) — three-level health checks -- [Backends](/backends/) — per-backend auth patterns -- [Configuration reference](reference/configuration.md) — full schema diff --git a/docs/comparisons/README.md b/docs/comparisons/README.md index 1d3a1da..dd7f486 100644 --- a/docs/comparisons/README.md +++ b/docs/comparisons/README.md @@ -1,19 +1,19 @@ # Tool Comparisons -How SecretEnv fits into your ecosystem — one page per alternative, each independently maintainable. +How SecretEnv fits into your ecosystem. One page per alternative. Each page includes cases where the alternative is the right answer. -The compact comparison matrix in the [Comparisons](/comparisons/) lists SecretEnv vs `.env` / fnox / direnv. The pages below are the deep, honest dives — including the cases where the alternative is the right answer for your team. +For the compact comparison matrix: [Comparisons](/comparisons/). --- -- **[vs `.env` files](vs-dotenv.md)** — the incumbent default; SecretEnv is the direct replacement -- **[vs fnox](vs-fnox.md)** — multi-mode tool (age + KMS + cloud refs); honest mode-by-mode breakdown including the maintainer's correspondence -- **[vs direnv](vs-direnv.md)** — shell hook for env vars; complementary, not competing -- **[vs single-backend wrappers (`op run`, `doppler run`, `infisical run`)](vs-op-run.md)** — wrappers vs multi-backend orchestration -- **[vs Pulumi ESC](vs-pulumi-esc.md)** — multi-backend with SaaS dependency vs local-first -- **[vs External Secrets Operator](vs-external-secrets-operator.md)** — Kubernetes-native vs CLI-first; complementary -- **[vs sops](vs-sops.md)** — encrypted-files-in-git vs runtime injection; different problems -- **[vs Vault Enterprise / CyberArk Conjur](vs-vault-and-conjur.md)** — identity platforms; SecretEnv routes to them as backends +- **[vs `.env` files](vs-dotenv.md)**, the incumbent default; SecretEnv is the direct replacement +- **[vs fnox](vs-fnox.md)**, multi-mode tool (age + KMS + cloud refs); honest mode-by-mode breakdown including the maintainer's correspondence +- **[vs direnv](vs-direnv.md)**, shell hook for env vars; complementary, not competing +- **[vs single-backend wrappers (`op run`, `doppler run`, `infisical run`)](vs-op-run.md)**, wrappers vs multi-backend orchestration +- **[vs Pulumi ESC](vs-pulumi-esc.md)**, multi-backend with SaaS dependency vs local-first +- **[vs External Secrets Operator](vs-external-secrets-operator.md)**, Kubernetes-native vs CLI-first; complementary +- **[vs sops](vs-sops.md)**, encrypted-files-in-git vs runtime injection; different problems +- **[vs Vault Enterprise / CyberArk Conjur](vs-vault-and-conjur.md)**, identity platforms; SecretEnv routes to them as backends --- diff --git a/docs/comparisons/vs-direnv.md b/docs/comparisons/vs-direnv.md index faeed83..d15c7f9 100644 --- a/docs/comparisons/vs-direnv.md +++ b/docs/comparisons/vs-direnv.md @@ -1,47 +1,40 @@ # SecretEnv vs `direnv` -**TL;DR.** [direnv](https://direnv.net/) is an excellent shell hook that loads per-directory environment variables when you `cd` into a project. It's the right tool for shell ergonomics and per-project env layering. It is **not a secrets tool** — backend integration is manual scripting per `.envrc`, paths live in the file, and there's no standard for what a project needs or where things live. SecretEnv solves the secrets-orchestration problem direnv was never designed for. +**TL;DR.** [direnv](https://direnv.net/) is a shell hook for per-directory env vars on `cd`. **Not a secrets tool.** Backend integration is manual per `.envrc`, no standard schema. SecretEnv solves secrets orchestration direnv wasn't designed for. --- ## Where direnv shines -- Loading non-secret env vars per project (`PYTHONPATH`, `NODE_ENV`, `RUST_LOG=debug`) -- Auto-activating shell environments (Python venvs, Node version pins, direnv-flake) -- Layered env composition (`.envrc.local`, `.envrc.private`) -- Shell-native UX — no extra invocation prefix needed +- Non-secret env vars per project (`PYTHONPATH`, `NODE_ENV`, `RUST_LOG=debug`) +- Auto-activating shell environments (venvs, version pins, direnv-flake) +- Layered composition (`.envrc.local`, `.envrc.private`) +- Shell-native UX, no invocation prefix -If you don't have secrets in your `.envrc`, direnv is great. Use it. +Use direnv if you don't have secrets. --- ## Where direnv falls short for secrets -| Property | direnv | SecretEnv | +| Property | SecretEnv | direnv | |---|---|---| -| Backend integration | Manual scripting per `.envrc` (custom shell functions calling `aws`, `op`, `vault`, ...) | Native — 15 backends, declarative `secretenv.toml` | -| What's in the repo | `.envrc` with backend paths + custom shell glue | `secretenv.toml` with alias names only | -| Standard for "what this project needs" | None — every `.envrc` is a snowflake | `secretenv.toml` schema | -| Multi-environment routing | Edit `.envrc` per checkout / per branch / via if-statements | `--registry dev` / `--registry prod` | -| Backend migration | Edit every `.envrc` | One `registry set` | -| Onboarding | "Read the README, install these CLIs, source this script, hope" | `secretenv profile install ` | -| Offboarding | Manual per-backend per-developer | Revoke registry-backend access | +| Backend integration | Native: 15 backends, declarative `secretenv.toml` | Manual scripting per `.envrc` (custom shell functions calling `aws`, `op`, `vault`, ...) | +| What's in the repo | `secretenv.toml` with alias names only | `.envrc` with backend paths + custom shell glue | +| Standard for "what this project needs" | `secretenv.toml` schema | None; every `.envrc` is a snowflake | +| Multi-environment routing | `--registry dev` / `--registry prod` | Edit `.envrc` per checkout / per branch / via if-statements | +| Backend migration | One `registry set` | Edit every `.envrc` | +| Onboarding | `secretenv profile install ` | "Read the README, install these CLIs, source this script, hope" | +| Offboarding | Revoke registry-backend access | Manual per-backend per-developer | --- ## They're complementary -You can run direnv AND SecretEnv together: -- direnv handles non-secret env vars + shell activation per project -- SecretEnv handles the secrets - -A common pattern: - ```bash -# .envrc +# .envrc, non-secret env only use flake export PYTHONPATH="$PWD/src" -# secrets are NOT here — they're in secretenv.toml ``` ```bash @@ -50,7 +43,7 @@ direnv allow secretenv run -- pytest ``` -direnv loads the non-secret env on `cd`; SecretEnv injects secrets at the moment of execution. Two tools, two responsibilities. +direnv auto-loads non-secrets on `cd`. SecretEnv injects secrets at execution. Two responsibilities, one flow. --- @@ -58,14 +51,11 @@ direnv loads the non-secret env on `cd`; SecretEnv injects secrets at the moment **Pick direnv (alone) if:** - Your project has no secrets -- Your "secrets" are non-sensitive dev defaults that genuinely belong in a committed file -- You want shell-native auto-activation and don't want a runtime invocation prefix +- You want shell-native auto-activation without a runtime prefix **Pick SecretEnv (alone) if:** -- You want one tool for the whole job and don't need per-directory shell hooks -- You want CI parity (`secretenv run --` works the same in CI as on your laptop) -- You don't want to re-invent per-project shell glue for every backend you use +- You want one tool for the whole job, no per-directory shell hooks +- You want CI parity (same `secretenv run --` on laptop and in CI) **Pick both if:** -- You want direnv's shell ergonomics + SecretEnv's secrets orchestration -- This is the most common pattern in practice +- You want direnv's shell ergonomics + SecretEnv's secrets orchestration (most common) diff --git a/docs/comparisons/vs-dotenv.md b/docs/comparisons/vs-dotenv.md index 375fe38..af1c626 100644 --- a/docs/comparisons/vs-dotenv.md +++ b/docs/comparisons/vs-dotenv.md @@ -1,58 +1,47 @@ # SecretEnv vs `.env` files -**TL;DR.** `.env` files are the incumbent bad habit. They're the default that won't die because every framework loads them automatically. SecretEnv is the direct replacement for the workflow they enable: secrets fetched at runtime, injected into the child process, gone when it exits. Nothing on disk, nothing committed, nothing to forget. +**TL;DR.** `.env` files are the incumbent bad habit. SecretEnv is the direct replacement: secrets fetched at runtime, injected into the child process, gone when it exits. Nothing on disk, nothing committed, nothing to forget. --- ## What `.env` actually does -`.env` is a convention, not a tool. Every framework parses it slightly differently. The shape is always `KEY=value` lines, loaded into the process environment at startup. The file lives in the repo root. It is supposed to be `.gitignore`'d. +`.env` is a convention: `KEY=value` lines in the repo root, loaded at startup. It's supposed to be `.gitignore`'d. In practice: -- Developers commit it accidentally (the default behaviour of many `.gitignore` files is missing `.env` entirely, or they create `.env.production` and forget the entry) -- Different developers have different values — so what works for one breaks for another -- New engineers spend their first day asking "where do I get the values for `.env`?" -- Offboarding requires every developer to confirm they don't have a `.env` with secrets that pre-dated the offboarded engineer's access -- Backend migrations require updating every repo's `.env.example` template + every developer's local `.env` + every CI's secrets store +- Developers commit it by accident (many `.gitignore`s are incomplete or add `.env.production` without the entry) +- Different developers have different values, breaking continuity +- Onboarding asks "where do I get `.env`?" +- Offboarding requires confirming no developer has an old `.env` with ex-member access +- Backend migrations require updating `.env.example`, every local `.env`, and every CI secret store --- ## What SecretEnv replaces -| Property | `.env` | SecretEnv | +| Property | SecretEnv | `.env` | |---|---|---| -| Where the secrets live | Plaintext file on disk in the repo root | In your existing backends (AWS SSM, Vault, 1Password, etc.) | -| What's in the repo | The values themselves (when committed accidentally) OR a `.env.example` with placeholders | A `secretenv.toml` declaring alias names — never values, never paths | -| How values get to a process | Loaded by framework at startup | Fetched at runtime by SecretEnv, injected into child env, gone when child exits | -| Onboarding | "Ask in Slack where things live" | `secretenv profile install ` + clone any repo | -| Offboarding | Manual checklist per developer per backend per repo | Revoke registry-backend access — covers everything | -| Backend migration | Edit every `.env`, every `.env.example`, every CI variable | One `secretenv registry set` | -| Rotation | Edit every `.env` again | Transparent — next run picks up the new value from the backend | -| Disk persistence of secrets | Yes (always — that's the whole point of `.env`) | No (zeroed in-memory; never written) | +| Where the secrets live | In your existing backends (AWS SSM, Vault, 1Password, etc.) | Plaintext file on disk in the repo root | +| What's in the repo | A `secretenv.toml` declaring alias names, never values, never paths | The values themselves (when committed accidentally) OR a `.env.example` with placeholders | +| How values get to a process | Fetched at runtime by SecretEnv, injected into child env, gone when child exits | Loaded by framework at startup | +| Onboarding | `secretenv profile install ` + clone any repo | "Ask in Slack where things live" | +| Offboarding | Revoke registry-backend access, covers everything | Manual checklist per developer per backend per repo | +| Backend migration | One `secretenv registry set` | Edit every `.env`, every `.env.example`, every CI variable | +| Rotation | Transparent; next run picks up the new value from the backend | Edit every `.env` again | +| Disk persistence of secrets | No (zeroed in-memory; never written) | Yes (always, that's the whole point of `.env`) | --- ## Migration path -Going from `.env` to SecretEnv is incremental: - 1. Move secret values into a backend you already have (AWS SSM, 1Password, etc.) 2. Add aliases to your registry pointing at those values -3. Replace your `.env` with a `secretenv.toml` declaring aliases for each variable -4. Add `secretenv run --` in front of your existing dev/start/test commands -5. Delete `.env`. Delete it from `.gitignore`. Move on. - -Any developer who has already been bitten by a committed `.env` will not need a longer pitch than this. +3. Replace `.env` with a `secretenv.toml` declaring aliases +4. Prefix your dev/start/test commands with `secretenv run --` +5. Delete `.env` and its `.gitignore` entry --- ## When `.env` is still the right answer -For a single-developer project with no production backend, `.env` is fine. If you're solo, don't have an AWS account, and the worst-case "secret" is a development-only Stripe test key — `.env` is a reasonable default until the project grows up. - -The moment you have: -- A second developer -- A production environment -- A backend that already holds the secret authoritatively - -`.env` is a workflow trap. Use SecretEnv (or one of the alternatives — see [the comparison overview](/comparisons/)). +For a solo project with no production backend and only dev-only secrets (test API keys), `.env` is fine. Once you have a second developer, a production environment, or a backend that holds secrets authoritatively, it becomes a workflow trap. diff --git a/docs/comparisons/vs-external-secrets-operator.md b/docs/comparisons/vs-external-secrets-operator.md index e6d5c3c..6889afe 100644 --- a/docs/comparisons/vs-external-secrets-operator.md +++ b/docs/comparisons/vs-external-secrets-operator.md @@ -1,6 +1,6 @@ # SecretEnv vs External Secrets Operator (ESO) -**TL;DR.** [External Secrets Operator](https://external-secrets.io/) is the dominant Kubernetes-native multi-backend secrets bridge. It runs in-cluster as an operator, syncs secrets from external backends into K8s `Secret` resources. SecretEnv runs as a CLI on developer laptops, in CI runners, and as a `secretenv run` wrapper for non-K8s production workloads. **They're complementary, not competing** — many teams run both. +**TL;DR.** [External Secrets Operator](https://external-secrets.io/) is the Kubernetes-native multi-backend bridge. It runs in-cluster, syncing secrets into K8s `Secret` resources. SecretEnv runs as a CLI on laptops, in CI, and as a `secretenv run` wrapper for non-K8s workloads. **Complementary, not competing.** --- @@ -8,48 +8,48 @@ - Kubernetes operator pattern (controllers + CRDs) - `ExternalSecret` and `ClusterSecretStore` CRDs -- Pulls from AWS SSM/SM, GCP SM, Azure KV, Vault, 1Password Connect, Doppler, Infisical, and many more -- Materializes into native K8s `Secret` resources for Pod consumption +- Pulls from AWS SSM/SM, GCP SM, Azure KV, Vault, 1Password Connect, Doppler, Infisical, etc. +- Materializes into K8s `Secret` resources - Webhook + push-based sync modes -- CNCF Sandbox project; broad community adoption +- CNCF Sandbox project -If your entire deployment is Kubernetes, ESO is the right answer for in-cluster secrets. +Use ESO if your entire deployment is Kubernetes. --- ## Where ESO doesn't fit -- **Local development.** ESO runs in K8s; it doesn't help a developer running `npm start` on their laptop. -- **Non-K8s CI/CD.** GitHub Actions, GitLab CI, Jenkins, BuildKite — ESO doesn't run there. -- **Non-K8s production.** Lambda, ECS Fargate, Cloud Run, plain VMs, Heroku-style PaaS — ESO has no story. +- **Local development.** ESO doesn't help `npm start` on a laptop. +- **Non-K8s CI/CD.** GitHub Actions, GitLab CI, Jenkins, BuildKite: ESO doesn't run there. +- **Non-K8s production.** Lambda, ECS Fargate, Cloud Run, VMs, Heroku-style PaaS: no ESO story. -For these contexts you need a CLI-first tool. SecretEnv fills that gap. +For these, use a CLI-first tool like SecretEnv. --- ## Comparison -| Property | ESO | SecretEnv | +| Property | SecretEnv | ESO | |---|---|---| -| Runtime model | Kubernetes operator (in-cluster) | CLI (local + CI + general-purpose runtime) | -| Multi-backend | ✓ (broad provider list) | ✓ (15 backends) | -| Local dev | ✗ | ✓ | -| GitHub Actions / GitLab / Jenkins | ✗ (you'd run something else) | ✓ | -| In-cluster sync to K8s `Secret` | ✓ (its primary purpose) | ✗ (use ESO for this) | -| Centrally-shared mutable alias registry | ✗ (config-as-code via CRDs) | ✓ | -| Backend migration | Edit every `ExternalSecret` CRD | One `registry set` | -| Repo contains backend topology | Yes (provider name in CRD `spec.dataFrom.extract.key`) | No (alias only) | +| Runtime model | CLI (local + CI + general-purpose runtime) | Kubernetes operator (in-cluster) | +| Multi-backend | ✓ (15 backends) | ✓ (broad provider list) | +| Local dev | ✓ | ✗ | +| GitHub Actions / GitLab / Jenkins | ✓ | ✗ (you'd run something else) | +| In-cluster sync to K8s `Secret` | ✗ (use ESO for this) | ✓ (its primary purpose) | +| Centrally-shared mutable alias registry | ✓ | ✗ (config-as-code via CRDs) | +| Backend migration | One `registry set` | Edit every `ExternalSecret` CRD | +| Repo contains backend topology | No (alias only) | Yes (provider name in CRD `spec.dataFrom.extract.key`) | --- ## Running both -A common pattern for K8s shops: +A common K8s pattern: -- **ESO in-cluster** for production workloads (Pods consuming `Secret` mounts) -- **SecretEnv on dev laptops + CI** for local dev parity, build pipelines, and any non-K8s component +- **ESO in-cluster** for production Pods +- **SecretEnv on dev laptops + CI** for local dev parity and non-K8s components -Both pull from the same underlying backends (AWS SSM, Vault, 1Password, etc.) so there's a single source of truth at the backend level. Each tool handles the workflow it's good at. +Both pull from the same backends (AWS SSM, Vault, 1Password, etc.). Each handles its workflow. --- diff --git a/docs/comparisons/vs-fnox.md b/docs/comparisons/vs-fnox.md index 24a9aa5..2e76594 100644 --- a/docs/comparisons/vs-fnox.md +++ b/docs/comparisons/vs-fnox.md @@ -1,23 +1,20 @@ # SecretEnv vs fnox -**TL;DR.** [fnox](https://github.com/jdx/fnox) is a thoughtful multi-mode secrets tool that handles client-side encryption (age + KMS providers), cloud secret references, and password managers. **In KMS modes, fnox closes the persistent-key and offboarding concerns at the KMS-key level.** SecretEnv's distinction is **orthogonal to encryption**: an alias-registry layer above the backend that decouples every repo from backend URIs entirely. Migrating a secret in fnox (any mode) means editing every `fnox.toml`. In SecretEnv, it's one `registry set`. Both are valid approaches; they solve different problems and can be used together. +**TL;DR.** [fnox](https://github.com/jdx/fnox) handles client-side encryption (age + KMS providers) and cloud secret references. **In KMS modes, fnox closes persistent-key and offboarding concerns.** SecretEnv's distinction is **orthogonal**: an alias-registry layer that decouples repos from backend URIs. fnox requires editing every `fnox.toml` to migrate. SecretEnv: one `registry set`. Both valid; they layer cleanly together. + --- ## fnox at a glance -fnox is itself multi-backend. It supports several distinct modes: +fnox supports several modes: -- **Encryption providers** (ciphertext stored in committed `fnox.toml`): - - `age` — local symmetric encryption; private key on disk - - `aws-kms` — ciphertext in fnox.toml; **decryption gated by IAM on the KMS key**; no persistent key on disk - - `azure-kms` — same model via Azure Key Vault Crypto User role - - `gcp-kms` — same model via GCP KMS -- **Cloud secret-storage providers** (references stored, fetched at runtime): `aws-ps`, `aws-sm`, `azure-sm`, `gcp-sm`, `bitwarden-sm`, `vault` -- **Password managers** (references stored): `1password`, `bitwarden`, `infisical` +- **Encryption providers** (ciphertext in `fnox.toml`): `age`, `aws-kms`, `azure-kms`, `gcp-kms` +- **Cloud secret-storage** (references only): `aws-sm`, `azure-sm`, `gcp-sm`, `bitwarden-sm`, `vault` +- **Password managers**: `1password`, `bitwarden`, `infisical` - **Local storage**: `keychain`, `keepass`, `password-store`, `plain` -Sources: [fnox README](https://github.com/jdx/fnox/blob/main/README.md), [fnox AWS KMS provider docs](https://fnox.jdx.dev/providers/aws-kms), [fnox Azure KMS provider docs](https://fnox.jdx.dev/providers/azure-kms). +[fnox README](https://github.com/jdx/fnox/blob/main/README.md) · [AWS KMS docs](https://fnox.jdx.dev/providers/aws-kms) · [Azure KMS docs](https://fnox.jdx.dev/providers/azure-kms). --- @@ -25,107 +22,99 @@ Sources: [fnox README](https://github.com/jdx/fnox/blob/main/README.md), [fnox A ### fnox (age) vs SecretEnv -| Property | fnox (age) | SecretEnv | +| Property | SecretEnv | fnox (age) | |---|---|---| -| Persistent decryption key on disk | ✓ age private key | None — no decryption surface | -| Offboarding | Re-encrypt every secret without ex-member's recipient key | Revoke registry-backend access; covers every repo | -| Re-encryption needed on team change | Yes | n/a (no encryption) | -| Network required to read | No (offline OK) | Yes (backend fetch on every run) | -| Secret material in repo | Ciphertext (committed) | Nothing (alias only) | -| Backend topology in repo | Yes (provider + path) | No (alias only) | +| Persistent decryption key on disk | None, no decryption surface | ✓ age private key | +| Offboarding | Revoke registry-backend access; covers every repo | Re-encrypt every secret without ex-member's recipient key | +| Re-encryption needed on team change | n/a (no encryption) | Yes | +| Network required to read | Yes (backend fetch on every run) | No (offline OK) | +| Secret material in repo | Nothing (alias only) | Ciphertext (committed) | +| Backend topology in repo | No (alias only) | Yes (provider + path) | -**Where fnox-age wins:** offline-first workflows, gitops where the encrypted blob travels with the code, environments with no cloud dependency. +**fnox-age wins on:** offline-first workflows, gitops with encrypted blobs in git, no cloud dependency. -**Where SecretEnv wins:** team changes don't trigger re-encryption sweeps; offboarding is one IAM operation; nothing in the repo reveals topology. +**SecretEnv wins on:** no re-encryption sweeps on team changes; offboarding is one IAM operation; repo doesn't reveal topology. -### fnox (KMS — aws-kms / azure-kms / gcp-kms) vs SecretEnv +### fnox (KMS, aws-kms / azure-kms / gcp-kms) vs SecretEnv -| Property | fnox (KMS mode) | SecretEnv | +| Property | SecretEnv | fnox (KMS mode) | |---|---|---| -| Persistent decryption key on disk | None — IAM-gated KMS calls | None — no decryption surface | -| Offboarding | IAM revoke on the KMS key (one operation) | Revoke registry-backend access (one operation) | -| Re-encryption needed on KMS key rotation | Yes (manual re-encrypt all secrets) | n/a | -| Network required to read | Yes (every read = live KMS API call) | Yes (backend fetch on every run) | -| Secret material in repo | Ciphertext (committed) | Nothing (alias only) | -| Backend topology in repo | Yes (provider name, KMS key id, region) | No (alias only) | -| Cross-backend migration (e.g., move from `aws-sm` to `vault`) | Edit every `fnox.toml`; if from KMS mode, re-encrypt | One `registry set`; every repo inherits on next run | -| Centrally-shared mutable alias registry | None — config IS source of truth, per-repo | Yes — registry lives in your backend | -| Same alias name routes per environment | Profiles override per env in config | Registry cascade routes per env (registry change, not config change) | +| Persistent decryption key on disk | None, no decryption surface | None, IAM-gated KMS calls | +| Offboarding | Revoke registry-backend access (one operation) | IAM revoke on the KMS key (one operation) | +| Re-encryption needed on KMS key rotation | n/a | Yes (manual re-encrypt all secrets) | +| Network required to read | Yes (backend fetch on every run) | Yes (every read = live KMS API call) | +| Secret material in repo | Nothing (alias only) | Ciphertext (committed) | +| Backend topology in repo | No (alias only) | Yes (provider name, KMS key id, region) | +| Cross-backend migration (e.g., move from `aws-sm` to `vault`) | One `registry set`; every repo inherits on next run | Edit every `fnox.toml`; if from KMS mode, re-encrypt | +| Centrally-shared mutable alias registry | Yes, registry lives in your backend | None, config IS source of truth, per-repo | +| Same alias name routes per environment | Registry cascade routes per env (registry change, not config change) | Profiles override per env in config | -**Where fnox-KMS wins:** ciphertext-in-repo property (gitops-friendly auditability of which secrets exist); offline read once ciphertext is fetched (after key cache; not the default); committed history of encrypted secret values. +**fnox-KMS wins on:** ciphertext-in-repo (gitops auditability); offline read after key cache; committed history of encrypted values. -**Where SecretEnv wins:** cross-backend migration is one line, not a repo-wide edit; nothing in the repo reveals the backend choice; the alias registry is a single source of truth that mutates without any config change in any repo. +**SecretEnv wins on:** cross-backend migration is one line, not repo-wide edits; repo doesn't reveal backend choice; registry is a single source of truth. -### fnox (cloud-reference modes — aws-sm / vault / 1password etc.) vs SecretEnv +### fnox (cloud-reference modes, aws-sm / vault / 1password etc.) vs SecretEnv In these modes fnox stores references like `aws-sm://...` directly in `fnox.toml`. There's no encryption involved. -| Property | fnox (reference mode) | SecretEnv | +| Property | SecretEnv | fnox (reference mode) | |---|---|---| | Persistent decryption key on disk | None | None | -| Offboarding | Revoke backend access (covers every repo using that backend) | Revoke registry-backend access (covers every repo) | -| Backend topology in repo | Yes (the reference URI) | No (alias only) | -| Cross-backend migration | Edit every `fnox.toml` to change the reference | One `registry set` | -| Centrally-shared mutable alias registry | None | Yes | +| Offboarding | Revoke registry-backend access (covers every repo) | Revoke backend access (covers every repo using that backend) | +| Backend topology in repo | No (alias only) | Yes (the reference URI) | +| Cross-backend migration | One `registry set` | Edit every `fnox.toml` to change the reference | +| Centrally-shared mutable alias registry | Yes | None | -**Where fnox-reference-mode wins:** simpler mental model when topology is intentionally public (small teams, internal-only repos); no extra registry to manage. +**fnox-reference-mode wins on:** simpler mental model for public topology (small teams); no registry to manage. -**Where SecretEnv wins:** the same indirection point that makes migration cheap (registry set); aliases are environment-agnostic, routed per-env via `--registry`. +**SecretEnv wins on:** indirection makes migration cheap; aliases route per-env via `--registry`. --- -## What's actually different (the orthogonal claim) - -The distinction between SecretEnv and fnox is **not encryption posture**. It's **alias indirection**. +## What's actually different -In fnox, the `fnox.toml` in each repo is the source of truth. It says "this secret comes from `aws-sm://prod-account/myapp/stripe`" (or, in KMS mode, "this is the ciphertext, decrypt it via this KMS key"). When you want to change where Stripe lives, you edit every `fnox.toml` in every repo that mentions it. +The distinction is **not encryption posture**. It's **alias indirection**. -In SecretEnv, the `secretenv.toml` says "this secret is `secretenv://stripe-key`." That's all. The actual location lives in a separate registry document, in a backend you control. When you want to change where Stripe lives, you run `secretenv registry set stripe-key ` once. Every repo picks it up on the next run. +In fnox, `fnox.toml` is source of truth. It says "this secret is `aws-sm://prod-account/myapp/stripe`" (or KMS ciphertext). To move Stripe, edit every repo's `fnox.toml`. -This is a difference in **architecture**, not in security. It's why you might choose to use both: -- SecretEnv to manage the alias-to-URI mapping at the org level -- fnox locally for offline-first dev workflows where the developer wants ciphertext-in-repo +In SecretEnv, `secretenv.toml` says "this secret is `secretenv://stripe-key`." The location lives in a separate registry. To move Stripe, run `secretenv registry set stripe-key ` once. Every repo picks it up. -They are layered, not competing. +**Architecture difference, not security difference.** You can layer them: +- SecretEnv for org-level alias-to-URI mapping +- fnox locally for offline-first dev with ciphertext-in-repo --- -## Operator's correspondence with the fnox maintainer +## Maintainer correspondence -For accurate context: the @TechAlchemistX's exchange with @jdx (fnox maintainer) on a prior version of this comparison surfaced that the v1 SecretEnv README modelled only fnox-age mode and missed fnox-KMS. The maintainer's clarification: +An earlier version of this comparison modelled only fnox-age and missed fnox-KMS. The @jdx (fnox maintainer) clarified: -> "I am the maintainer of fnox. This is only true if you use the encryption providers. If you don't, nothing is encrypted obviously. Your doc also doesn't seem to take into account my preferred way of using it with KMS that solves a lot of the problems mentioned." +> "This is only true if you use the encryption providers. Your doc doesn't account for KMS-mode, which solves many problems mentioned." -TechAlchemistX's reply: +@TechAlchemistX's reply: -> "Fair point, the table only models age-mode and that's a real miss. KMS-mode closes a lot of what I flagged (offboarding via IAM revocation on the KMS key, no persistent decryption key on disk, transparent rotation through aws-sm). Separately, I still do think secretenv's registry indirection adds something orthogonal to encryption (cross-backend migration is one line vs editing every fnox.toml), but that's a different conversation." +> "Fair point. KMS-mode closes offboarding via IAM revocation, no persistent key on disk, transparent rotation. SecretEnv's registry indirection is orthogonal. Cross-backend migration is one line vs editing every fnox.toml." -This page is the corrected version. If anything here misrepresents fnox in any mode, please open an issue against [TechAlchemistX/secretenv](https://github.com/TechAlchemistX/secretenv/issues) — accuracy matters more than rhetoric. +If anything here misrepresents fnox, please open an issue: [TechAlchemistX/secretenv issues](https://github.com/TechAlchemistX/secretenv/issues). Accuracy matters more. --- ## When to pick which **Pick fnox if:** -- You want client-side encryption with secrets-in-config (committed ciphertext) -- You want gitops auditability of which encrypted secret existed when -- You're already in the `mise` ecosystem and value tight integration -- KMS-mode offboarding via IAM revocation matches your security model +- You want client-side encryption (committed ciphertext) +- You want gitops auditability of encrypted secrets +- You're in the `mise` ecosystem +- KMS-mode IAM revocation matches your security model **Pick SecretEnv if:** -- You want to remove backend topology from every repo -- You expect to migrate secrets across backends and want a one-line operation -- You manage a centrally-shared alias registry across many teams / repos -- Your `secretenv.toml` should never know which backend a secret lives in +- You want to remove backend topology from repos +- You expect cross-backend secret migration +- You manage a centrally-shared alias registry +- Repos should never know which backend holds a secret **Pick both if:** -- You want fnox's encryption-in-config locally + SecretEnv's registry indirection for org-wide routing — they layer cleanly. +- You want fnox's local encryption + SecretEnv's org-wide registry; they layer cleanly. --- -## Sources - -- fnox README: https://github.com/jdx/fnox/blob/main/README.md -- fnox AWS KMS provider: https://fnox.jdx.dev/providers/aws-kms -- fnox Azure KMS provider: https://fnox.jdx.dev/providers/azure-kms -- fnox GCP KMS provider: https://fnox.jdx.dev/providers/gcp-kms diff --git a/docs/comparisons/vs-op-run.md b/docs/comparisons/vs-op-run.md index 17609f9..00dc6e1 100644 --- a/docs/comparisons/vs-op-run.md +++ b/docs/comparisons/vs-op-run.md @@ -1,47 +1,45 @@ # SecretEnv vs single-backend wrappers (`op run`, `doppler run`, `infisical run`) -**TL;DR.** `op run` (1Password), `doppler run` (Doppler), `infisical run` (Infisical), and similar single-backend wrappers are **excellent if their backend is your only secrets backend.** They're tightly integrated with the backend's UI, ergonomics, and identity model. SecretEnv is for the case where your team uses two or more — at which point the single-backend wrappers stop helping and start hurting (one wrapper per repo, hardcoded backend URIs, no migration story). +**TL;DR.** `op run`, `doppler run`, `infisical run`, and similar single-backend wrappers are excellent if that backend is your only source. SecretEnv is for teams with 2+ backends, at which point single-backend wrappers create friction (one wrapper per repo, hardcoded URIs, no migration path). --- ## What single-backend wrappers do well -- Native UX — `op://` URIs, `doppler.yaml` config, `infisical secrets get` all feel like part of their parent product -- Tight identity integration — 1Password's biometric unlock, Doppler's fine-grained access tokens, Infisical's machine identities -- First-class web UIs for managing secrets and access -- Vendor support, audit logs, rotation orchestration as a service (where applicable) +- Native UX (`op://` URIs, `doppler.yaml` config) integrated with the parent product +- Tight identity integration (biometric unlock, fine-grained tokens, machine identities) +- Web UIs for secret management +- Vendor support, audit logs, rotation orchestration -If you're 100% on one backend and don't expect that to change, the matching wrapper is probably the right answer. +If you're 100% on one backend with no migration expected, the matching wrapper is the right answer. --- ## Where they fall short for multi-backend teams -The fundamental constraint: **single-backend wrappers assume they are the only wrapper.** When you have AWS SSM for infra credentials AND 1Password for team secrets AND Vault for service tokens, you need either: +The fundamental constraint: **single-backend wrappers assume they are the only wrapper.** When you have AWS SSM for infra, 1Password for team secrets, and Vault for service tokens, you need either: -- Three different wrappers, manually composed in shell scripts per repo +- Three wrappers, manually composed per repo - A homegrown orchestration layer - A multi-backend tool (SecretEnv, fnox, Pulumi ESC) -| Property | `op run` (and similar) | SecretEnv | +| Property | SecretEnv | `op run` (and similar) | |---|---|---| -| Multi-backend in one invocation | No (single backend) | Yes (15 backends, parallel fetch) | -| URIs in repo | `op://vault/item/field` (or `dp.` env vars) | `secretenv://alias-name` (no backend info) | -| Migrating from this backend to another | Touch every repo that uses the wrapper | One `registry set` | -| Multi-account in one workflow | Limited (depends on wrapper) | Native — name backend instances per account | -| CI/CD pattern | Wrapper-specific | One pattern (`SECRETENV_REGISTRY` env var) for all backends | +| Multi-backend in one invocation | Yes (15 backends, parallel fetch) | No (single backend) | +| URIs in repo | `secretenv://alias-name` (no backend info) | `op://vault/item/field` (or `dp.` env vars) | +| Migrating from this backend to another | One `registry set` | Touch every repo that uses the wrapper | +| Multi-account in one workflow | Native: name backend instances per account | Limited (depends on wrapper) | +| CI/CD pattern | One pattern (`SECRETENV_REGISTRY` env var) for all backends | Wrapper-specific | --- ## What SecretEnv loses by being multi-backend -Honest: +- **No backend-specific UX polish.** SecretEnv wraps CLIs; it doesn't add anything 1Password's browser extension or Doppler's web UI provide. +- **No vendor support.** Breaks go to community channels, not 1Password Support. +- **Generic error messages.** SecretEnv surfaces what the wrapped CLI says. -- **No backend-specific UX polish.** SecretEnv talks to `op` / `doppler` / `infisical` via their CLIs; it doesn't add anything 1Password's browser extension or Doppler's web UI provide. -- **No vendor support.** If something breaks at the wrapper level, you're on community channels, not 1Password Support. -- **Generic error messages.** `op` knows what an "invalid item reference" is; SecretEnv mostly surfaces what the wrapped CLI says. - -If your only backend is 1Password and you love `op` — keep using `op run`. SecretEnv doesn't make you switch. +If your only backend is 1Password and you love `op run`, keep using it. SecretEnv is not a replacement. --- diff --git a/docs/comparisons/vs-pulumi-esc.md b/docs/comparisons/vs-pulumi-esc.md index a245ca1..1275d57 100644 --- a/docs/comparisons/vs-pulumi-esc.md +++ b/docs/comparisons/vs-pulumi-esc.md @@ -1,6 +1,6 @@ # SecretEnv vs Pulumi ESC -**TL;DR.** [Pulumi ESC](https://www.pulumi.com/docs/esc/) (Environments, Secrets, and Configuration) is the closest architectural cousin to SecretEnv: both are multi-backend, both abstract storage from consumption, both let you compose secrets across providers. **The differentiator is local-first vs SaaS-first.** Pulumi ESC requires a Pulumi Cloud account and runs orchestration through Pulumi's hosted service. SecretEnv runs entirely on your machine — no SaaS gate, no external dependency for a core workflow tool. +**TL;DR.** [Pulumi ESC](https://www.pulumi.com/docs/esc/) is the closest cousin: both multi-backend, both abstract storage from consumption. **The differentiator: local-first vs SaaS-first.** ESC requires Pulumi Cloud. SecretEnv runs on your machine: no SaaS gate for core workflows. --- @@ -8,40 +8,38 @@ - Multi-backend abstraction (AWS, Azure, GCP, Vault, 1Password, Doppler, ...) - Hosted environment definitions in Pulumi Cloud -- Policy-as-code via Pulumi's policy engine -- Hosted audit log -- Drift detection across environments -- Tight integration with Pulumi IaC +- Policy-as-code engine, hosted audit log, drift detection +- Tight Pulumi IaC integration -It is genuinely well-architected. If your org is already deep in the Pulumi ecosystem, ESC is a natural extension. +If your org uses Pulumi extensively, ESC is a natural extension. --- ## The SaaS dependency -Pulumi ESC's environment definitions live in Pulumi Cloud. To use it you need: -- A Pulumi Cloud account (free tier exists; usage-priced beyond) -- Network access to `api.pulumi.com` from every machine that resolves secrets -- Trust in Pulumi as a service-availability dependency for your local-dev workflow +ESC environment definitions live in Pulumi Cloud. You need: +- A Pulumi Cloud account (free tier + usage-based pricing) +- Network access to `api.pulumi.com` from every machine +- Trust in Pulumi's availability for your local-dev workflow -For some teams that's fine. For others — especially security-conscious teams that won't add SaaS for a core workflow tool, or air-gapped environments — it's a non-starter. +Some teams accept this. Others, especially security-conscious or air-gapped teams, won't add SaaS to core workflows. --- ## Comparison -| Property | Pulumi ESC | SecretEnv | +| Property | SecretEnv | Pulumi ESC | |---|---|---| | Multi-backend orchestration | ✓ | ✓ | -| Local-first | ✗ (requires Pulumi Cloud) | ✓ | -| Backend topology hidden from repos | ✓ (via ESC environment refs) | ✓ (via alias registry) | -| Policy engine (policy-as-code) | ✓ | ✗ (delegate to backend's IAM/ACLs) | -| Hosted audit log | ✓ | ✗ (delegate to backend) | -| Drift detection across environments | ✓ | ✗ | -| UI for managing environments | ✓ (Pulumi Cloud UI) | ✗ (config files + CLI only) | -| Cost | Free tier; paid above | Free (AGPL) | -| Network required to read | Yes (Pulumi Cloud + backend) | Yes (backend only) | -| Pulumi IaC integration | ✓ (native) | None | +| Local-first | ✓ | ✗ (requires Pulumi Cloud) | +| Backend topology hidden from repos | ✓ (via alias registry) | ✓ (via ESC environment refs) | +| Policy engine (policy-as-code) | ✗ (delegate to backend's IAM/ACLs) | ✓ | +| Hosted audit log | ✗ (delegate to backend) | ✓ | +| Drift detection across environments | ✗ | ✓ | +| UI for managing environments | ✗ (config files + CLI only) | ✓ (Pulumi Cloud UI) | +| Cost | Free (AGPL) | Free tier; paid above | +| Network required to read | Yes (backend only) | Yes (Pulumi Cloud + backend) | +| Pulumi IaC integration | None | ✓ (native) | --- diff --git a/docs/comparisons/vs-sops.md b/docs/comparisons/vs-sops.md index 113448f..9581509 100644 --- a/docs/comparisons/vs-sops.md +++ b/docs/comparisons/vs-sops.md @@ -1,34 +1,34 @@ # SecretEnv vs sops -**TL;DR.** [sops](https://github.com/getsops/sops) (Mozilla / CNCF) is the canonical tool for **encrypted-file-at-rest in git**. It encrypts YAML/JSON/dotenv/binary files using KMS, age, GPG, or HashiCorp Vault transit; the encrypted blob lives in your repo. SecretEnv is a runtime-injection tool that stores nothing. **They solve different problems and can be used together.** +**TL;DR.** [sops](https://github.com/getsops/sops) encrypts files in git (KMS, age, GPG, Vault transit). SecretEnv injects secrets at runtime. **Different problems, complementary tools.** --- ## sops at a glance -- File-level encryption (whole file or per-key for structured formats) -- Key sources: AWS KMS, GCP KMS, Azure Key Vault, age, GPG, HashiCorp Vault transit -- Encrypted file is committed to git; reviewers see the structure but not the values -- `sops decrypt` returns plaintext; `sops exec-env` runs a command with decrypted env -- Standard for gitops-style secret management (Flux, ArgoCD via sops integration) +- File-level encryption (whole file or per-key in structured formats) +- Key sources: AWS KMS, GCP KMS, Azure Key Vault, age, GPG, Vault transit +- Encrypted file lives in git; structure visible, values encrypted +- `sops decrypt` returns plaintext; `sops exec-env` runs commands with decrypted env +- Standard for gitops workflows (Flux, ArgoCD) -If your workflow needs **encrypted secrets in git** (the gitops pattern), sops is the right answer. +Use sops if you need encrypted secrets in git. --- ## Comparison -| Property | sops | SecretEnv | +| Property | SecretEnv | sops | |---|---|---| -| Where secrets live | Encrypted file in your repo | In your existing backends | -| Repo contains secret material | Yes (ciphertext) | No (alias only) | -| Backend topology in repo | Yes (KMS key ARN, sops metadata) | No | -| Multi-backend orchestration in one invocation | No (one sops file = one set of recipients) | Yes (15 backends) | -| Cross-backend migration | Re-encrypt the file with the new recipients | One `registry set` | -| Offboarding (specific user) | Re-encrypt every sops file without the ex-member's recipient key | Revoke registry-backend access | -| Centrally-shared mutable alias registry | None | Yes | -| Network required to read | Only for KMS modes | Yes (every read) | -| Gitops-friendly (encrypted file in git) | ✓ (its primary use case) | ✗ (not the model) | +| Where secrets live | In your existing backends | Encrypted file in your repo | +| Repo contains secret material | No (alias only) | Yes (ciphertext) | +| Backend topology in repo | No | Yes (KMS key ARN, sops metadata) | +| Multi-backend orchestration in one invocation | Yes (15 backends) | No (one sops file = one set of recipients) | +| Cross-backend migration | One `registry set` | Re-encrypt the file with the new recipients | +| Offboarding (specific user) | Revoke registry-backend access | Re-encrypt every sops file without the ex-member's recipient key | +| Centrally-shared mutable alias registry | Yes | None | +| Network required to read | Yes (every read) | Only for KMS modes | +| Gitops-friendly (encrypted file in git) | ✗ (not the model) | ✓ (its primary use case) | --- @@ -36,10 +36,10 @@ If your workflow needs **encrypted secrets in git** (the gitops pattern), sops i A common pattern: -- **sops** handles encrypted-config-in-git for things that genuinely should be versioned with the code (e.g., per-environment K8s manifest secrets, deployment configs that reference external secrets) -- **SecretEnv** handles runtime secret injection for dev + CI + general-purpose workloads +- **sops** for encrypted config in git (per-environment K8s manifests, deployment configs) +- **SecretEnv** for runtime injection in dev + CI + general workloads -These don't conflict. sops controls "what config did this deployment use, encrypted in git." SecretEnv controls "fetch fresh secrets from authoritative backends and inject at runtime." +sops controls versioned, encrypted config. SecretEnv fetches fresh secrets from backends at runtime. --- diff --git a/docs/comparisons/vs-vault-and-conjur.md b/docs/comparisons/vs-vault-and-conjur.md index 5f80db6..2aa144c 100644 --- a/docs/comparisons/vs-vault-and-conjur.md +++ b/docs/comparisons/vs-vault-and-conjur.md @@ -1,32 +1,32 @@ # SecretEnv vs Vault Enterprise / CyberArk Conjur (identity platforms) -**TL;DR.** HashiCorp Vault Enterprise and CyberArk Conjur are full **identity platforms** — they provide policy engines, dynamic secrets, lease management, certificate authorities, RBAC, multi-tenancy, audit logs, HSM integration, and more. SecretEnv runs **on top of** either of them as one of many backends. They're not competitors; they're a category SecretEnv routes to. +**TL;DR.** Vault Enterprise and CyberArk Conjur are **identity platforms** with policy engines, dynamic secrets, lease management, CAs, RBAC, audit logs, HSM integration. SecretEnv runs **on top of** them as one of 15 backends. Not competitors; a category SecretEnv routes to. --- ## What Vault Enterprise / Conjur do -These are **secrets-and-identity platforms** with capabilities far beyond what a CLI orchestration tool covers: +**Secrets-and-identity platforms** with capabilities a CLI orchestration tool doesn't: -- **Policy engines** — fine-grained ACLs, Sentinel (Vault) or Conjur policy language -- **Dynamic secrets** — short-lived database credentials, AWS STS tokens, SSH certs generated on demand -- **Lease management** — automatic renewal and revocation cycles -- **Audit logs** — tamper-evident, hash-chained records of every access -- **Multi-tenancy** — namespaces (Vault), accounts (Conjur) -- **HSM integration** — root key sealed in hardware -- **PKI / Certificate authority** — issue and rotate certs -- **Transit encryption** — encryption-as-a-service for app-managed data -- **Vendor support** — enterprise SLAs, professional services +- **Policy engines**: Sentinel (Vault) or Conjur policy language +- **Dynamic secrets**: short-lived DB creds, STS tokens, SSH certs on demand +- **Lease management**: automatic renewal and revocation +- **Audit logs**: tamper-evident, hash-chained +- **Multi-tenancy**: namespaces (Vault), accounts (Conjur) +- **HSM integration**: hardware-sealed root keys +- **PKI / Certificate authority**: issue and rotate certs +- **Transit encryption**: encryption-as-a-service +- **Vendor support**: enterprise SLAs, professional services -If you need any of the above, you need Vault Enterprise or Conjur. SecretEnv does not replace them. +SecretEnv does not replace these capabilities. --- ## How SecretEnv coexists -SecretEnv has `vault` and `conjur` backends. They're CLI wrappers — `vault kv get` and `conjur variable get` under the hood. SecretEnv treats Vault/Conjur as one of 15 backends it can fetch from. +SecretEnv has `vault` and `conjur` backends (`vault kv get`, `conjur variable get` under the hood). SecretEnv treats Vault/Conjur as one of 15 backends. -A typical multi-backend org: +Example multi-backend org: ```toml # ~/.config/secretenv/config.toml @@ -47,24 +47,24 @@ op_account = "acme.1password.com" sources = ["vault-prod://secret/secretenv/registry"] ``` -The registry itself can live in Vault (or anywhere). Aliases route to Vault for some secrets, AWS SSM for others, 1Password for others. SecretEnv handles the orchestration; Vault handles its own piece. +The registry can live in Vault (or anywhere). Aliases route to Vault, AWS SSM, 1Password selectively. SecretEnv orchestrates; Vault handles policy and lease management. --- ## Comparison -| Property | Vault Enterprise / Conjur | SecretEnv | +| Property | SecretEnv | Vault Enterprise / Conjur | |---|---|---| -| Multi-backend orchestration across heterogeneous sources | ✗ (Vault IS the source) | ✓ | -| Policy engine | ✓ (Sentinel / Conjur policy) | ✗ (delegate to backend ACLs) | -| Dynamic secrets / lease management | ✓ | ✗ | -| Audit log | ✓ (hash-chained) | ✗ (delegate to backend logs) | -| HSM integration | ✓ | ✗ | -| PKI / Certificate authority | ✓ | ✗ | -| Centrally-shared mutable alias registry | ✗ | ✓ | -| Backend migration (cross-tool) | ✗ (Vault is the only backend) | ✓ | -| Local-dev ergonomics | Heavy (Vault server + auth + policy) | Lightweight (CLI only) | -| Cost | Enterprise license | Free (AGPL) | +| Multi-backend orchestration across heterogeneous sources | ✓ | ✗ (Vault IS the source) | +| Policy engine | ✗ (delegate to backend ACLs) | ✓ (Sentinel / Conjur policy) | +| Dynamic secrets / lease management | ✗ | ✓ | +| Audit log | ✗ (delegate to backend logs) | ✓ (hash-chained) | +| HSM integration | ✗ | ✓ | +| PKI / Certificate authority | ✗ | ✓ | +| Centrally-shared mutable alias registry | ✓ | ✗ | +| Backend migration (cross-tool) | ✓ | ✗ (Vault is the only backend) | +| Local-dev ergonomics | Lightweight (CLI only) | Heavy (Vault server + auth + policy) | +| Cost | Free (AGPL) | Enterprise license | --- @@ -83,4 +83,4 @@ The registry itself can live in Vault (or anywhere). Aliases route to Vault for - You don't need (or already have separately) policy engines and audit infrastructure **Run both if:** -- Vault is your primary identity platform AND you also use AWS SSM, 1Password, or others — SecretEnv routes across all of them; Vault remains the policy / lease authority for what lives in Vault. +- Vault is your primary identity platform AND you also use AWS SSM, 1Password, or others. SecretEnv routes across all of them; Vault remains the policy / lease authority for what lives in Vault. diff --git a/docs/guides/ci-cd.md b/docs/guides/ci-cd.md new file mode 100644 index 0000000..6a00b99 --- /dev/null +++ b/docs/guides/ci-cd.md @@ -0,0 +1,218 @@ +# CI/CD Integration + +SecretEnv works in CI via the `SECRETENV_REGISTRY` environment variable. No config file needed. Set it once at org or repo level and every `secretenv run` resolves from the right registry. + +The [README's CI/CD section](../../README.md#cicd) has the headline GitHub Actions example; this page is the per-platform deep dive. + +--- + +## Core Pattern + +```bash +SECRETENV_REGISTRY=aws-ssm:///secretenv/registry secretenv run -- ./deploy.sh +``` + +`SECRETENV_REGISTRY` accepts a registry name (if `config.toml` exists) or a direct URI (no config needed). **For ephemeral runners, use a direct URI.** Persistent agents can stage `config.toml` once and reference it by name. + +**You authenticate the backend CLI, not SecretEnv.** Set up the backend service account the same way you'd use it directly. SecretEnv adds no auth layer. + +--- + +## GitHub Actions + +Ephemeral runners use OIDC federation (no static credentials in GitHub). + +The full GitHub Actions example is in the [README](../../README.md#cicd). Key patterns: +- AWS OIDC + `SECRETENV_REGISTRY` +- Org-level `SECRETENV_REGISTRY` for consistency + +### 1Password in GitHub Actions + +```yaml +- name: Run with 1Password secrets + env: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + SECRETENV_REGISTRY: 1password-work://secretenv/registry + run: secretenv run -- ./deploy.sh +``` + +Create service accounts in the 1Password admin console and scope them to specific vaults. + +### Vault via `hashicorp/vault-action` + +```yaml +- name: Authenticate to Vault + uses: hashicorp/vault-action@v3 + with: + url: https://vault.company.com + method: jwt + role: github-actions + secrets: | + secret/data/ci/runner-token token | VAULT_TOKEN + +- name: Run with Vault secrets + env: + SECRETENV_REGISTRY: vault://secret/secretenv/registry + run: secretenv run -- ./deploy.sh +``` + +`vault-action` exchanges GitHub's OIDC token for a short-lived `VAULT_TOKEN` that `secretenv` inherits. + +--- + +## Jenkins + +Persistent agents. Install backend CLIs and `secretenv` once on the agent image. Set `SECRETENV_REGISTRY` in **Manage Jenkins → System → Global properties**. + +### Agent provisioning + +```bash +# One-time on each Jenkins agent image +brew install secretenv awscli # macOS agents +# or: +curl -sfS https://secretenv.io/install.sh | sh # Linux agents + +# Validate +secretenv doctor --json | jq -r '.summary' +``` + +### Pipeline with health gate + +```groovy +pipeline { + agent any + environment { + SECRETENV_REGISTRY = 'aws-ssm:///secretenv/registry' + } + stages { + stage('Pre-deploy health check') { + steps { + sh 'secretenv doctor --json' + } + } + stage('Deploy') { + steps { + sh 'secretenv run -- ./deploy.sh' + } + } + } +} +``` + +`secretenv doctor` exits non-zero on any backend not-Ok state, failing the build before deploy. Use `--json` for monitoring integration. + +### AWS auth on Jenkins + +Use **EC2 instance role** (agent assumes IAM role) or **Credentials Plugin** (`withAWS(credentials: 'my-aws-creds') { ... }`). Avoid static `AWS_ACCESS_KEY_ID`. They leak frequently. + +--- + +## GitLab CI + +Both ephemeral and persistent agents work. GitLab's Vault JWT integration pairs with `vault://...` registries. + +```yaml +deploy: + image: ubuntu:24.04 + before_script: + - apt-get update && apt-get install -y curl jq + - curl -sfS https://secretenv.io/install.sh | sh + - curl -sfS https://aws-cli-install-url | sh # or use a prebuilt runner image + script: + - export VAULT_TOKEN=$(vault write -field=token auth/jwt/login role=ci-runner jwt=$CI_JOB_JWT_V2) + - SECRETENV_REGISTRY="vault://secret/secretenv/registry" secretenv run -- ./deploy.sh +``` + +`$CI_JOB_JWT_V2` is exchanged for a short-lived Vault token at job start. + +--- + +## BuildKite + +Persistent agents. Install once, gate via hooks. + +```bash +# /etc/buildkite-agent/hooks/pre-command +#!/usr/bin/env bash +set -e +secretenv doctor --json >/dev/null +export SECRETENV_REGISTRY="aws-ssm:///secretenv/registry" +``` + +Pipeline steps: + +```yaml +steps: + - command: "secretenv run -- ./deploy.sh" + label: ":rocket: Deploy" +``` + +The pre-command hook runs `doctor` for every job, cheap (sub-2s) and catches failures before deploy. + +--- + +## CircleCI + +Ephemeral runners. Contexts + OIDC pattern similar to GitHub Actions. + +```yaml +version: 2.1 +jobs: + deploy: + docker: + - image: cimg/base:current + steps: + - checkout + - run: + name: Install SecretEnv + command: curl -sfS https://secretenv.io/install.sh | sh + - run: + name: Configure AWS via OIDC + command: | + aws configure set web_identity_token_file "$CIRCLE_OIDC_TOKEN_FILE" + aws configure set role_arn "arn:aws:iam::123456789012:role/circleci-role" + - run: + name: Deploy with secrets + environment: + SECRETENV_REGISTRY: aws-ssm:///secretenv/registry + command: secretenv run -- ./deploy.sh + +workflows: + deploy-workflow: + jobs: + - deploy: + context: aws-prod +``` + +--- + +## Platform Comparison + +| Platform | Runner model | Auth pattern | Recommended | +|---|---|---|---| +| GitHub Actions | Ephemeral | OIDC federation | Set `SECRETENV_REGISTRY` at org or repo level | +| GitLab CI | Ephemeral / persistent | Native Vault JWT (`CI_JOB_JWT_V2`) or CI variables | Use Vault JWT for production deploys | +| Jenkins | Persistent | Agent IAM role / Credentials Plugin | Bake CLIs into agent images; use `doctor --json` as a pre-deploy gate | +| BuildKite | Persistent | Agent IAM role / pre-command hooks | Run `doctor` in pre-command hook | +| CircleCI | Ephemeral | Contexts + OIDC | Same shape as GitHub Actions | + +--- + +## Pipeline Gate: `secretenv doctor` + +Run `secretenv doctor --json` before any deploy step. It catches: +- Backend CLI missing +- Backend not authenticated +- Registry unreachable +- Missing aliases + +Exit code is non-zero on any not-Ok state. Avg runtime: under 2s for 10 backends. Use as a per-job pre-step or scheduled probe. + +--- + +## See Also + +- [CI/CD overview (README)](../../README.md#cicd) +- [`secretenv doctor`](/reference/cli-reference-full#secretenv-doctor) +- [Backends](/backends/) +- [Configuration reference](../reference/configuration.md) diff --git a/docs/guides/rollout.md b/docs/guides/rollout.md new file mode 100644 index 0000000..d4b7dbe --- /dev/null +++ b/docs/guides/rollout.md @@ -0,0 +1,44 @@ +# Rolling out SecretEnv across your org + +Adopting SecretEnv at scale follows a predictable sequence. Each stage is independently reversible, so you can pause or roll back at any point without stranding a team. + +## The six stages + +1. **Discovery.** Inventory the secret backends already in use. Pick a registry host: any backend the platform team controls and every engineer can reach (AWS SSM, Vault, 1Password, Cloudflare KV all work well). + +2. **Pilot.** One team, one registry, a hand-written `config.toml`. Validate end to end with `secretenv doctor --extensive`. + +3. **Author the org profile.** Publish a single `acme-defaults.toml` to an HTTPS-reachable host: a CDN, an internal artifact store, or `secretenv.io/profiles`. A profile is a TOML fragment of `[registries.*]` and `[backends.*]` blocks. + +4. **Org-wide install.** Hand out the one-line installer. New joiners get the right config from minute one. Local `config.toml` always wins where keys overlap, so a profile can never silently break a developer's setup. + +5. **CI integration.** Set `SECRETENV_REGISTRY` at org-level CI variable scope, and add `secretenv doctor --json` as a pre-deploy gate. + +6. **Offboarding playbook.** Codify "revoke registry-backend access" in your IAM runbook. One operation; covers every repo at once. + +## Profiles: how a team converges every machine + +Profiles let a platform team push its intended config to every developer without ever logging into anyone's laptop. The developer pulls; there is no server-pushed update channel. + +```bash +# Install (or re-install with updated metadata) +secretenv profile install acme-corp --url https://internal.acme.com/secretenv/acme-corp.toml + +# Update, ETag-conditional re-fetch; reports up-to-date or refreshed +secretenv profile update + +secretenv profile list +secretenv profile uninstall acme-corp +``` + +Profiles are **additive merges, never overrides**. Local `config.toml` always wins. Self-hosted and air-gapped orgs point at their own base with `SECRETENV_PROFILE_URL`. A hard 1 MiB size cap per profile guards against a compromised distribution. + +**Rollback** is a profile re-publish followed by `secretenv profile update` across the fleet. Again, the developer pulls. + +Full profile reference: [Profiles](../reference/profiles.md). + +## See also + +- [CI/CD integration](ci-cd.md): per-platform runner patterns +- [The three-file model](../reference/three-file-model-deep.md): why config, manifest, and registry are separate +- [`secretenv doctor`](../reference/cli-reference-full.md#secretenv-doctor): the validation gate referenced throughout diff --git a/docs/index.md b/docs/index.md index 6a8a72a..3f9e48e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,7 +19,7 @@ hero: features: - icon: 🗂 title: Three files, three owners - details: 'secretenv.toml in the repo lists alias names. Machine config names backend instances. The registry — inside a backend you control — maps aliases to backend URIs.' + details: 'secretenv.toml in the repo lists alias names. Machine config names backend instances. The registry, inside a backend you control, maps aliases to backend URIs.' link: /reference/three-file-model-deep linkText: Read the model @@ -31,14 +31,14 @@ features: - icon: 🔁 title: Migrate without touching repos - details: 'Move a secret from 1Password to Vault: secretenv registry set. Every repo picks it up on the next run. No PRs, no re-encryption, no coordination.' + details: 'Move a secret from 1Password to Vault in one command: secretenv registry migrate. Every repo picks it up on the next run. No PRs, no re-encryption, no coordination.' link: /reference/registry linkText: Registry workflow - icon: ⚙️ title: CI-first integration details: 'Set SECRETENV_REGISTRY once. OIDC patterns for GitHub Actions, GitLab, Jenkins, BuildKite, CircleCI. doctor --json as a pre-deploy gate.' - link: /ci-cd + link: /guides/ci-cd linkText: CI/CD guide - icon: 🧭 @@ -107,20 +107,20 @@ Secrets are fetched from whichever backends the registry points to, injected as The **manifest** says *what* the project needs. The **machine config** says *which backends* this machine has. The **registry** says *which backend URI* each alias resolves to. ::: tip Why the indirection? -Repos commit alias names like `secretenv://stripe-key`. The registry maps each alias to its current backend location. Migrating from 1Password to Vault becomes one `secretenv registry set` — every repo picks it up on the next run, no PRs. +Repos commit alias names like `secretenv://stripe-key`. The registry maps each alias to its current backend location. Migrating from 1Password to Vault becomes one `secretenv registry set`. Every repo picks it up on the next run, no PRs. ::: -For the full schemas, validation rules, and 5-phase resolution flow: [The Three-File Model — Deep Reference](/reference/three-file-model-deep). +For the full schemas, validation rules, and 5-phase resolution flow: [The Three-File Model, Deep Reference](/reference/three-file-model-deep). --- ## Where to go next -- [**Backends**](/backends/) — 15 backend pages with config, URI format, examples, tested CLI versions -- [**CLI Reference**](/reference/cli-reference-full) — every command, every flag, every exit code -- [**CI/CD Integration**](/ci-cd) — GitHub Actions, GitLab, Jenkins, BuildKite, CircleCI patterns -- [**Threat Model**](/security) — 14-category honest comparison with `.env`, fnox, op-run, direnv -- [**Comparisons**](/comparisons/) — when to pick secretenv vs. an alternative +- [**Backends**](/backends/): 15 backend pages with config, URI format, examples, tested CLI versions +- [**CLI Reference**](/reference/cli-reference-full): every command, every flag, every exit code +- [**CI/CD Integration**](/guides/ci-cd): GitHub Actions, GitLab, Jenkins, BuildKite, CircleCI patterns +- [**Threat Model**](/security): 14-category honest comparison with `.env`, fnox, op-run, direnv +- [**Comparisons**](/comparisons/): when to pick secretenv vs. an alternative ::: info Try it now `secretenv doctor` is the front door for validating your config against any backend. Run it after install, after every config change, and as a CI pre-deploy gate. diff --git a/docs/public/stability-growth.svg b/docs/public/stability-growth.svg new file mode 100644 index 0000000..9b2c2d6 --- /dev/null +++ b/docs/public/stability-growth.svg @@ -0,0 +1,56 @@ + + + +Live-backend smoke assertions per release +Real binary · real backend CLIs · real shells · v0.2.0 → v0.19.0 · 15 backends + +0 + +200 + +400 + +600 + +800 + + + +0.2 + +0.3 + +0.4 + +0.5 + +0.6 + +0.7 + +0.8 + +0.9 + +0.10 + +0.11 + +0.12 + +0.13 + +0.14 + +0.15 + +0.16 + +0.17 + +0.18 + +0.19 +779 +release + \ No newline at end of file diff --git a/docs/public/three-file-model.svg b/docs/public/three-file-model.svg new file mode 100644 index 0000000..5ec7ca1 --- /dev/null +++ b/docs/public/three-file-model.svg @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + +The three-file model +Name a secret once. The registry decides where it lives. + + + + + + + + + +1 +secretenv.toml + +owner: Developer +committed to repo + + + +STRIPE_KEY = { from = "secretenv://stripe-key" } +the manifest says WHAT is needed, alias names only + + + + + + + + + + + + +2 +Alias registry + +owner: Platform / Security +inside a backend you control + + + +stripe-key1password-work://payments/stripe/api_key +the registry says WHERE it lives, alias resolves to a backend URI + + + + + + + + + + + + +3 +config.toml + +owner: each dev +machine XDG dir · ~/.config/secretenv/ + + + +[backends.1password-work] +the machine config says WHICH backends exist here, credentials & endpoints + + + + + + + + + + + + +at runtime + + +$ op read "op://payments/stripe/api_key" + + + + + +$STRIPE_KEY = sk_live_… + + + + + +child process + + +fetched at runtime, gone when the process exits, never written to disk + + + +Change where the alias points every repo picks it up on its next run. No PRs, no re-encryption. + diff --git a/docs/reference/adding-a-backend.md b/docs/reference/adding-a-backend.md index e89005e..1b05607 100644 --- a/docs/reference/adding-a-backend.md +++ b/docs/reference/adding-a-backend.md @@ -1,17 +1,13 @@ # Adding a New Backend -Every backend in secretenv is an independent Rust crate implementing a common trait. Adding a new backend never touches core. This guide walks through building one from scratch. +Every backend in secretenv is an independent Rust crate implementing the `Backend` trait. Adding a new backend never touches core. ---- - -## Overview - -A backend crate provides two things: +A backend crate provides: -1. A **factory** — constructs named instances from raw config -2. A **backend implementation** — implements the `Backend` trait +1. A **factory**: constructs named instances from raw config +2. A **backend implementation**: implements the `Backend` trait -The core binary registers every compiled-in factory unconditionally at startup. There are no Cargo feature flags — all backends are present in the single binary, and `[backends.]` blocks in `config.toml` determine which ones are actually instantiated at runtime. The factory creates instances from those blocks. The trait is all core ever calls. +All backends are compiled in. The core binary registers every factory unconditionally at startup; `[backends.]` blocks in `config.toml` determine which are instantiated at runtime. The factory creates instances; the trait is all core ever calls. --- @@ -87,7 +83,7 @@ pub struct MyServiceFactory; impl BackendFactory for MyServiceFactory { fn backend_type(&self) -> &str { - "myservice" // matches `type = "myservice"` in config.toml + "myservice" } fn create( @@ -95,11 +91,6 @@ impl BackendFactory for MyServiceFactory { instance_name: &str, config: &HashMap, ) -> Result> { - // Use the secretenv_core helpers to extract config fields. - // required_string returns Err with a structured message when the - // field is absent or not a string. optional_string returns None - // when the field is absent and Err when it is present but not a - // string. let api_url = required_string(config, "api_url", "myservice", instance_name)?; let token_env = optional_string(config, "token_env", "myservice", instance_name)? @@ -174,9 +165,6 @@ impl Backend for MyServiceBackend { } async fn get(&self, uri: &BackendUri) -> Result> { - // CRITICAL: Always use .args() with separate strings. - // Never use shell interpolation with uri.path. - // This is a hard security requirement — see security docs. let output = tokio::process::Command::new("myservice-cli") .args(["secret", "get", &uri.path, "--output", "raw"]) .output() @@ -274,7 +262,7 @@ impl Backend for MyServiceBackend { ## 3. Register at Startup -In `crates/secretenv-backends-init/src/lib.rs`, add your factory to the registration list — unconditionally, alongside the other backends: +In `crates/secretenv-backends-init/src/lib.rs`, add your factory to the registration list, unconditionally, alongside the other backends: ```rust pub fn build_registry(config: &Config) -> Result { @@ -298,7 +286,7 @@ secretenv-backend-myservice.workspace = true ## 4. Write Tests -Each backend crate should have tests that mock the CLI binary. Add the shared `secretenv-testing` harness under `[dev-dependencies]`: +Add the shared `secretenv-testing` harness to test CLI mocks: ```toml [dev-dependencies] @@ -306,7 +294,7 @@ secretenv-testing.workspace = true tempfile.workspace = true ``` -Then call `install_mock` to drop a POSIX shell script on disk with the ETXTBSY probe loop already handled: +Call `install_mock` to drop a POSIX shell script on disk (ETXTBSY probe loop already handled): ```rust #[cfg(test)] @@ -342,32 +330,30 @@ exit 1 } ``` -For backends shelling out to a well-known CLI with a fixed binary name, `install_mock_aws` / `install_mock_op` already exist as convenience wrappers — add one in the shared crate if your backend's CLI sees frequent reuse. +For well-known CLIs, `install_mock_aws` / `install_mock_op` exist as convenience wrappers. Add one in the shared crate if needed. --- -## Security Requirements for Backend Authors +## Security Requirements -These are non-negotiable. PRs that violate them will not be merged. +Non-negotiable. PRs that violate them will not be merged. **1. Never use shell interpolation with URI-derived values.** ```rust -// ✅ Correct — always +// ✅ Correct Command::new("myservice-cli") .args(["secret", "get", &uri.path]) -// ❌ Never — injectable +// ❌ Never, injectable Command::new("sh") .arg("-c") .arg(format!("myservice-cli secret get {}", uri.path)) ``` -The URI path comes from the alias registry. If the registry were compromised, a malicious path could inject shell commands. Argument passing prevents this structurally. - -**2. Include the instance name and URI in all error messages.** +URI paths come from the registry. Compromised registries could inject shell commands. Argument passing prevents this structurally. -Users need to know which backend and which URI caused a failure. Raw CLI error messages without context make debugging impossible. +**2. Include instance name and URI in all error messages.** ```rust // ✅ Correct @@ -376,36 +362,36 @@ anyhow::bail!( uri.raw, self.instance_name, stderr.trim() ); -// ❌ Not enough context +// ❌ Insufficient anyhow::bail!("command failed: {}", stderr); ``` **3. Never log or print secret values.** -Debug output, verbose output, and error messages must never include the actual value returned by `get()`. +Debug, verbose, and error output must never include values from `get()`. **4. Return `BackendStatus::CliMissing` with an install hint.** -Users will encounter missing CLIs. The install hint should be a real, copy-pasteable command. +Provide real, copy-pasteable install commands. --- ## Checklist Before Opening a PR -- [ ] Factory registered in `crates/secretenv-backends-init/src/lib.rs` alongside the other factories -- [ ] All eight required `Backend` trait methods implemented (`backend_type`, `instance_name`, `check`, `check_extensive`, `get`, `set`, `delete`, `list`) -- [ ] `check()` implements both Level 1 (CLI present) and Level 2 (authenticated) +- [ ] Factory registered in `crates/secretenv-backends-init/src/lib.rs` +- [ ] All eight `Backend` trait methods implemented: `backend_type`, `instance_name`, `check`, `check_extensive`, `get`, `set`, `delete`, `list` +- [ ] `check()` implements Level 1 (CLI present) and Level 2 (authenticated) - [ ] `check_extensive()` implemented -- [ ] All CLI calls use `.args()` with separate strings — no `sh -c` with interpolation -- [ ] Error messages include instance name and `uri.raw` plus trimmed CLI stderr -- [ ] Tests cover `get()`, `check()` success + failure variants via the shared `secretenv-testing::install_mock` harness (see `§4 Write Tests` above) -- [ ] `docs/backends/.md` written following the existing format -- [ ] Path-dep added to `workspace.dependencies` and to `crates/secretenv-backends-init/Cargo.toml` -- [ ] Crate name added to `deny.toml`'s AGPL exception list in the same commit that creates `Cargo.toml` -- [ ] Crate added to `.github/workflows/release.yml` publish-crates step in topological dependency order +- [ ] All CLI calls use `.args()` with separate strings; no `sh -c` +- [ ] Error messages include instance name, `uri.raw`, and trimmed CLI stderr +- [ ] Tests via `secretenv-testing::install_mock` harness cover `get()` and `check()` success/failure +- [ ] `docs/backends/.md` written +- [ ] Path-dep added to `workspace.dependencies` and `crates/secretenv-backends-init/Cargo.toml` +- [ ] Crate name added to `deny.toml`'s AGPL exception list in same commit as `Cargo.toml` +- [ ] Crate added to `.github/workflows/release.yml` publish-crates in topological order --- ## Getting Help -Open a GitHub issue tagged `backend-development` before starting work on a new backend. This avoids duplicate effort and lets maintainers flag any design considerations specific to that backend's CLI behavior before you've written the code. +Open a GitHub issue tagged `backend-development` before starting. This avoids duplicate effort and surfaces design considerations specific to the backend's CLI behavior. diff --git a/docs/reference/cli-reference-full.md b/docs/reference/cli-reference-full.md index f012f1e..176288a 100644 --- a/docs/reference/cli-reference-full.md +++ b/docs/reference/cli-reference-full.md @@ -1,6 +1,6 @@ # SecretEnv CLI Reference (Full) -Complete per-flag reference for SecretEnv v0.18.0. The README has the compact summary; this page is the deep reference for every command, every flag, every exit code. +Complete per-flag reference for SecretEnv v0.19.0. The README has the compact summary; this page is the deep reference for every command, every flag, every exit code. --- @@ -233,12 +233,12 @@ secretenv doctor [--json] [--fix] [--extensive] | `--json` | Machine-readable output. Status variants: `ok`, `not_authenticated`, `cli_missing`, `error`. Suitable for CI gates and monitoring probes. | | `--fix` | For each `not_authenticated` backend, run canonical remediation CLI interactively (`aws sso login`, `op signin`, `gcloud auth login`, `az login`, `vault login`). Re-checks after. | | `--extensive` | Level-3 deep probe: attempts `list()` against each registry source, counts aliases, reports permission scope. | -| `--trace` | Capture OTel spans emitted during the doctor pass into a local in-memory exporter and render them as a chronologically-sorted table. No OTLP collector required — useful for operator observability without standing up infrastructure. | +| `--trace` | Capture OTel spans emitted during the doctor pass into a local in-memory exporter and render them as a chronologically-sorted table. No OTLP collector required, useful for operator observability without standing up infrastructure. | **Three levels:** -- **L1** — CLI installed (e.g., `aws --version` exits 0) -- **L2** — Backend authenticated (e.g., `aws sts get-caller-identity` succeeds) -- **L3** — Can read registry source (counts aliases). `--extensive` only. +- **L1**: CLI installed (e.g., `aws --version` exits 0) +- **L2**: Backend authenticated (e.g., `aws sts get-caller-identity` succeeds) +- **L3**: Can read registry source (counts aliases). `--extensive` only. Default is L1 + L2. All checks run concurrently with a 5-second per-check timeout. Exit code is non-zero if any backend reports anything other than `ok` (after remediation if `--fix` was passed). L3 failures report but don't change exit code. @@ -275,7 +275,7 @@ secretenv redact | Flag | Description | |---|---| -| `--registry ` | Registry selection — same semantics as `secretenv run --registry`. | +| `--registry ` | Registry selection, same semantics as `secretenv run --registry`. | | `--alias ` | Restrict the tainted set to these alias names. Repeatable; comma-separated values also accepted. Default: every alias resolvable from the active manifest and registry cascade. | | `--in-place` | Rewrite the file in place (atomic rename). Without this flag, the scrubbed output is written to stdout and the original file is untouched. | | `--backup ` | With `--in-place`, keep a backup of the original at `` (e.g. `--backup=.bak`). Requires `--in-place`. | @@ -283,7 +283,7 @@ secretenv redact | `--allow-foreign-owner` | Bypass the foreign-owner refusal that fires when the target file's UID differs from the caller's effective UID. | | `--redact-token ` | Override the substitution token. Default is `[redacted:]`. | -**Behavior:** Resolves every alias's current value from the active registry, builds a tainted set, and rewrites the file replacing any occurrence with the substitution token. All-or-nothing — a single unresolvable alias aborts before writing. Symlinks are refused (`O_NOFOLLOW`); special files (devices, sockets) are refused. Files owned by a different UID are refused unless `--allow-foreign-owner` is passed. +**Behavior:** Resolves every alias's current value from the active registry, builds a tainted set, and rewrites the file replacing any occurrence with the substitution token. All-or-nothing: a single unresolvable alias aborts before writing. Symlinks are refused (`O_NOFOLLOW`); special files (devices, sockets) are refused. Files owned by a different UID are refused unless `--allow-foreign-owner` is passed. --- @@ -297,7 +297,7 @@ Model Context Protocol (MCP) server operations. secretenv mcp serve [--allow-mutations ] [--confirm-via ] ``` -Run the MCP server over stdio until the transport closes. Honors the disable sentinel — if present and unexpired, exits with a clear stderr message before binding. +Run the MCP server over stdio until the transport closes. Honors the disable sentinel: if present and unexpired, exits with a clear stderr message before binding. | Flag | Description | |---|---| @@ -314,7 +314,7 @@ Disable the MCP server by writing the sentinel file. Subsequent `mcp serve` invo | Flag | Description | |---|---| -| `--duration ` | Optional auto-expiry — e.g. `30m`, `2h`, `1d`. Without this flag the disable is indefinite. | +| `--duration ` | Optional auto-expiry, e.g. `30m`, `2h`, `1d`. Without this flag the disable is indefinite. | ### `mcp enable` @@ -357,6 +357,15 @@ Print the last N audit-log entries (default 50) in chronological order. Output i --- +## Failure modes + +- **Resolution is all-or-nothing per invocation.** If any required alias fails to resolve, the child process never starts. Partial environments are never injected. +- **No on-disk cache.** Every run hits live backends, so there's no stale-cache class of bug and no key material left between runs. +- **Failures report cleanly.** `BackendUnauthenticated`, `AliasNotFound`, `RegistryUnreachable`, and `BackendCliMissing` are the four operationally interesting failure shapes; each carries enough context to triage without re-running. +- **Logging.** `RUST_LOG=secretenv=debug` emits structured logs to stderr; `run --verbose` adds per-secret fetch progress. + +--- + ## URI grammar ``` @@ -364,7 +373,7 @@ Print the last N audit-log entries (default 50) in chronological order. Output i ``` - **Scheme:** alphanumeric + `_`/`-`, must start with alphanumeric. The scheme is your **named instance** (e.g., `aws-ssm-prod`, `1password-work`, `vault-eng`). -- **Authority:** SecretEnv URIs have no authority — the "host" position is empty. This produces the triple-slash form `:///` for paths beginning with `/`. +- **Authority:** SecretEnv URIs have no authority: the "host" position is empty. This produces the triple-slash form `:///` for paths beginning with `/`. - **Path:** Non-empty. Control characters (NUL + ASCII <0x20 except tab) are rejected at parse time. - **Fragment:** Optional `key=value[,key=value]*` directive map. Common directives: `json-key=` (extract JSON field from response), `version=` (pin version, where supported). See [fragment-vocabulary.md](fragment-vocabulary.md). @@ -397,7 +406,7 @@ type = "" ### Profile (`/profiles/.toml`) -Same shape as machine config — `[registries.*]` + `[backends.*]` blocks. Auto-merged on every load. User config wins on key collision. +Same shape as machine config: `[registries.*]` + `[backends.*]` blocks. Auto-merged on every load. User config wins on key collision. ### Registry document (stored in any backend) diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 670d437..12e87ca 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -9,14 +9,14 @@ secretenv uses two configuration files with completely separate responsibilities --- -## `secretenv.toml` — Project Manifest +## `secretenv.toml`: Project Manifest ### Rules 1. Only two value types: `from` and `default`. No exceptions. -2. No backend URIs. Only `secretenv://` aliases or static defaults. -3. No environment blocks. One `[secrets]` block only. -4. Safe to commit. Contains zero infrastructure information. +2. No backend URIs; use `secretenv://` aliases or static defaults only. +3. One `[secrets]` block; no environment blocks. +4. Safe to commit. Zero infrastructure information. ### Format @@ -25,14 +25,14 @@ secretenv uses two configuration files with completely separate responsibilities # Committed to git. Contains no secrets, no paths, no environment logic. [secrets] -# Alias lookup — resolved via registry at runtime +# Alias lookup, resolved via registry at runtime STRIPE_KEY = { from = "secretenv://stripe-key" } DATABASE_URL = { from = "secretenv://db-url" } DATADOG_API_KEY = { from = "secretenv://datadog-api-key" } REDIS_URL = { from = "secretenv://redis-url" } VAULT_TOKEN = { from = "secretenv://vault-token" } -# Static default — injected directly, no backend involved +# Static default, injected directly, no backend involved LOG_LEVEL = { default = "info" } APP_ENV = { default = "development" } ``` @@ -45,33 +45,22 @@ APP_ENV = { default = "development" } | Static default | `{ default = "value" }` | Injected as-is, no registry or backend involved | | Direct backend URI | `{ from = "aws-ssm://..." }` | **Hard error. Prohibited without exception.** | -### What This File Does Not Do - -- Does not know which environment is active -- Does not know which AWS account or region holds a secret -- Does not know which registry to read from -- Does not know what any alias resolves to - -An attacker reading this file learns what secrets the project needs. Nothing about where they live. +An attacker reading this file learns only what secrets the project needs, not where they live. --- -## `config.toml` — Machine Configuration +## `config.toml`: Machine Configuration -### Location +### Location and Structure ``` ~/.config/secretenv/config.toml ``` -Follows XDG convention. One file per machine. Written by `secretenv setup`, pre-populated by a distribution profile, or created manually. - -### Structure +One file per machine (XDG convention). Two top-level sections: -Two top-level sections: - -- `[registries]` — named registry configurations with cascading sources -- `[backends]` — named backend instances with credential configuration +- `[registries]`, named registry configurations with cascading sources +- `[backends]`, named backend instances with credential configuration --- @@ -79,10 +68,9 @@ Two top-level sections: ### Behavior -- `[registries.default]` is optional. If absent and no `--registry` flag or `SECRETENV_REGISTRY` is set, secretenv errors hard. -- Each registry has a `sources` array. Sources cascade — first match wins. -- Cascading is intentional. Earlier sources shadow later ones. -- Missing registry name → hard error. No silent fallback to default. +- `[registries.default]` is optional; missing registry name is a hard error. +- Each registry has a `sources` array. Sources cascade. First match wins. +- Earlier sources shadow later ones by design. ### Format @@ -93,7 +81,7 @@ sources = [ "aws-ssm-platform:///secretenv/org-registry", ] -# Named registries — activated via --registry +# Named registries, activated via --registry [registries.dev] sources = [ "aws-ssm-dev:///secretenv/dev-registry", # team aliases, checked first @@ -130,9 +118,7 @@ named instance path to registry document ### Named Instances -A backend instance is a named configuration of a backend type. The instance name becomes the URI scheme used in registry documents and `sources` arrays. - -Multiple instances of the same type are normal: +A backend instance is a named configuration of a backend type; the instance name becomes the URI scheme. Multiple instances of the same type are normal: ```toml [backends.aws-ssm-dev] @@ -146,19 +132,19 @@ aws_profile = "prod" aws_region = "us-east-1" ``` -One plugin. Two instances. Two credential sets. No new code. +One plugin, two instances, two credential sets, no new code. ### Backend Types and Fields -**`aws-ssm` — AWS SSM Parameter Store** +**`aws-ssm`: AWS SSM Parameter Store** ```toml [backends.aws-ssm-dev] type = "aws-ssm" # required -aws_profile = "dev" # optional — omit to use ambient credentials +aws_profile = "dev" # optional, omit to use ambient credentials aws_region = "us-east-1" # required ``` -**`aws-secrets` — AWS Secrets Manager** +**`aws-secrets`: AWS Secrets Manager** ```toml [backends.aws-secrets-prod] type = "aws-secrets" @@ -166,48 +152,48 @@ aws_profile = "prod" aws_region = "us-east-1" ``` -**`1password` — 1Password** +**`1password`: 1Password** ```toml [backends.1password-work] type = "1password" -op_account = "company.1password.com" # optional — omit for single-account setups +op_account = "company.1password.com" # optional, omit for single-account setups ``` -**`vault` — HashiCorp Vault** +**`vault`: HashiCorp Vault** ```toml [backends.vault-eng] type = "vault" vault_address = "https://vault.company.com" # required -vault_namespace = "engineering" # optional — Vault Enterprise only +vault_namespace = "engineering" # optional, Vault Enterprise only ``` -**`gcp` — GCP Secret Manager** +**`gcp`: GCP Secret Manager** ```toml [backends.gcp-prod] type = "gcp" gcp_project = "my-project-prod" # required ``` -**`azure` — Azure Key Vault** +**`azure`: Azure Key Vault** ```toml [backends.azure-prod] type = "azure" azure_vault_url = "https://my-keyvault.vault.azure.net" # required ``` -**`keeper` — Keeper** +**`keeper`: Keeper** ```toml [backends.keeper-work] type = "keeper" ``` -**`keychain` — macOS Keychain / Linux Secret Service** +**`keychain`: macOS Keychain / Linux Secret Service** ```toml [backends.keychain] type = "keychain" ``` -**`local` — Local file (zero infrastructure)** +**`local`: Local file (zero infrastructure)** ```toml [backends.local] type = "local" @@ -275,18 +261,16 @@ vault_namespace = "engineering" 1. --registry ← explicit per-invocation 2. SECRETENV_REGISTRY= ← CI / shell-session override 3. [registries.default] in config ← machine default -4. hard error ← no assumption made +4. hard error ``` ### Name vs URI Disambiguation -The `--registry` flag and `SECRETENV_REGISTRY` variable accept either a registry name or a direct URI. The rule is structural: +The `--registry` flag and `SECRETENV_REGISTRY` variable accept either a name or direct URI: - Contains `://` → treat as direct URI (single source, no cascade) - No `://` → treat as registry name, look up `[registries.]` -TOML keys cannot contain `://`, so a registry name can never be mistaken for a URI. - --- ## Error Reference @@ -294,8 +278,8 @@ TOML keys cannot contain `://`, so a registry name can never be mistaken for a U | Condition | Error | |---|---| | `--registry foo` but no `[registries.foo]` | `error: no registry named 'foo' in config` | -| No registry configured anywhere | `error: no registry configured — use --registry or set SECRETENV_REGISTRY` | +| No registry configured anywhere | `error: no registry configured. Use --registry or set SECRETENV_REGISTRY` | | Alias not found in cascade | `error: alias 'stripe-key' not found in registry 'dev' (checked 2 sources)` | -| Unknown backend instance in URI | `error: unknown backend 'aws-ssm-dev' — is it defined in config.toml?` | +| Unknown backend instance in URI | `error: unknown backend 'aws-ssm-dev'. Is it defined in config.toml?` | | Backend fetch fails | `error: failed to fetch 'aws-ssm-dev:///myapp/dev/stripe' (alias: stripe-key): ` | -| Direct backend URI in secretenv.toml | `error: direct backend URIs are not allowed in secretenv.toml — use a secretenv:// alias` | +| Direct backend URI in secretenv.toml | `error: direct backend URIs are not allowed in secretenv.toml. Use a secretenv:// alias` | diff --git a/docs/reference/fragment-vocabulary.md b/docs/reference/fragment-vocabulary.md index d0ac410..f37cfe5 100644 --- a/docs/reference/fragment-vocabulary.md +++ b/docs/reference/fragment-vocabulary.md @@ -1,6 +1,6 @@ # Fragment Vocabulary -SecretEnv URIs are shaped `://[#]`. The optional `#` suffix carries per-request directives — which JSON field to extract, which version to pin, and so on. Each backend declares the directive keys it understands; the URI layer enforces a single canonical grammar everyone agrees on. +SecretEnv URIs are shaped `://[#]`. The optional `#` suffix carries per-request directives (JSON field extraction, version pinning, etc.). Each backend declares which directive keys it recognizes; the URI layer enforces a single canonical grammar. ## Canonical grammar @@ -21,46 +21,45 @@ azure-prod://my-secret#version=12345678-abcd-1234-ef56-9876 ## Rules -- **Every directive is `key=value`.** No plain-string shorthand; no flags-without-values. -- **Keys are lowercase kebab-case** — letter-led, then letters/digits/hyphens. `json-key`, `version2`, and `max-age` all parse; `JsonKey`, `json_key`, `1version`, and `json.key` do not. -- **Values are non-empty** and must not contain `,` or `=`. Spaces, dots, hyphens, and most punctuation are fine. -- **Separate multiple directives with a single comma.** No whitespace around the comma; no trailing or leading commas. -- **Each key appears at most once.** A duplicate key is a hard error (no implicit merging). -- **Directive semantics are backend-local.** The URI layer enforces the grammar; each backend decides which keys it accepts and what their values mean. A key recognized by one backend is not automatically recognized by another. +- **Every directive is `key=value`.** No shorthand; no flags-without-values. +- **Keys are lowercase kebab-case:** letter-led, then letters/digits/hyphens only. `json-key`, `version2`, `max-age` OK; `JsonKey`, `json_key`, `1version`, `json.key` fail. +- **Values are non-empty,** must not contain `,` or `=`. Spaces, dots, hyphens, most punctuation OK. +- **Separate directives with single comma.** No whitespace around comma; no trailing/leading commas. +- **Each key appears at most once.** Duplicates are hard errors. +- **Directive semantics are backend-local.** URI layer enforces grammar; each backend decides which keys it accepts. -## Directive registry +## Directive Registry -The registry lists every directive key SecretEnv backends recognize today. An unrecognized directive on a backend is a hard error that lists what is recognized. +Lists every directive key SecretEnv backends recognize. Unrecognized directives are hard errors. | Backend | Directive | Meaning | Since | |---|---|---|---| | `aws-secrets` | `json-key` | Extract a top-level JSON field from the secret body. Value is the field name. | v0.2.1 | | `gcp` | `version` | Pin a specific GCP Secret Manager version. Positive integer or `latest`. `latest` is equivalent to no fragment. | v0.3 | | `azure` | `version` | Pin a specific Azure Key Vault secret version. 32-character lowercase hex string (server-generated) or `latest`. | v0.3 | -| `keeper` | `field` | Select a named Keeper record field. Case-insensitive — matches custom-field label first, then typed-field label, then typed-field type name. Without this directive the `password` field is returned. Note: the `keeper` backend parses this directive directly from the raw fragment rather than through the canonical grammar parser; the directive key still follows the `key=value` shape. | v0.8 | +| `keeper` | `field` | Select a named Keeper record field. Case-insensitive, matches custom-field label first, then typed-field label, then typed-field type name. Without this directive the `password` field is returned. Note: the `keeper` backend parses this directive directly from the raw fragment rather than through the canonical grammar parser; the directive key still follows the `key=value` shape. | v0.8 | -## Error reporting +## Error Reporting -Every fragment-grammar error quotes the full URI verbatim so you can grep your logs or config for the offender: +All fragment-grammar errors quote the full URI verbatim for log grepping: ``` fragment '#password' on URI 'aws-secrets-prod:///db-creds#password' uses legacy -plain-string shorthand; rewrite as '#=' (for example, the -aws-secrets backend now requires '#json-key=password'). See -docs/fragment-vocabulary.md +plain-string shorthand; rewrite as '#=' (for example, +aws-secrets requires '#json-key=password'). See docs/fragment-vocabulary.md ``` -## Migrating from v0.2.0 +## Migration from v0.2.0 -v0.2.0 shipped the aws-secrets backend with a plain-string shorthand: `#password` meant "extract the `password` JSON field." That form is rejected in v0.2.1 with a migration hint pointing at the canonical replacement. +v0.2.0 shipped plain-string shorthand (`#password` = extract `password` field). Rejected in v0.2.1+: | v0.2.0 (removed) | v0.2.1+ (canonical) | |---|---| | `aws-secrets-prod:///db#password` | `aws-secrets-prod:///db#json-key=password` | | `aws-secrets-prod:///db#host` | `aws-secrets-prod:///db#json-key=host` | -If you see `ShorthandRejected` in your logs, your URI uses the old form — rewrite it using the table above. +If you see `ShorthandRejected` in logs, rewrite using the table above. -## Why this shape +## Why This Shape -Earlier internal drafts let each backend define its own fragment shape. That was cheap on day one but it forces every user, every backend contributor, and every IDE autocomplete story to re-learn the grammar per backend. A single URI-layer grammar with a backend-local directive registry scales better: new backends pick well-defined keys without re-litigating punctuation, old backends don't drift, and tooling can validate fragments before a single command reaches the wire. +Earlier drafts let each backend define its own grammar. One canonical URI-layer grammar with backend-local directive registry scales better: new backends reuse well-defined keys, old backends don't drift, and tooling validates fragments early. diff --git a/docs/reference/mcp.md b/docs/reference/mcp.md index dbabcf4..8c68bd8 100644 --- a/docs/reference/mcp.md +++ b/docs/reference/mcp.md @@ -1,10 +1,8 @@ -# `secretenv mcp` — Model Context Protocol server +# `secretenv mcp`: Model Context Protocol server -`secretenv mcp serve` is a stdio-only [Model Context Protocol][mcp] server that gives AI coding agents (Claude Code, Cursor, Cline, Gemini CLI / Code Assist, Codex, OpenCode, VS Code Copilot, Continue) structured access to your SecretEnv registry — **without ever returning a resolved secret value**. +`secretenv mcp serve` is a stdio-only [MCP][mcp] server providing AI coding agents structured access to SecretEnv registries, **without returning resolved secret values**. -This reference covers everything an operator needs: setup per IDE, the 14 tools the server exposes, the confirmation surface, the audit log, and the known limitations as of v0.19.0. - -For the design rationale + implementation walkthrough, see [`kb/wiki/build-plan-v0.16-mcp.md`](https://github.com/TechAlchemistX/secretenv/blob/main/kb/wiki/build-plan-v0.16-mcp.md) (build plan) + [`docs/reference/redact.md`](redact.md) + [`docs/reference/migrate.md`](migrate.md) for the v0.14/v0.15 features the MCP server wraps. +Setup per IDE, 14 tools, confirmation surface, audit log, and v0.19.0 limitations covered here. See [`redact.md`](redact.md) and [`migrate.md`](migrate.md) for the wrapped features. [mcp]: https://modelcontextprotocol.io @@ -35,7 +33,7 @@ secretenv mcp setup --ide claude-code --write # writes to the IDE's config file ## The 14 tools -Each tool's name is exactly what the agent calls. All tools return **structured JSON responses** — never plain text — and **none of them return a resolved secret value**. +All tools return **structured JSON** and **never return resolved secret values**. ### Read-only (8) @@ -45,51 +43,51 @@ Each tool's name is exactly what the agent calls. All tools return **structured | `version_info` | secretenv version + rmcp SDK version + the 14-tool inventory. | | `redact_status` | Whether `secretenv run --redact` is enabled (always true since v0.14). | | `list_backends` | All configured `[backends.*]` instances + their type. | -| `detect_password_managers` | Which password-manager CLIs are installed on this machine (op, vault, doppler, etc.) — for suggesting backends the operator could add. | -| `doctor` | Backend health probe — auth status per configured instance. Mirrors `secretenv doctor --json`. | +| `detect_password_managers` | Which password-manager CLIs are installed on this machine (op, vault, doppler, etc.), for suggesting backends the operator could add. | +| `doctor` | Backend health probe: auth status per configured instance. Mirrors `secretenv doctor --json`. | | `resolve_status` | Per-registry probe: is the registry's primary source URI's backend reachable + authenticated? Per-alias info comes from `list_aliases`. | -| `list_aliases` | Every alias across every registry + the backend instance/type it points at. **No URI paths, no values** — just alias names + their target backend instance name. | +| `list_aliases` | Every alias across every registry + the backend instance/type it points at. **No URI paths, no values**, just alias names + their target backend instance name. | ### Mutations (4) -Mutations go through the `[mcp].allow_mutations` policy gate and are recorded in the mutation audit log. See [Confirmation surface](#confirmation-surface) below. +Mutations gate on `[mcp].allow_mutations` policy and record to audit log (see [Confirmation surface](#confirmation-surface)). | Tool | Purpose | |---|---| -| `set_alias` | Create / update an alias → backend-URI mapping in a registry. **Does NOT create the backend secret itself** — only the registry pointer. | -| `delete_alias` | Remove an alias from a registry. **Does NOT delete the backend secret** — call the backend's native delete CLI for that. ALWAYS CONFIRM PER ALIAS; never batched. | -| `init_project` | Scaffold a `secretenv.toml` from a `.env` file. KEY NAMES only — values structurally cannot be read (the parser stops at `=`). `apply=false` (default) returns the proposed manifest without writing. | -| `redact_file` | Post-hoc file scrubbing — replaces every alias's resolved value with `[redacted:]`. Returns COUNTS only, never matched bytes. `apply=false` (default) is a dry-run dual to `secretenv redact --dry-run`. | +| `set_alias` | Create / update an alias → backend-URI mapping in a registry. **Does NOT create the backend secret itself**, only the registry pointer. | +| `delete_alias` | Remove an alias from a registry. **Does NOT delete the backend secret**. Call the backend's native delete CLI for that. ALWAYS CONFIRM PER ALIAS; never batched. | +| `init_project` | Scaffold a `secretenv.toml` from a `.env` file. KEY NAMES only, values structurally cannot be read (the parser stops at `=`). `apply=false` (default) returns the proposed manifest without writing. | +| `redact_file` | Post-hoc file scrubbing: replaces every alias's resolved value with `[redacted:]`. Returns COUNTS only, never matched bytes. `apply=false` (default) is a dry-run dual to `secretenv redact --dry-run`. | ### Generation + migration (2) | Tool | Purpose | |---|---| -| `gen_password` | Generate a cryptographically random value, write it to a backend URI, and register an alias for it. The value **never crosses the MCP boundary** — written directly to the backend. Charsets: `alphanumeric`, `alphanumeric_symbols`, `hex`, `base64_url_safe`. Length floor 16, ceiling 1024. | +| `gen_password` | Generate a cryptographically random value, write it to a backend URI, and register an alias for it. The value **never crosses the MCP boundary**, written directly to the backend. Charsets: `alphanumeric`, `alphanumeric_symbols`, `hex`, `base64_url_safe`. Length floor 16, ceiling 1024. | | `migrate_alias` | Migrate an alias's value from one backend to another. Wraps `secretenv registry migrate`. `dry_run=true` for probe + plan without mutation; `delete_source=true` opts into post-commit source cleanup. | --- ## Per-IDE setup -Run `secretenv mcp setup --list-ides` to see all 8. Each profile auto-emits a `--allow-mutations=always` flag if Phase 8b empirical testing showed the IDE lacks working MCP elicitation — see [Confirmation surface](#confirmation-surface). +Run `secretenv mcp setup --list-ides` to see all 8. Testing determined which IDEs lack MCP elicitation; those profiles auto-emit `--allow-mutations=always` (see [Confirmation surface](#confirmation-surface)). -| IDE | Helper key | Config path | Elicitation status (v0.16) | +| IDE | Helper key | Config path | Elicitation (v0.16) | |---|---|---|---| -| Claude Code | `claude-code` | `~/.claude.json` (use `claude mcp add`) | ✅ Works end-to-end (modal renders, single-click) | -| Cursor | `cursor` | `~/.cursor/mcp.json` | ⏸ Untested — helper ships speculative `--allow-mutations=always` | -| Codex (OpenAI) | `codex` | `~/.codex/config.toml` | ❌ No elicitation — helper ships `--allow-mutations=always`. Codex maintains its own per-tool approval DB (`[mcp_servers.X.tools.Y] approval_mode`) | -| VS Code Copilot | `vscode-copilot` | `.vscode/mcp.json` (workspace-scoped) | ❌ Advertises capability but does not render empty-schema requests — helper ships `--allow-mutations=always`. v0.16.1 will investigate a single-field schema variant. | -| Continue | `continue` | `~/.continue/config.json` | ⏸ Untested — helper ships speculative `--allow-mutations=always` | -| Cline | `cline` | `~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json` | ❌ No elicitation — operator adds `--allow-mutations=always` manually (Cline's own "Run Tool" UI gate fires too) | -| Gemini CLI + Gemini Code Assist | `gemini` | `~/.gemini/settings.json` | ❌ No elicitation — helper ships `--allow-mutations=always`. Single config covers both the standalone Gemini CLI and the Gemini Code Assist IDE extension. | -| OpenCode | `opencode` | `~/.config/opencode/opencode.jsonc` | ❌ No elicitation — helper ships `--allow-mutations=always`. OpenCode + Codex agents BOTH demonstrate model-level conversational confirmation (the agent asks the operator in chat before firing mutations) — meaningful defense-in-depth at the model layer even when MCP-protocol elicitation is silenced. | +| Claude Code | `claude-code` | `~/.claude.json` (use `claude mcp add`) | ✅ End-to-end (modal + single-click) | +| Cursor | `cursor` | `~/.cursor/mcp.json` | ⏸ Speculative `--allow-mutations=always` | +| Codex (OpenAI) | `codex` | `~/.codex/config.toml` | ❌ No elicitation; ships `--allow-mutations=always`. Maintains own per-tool approval DB. | +| VS Code Copilot | `vscode-copilot` | `.vscode/mcp.json` | ❌ No empty-schema render; ships `--allow-mutations=always` (single-field variant pending v0.16.1). | +| Continue | `continue` | `~/.continue/config.json` | ⏸ Speculative `--allow-mutations=always` | +| Cline | `cline` | `~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json` | ❌ No elicitation; operator adds flag manually (Cline UI gate also fires). | +| Gemini CLI + Gemini Code Assist | `gemini` | `~/.gemini/settings.json` | ❌ No elicitation; ships `--allow-mutations=always` (single config for both tools). | +| OpenCode | `opencode` | `~/.config/opencode/opencode.jsonc` | ❌ No MCP elicitation; model-level conversational confirmation (agent asks in chat). Ships `--allow-mutations=always`. | ### `--write` mode -`secretenv mcp setup --ide --write` writes the config file directly. Refuses if the target file already exists unless you pass `--force`. The `claude-code` profile is special: it emits a `claude mcp add` shell command rather than writing JSON, because `~/.claude.json` is a 1000+ line shared config file with lots of unrelated Claude Code state. +`secretenv mcp setup --ide --write` writes the config file (refuses if exists unless `--force`). `claude-code` is special: emits `claude mcp add` command (not JSON) because `~/.claude.json` is 1000+ lines of shared state. -For IDEs with pre-existing settings.json content (Gemini, Cline, Continue): the helper has no merge logic yet (v0.16.1). Use `jq` to merge: +For IDEs with existing settings.json (Gemini, Cline, Continue): no merge logic yet (v0.16.1). Use `jq`: ```bash jq '. * {"mcpServers": {"secretenv": {"command": "secretenv", "args": ["mcp", "serve", "--allow-mutations", "always"]}}}' \ @@ -99,42 +97,38 @@ mv ~/.gemini/settings.json.new ~/.gemini/settings.json ### `--binary ` for portability -By default the rendered config uses `"command": "secretenv"` — relying on the IDE's `$PATH`. Some IDEs spawn MCP servers with a sparser environment than your shell. For maximum portability: +By default, config uses `"command": "secretenv"` (relying on IDE's `$PATH`). Some IDEs spawn with sparser env. For portability: ```bash secretenv mcp setup --ide claude-code --binary $(which secretenv) ``` -This bakes the absolute path into the config block. - ### `--ide generic` -Print-only profile for any IDE adopting the de-facto Claude `mcpServers` shape. Useful for IDEs not yet in the per-IDE list (or that adopt the shape post-v0.16). Compatible with Claude Code, Cursor, Cline, Gemini. NOT compatible with VS Code Copilot (needs `"type": "stdio"`), Continue (`experimental.modelContextProtocolServers`), OpenCode (`command`-as-list), or Codex (TOML). +Print-only profile for any IDE adopting the Claude `mcpServers` shape. Compatible with Claude Code, Cursor, Cline, Gemini. Incompatible with VS Code Copilot (`"type": "stdio"`), Continue (`experimental.modelContextProtocolServers`), OpenCode (`command`-as-list), Codex (TOML). --- ## Confirmation surface -The `[mcp].allow_mutations` policy controls how the server gates mutation tool calls: - -- `never` — every mutation tool returns a structured refusal. Mutation tools are still listed in `tools/list`. -- `confirm` (default) — every mutation gates on operator confirmation surfaced per `[mcp].confirm_via`. -- `always` — every mutation auto-approves. The audit log still records every call. - -The `[mcp].confirm_via` value controls how the confirmation prompt reaches you: - -- `auto` (default since v0.16 Phase 7c) — **resolves at runtime per request**: - 1. If the MCP client declared the elicitation capability at the initialize handshake → uses `Elicitation`. - 2. Else if `stdin` is a TTY (server launched standalone from an interactive shell) → uses `Tty`. - 3. Else refuses the mutation with a clear error pointing at remediation. -- `elicitation` — MCP server→client elicit RPC. Modal renders in the IDE's native UI. **Only works on clients that advertised the elicitation capability** (Claude Code in v0.16). -- `tty` — Prompt on `/dev/tty`. **Deadlocks inside TUI host IDEs** that own the controlling terminal (Claude Code, Cline, OpenCode TUI, Codex REPL). Safe for standalone `secretenv mcp serve` from an interactive shell. -- `notification` — Desktop notification (planned, currently returns an error). -- `none` — no confirmation surface; equivalent to `allow_mutations = "always"` but emitted as a distinct flag in the audit log. +**`[mcp].allow_mutations` policy:** +- `never`: mutations return refusal (still listed in `tools/list`) +- `confirm` (default): gates on confirmation per `[mcp].confirm_via` +- `always`: auto-approve (audit log records all) + +**`[mcp].confirm_via` method:** +- `auto` (default v0.16+), resolves per request: + 1. Client advertised elicitation at initialize → use `Elicitation` (modal) + 2. Else if `stdin` is TTY → use `Tty` prompt + 3. Else refuse with remediation hint +- `elicitation`: MCP server→client modal (only works if client advertised; Claude Code v0.16) +- `tty`: `/dev/tty` prompt (deadlocks in TUI host IDEs: Claude Code, Cline, OpenCode TUI, Codex REPL; safe standalone) +- `notification`: desktop notification (planned; errors currently) +- `none`: no surface (equivalent to `always`; audit log records as distinct flag) ### Per-IDE policy override -Operators scope the override to a specific IDE's `mcpServers` args block rather than weakening their global config. The setup helper auto-emits this for IDEs that need it: +Scope overrides to a specific IDE's `mcpServers` args block (not global config): ```json "mcpServers": { @@ -145,9 +139,7 @@ Operators scope the override to a specific IDE's `mcpServers` args block rather } ``` -When this server spawns, it reads `[mcp].allow_mutations = "confirm"` from your global config (the safer default), then applies the CLI override to bump it to `always` **for this subprocess only**. Other IDEs (Claude Code, etc.) spawn the binary without the flag and stay on the safer policy. - -The override is logged via `tracing::info!`: +Server reads global `[mcp].allow_mutations = "confirm"`, then CLI flag bumps to `always` **for this subprocess only**. Other IDEs spawn without the flag and stay safer. Logged: ``` INFO policy override from CLI flag: allow_mutations = Always (was Confirm in config) @@ -157,7 +149,7 @@ INFO policy override from CLI flag: allow_mutations = Always (was Confirm in con ## Mutation audit log -Every mutation tool call writes one JSON-Lines entry to `$XDG_STATE_HOME/secretenv/mcp-mutations.log` (or platform equivalent — `~/.local/state/` on Linux, `~/Library/Application Support/` on macOS, `%LOCALAPPDATA%` on Windows). +Every mutation writes JSON-Lines to `$XDG_STATE_HOME/secretenv/mcp-mutations.log` (`~/.local/state/` Linux, `~/Library/Application Support/` macOS, `%LOCALAPPDATA%` Windows): ```json { @@ -171,64 +163,56 @@ Every mutation tool call writes one JSON-Lines entry to `$XDG_STATE_HOME/secrete } ``` -`operator_decision` is one of: +**`operator_decision`:** `approved` (clicked Accept / typed `y`) | `denied` (clicked Decline / typed `n`) | `timeout` (30s no response) | `autoapproved` (policy=`always`/per-IDE override) | `policy_refusal` (Auto resolver found no surface). -- `approved` — operator clicked Accept in the elicitation modal or typed `y` at the TTY prompt -- `denied` — operator clicked Decline / Cancel or typed `n` -- `timeout` — operator did not respond within 30s -- `autoapproved` — policy was `always` (or per-IDE override; see above) — no operator gate fired -- `policy_refusal` — policy refused outright (e.g. `Auto` resolver found no usable surface) +File created mode `0o600` (operator-only). Tampering protection is operator's responsibility. -The file is created mode `0o600` (operator-only). Tampering protection is operator's responsibility: store on a non-shared filesystem, ship to a write-once log shipper if needed. - -**`mcp_client_id`** resolves from the rmcp `initialize` handshake's `clientInfo.name` (landed v0.16.1 F-7). Falls back to `"unknown"` only when peer info is unavailable during the handshake itself — never inside a tool handler. +**`mcp_client_id`** from rmcp `initialize` handshake `clientInfo.name` (v0.16.1+). Falls back to `"unknown"` only if handshake unavailable. --- ## Tool disabling -To remove specific tools from the inventory (e.g. ban `gen_password` in a production environment): - ```toml [mcp] disabled_tools = ["gen_password", "redact_file"] ``` -Disabled tools are absent from BOTH `tools/list` AND dispatch — the agent literally cannot see or invoke them. +Disabled tools are absent from `tools/list` and dispatch. Agents cannot see or invoke them. --- ## Disable / enable the server ```bash -secretenv mcp disable # indefinite — sentinel at $XDG_CONFIG_HOME/secretenv/mcp-disabled +secretenv mcp disable # indefinite (sentinel at $XDG_CONFIG_HOME/secretenv/mcp-disabled) secretenv mcp disable --duration 2h -secretenv mcp enable # remove the sentinel +secretenv mcp enable # remove sentinel ``` -When the sentinel is present, every `mcp serve` invocation exits immediately with a clear stderr message (no transport bind, no tool registration). Useful for incident response or maintenance windows where you want to block all IDEs from invoking secretenv without removing the per-IDE config files. +Sentinel present: `mcp serve` exits immediately with clear message (no transport, no tools). Useful for incidents or maintenance without removing per-IDE configs. --- ## Security model -The full SEC-INV catalog is at `kb/wiki/security-invariants.md`. The v0.16-specific guarantees: +The MCP server enforces these structural security guarantees: -- **SEC-INV-02:** The `secretenv-mcp` crate **structurally cannot construct, deserialize, or serialize** a `Secret`. Three CI gates enforce this: clippy `disallowed-types`, `tests/boundary_test.rs` compile-time assertions, and the Phase 8 live-smoke value-grep. The Cargo feature `value-access` is documentation, not the structural guarantee. -- **SEC-INV-12:** `agent_reason` is recorded verbatim in the audit log but NEVER included in the JSON-RPC tool-result payload returned to the agent NOR set as an OTel span attribute. Operator-facing surfaces (TTY prompt body, elicitation modal body) MAY render it so the operator can evaluate intent. -- **SEC-INV-15:** `gen_password` response carries **no value bytes** — only metadata (alias name, charset, length, success/failure outcome). -- **SEC-INV-20:** Backend URIs do NOT appear in `Err::Display` paths flowing into MCP response `error_message` fields. Two-layer defense: source-side cleanup of `with_context` strings + `safe_error_message()` runtime scrubber that rewrites `scheme://body` → `scheme://[redacted]`. Compile-time regression guard in `tests/uri_not_in_error_message.rs::no_raw_anyhow_format_in_tool_module` catches future re-introduction. +- **No `Secret` in the MCP crate:** `secretenv-mcp` structurally cannot construct/deserialize/serialize `Secret`. Enforced by clippy `disallowed-types`, `tests/boundary_test.rs` compile-time assertions, and a live-smoke value-grep. The Cargo feature `value-access` is documentation only. +- **`agent_reason` is audit-only:** recorded in the audit log but NEVER in the tool-result JSON-RPC payload or OTel attributes. Operator surfaces (TTY/elicitation modal) MAY render it for intent evaluation. +- **No password bytes in `gen_password` output:** the response carries only metadata (alias, charset, length, outcome), never value bytes. +- **No backend URIs in error messages:** backend URIs are absent from `Err::Display` in MCP response `error_message` fields. Two-layer defense: source-side `with_context` cleanup plus a `safe_error_message()` scrubber (`scheme://body` → `scheme://[redacted]`). A compile-time guard in `tests/uri_not_in_error_message.rs` prevents regression. --- ## Known limitations -See the `Known limitations` section of [`CHANGELOG.md`](../../CHANGELOG.md) for the full catalog. Summary: +Full catalog in [`CHANGELOG.md`](../../CHANGELOG.md). Key items: -- **Only Claude Code has working MCP elicitation.** All 5 other tested non-Claude IDEs (Gemini, VS Code Copilot, Cline, Codex, OpenCode) need the per-IDE `--allow-mutations=always` override. Upstream PRs queued. -- **Per-IDE override has no user-scope opt-out by default.** A hostile workspace `.mcp.json` can silently weaken your global mutation policy. Mitigated by IDE-side workspace-trust prompts + audit log. Set `[mcp].allow_cli_overrides = false` in your global config to block all per-IDE `--allow-mutations` overrides (shipped v0.18 F-3). -- **TTY TOCTOU + migrate dual-control collapse + dry-run reconnaissance gate** — Phase 7 audit M-7/M-9/M-12 carry-forwards. -- **No `--merge` mode in setup helper** for IDEs with existing settings.json. Use `jq`. +- **Only Claude Code has working elicitation.** All other IDEs (Gemini, VS Code Copilot, Cline, Codex, OpenCode) need per-IDE `--allow-mutations=always` override (upstream PRs queued). +- **Per-IDE override has no user opt-out by default.** Hostile workspace `.mcp.json` can weaken global policy. Mitigated by IDE-side workspace-trust + audit log. Set `[mcp].allow_cli_overrides = false` to block per-IDE overrides. +- **TTY TOCTOU, migrate dual-control collapse, and dry-run reconnaissance gate**: carry-forward hardening for a future cycle. +- **No `--merge` mode in setup helper** for existing settings.json. Use `jq`. --- @@ -236,21 +220,19 @@ See the `Known limitations` section of [`CHANGELOG.md`](../../CHANGELOG.md) for | Symptom | Likely cause | Fix | |---|---|---| -| `policy_refusal: no usable confirmation surface` | Client doesn't advertise MCP elicitation + stdin isn't a TTY | Add `--allow-mutations always` to that IDE's args, OR run from a shell terminal directly | -| Mutation times out after 30s | Elicitation request sent but client didn't render the modal | Same fix as above. Likely F-11 (Copilot empty-schema) if VS Code Copilot. | -| UI freezes when triggering a mutation | `confirm_via = "tty"` deadlocked the TUI host | Set `confirm_via = "auto"` (the default since v0.16) — the resolver picks the safe path | -| `secretenv: command not found` in IDE logs | IDE spawned with a sparser `$PATH` than your shell | Re-run `secretenv mcp setup --ide --binary $(which secretenv)` and re-apply the config | -| macOS: binary SIGKILL on first run | Unsigned binary copied via `cp` (not via Homebrew which signs on install) | `codesign --remove-signature ~/.cargo/bin/secretenv && codesign --sign - ~/.cargo/bin/secretenv` | -| Tool descriptions truncated in IDE | IDE's MCP UI has a per-tool character budget | Cosmetic; full descriptions are in the `tools/list` response — IDE UI is the limitation | -| `--allow-mutations=always` reads as a security loss | It is, scoped to that IDE. Audit log still captures every mutation; backend access controls are unaffected | Document the trust trade-off in your team's onboarding; remove the flag when the IDE adds elicitation upstream | +| `policy_refusal: no usable confirmation surface` | No MCP elicitation + stdin not TTY | Add `--allow-mutations always` to IDE args OR run from shell | +| Mutation timeout after 30s | Elicitation sent, client didn't render modal | Same. Likely the Copilot empty-schema issue if VS Code. | +| UI freezes on mutation | `confirm_via = "tty"` deadlocked TUI host | Set `confirm_via = "auto"` (default v0.16) | +| `secretenv: command not found` in IDE logs | IDE spawned with sparse `$PATH` | Re-run `secretenv mcp setup --ide --binary $(which secretenv)` | +| macOS binary SIGKILL on first run | Unsigned binary (copied via `cp`, not Homebrew) | `codesign --remove-signature ~/.cargo/bin/secretenv && codesign --sign - ~/.cargo/bin/secretenv` | +| Tool descriptions truncated | IDE UI character budget per tool | Cosmetic; full descriptions in `tools/list` response | +| `--allow-mutations=always` as security loss | True, scoped to that IDE; audit log + backend controls unaffected | Document trust trade-off in onboarding; remove when IDE adds elicitation | --- ## See also -- [`docs/reference/redact.md`](redact.md) — the `redact_file` tool wraps `secretenv redact` -- [`docs/reference/migrate.md`](migrate.md) — the `migrate_alias` tool wraps `secretenv registry migrate` -- [`docs/reference/configuration.md`](configuration.md) — full `[mcp]` config section reference -- [`kb/wiki/build-plan-v0.16-mcp.md`](https://github.com/TechAlchemistX/secretenv/blob/main/kb/wiki/build-plan-v0.16-mcp.md) — v0.16 design + implementation walkthrough -- [`kb/wiki/v0.16-phase-8b-checklist.md`](https://github.com/TechAlchemistX/secretenv/blob/main/kb/wiki/v0.16-phase-8b-checklist.md) — per-IDE manual gate sign-off -- [`kb/wiki/audits/2026-05-24-v0.16-phase9-*.md`](https://github.com/TechAlchemistX/secretenv/tree/main/kb/wiki/audits) — Phase 9 release-prep audit trio +- [Redaction](redact.md): the `redact_file` tool wraps `secretenv redact` +- [Registry migrate](migrate.md): the `migrate_alias` tool wraps `secretenv registry migrate` +- [CLI Reference: `secretenv mcp`](cli-reference-full.md#secretenv-mcp): the `mcp` subcommands and flags +- [Security & threat model](../security.md): the MCP no-leak invariants in context diff --git a/docs/reference/migrate.md b/docs/reference/migrate.md index 49f4296..8175013 100644 --- a/docs/reference/migrate.md +++ b/docs/reference/migrate.md @@ -1,7 +1,7 @@ # `secretenv registry migrate` `secretenv registry migrate` moves an alias's secret value from one backend to -another **in a single operation** — read the value from the current backend, +another **in a single operation**: read the value from the current backend, write it to the destination, then atomically flip the registry pointer so the alias resolves to the new location. No repo touches a backend URI directly, so a migration never requires a code change in any consuming project. @@ -27,11 +27,11 @@ secretenv registry migrate | Argument / flag | Default | Effect | |---|---|---| | `` | (required) | The registry alias to migrate. Must already exist in the active registry. | -| `` | (required) | The destination backend URI (e.g. `vault-prod:///secret/payments/stripe`). Must be a direct backend URI — not a `secretenv://` alias. The destination backend instance must be configured in `config.toml`. | +| `` | (required) | The destination backend URI (e.g. `vault-prod:///secret/payments/stripe`). Must be a direct backend URI, not a `secretenv://` alias. The destination backend instance must be configured in `config.toml`. | | `--dry-run` | off | Plan-only mode. Runs the source/destination probes, prints the migration plan, and exits **without any mutation**. Use this first on every migration. | | `--yes` / `-y` | off | Skip the top-level confirmation prompt. **Does not** skip the `--delete-source` confirmation (see below). | | `--from ` | (inferred) | Override the source URI. By default the source is the alias's current registry pointer; `--from` is for recovery flows where the pointer was already flipped but the value still lives in the old backend. | -| `--delete-source` | off | After a successful migration, delete the value from the source backend. Opt-in and separately confirmed — see [`--delete-source`](#delete-source-opt-in-cleanup). | +| `--delete-source` | off | After a successful migration, delete the value from the source backend. Opt-in and separately confirmed. See [`--delete-source`](#opt-in-source-cleanup). | | `--json` | off | Emit a machine-readable `MigrateReport` as JSON to **stdout**. For CI consumption. Without `--json`, the human progress + summary output goes to **stderr** (so `2>/dev/null` suppresses it). | | `--registry ` | active registry | Select which registry to operate on. Same semantics as `secretenv run --registry`. | @@ -41,33 +41,26 @@ secretenv registry migrate A migration is a **three-step transaction** with an optional fourth cleanup step: -1. **Read** — fetch the value from the source backend. -2. **Write** — write the value to the destination backend. -3. **Pointer flip** — rewrite the registry document so the alias points at the +1. **Read**: fetch the value from the source backend. +2. **Write**: write the value to the destination backend. +3. **Pointer flip**: rewrite the registry document so the alias points at the destination URI. **This is the commit point.** -4. **Source delete** *(optional)* — only when `--delete-source` is passed and +4. **Source delete** *(optional)*: only when `--delete-source` is passed and separately confirmed. The commit point is step 3. Before it, nothing the operator depends on has -changed — the alias still resolves to the source. After it, the alias resolves +changed. The alias still resolves to the source. After it, the alias resolves to the destination. ### What is NOT atomic -The pointer flip itself is implemented as read-modify-write of the registry -document (`list` → mutate → `set`). It is **not** atomic at the storage layer: -a concurrent `secretenv registry set` against the same registry instance can -race it. The window is one round-trip and registry mutations are rare, -operator-driven events — but if you script migrations, **serialize them** -against a single registry instance. A content-addressed (`cas_set`) protocol -is planned for a later release. +The pointer flip is a read-modify-write (`list` → mutate → `set`), not atomic at the storage layer. Concurrent `secretenv registry set` calls against the same registry instance can race it. The window is one round-trip and mutations are rare, operator-driven events. But if you script migrations, **serialize them** against a single registry instance. A content-addressed (`cas_set`) protocol is planned for a later release. --- ## Dry run -Always dry-run a migration first. `--dry-run` probes both ends and prints the -plan with zero mutation: +Always dry-run first. `--dry-run` probes both ends with zero mutation: ``` $ secretenv registry migrate stripe-key vault-prod:///secret/payments/stripe --dry-run @@ -84,23 +77,18 @@ Probes: Dry-run complete. No changes made. Remove --dry-run to execute. ``` -The **probe line** for the destination distinguishes two cases: +The **probe line** distinguishes two cases: -- `ok (probed)` — the backend ran a real write-permission probe and it passed. - Only HashiCorp Vault does this in the current release (`vault token - capabilities`). -- `ok (no probe available for this backend)` — the backend has no cheap - permission probe; the dry-run cannot pre-verify write access. The real write - in a non-dry-run will surface any permission failure. +- `ok (probed)`: the backend ran a real write-permission probe (e.g. `vault token capabilities`). Only Vault does this currently. +- `ok (no probe available for this backend)`: no cheap permission probe; the real write will surface any failure. -A destination probe that **definitively** fails (e.g. Vault returns a `deny` -capability) aborts the dry-run — and a real migration — before any read. +A **definitive** probe failure (e.g. Vault returns `deny`) aborts before any read. --- ## Confirmation prompts -By default `migrate` prompts once before doing anything: +By default, one prompt before any mutation: ``` About to migrate stripe-key: @@ -118,22 +106,9 @@ Continue? [y/N] --- -## `--delete-source`: opt-in cleanup +## Opt-in source cleanup -By default a migration **leaves the source value in place**. The success -message prints a copy-paste cleanup command: - -``` -Migration complete. - alias: stripe-key - probe / read / write / flip ms: 84 / 341 / 92 / 121 - source value still present. To remove it: - op item edit "Stripe" "api_key=" --vault "Payments" -``` - -To delete the source value as part of the migration, pass `--delete-source`. -This triggers a **second confirmation** that fires **even when `--yes` is -set** — destructive cleanup is never silent: +By default migrations **leave the source value in place** and print a copy-paste cleanup command. To delete the source as part of the migration, pass `--delete-source`. This triggers a **second confirmation** even when `--yes` is set. Destructive cleanup is never silent: ``` 4/4 About to permanently delete 1password-work://Payments/Stripe/api_key. @@ -141,27 +116,17 @@ set** — destructive cleanup is never silent: Continue? [y/N] ``` -The second prompt fires **after** the pointer-flip commit. If you decline it, -the migration is already complete — the alias resolves to the destination — -and the source value simply remains for you to clean up manually. Declining is -not an error; the command still exits 0. +The second prompt fires **after** the pointer-flip commit. Declining leaves the migration complete (alias already resolves to destination) but the source value remains for manual cleanup. Not an error; exits 0. -If the source delete itself fails (after a successful commit), the migration -is still complete. The outcome is reported as a cleanup failure, and the -copy-paste cleanup command is printed so you can retry by hand. +If source delete fails post-commit, migration is still complete. The outcome is reported as a cleanup failure with a copy-paste cleanup command for manual retry. --- ## Partial failure -If the destination **write** fails, nothing was committed — the source is -untouched and the alias still resolves to it. Re-run when the destination is -healthy. +If the destination **write** fails, nothing is committed: source untouched, alias still resolves to it. Re-run when the destination is healthy. -If the **pointer flip** fails *after* a successful destination write, the -value now exists in **both** backends. `migrate` does **not** auto-roll-back by -deleting the destination — automatic destructive recovery is never performed. -Instead it prints the manual recovery options: +If the **pointer flip** fails after a successful write, the value exists in **both** backends. `migrate` does **not** auto-roll-back. Automatic destructive recovery never happens. Instead it prints manual recovery options: ``` Error: migration partially failed. @@ -181,36 +146,24 @@ To roll back (delete from destination): VAULT_ADDR=https://vault.example.com vault kv delete secret/payments/stripe ``` -You decide whether to complete or roll back. The recovery block is printed to -your terminal only — it is never emitted to logs or telemetry. +You decide whether to complete or roll back. The recovery block is terminal-only; never emitted to logs or telemetry. --- ## Per-backend write capability -A migration's destination must be a backend that can be written to. +A migration's destination must support writes. -- **Native destinations** (12 backends) — `local`, `aws-ssm`, `aws-secrets`, - `vault`, `gcp`, `azure`, `keychain`, `doppler`, `infisical`, `cf-kv`, - `openbao`, `conjur` — accept migrations with no extra configuration. -- **Gated destinations** (`1password`, `bitwarden-sm`, and `keeper` for - writes) — refuse to be a migration destination unless you opt in via the - backend's `*_unsafe_set` config flag (`op_unsafe_set`, - `bitwarden_unsafe_set`, `keeper_unsafe_set`). These backends' CLIs pass the - value through process arguments, a known exposure on multi-user hosts; the - flag is your explicit acknowledgement. Without it, `migrate` fails fast with - a clear message naming the flag, before reading the source value. +- **Native destinations** (12 backends), `local`, `aws-ssm`, `aws-secrets`, `vault`, `gcp`, `azure`, `keychain`, `doppler`, `infisical`, `cf-kv`, `openbao`, `conjur`, accept migrations with no extra configuration. +- **Gated destinations** (`1password`, `bitwarden-sm`, `keeper`), require explicit opt-in via `*_unsafe_set` flags (`op_unsafe_set`, `bitwarden_unsafe_set`, `keeper_unsafe_set`). Their CLIs pass values through process arguments, a known multi-user host exposure. Without the flag, `migrate` fails fast, before reading the source. -Some destination backends require the destination container to **already -exist** — for example AWS Secrets Manager and GCP Secret Manager will not -auto-create a secret, and 1Password will not auto-create an item. Create the -empty destination first, then migrate into it. +Some destination backends require the container to **already exist** (e.g. AWS Secrets Manager, GCP Secret Manager). Create the empty destination first, then migrate into it. --- ## JSON output -`--json` emits a machine-readable report to stdout: +Machine-readable report to stdout: ```json { @@ -233,13 +186,9 @@ empty destination first, then migrate into it. } ``` -Source and destination **URI bodies are never emitted** to JSON — only the -backend *type* strings. The cleanup hint (which contains URI path components) -is terminal-only and is likewise absent from JSON. This keeps `--json` output -safe to pipe into a log aggregator. +Source and destination **URI bodies never appear in JSON**: only backend *type* strings. The cleanup hint (which contains URI paths) is terminal-only. This keeps `--json` safe to pipe into a log aggregator. -`outcome` is one of `success`, `dry-run`, `source-delete-failed-post-commit`, -or `partial-failure-pointer-flip`. +`outcome` is one of `success`, `dry-run`, `source-delete-failed-post-commit`, or `partial-failure-pointer-flip`. --- @@ -252,14 +201,14 @@ secretenv registry migrate db-url aws-ssm-prod:///prod/db-url --dry-run # Execute, with the top-level prompt. secretenv registry migrate db-url aws-ssm-prod:///prod/db-url -# Non-interactive (CI). Top-level prompt skipped; still safe — no deletion. +# Non-interactive (CI). Top-level prompt skipped; still safe, no deletion. secretenv registry migrate db-url aws-ssm-prod:///prod/db-url --yes # Migrate and clean up the source (second confirmation still fires). secretenv registry migrate db-url aws-ssm-prod:///prod/db-url --delete-source # Recovery: the pointer was already flipped but the value is still in the old -# backend — point --from at the stale source explicitly. +# backend. Point --from at the stale source explicitly. secretenv registry migrate db-url aws-ssm-prod:///prod/db-url \ --from vault-old:///secret/db-url diff --git a/docs/reference/opentelemetry.md b/docs/reference/opentelemetry.md index a47b6b8..4cd219f 100644 --- a/docs/reference/opentelemetry.md +++ b/docs/reference/opentelemetry.md @@ -1,8 +1,8 @@ # OpenTelemetry -SecretEnv emits OpenTelemetry traces and metrics for every secret resolution, backend probe, MCP tool call, redaction event, and migration. Telemetry is **opt-in** — set `OTEL_EXPORTER_OTLP_ENDPOINT` to point at any OTLP-compatible collector (Jaeger, Tempo, Honeycomb, Datadog, the OTel collector itself). With no endpoint configured, SecretEnv installs no exporter and has zero startup overhead. +SecretEnv emits traces and metrics for secret resolution, backend probes, MCP tool calls, redactions, and migrations. Telemetry is **opt-in**: set `OTEL_EXPORTER_OTLP_ENDPOINT` to point at any OTLP-compatible collector (Jaeger, Tempo, Honeycomb, Datadog, etc.). With no endpoint, SecretEnv installs no exporter and has zero startup overhead. -This document is the **audit-facing contract**. Every attribute SecretEnv emits is enumerated below with an explicit ALLOW/DENY classification. The classifications are enforced at compile time: the typed `SecretEnvSpan` builder in `secretenv-telemetry` exposes one method per ALLOW attribute and no method for any DENY attribute. There is no `set_attribute(key, value)` escape hatch. +This document is the **audit-facing contract**. Every emitted attribute has an explicit ALLOW/DENY classification enforced at compile time: the typed `SecretEnvSpan` builder exposes one method per ALLOW attribute and no generic `set_attribute` escape hatch. --- @@ -24,7 +24,7 @@ SecretEnv does **not** require a collector to be useful. The `secretenv doctor - ## 2. Attribute schema -The full attribute matrix. **ALLOW** attributes have a typed setter on `SecretEnvSpan` and may appear on emitted spans. **DENY** attributes have no setter and cannot be emitted; the typed builder enforces this at compile time. +**ALLOW** attributes have typed setters on `SecretEnvSpan` and appear on spans. **DENY** attributes have no setter; compile-time enforcement prevents emission. ### 2.1 SecretEnv core @@ -68,10 +68,10 @@ The full attribute matrix. **ALLOW** attributes have a typed setter on `SecretEn | `secretenv.backend.cli.version` | ALLOW | Useful for triage | | `secretenv.backend.cli.identity` | **DENY** | Account email / ARN | | `secretenv.backend.auth_method` | ALLOW | Closed enum: `env-token` / `cli-session` / `instance-role` / `service-account-key` / `oauth-refresh` / `none` / `unknown`; never the credential | -| `secretenv.backend.probe.level` | ALLOW | Closed enum `l1-cli` / `l2-auth` / `l3-read` (per-backend probe depth, shared with the `backend.probe.count` metric). Distinct from `secretenv.doctor.check_level` (the doctor invocation mode). The run-path `secretenv.backend.probe` span emits `l3-read` (it wraps a real read; v0.20 Arch-W-2 will stage true L1/L2/L3 probing). | +| `secretenv.backend.probe.level` | ALLOW | Closed enum `l1-cli` / `l2-auth` / `l3-read` (per-backend probe depth, shared with the `backend.probe.count` metric). Distinct from `secretenv.doctor.check_level` (the doctor invocation mode). The run-path `secretenv.backend.probe` span emits `l3-read` (it wraps a real read; v0.20 will stage true L1/L2/L3 probing). | | `secretenv.backend.probe.outcome` | ALLOW | Closed enum `ok` / `cli-missing` / `not-authenticated` / `registry-unreachable` / `timeout` / `unknown` (shared with the `backend.probe.count` metric) | | `secretenv.backend.error.kind` | ALLOW | Closed enum `SecretEnvErrorKind` | -| `secretenv.backend.error.message` | **DENY by default** | Per-run opt-in via `--otel-include-error-detail`; even then, scrubbed via SEC-INV-20 before any emission | +| `secretenv.backend.error.message` | **DENY by default** | Per-run opt-in via `--otel-include-error-detail`; even then, scrubbed before any emission | | `secretenv.backend.error.cli_stderr` | **DENY** | Raw stderr; topology + credential leak risk | | `secretenv.backend.fetch.outcome` | ALLOW | Operational | | `secretenv.backend.fetch.timeout_ms` | ALLOW | | @@ -102,7 +102,7 @@ The full attribute matrix. **ALLOW** attributes have a typed setter on `SecretEn | `secretenv.resolution.latency_ms` | ALLOW | | | `secretenv.run.dry_run` | ALLOW | bool | | `secretenv.run.verbose` | ALLOW | bool | -| `secretenv.run.command_name` | ALLOW | Basename of `argv[0]` only — any absolute or relative path prefix is stripped before emission to avoid leaking host filesystem layout (Phase 9b Sec F-1) | +| `secretenv.run.command_name` | ALLOW | Basename of `argv[0]` only, any absolute or relative path prefix is stripped before emission to avoid leaking host filesystem layout | | `secretenv.run.command_argv` | **DENY** | Full argv may contain secrets | | `secretenv.run.env_var_count` | ALLOW | Aggregate | | `secretenv.run.env_var_value` | **DENY** | | @@ -121,7 +121,7 @@ The full attribute matrix. **ALLOW** attributes have a typed setter on `SecretEn | `secretenv.redact.replacement_token` | ALLOW | The literal token written in place of the match | | `secretenv.redact.match_context` | ALLOW | `exact` / `substring` / `base64-form` | | `secretenv.redact.source` | ALLOW | Closed enum: `mode-a` (runtime pipe) / `mode-b` (post-hoc file rewrite). Distinguishes which redaction path scrubbed the match for percentile-by-mode triage. | -| `secretenv.redact.alias_name` | **DENY in OTel** | SEC-INV-19. The alias name appears in the operator-local redaction token; it does **not** appear as an OTel attribute. (Resolves the conflict between the OTel spec's permissive position and the security invariant; security wins for OTel emission.) | +| `secretenv.redact.alias_name` | **DENY in OTel** | The alias name appears in the operator-local redaction token; it does **not** appear as an OTel attribute. (Resolves the conflict between the OTel spec's permissive position and the security invariant; security wins for OTel emission.) | | `secretenv.redact.matched_value` / `.value_length` | **DENY** | | ### 2.7 Migrate @@ -137,7 +137,7 @@ The full attribute matrix. **ALLOW** attributes have a typed setter on `SecretEn | `secretenv.migrate.value` | **DENY** | The migrated secret value never appears on any attribute; explicit fail-closed guard row | | `secretenv.migrate.phase` | ALLOW | Closed enum: `probe` / `read` / `write` / `pointer-flip` / `delete-source` | | `secretenv.migrate.outcome` | ALLOW | Closed enum | -| `secretenv.migrate.partial_failure_stage` | ALLOW | Closed enum (same as `phase`); reserved — schema slot is locked but no typed setter is wired yet (a caller lands when the partial-failure path emits it) | +| `secretenv.migrate.partial_failure_stage` | ALLOW | Closed enum (same as `phase`); reserved. Schema slot is locked but no typed setter is wired yet (a caller lands when the partial-failure path emits it) | | `secretenv.migrate.delete_source` | ALLOW | bool | | `secretenv.migrate.transaction_id` | ALLOW | UUIDv4 | | `secretenv.migrate.collapsed` | ALLOW | bool; `true` when the transaction collapses into a single backend-side atomic operation (no backend exposes this yet; emitted as `false` in v0.18) | @@ -155,7 +155,7 @@ The full attribute matrix. **ALLOW** attributes have a typed setter on `SecretEn | `secretenv.mcp.mutation_confirmed` | ALLOW | bool | | `secretenv.mcp.argument_alias_name` | ALLOW | Mutation audit needs this | | `secretenv.mcp.argument_uri` | **DENY** | URI topology | -| `secretenv.mcp.argument_reason` | **DENY** | SEC-INV-12. Prompt-injection vehicle; appears in audit log only, never as an OTel attribute | +| `secretenv.mcp.argument_reason` | **DENY** | Prompt-injection vehicle; appears in audit log only, never as an OTel attribute | | `secretenv.mcp.resolved_value` / `.tool.output_raw` | **DENY** | | ### 2.9 Doctor & gen @@ -176,63 +176,31 @@ The full attribute matrix. **ALLOW** attributes have a typed setter on `SecretEn |---|---|---| | `service.name` | ALLOW | OTel standard resource | | `service.version` | ALLOW | OTel standard resource | -| `host.name` / `host.arch` / `os.type` / `process.pid` | ALLOW | OTel standard resource conventions. **v0.18 Sec-L-3 note:** `host.name` is set from `hostname::get()`, which on corporate CI runners and bare-metal hosts may surface an FQDN like `ip-10-0-1-23.us-west-2.compute.internal` or `runner-prod-build-42.corp.example.com` — those carry network topology hints. Operators who want to scrub or pin this attribute can override via `OTEL_RESOURCE_ATTRIBUTES=host.name=` at the process env layer; it's last-write-wins against our default emission. | +| `host.name` / `host.arch` / `os.type` / `process.pid` | ALLOW | OTel standard resource conventions. **Note:** `host.name` is set from `hostname::get()`, which on corporate CI runners and bare-metal hosts may surface an FQDN like `ip-10-0-1-23.us-west-2.compute.internal` or `runner-prod-build-42.corp.example.com`, those carry network topology hints. Operators who want to scrub or pin this attribute can override via `OTEL_RESOURCE_ATTRIBUTES=host.name=` at the process env layer; it's last-write-wins against our default emission. | | `deployment.environment.name` | ALLOW (opt-in only) | NOT auto-inferred from `CI=true`; operator-supplied via `[otel]` config or `OTEL_RESOURCE_ATTRIBUTES` | -**Matrix totals:** 74 ALLOW · 31 DENY (30 hard-DENY + 1 DENY-by-default) · **105 attributes** — mirrors the authoritative `secretenv-telemetry::policy::CANONICAL` table one-for-one. (A few rows group sibling attributes — e.g. the three `.alias.uri*` variants and the three `.value*` variants share a row — so the visible row count is lower than 105.) +**Matrix totals:** 74 ALLOW · 31 DENY (30 hard-DENY + 1 DENY-by-default) · **105 attributes**, mirrors the authoritative `secretenv-telemetry::policy::CANONICAL` table one-for-one. (A few rows group sibling attributes, e.g. the three `.alias.uri*` variants and the three `.value*` variants share a row, so the visible row count is lower than 105.) --- ## 3. Redaction taxonomy (Tier 1 / Tier 2) -SecretEnv classifies every data element into one of two tiers: +- **Tier 1, never emitted.** Secret values, raw backend stderr, raw MCP output, full argv, env var values, account IDs, internal addresses, URI paths. The `SecretEnvSpan` builder makes such emission a compile error. +- **Tier 2, trusted surfaces only.** Operator-stated identifiers, closed-enum outcomes, aggregate counts, timing. Appear on spans, metrics, and `--verbose` output. -- **Tier 1 — Never emitted to any surface.** Secret values themselves, raw backend stderr, raw MCP tool output, full argv, environment variable values, backend account IDs, internal addresses, URI paths. No code path inside SecretEnv emits a Tier 1 element to telemetry — and the `SecretEnvSpan` builder makes such emission a compile error rather than a runtime check. -- **Tier 2 — Emitted to trusted surfaces only.** Operator-stated identifiers (alias names, registry names, instance labels), closed-enum operational outcomes, aggregate counts, timing data. These appear on spans, on metrics, and in the operator's terminal under `--verbose`. +**Set-site enforcement.** Every ALLOW attribute has one typed method on `SecretEnvSpan` (e.g. `record_alias_name`, `record_backend_type`). v0.17 ships 26 active setters; the remaining ALLOW attributes are schema-locked and gain setters as callers wire them (metrics, operator UX, or downstream cycles). Adding a setter is a PR-reviewed code change tied to doc entry. No generic `set_attribute(key, value)` escape hatch exists. A CI gate (`scripts/check_tracing_leaks.sh`) fails the build on any `Secret::expose_secret`, `{value}`, `{uri.raw}`, or `{secret}` inside `tracing::*!` macro arguments. -**Set-site enforcement (SEC-INV-04).** Every ALLOW attribute that any code path emits has exactly one typed method on `SecretEnvSpan` (e.g. `record_alias_name`, `record_backend_type`). v0.17 ships the 26 setters active callers in `secretenv-core` / `secretenv-migrate` / `secretenv-mcp` / `secretenv-cli` need; the remaining ALLOW attributes listed in §2 are part of the locked schema and gain typed setters as Phase 4/6 work (metrics, operator UX) or downstream cycles wire callers for them — adding a new setter is itself a PR-reviewed code change, so a setter cannot land without the corresponding doc entry. The builder does **not** expose a generic `set_attribute(key: &str, value: &str)`, so no call site can smuggle an attribute that lacks a setter. A CI grep gate (`scripts/check_tracing_leaks.sh`) fails the build if any call site references `Secret::expose_secret`, `{value}`, `{uri.raw}`, or `{secret}` inside a `tracing::*!` macro argument list. - -**Scrubbing of `backend.error.message`.** The single exception to the "no free-string attribute" rule is `backend.error.message`, which is **DENY by default**. Operators may opt in per-run via `--otel-include-error-detail`. Even when opted in, the message is passed through the SEC-INV-20 scrubber before emission: URI-shaped substrings are replaced with ``, AWS 12-digit account numbers with ``, and high-entropy tokens (32+ chars from the base64-safe alphabet `A-Za-z0-9+/=_-`) with ``. Raw backend stderr (`backend.error.cli_stderr`) remains DENY in all cases. - -> **Status (v0.19).** The `--otel-include-error-detail` flag, the SEC-INV-20 scrubber, and the actual emission of `secretenv.backend.error.message` all ship as of v0.19 (Sec-F-5 wire-up). Prior to v0.19 the flag and scrubber existed but emission was a reserved no-op; v0.19 closes that gap. The DENY-by-default posture remains: `secretenv.backend.error.message` is structurally absent from any span unless the operator passes `--otel-include-error-detail` at run time. Even when opted in, the message is passed through the SEC-INV-20 scrubber before emission. Raw backend stderr (`backend.error.cli_stderr`) remains DENY in all cases. +**Scrubbing of `backend.error.message`.** This is **DENY by default**, enabled per-run via `--otel-include-error-detail`. When opted in, the scrubber replaces URIs with ``, AWS account numbers with ``, and high-entropy tokens (32+ chars, base64-safe alphabet) with ``. Raw backend stderr (`backend.error.cli_stderr`) is always DENY. (v0.19: shipped both scrubber and emission; prior versions had the flag but emission was a no-op.) --- ## 4. Span topology -Root spans correspond to top-level invocations. Child spans correspond to logical phases. Span names are stable and form part of the audit contract. - -> **v0.17 shipped subset.** v0.17 ships the load-bearing spans: -> `secretenv.run`, `secretenv.resolution`, `secretenv.backend.fetch`, -> `secretenv.redact.filter_event`, `secretenv.registry.migrate` (+ its -> 5 phase children: `probe` / `read` / `write` / `pointer_flip` / -> `delete`), `secretenv.doctor.backend`, and `secretenv.mcp.tool.` -> for all 14 MCP tools. -> -> **v0.18 update (Phase 4, Arch-M6 subset):** 5 of the 11 previously -> schema-reserved spans now emit: -> - `secretenv.manifest.load` — `Manifest::load_from` -> - `secretenv.registry.load` — `resolve_registry` -> - `secretenv.backend.probe` — `fetch_one` (sibling of -> `secretenv.backend.fetch` — parent-child linkage tracked separately -> under Arch-M1, deferred to v0.20) -> - `secretenv.exec.prepare` — `exec_with_env` -> - `secretenv.doctor.registry` — `run_doctor` -> -> The following **6 spans** remain schema-reserved and **not emitted**: -> -> - §4.1 run subtree: `secretenv.exec.flush` (hand-off to `execve` -> covered by an explicit `flush_before_exec` call; emitting as a -> span would require an `execve`-aware lifecycle with a `pre_exec` -> hook + manual flush sequencing — deferred to v0.20) -> - §4.3 doctor subtree: `secretenv.doctor` root -> - §4.4 MCP subtree: `secretenv.mcp.policy.evaluate`, -> `secretenv.mcp.confirm`, `secretenv.registry.transaction`, -> `secretenv.audit.append` -> -> The MCP policy/confirm/audit events are captured in `audit_log.rs` -> as structured records but not as OTel spans. None of the remaining -> schema-reserved spans affect any SEC-INV invariant. +Root spans correspond to top-level invocations; child spans to logical phases. Span names are stable and part of the audit contract. + +**v0.17+ status:** +- **Emitted:** `secretenv.run`, `secretenv.resolution`, `secretenv.backend.fetch`, `secretenv.redact.filter_event`, `secretenv.registry.migrate` (+ 5 phase children: `probe`/`read`/`write`/`pointer_flip`/`delete`), `secretenv.doctor.backend`, `secretenv.mcp.tool.` (all 14 tools), `secretenv.manifest.load`, `secretenv.registry.load`, `secretenv.backend.probe`, `secretenv.exec.prepare`, `secretenv.doctor.registry` (v0.18+). +- **Schema-reserved, not emitted:** `secretenv.exec.flush` (deferred to v0.20; `execve` hand-off covered by explicit `flush_before_exec`), `secretenv.doctor` root, `secretenv.mcp.policy.evaluate`, `secretenv.mcp.confirm`, `secretenv.registry.transaction`, `secretenv.audit.append` (MCP events captured in `audit_log.rs` but not as OTel spans). None affect the security invariants. ### 4.1 `secretenv.run` @@ -268,11 +236,11 @@ secretenv.doctor (root; one per `secretenv doctor` invocatio └── … ``` -> **Topology note (v0.18 Phase 7b Arch-F-10):** `secretenv.doctor.registry` +> **Topology note:** `secretenv.doctor.registry` > is emitted as a SIBLING of `secretenv.doctor.backend`, not a parent. > This matches the §4.1 flat-topology compromise: parent-child linkage > between higher-level orchestration spans and per-resource spans is -> deferred to **v0.20** under the Arch-M1 hierarchical-topology pass. +> deferred to **v0.20** under the hierarchical-topology pass. > Earlier revisions of this spec drew the relationship as parent-child; > that diagram was aspirational, not implemented. @@ -286,7 +254,7 @@ secretenv.mcp.tool.set_alias (or .delete_alias / .migrate_alias / .gen_pa └── secretenv.audit.append ``` -Mutation tool spans (`set_alias`, `delete_alias`, `migrate_alias`, `gen_password`) are non-droppable — see §6. +Mutation tool spans (`set_alias`, `delete_alias`, `migrate_alias`, `gen_password`) are non-droppable. See §6. --- @@ -298,7 +266,7 @@ Mutation tool spans (`set_alias`, `delete_alias`, `migrate_alias`, `gen_password | `secretenv.resolution.count` | Counter | `{resolution}` | `registry.name`, `run.outcome` | Low | | `secretenv.backend.probe.count` | Counter | `{probe}` | `backend.type`, `backend.instance_name`, `probe.level`, `probe.outcome` | O(instances × 18) | | `secretenv.backend.fetch.duration` | Histogram | `ms` | `backend.type`, `backend.instance_name`, `fetch.outcome` | O(backends × 3); `alias.name` explicitly excluded | -| `secretenv.redact.events` | Counter | `{event}` | `redact.mode`, `redact.match_context` | Low; `alias.name` excluded per SEC-INV-19 | +| `secretenv.redact.events` | Counter | `{event}` | `redact.mode`, `redact.match_context` | Low; `alias.name` excluded | | `secretenv.mcp.tool.calls` | Counter | `{call}` | `mcp.tool_name`, `mcp.outcome` | Low (closed enum tool names) | | `secretenv.mcp.tool.duration` | Histogram | `ms` | `mcp.tool_name`, `mcp.outcome` | Low | | `secretenv.doctor.failure.count` | Counter | `{failure}` | `backend.type`, `backend.instance_name`, `probe.outcome` | Low (failure only; success silent) | @@ -316,22 +284,14 @@ Mutation tool spans (`set_alias`, `delete_alias`, `migrate_alias`, `gen_password ## 6. Sampling -**Default sampler:** `parentbased_always_on`. Secret resolution is a rare, high-value event (50/developer-day, 500/CI-day typical). Sampling drops audit value without meaningful cardinality benefit. +**Default sampler:** `parentbased_always_on`. Secret resolution is rare and high-value (50/developer-day, 500/CI-day typical); sampling drops audit value with minimal cardinality gain. -**Mutation spans are non-droppable.** A custom sampler wrapper returns `RecordAndSample` for spans whose name matches the mutation set, regardless of the parent sampler decision: +**Mutation spans are non-droppable.** A custom sampler returns `RecordAndSample` for mutations regardless of parent sampler: -- `secretenv.mcp.tool.set_alias` -- `secretenv.mcp.tool.delete_alias` -- `secretenv.mcp.tool.migrate_alias` -- `secretenv.mcp.tool.gen_password` -- `secretenv.migrate.read` -- `secretenv.migrate.write` -- `secretenv.migrate.pointer_flip` -- `secretenv.migrate.delete` +- `secretenv.mcp.tool.set_alias`, `delete_alias`, `migrate_alias`, `gen_password` +- `secretenv.migrate.read`, `write`, `pointer_flip`, `delete` -This is the canonical mutation set — the eight variants of `MutationSpanName` (`secretenv_telemetry::span`), which is the single source of truth for both the span name (via `start_mutation`) and the sampler whitelist. `secretenv.migrate.probe` is **not** in the set: the probe phase is read-only. - -This implements SEC-INV-22: mutation events are never absent from the trace stream, even when the operator has configured aggressive ratio sampling for high-volume CI. +The canonical set is `MutationSpanName` (`secretenv_telemetry::span`), the single source of truth for span names and sampler whitelist. `secretenv.migrate.probe` is excluded (read-only). This ensures mutation events are never absent from the trace stream. **Override:** @@ -359,25 +319,25 @@ OTEL_TRACES_SAMPLER_ARG=0.1 # 10% for high-volume CI; mutations still emit at | `OTEL_TRACES_SAMPLER_ARG` | Sampler arg | none | | `OTEL_PROPAGATORS` | Context propagators | `tracecontext,baggage` | -**Service name.** Default is `secretenv`. Operators using per-project naming should set `OTEL_RESOURCE_ATTRIBUTES=service.name=payments-secretenv`. SecretEnv deliberately does **not** auto-derive the service name from the git repo name — that introduces cardinality explosions in trace backends and breaks comparison across forks. +**Service name.** Default is `secretenv`. Per-project naming: `OTEL_RESOURCE_ATTRIBUTES=service.name=payments-secretenv`. SecretEnv does not auto-derive from git repo (cardinality explosion + breaks fork comparison). -**Merge precedence (highest wins):** `OTEL_*` env vars > machine config `[otel]` > `secretenv.toml` `[otel]` > SecretEnv defaults. +**Merge precedence:** `OTEL_*` env vars > machine `[otel]` > `secretenv.toml` `[otel]` > defaults. -**No-op default.** When none of `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_TRACES_EXPORTER`, `OTEL_METRICS_EXPORTER` is set, SecretEnv installs no exporter. Zero startup overhead. Zero noise. The OTel deps are still linked in but no `TracerProvider` is created. +**No-op default.** When none of `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_TRACES_EXPORTER`, `OTEL_METRICS_EXPORTER` is set, no exporter is installed. Zero startup overhead. OTel deps remain linked but inert. -**Flush before exec().** Before `secretenv run` calls `execve()` to hand off to the child binary, SecretEnv invokes `force_flush()` on the configured exporter with a **bounded 1-second `tokio::time::timeout`**. If the collector is slow or unreachable, pending spans are dropped silently and a `tracing::debug!` event is emitted (`otel flush timed out; dropping pending spans`). A down or slow collector degrades to data loss, never to a latency cliff on every `secretenv run` invocation. The SEC-INV-22 test `exec_flush.rs` verifies this bound. +**Flush before exec().** Before `execve()`, SecretEnv calls `force_flush()` with a **1-second `tokio::time::timeout`**. Slow/unreachable collectors drop spans silently and emit `tracing::debug!` (`otel flush timed out`). Data loss acceptable; latency cliff is not. The `exec_flush.rs` test verifies the bound. -**W3C trace context propagation.** Inbound only in v0.17. SecretEnv honors `TRACEPARENT` and `TRACESTATE` env vars set by parent CI systems, attaching SecretEnv's root span as a child of the parent trace. SecretEnv does **not** propagate context out to the child process started by `secretenv run` — wiring arbitrary child binaries into the trace tree requires instrumenting those binaries, which is not SecretEnv's responsibility. Child-process propagation is deferred to v1.0+. +**W3C trace context propagation.** Inbound only. SecretEnv honors `TRACEPARENT` and `TRACESTATE` env vars from parent CI, attaching its root span as a child. Outbound propagation to child processes is deferred (requires instrumenting child binaries; not SecretEnv's responsibility). --- ## 8. Operator UX without a collector -You do not need a collector to get observability value from SecretEnv. Three modes ship in v0.17: +No collector required for observability: ### 8.1 `secretenv run --verbose` -Per-alias resolution timing on stderr. No collector required. +Per-alias resolution timing on stderr: ``` $ secretenv run --verbose -- ./deploy.sh @@ -389,7 +349,7 @@ secretenv: 3 aliases resolved in 422ms ### 8.2 `secretenv doctor --trace` -Local span table render. Runs a dry-run resolution pass against the configured registries, captures spans to an in-process `InMemorySpanExporter`, and renders the result. No OTLP endpoint required. +Local span table. Dry-run resolution against configured registries, in-memory capture, table render: ``` $ secretenv doctor --trace @@ -404,7 +364,7 @@ Spans (dry-run resolution pass): ### 8.3 `OTEL_TRACES_EXPORTER=console` -For ad-hoc debugging of a single invocation, the stdout exporter writes the full span tree to stderr in OTel's standard JSON form. Useful when comparing emitted spans to the schema in §2. +Writes full span tree to stderr in OTel JSON form. Useful for schema comparison (§2): ``` $ OTEL_TRACES_EXPORTER=console secretenv run -- echo hello @@ -429,48 +389,42 @@ OTel collector: ## 9. Compliance & audit considerations -**OTel traces are operational data, not compliance audit logs.** SecretEnv's OTel emission has no integrity guarantee, no append-only enforcement, no signing, and no replay protection. Traces are best-effort observational data for operators and SREs. For regulated environments (SOC2, ISO27001, HIPAA), use the **backend's own audit trail** as the compliance artifact: - -- **Vault** — Vault audit log (file or socket sink) -- **OpenBao** — same as Vault -- **AWS Secrets Manager / SSM** — AWS CloudTrail events -- **1Password** — 1Password Business audit events -- **GCP Secret Manager** — Cloud Audit Logs +**OTel traces are operational data, not compliance audit logs.** No integrity guarantee, no append-only enforcement, no signing. For regulated environments (SOC2, ISO27001, HIPAA), use the **backend's audit trail** (Vault audit log, AWS CloudTrail, 1Password Business events, GCP Cloud Audit Logs). -SecretEnv's MCP layer additionally maintains its own append-only audit log at `~/.config/secretenv/audit.log` (configurable via `[mcp].audit_log_path`), with `flock(LOCK_EX)` serialization and size-based rotation. The MCP audit log is the compliance artifact for MCP-mediated mutations; OTel spans on those mutations are operational data only. +SecretEnv's MCP layer maintains an append-only audit log at `~/.config/secretenv/audit.log` (configurable via `[mcp].audit_log_path`), with `flock(LOCK_EX)` serialization and rotation. The MCP audit log is the compliance artifact for MCP mutations; OTel spans are operational data. -**Verifiable claim: no secret value reaches any OTel attribute.** The `SecretEnvSpan` builder exposes one typed setter per ALLOW attribute and no generic `set_attribute` method. The `secretenv-telemetry/tests/no_escape_hatch.rs` compile-test asserts the absence of `set_attribute`. The `secretenv-telemetry/tests/no_redact_alias_in_otel.rs` test asserts `set_redact_alias_name` does not exist (SEC-INV-19). The CI grep gate `scripts/check_tracing_leaks.sh` fails the build on any reference to `Secret::expose_secret`, `{value}`, `{uri.raw}`, or `{secret}` inside a `tracing::*!` macro. These are structural enforcements, not runtime checks. +**Verifiable: no secret value in any OTel attribute.** `SecretEnvSpan` has one typed setter per ALLOW attribute, no generic `set_attribute`. Enforced via: `tests/no_escape_hatch.rs` (absent `set_attribute`), `tests/no_redact_alias_in_otel.rs` (no `set_redact_alias_name`), CI gate `scripts/check_tracing_leaks.sh` (fails on `Secret::expose_secret`, `{value}`, `{uri.raw}`, `{secret}` in `tracing::*!`). Structural, not runtime. --- ## 10. FAQ **Does SecretEnv emit secret values to my collector?** -No. SEC-INV-04 enforces this at compile time. The `SecretEnvSpan` builder has no method to set a value-shaped attribute, and there is no `set_attribute(key, value)` escape hatch. Adding a value-shaped attribute requires writing a typed setter, which a PR review would reject. +No. This is enforced at compile time. `SecretEnvSpan` has no value-shaped setter and no generic `set_attribute` escape hatch. Any value-shaped attribute requires a typed setter, which PR review rejects. **What happens when no OTLP endpoint is configured?** -No-op. SecretEnv installs no exporter, creates no `TracerProvider`, and emits no spans. Zero startup overhead. The OTel deps are linked in but inert. +No-op. No exporter, no `TracerProvider`, no spans. Zero overhead. OTel deps remain linked but inert. **Can I use Prometheus?** -Use the OpenTelemetry Collector with a Prometheus scrape endpoint on the collector side. SecretEnv does not ship a Prometheus pull exporter — a CLI binary cannot reliably expose an HTTP server given its short lifetime. The OTel collector is the supported scrape source. +Use the OTel Collector with a Prometheus scrape endpoint on the collector side. SecretEnv does not ship a Prometheus pull exporter (CLI binaries cannot reliably expose HTTP servers). OTel collector is the supported source. **Why is `alias.name` ALLOWED but `alias.uri` DENIED?** -Alias names are the operator's diagnostic handle — "which secret failed to resolve" is the first question on any incident, and `alias.name` is the answer. Alias URIs reveal backend topology (`aws-ssm:///payments/stripe/prod-rotation-2`), which is a credential-enumeration surface and a competitive-intelligence leak. +`alias.name` is the operator's diagnostic handle (first incident question). URIs reveal backend topology (`aws-ssm:///payments/stripe/prod-rotation-2`), enabling credential enumeration and competitive intelligence. **What is the flush guarantee before `exec()`?** -1-second bounded timeout. If `force_flush()` does not complete within 1s, pending spans drop and a `tracing::debug!` message is emitted. A slow collector cannot turn `secretenv run` into a latency cliff. Trade-off accepted: better data loss than `exec()` blocking. +1-second timeout. If `force_flush()` does not complete, pending spans drop with a `tracing::debug!` message. Data loss acceptable; latency cliff is not. **Does SecretEnv propagate trace context to my child process?** -No. SecretEnv honors `TRACEPARENT` / `TRACESTATE` env vars set by a parent CI system (inbound propagation), attaching its root span to the parent trace. SecretEnv does **not** set those env vars for the child started by `secretenv run` — instrumenting arbitrary child binaries to honor W3C context is the child's responsibility. Outbound propagation is a v1.0+ item. +No. Inbound only: SecretEnv honors `TRACEPARENT`/`TRACESTATE` from parent CI, attaching its root span to the parent trace. Outbound propagation (setting env vars for the child) is v1.0+ (requires child-side instrumentation). **How do I disable OTel without unsetting `OTEL_EXPORTER_OTLP_ENDPOINT`?** -Set `OTEL_TRACES_EXPORTER=none` (or the per-signal variant). SecretEnv deliberately does not ship a `[telemetry] enabled = false` config-file toggle — a committed `false` value would silently disable OTel team-wide, which is a footgun. The env var is the correct kill switch. +Set `OTEL_TRACES_EXPORTER=none` (or per-signal variant). SecretEnv does not ship a `[telemetry] enabled = false` config toggle (committed `false` would silently disable team-wide, a footgun). Env var is the correct kill switch. --- ## Related -- [`docs/reference/redact.md`](redact.md) — redaction modes; how matches relate to `secretenv.redact.*` attributes -- [`docs/reference/migrate.md`](migrate.md) — `secretenv registry migrate`; emits the migrate span tree -- [`docs/reference/mcp.md`](mcp.md) — MCP tool surface; emits `secretenv.mcp.tool.` spans -- [`docs/reference/configuration.md`](configuration.md) — `[otel]` table in `secretenv.toml` +- [Redaction](redact.md): redaction modes and the `secretenv.redact.*` attributes +- [Registry migrate](migrate.md): emits the migrate span tree +- [MCP server](mcp.md): emits `secretenv.mcp.tool.` spans +- [CLI Reference: `secretenv doctor`](cli-reference-full.md#secretenv-doctor): `doctor --trace` renders a local span table without a collector diff --git a/docs/reference/profiles.md b/docs/reference/profiles.md index fccbde7..45c3e6b 100644 --- a/docs/reference/profiles.md +++ b/docs/reference/profiles.md @@ -1,27 +1,25 @@ # Distribution profiles (`secretenv profile`) -Profiles are shared, TOML-formatted config fragments hosted over HTTPS that teams can publish once and everyone installs with a single command. A profile defines `[backends.*]` and/or `[registries.*]` entries that get **merged into** the user's local `config.toml` at load time — no manual editing required. +Profiles are shared, TOML-formatted config fragments hosted over HTTPS that teams can publish once and everyone installs with a single command. A profile defines `[backends.*]` and/or `[registries.*]` entries that get **merged into** the user's local `config.toml` at load time, no manual editing required. This is the v0.4 headline feature. The canonical host is `https://secretenv.io/profiles`, and you can also install from any URL (including private mirrors or `file://` paths). ## Why profiles -Onboarding a new engineer traditionally involves copy-pasting a chunk of `config.toml` from a wiki page or Slack thread. Profiles replace that with: +Publish backend and registry config once; install everywhere with one command. No copy-paste, no doc drift: ```sh secretenv profile install acme-defaults -secretenv doctor # now sees every team backend +secretenv doctor # sees every team backend ``` -Your org publishes `https://secretenv.io/profiles/acme-defaults.toml` (or any URL you control) once; everyone runs the same install command. Updates propagate with `secretenv profile update`. +Updates propagate with `secretenv profile update`. ## The merge model -- Profiles **fill gaps**, they don't override. If your `config.toml` defines `[backends.aws-prod]` and a profile also defines it, your local version wins. -- Files in `/profiles/*.toml` are merged in alphabetical order. First profile to define a key wins among profiles. -- Your own `config.toml` always wins over every profile. - -This means a profile can never silently change your local behavior — it can only add things you haven't defined. +- Profiles **fill gaps**, they don't override. Your `config.toml` always wins. +- Files in `/profiles/*.toml` are merged alphabetically. First definition wins. +- A profile can only add new entries, never change existing ones. ## CLI surface @@ -43,7 +41,7 @@ secretenv profile install my-team --url https://vault.acme.corp/profiles/team.to secretenv profile install local-dev --url file:///tmp/draft-profile.toml ``` -The downloaded TOML is validated as a SecretEnv config fragment before it's written — a malformed profile never reaches the filesystem. A sidecar `.meta.json` captures the source URL, the server's `ETag`, and the install timestamp so `update` can do conditional re-fetch. +Validation happens before write. Malformed profiles never reach disk. A sidecar `.meta.json` stores the source URL, server `ETag`, and install timestamp for conditional re-fetch on `update`. ### `list` @@ -69,7 +67,7 @@ secretenv profile update acme-defaults secretenv profile update ``` -The updater sends an `If-None-Match: ` header; on `304 Not Modified` the local file is untouched and you see `up to date`. On `200 OK` the file is replaced and the sidecar metadata gets a fresh timestamp + ETag. +Uses `If-None-Match: ` for conditional fetch. `304 Not Modified` → no change; `200 OK` → file replaced and metadata refreshed. ### `uninstall` @@ -89,7 +87,7 @@ secretenv profile install team-defaults # Fetches https://mirror.acme.corp/profiles/team-defaults.toml ``` -Passing `--url ` bypasses the base entirely — useful for one-off installs from arbitrary URLs. +Passing `--url ` bypasses the base entirely, useful for one-off installs from arbitrary URLs. ## Authoring a profile @@ -113,7 +111,7 @@ sources = [ ] ``` -After an engineer runs `secretenv profile install acme-defaults`, they can immediately do `secretenv --registry acme get any-alias` — no manual config editing. +After an engineer runs `secretenv profile install acme-defaults`, they can immediately do `secretenv --registry acme get any-alias`, no manual config editing. ## Storage layout @@ -127,22 +125,20 @@ $XDG_CONFIG_HOME/secretenv/ └── my-team.meta.json ``` -You can also drop a `.toml` file into `profiles/` manually — it will be auto-merged on the next load. Such files show up in `profile list` as `(manual)` source; `profile update` errors out because there's no sidecar to tell it what URL to re-fetch. +Manual `.toml` files dropped into `profiles/` auto-merge on load and show as `(manual)` source in `profile list`. `profile update` errors on these (no sidecar URL). ## Security considerations -v0.4 delivers **unsigned profiles over HTTPS** — the threat model relies on TLS to guarantee integrity in transit and the canonical host's access control to prevent tampering at rest. - -If the canonical profile host is compromised, an attacker could ship a profile that defines a malicious backend instance (e.g. a Vault pointing at an attacker-controlled URL). SecretEnv still refuses to leak secrets on argv/stdin (CV-1 guarantees), but the attacker could trick users into **writing** secrets to a malicious target via `registry set`. +v0.4 delivers **unsigned profiles over HTTPS**. A compromised profile host could inject a malicious backend URI. While SecretEnv still refuses to leak secrets on argv/stdin (CV-1), attackers could trick users into **writing** secrets to a malicious target via `registry set`. -**Signed profiles (minisign / sigstore / plain SHA256 manifests) are a v0.5+ hardening.** Until then: +**Signed profiles (minisign / sigstore) are a v0.5+ hardening.** Until then: -- Treat `SECRETENV_PROFILE_URL` overrides like `curl | sh` — only install from hosts you trust. -- Review profile contents before installing: `curl -fsSL https://.../team.toml | less` -- For the most sensitive teams, host profiles behind a VPN or authenticated proxy. +- Treat `SECRETENV_PROFILE_URL` like `curl | sh`: install from trusted hosts only. +- Review contents: `curl -fsSL https://.../team.toml | less` +- For sensitive teams, host profiles behind a VPN or authenticated proxy. ## Known limitations -- **No signing yet** — see above. Planned for v0.5. -- **No profile index / search** — you need to know the profile name or URL. A central index (`secretenv.io/profiles/index.toml`) + `secretenv profile list --available` is a v0.4.1 / v0.5 idea. -- **No version pinning** — `profile install` always fetches the current version at that URL. If your profile host also publishes versioned URLs (e.g. `acme-defaults-v2.toml`), you can pin with `--url`. +- **No signing yet**: planned for v0.5. +- **No profile index / search**: know the name or URL; central index planned for v0.5. +- **No version pinning**: always fetches current. Pin manually via `--url` if your host publishes versioned URLs. diff --git a/docs/reference/redact.md b/docs/reference/redact.md index 6e6cb4b..c8f33a1 100644 --- a/docs/reference/redact.md +++ b/docs/reference/redact.md @@ -2,14 +2,14 @@ `secretenv` ships two redaction surfaces in v0.14: -- **Mode A (runtime)** — built into `secretenv run`. Pipes the child's stdout/stderr through a streaming scrubber that substitutes resolved values with `[redacted:]`. **On by default.** -- **Mode B (post-hoc)** — the dedicated `secretenv redact ` subcommand. Scrubs an existing file or stream. +- **Mode A (runtime)**: built into `secretenv run`. Pipes child stdout/stderr through a streaming scrubber, substituting resolved values with `[redacted:]`. On by default. +- **Mode B (post-hoc)**: `secretenv redact ` subcommand. Scrubs existing files or streams. -Both share one engine (Aho-Corasick byte scanner, alias-aware substitution) and one set of safety guards. The threat-model summary lives in [`security.md`](../security.md#redaction-v014); this page is the operator-facing reference. +Both use one engine (Aho-Corasick byte scanner, alias-aware substitution) and one safety guard set. See [`security.md`](../security.md#redaction-v014) for threat model; this page is the operator reference. --- -## Mode A — runtime stdout/stderr filter +## Mode A: runtime stdout/stderr filter ``` secretenv run [--registry ] [--dry-run] [--verbose] @@ -29,22 +29,22 @@ secretenv run [--registry ] [--dry-run] [--verbose] | Parent stdin | Behavior | |---|---| -| TTY (interactive) | `Auto` falls back to `exec()`. One-line stderr advisory: `secretenv: interactive TTY detected; runtime redaction disabled for this invocation. Run with --redact to force pipe-based redaction (may break PTY-bound prompts).` | -| Non-TTY (CI, pipe, redirect) | Pipe-based redaction. Child runs under `tokio::process::Command` with stdout/stderr piped; both streams flow through independent `StreamingScrubber`s before reaching the parent's stdout/stderr. | +| TTY (interactive) | Falls back to `exec()`. Advisory: `secretenv: interactive TTY detected; runtime redaction disabled for this invocation. Run with --redact to force pipe-based redaction (may break PTY-bound prompts).` | +| Non-TTY (CI, pipe, redirect) | Pipe-based redaction via `tokio::process::Command`; both streams flow through independent `StreamingScrubber`s. | -The auto fallback exists because pipe-based stdio breaks `tcgetattr`, `tcsetattr`, and `ioctl(TIOCGWINSZ)` — the kernel-level contract any TUI relies on. `secretenv run -- vim file` under pipe redaction would render garbage. +The fallback exists because pipe-based stdio breaks `tcgetattr`, `tcsetattr`, and `ioctl(TIOCGWINSZ)`, the kernel contract TUIs rely on. `secretenv run -- vim file` under pipe redaction would render garbage. ### Signal forwarding -While in pipe-based mode, the parent forwards `SIGINT`, `SIGTERM`, and `SIGHUP` to the child via `kill(2)`. Ctrl-C in the parent terminal tears down the child cleanly instead of orphaning it. +In pipe-based mode, the parent forwards `SIGINT`, `SIGTERM`, and `SIGHUP` to the child via `kill(2)`. Ctrl-C tears down the child cleanly instead of orphaning it. ### Streaming buffering -The scrubber maintains a carry-over tail of `max(pattern_len) - 1` bytes across pipe reads so a tainted value split across two `read()` calls still matches. Maximum supported pattern length: **64 KiB**. Larger patterns refuse mode-A redaction at startup with `redact mode A: a tainted value exceeds the 65536-byte tail-window cap; refusing to start runtime redaction (would otherwise miss matches split across chunk boundaries)`. +The scrubber maintains a `max(pattern_len) - 1` byte tail across pipe reads to catch values split across `read()` calls. Maximum pattern length: **64 KiB**. Larger patterns refuse mode-A redaction at startup. --- -## Mode B — `secretenv redact ` +## Mode B: `secretenv redact ` ``` secretenv redact @@ -58,37 +58,43 @@ secretenv redact | Flag | Default | Effect | |---|---|---| -| `` | required | File to scrub. v0.14 requires a regular-file path; stdin (`-`) is reserved for a future cycle. | -| `--registry ` | active | Same semantics as `run --registry`. Determines which aliases populate the tainted set. | -| `--alias ` | every alias | Restrict the tainted set. Repeatable; comma-separated also accepted. | -| `--in-place` | off | Atomic rewrite via sibling tempfile + `rename(2)`. **Mode** (`0o600` etc.) is preserved. **Ownership is NOT preserved** — the persisted file is owned by the caller's EUID; root-run scrubs of a sub-user-owned file end up root-owned. Conflicts with `--dry-run`. | -| `--backup ` | (none) | When `--in-place` is set, also keep a backup at `` (e.g. `--backup .bak`). The backup write uses `O_CREAT \| O_EXCL \| O_NOFOLLOW`; refuses if `` already exists or is a symlink. | -| `--dry-run` | off | Count matches without writing. Implies neither `--in-place` nor stdout emission. | -| `--allow-foreign-owner` | off | Bypass the foreign-owner refusal that fires when the target file's UID differs from the caller's EUID. By default mode B refuses such files (defense against a maliciously-planted log file in a shared directory). | -| `--redact-token ` | (alias-aware) | Override the substitution (same as mode A). | +| `` | required | File to scrub. v0.14 requires regular-file path; stdin (`-`) reserved for future. | +| `--registry ` | active | Same semantics as `run --registry`. Determines alias taint set. | +| `--alias ` | every alias | Restrict taint set. Repeatable; comma-separated accepted. | +| `--in-place` | off | Atomic rewrite via sibling tempfile + `rename(2)`. Mode preserved; ownership NOT preserved (file becomes caller's EUID). Conflicts with `--dry-run`. | +| `--backup ` | (none) | Backup at `` when `--in-place`. Uses `O_CREAT \| O_EXCL \| O_NOFOLLOW`; refuses if suffix exists or is a symlink. | +| `--dry-run` | off | Count matches, no write or stdout. | +| `--allow-foreign-owner` | off | Bypass foreign-owner refusal (default mode B defense against malicious log planting). | +| `--redact-token ` | (alias-aware) | Override substitution (same as mode A). | ### Example ```sh -# Scrub a CI build log in place, keep a .bak copy: +# Scrub in place, keep .bak: $ secretenv redact /tmp/ci-build.log --in-place --backup=.bak -secretenv redact: rewrote '/tmp/ci-build.log' — 14 match(es), 252 byte(s) replaced; backup at '/tmp/ci-build.log.bak' +secretenv redact: rewrote '/tmp/ci-build.log', 14 match(es), 252 byte(s) replaced; backup at '/tmp/ci-build.log.bak' -# Stream-scrub to stdout (default): +# Stream to stdout (default): $ secretenv redact build.log | grep ERROR -# Just count what would be scrubbed: +# Count matches only: $ secretenv redact build.log --dry-run secretenv redact: would redact 14 match(es) totaling 252 byte(s) in 'build.log' ``` --- +## Substitution token + +The default token is `[redacted:]`. The alias name is operator-chosen and treated as non-sensitive (it already appears in your `secretenv.toml` and the registry document), so it stays in the token as a diagnostic breadcrumb in build logs without leaking the value. Pass `--redact-token ''` (for example `[REDACTED]` or `***`) to emit a constant token regardless of which alias matched, for cases where alias names themselves are sensitive by policy. + +--- + ## Shared safety guards | Guard | Mode A | Mode B | Bypass | |---|---|---|---| -| Minimum value length (8 bytes) | enforced | enforced | none — vendor problem; rotate to a longer credential | +| Minimum value length (8 bytes) | enforced | enforced | none: vendor problem; rotate to a longer credential | | `O_NOFOLLOW` open | n/a (no file) | enforced | none | | Foreign-owner refusal | n/a | enforced | `--allow-foreign-owner` | | `/proc`, `/sys`, `/dev` refusal | n/a | enforced | none | @@ -101,18 +107,18 @@ secretenv redact: would redact 14 match(es) totaling 252 byte(s) in 'build.log' | Error fragment | Cause | Resolution | |---|---|---| -| `tainted value below minimum length` | An alias's resolved value is < 8 bytes. | Skipped; warning lists alias name (never value or length). Rotate to a longer credential or accept the gap. | -| `tail-window cap` | An alias's resolved value > 64 KiB (mode A only). | Mode A refuses to start. Use mode B for very large blobs, or rotate. | -| `O_NOFOLLOW` / `symbolic link` / `Too many levels of symbolic links` | A symlink was placed at the target path between stat and open. | Resolve the symlink to its target and re-run. | -| `file is owned by UID N` | Foreign-owner refusal fired. | Use `--allow-foreign-owner` if intentional (e.g. scrubbing a root-owned log as the current user). | -| `kernel pseudo-filesystems (/proc, /sys, /dev) are not safe redact targets` | Path starts with `/proc`, `/sys`, or `/dev`. | These paths cannot be scrubbed meaningfully; refuses outright. | -| `interactive TTY detected; runtime redaction disabled for this invocation` | Mode A `Auto` fell back to `exec()` because stdin is a TTY. | Use `--redact` to force pipe-based mode, or accept the fallback and confirm your child doesn't print secrets to the terminal. | -| `--no-redact requires --i-know` (clap-emitted) | Operator passed `--no-redact` without `--i-know`. | Add `--i-know`. The two-flag dance is deliberate; it makes the disable a code-review event. | +| `tainted value below minimum length` | Alias resolved value < 8 bytes. | Skipped; warning lists alias (never value). Rotate to longer credential. | +| `tail-window cap` | Alias resolved value > 64 KiB (mode A only). | Use mode B for large blobs, or rotate. | +| `O_NOFOLLOW` / `symbolic link` | Symlink placed at target between stat and open. | Resolve to target and re-run. | +| `file is owned by UID N` | Foreign-owner refusal. | Use `--allow-foreign-owner` if intentional. | +| `kernel pseudo-filesystems` | Path starts with `/proc`, `/sys`, or `/dev`. | These paths refuse outright; not safe to scrub. | +| `interactive TTY detected` | Mode A `Auto` fell back to `exec()` (TTY stdin). | Use `--redact` to force pipe mode, or confirm child doesn't leak secrets. | +| `--no-redact requires --i-know` | Missing `--i-know` flag. | Add `--i-know`. Two-flag dance makes disable a code-review event. | --- ## See also -- [`security.md`](../security.md#redaction-v014) — threat model + Limits matrix -- v0.14 build plan ([[build-plan-v0.14-redact]] in the SecretEnv design KB) -- Synthesis §2.3 ([[v0.14-plus-synthesis]] in the SecretEnv design KB) — Q-O2 (substitution token) and Q-O3 (isatty fallback) resolutions +- [Security: redaction threat model](../security.md#redaction-v014): defense-in-depth framing and the limits matrix +- [CLI Reference: `secretenv redact`](cli-reference-full.md#secretenv-redact): full flag reference for the post-hoc scrubber +- [CLI Reference: `secretenv run`](cli-reference-full.md#secretenv-run): the runtime redaction flags (`--redact`, `--no-redact`) diff --git a/docs/reference/registry.md b/docs/reference/registry.md index 347b9c4..f854ddd 100644 --- a/docs/reference/registry.md +++ b/docs/reference/registry.md @@ -22,9 +22,30 @@ Keys are alias names. Values are backend URIs using named instances as the schem --- +## Selecting a Registry + +Every command resolves which registry to use in this order, the first hit wins, and there is no implicit fallback at the bottom: + +``` +1. --registry explicit, per-invocation +2. SECRETENV_REGISTRY= CI / shell-session override +3. [registries.default] in config machine default +4. hard error no assumption is made +``` + +The `--registry` value (and `SECRETENV_REGISTRY`) is disambiguated by content: if it contains `://` it's a **direct URI** (a single source, no cascade). Otherwise it's a **name** looked up in `[registries.]`. `SECRETENV_REGISTRY` is the canonical mechanism for CI, where no config file lives on the runner. + +```bash +secretenv run -- npm start # [registries.default] +secretenv run --registry dev -- npm start # named registry (may cascade) +secretenv run --registry aws-ssm-dev:///secretenv/reg -- ... # direct URI, single source +``` + +--- + ## Cascading Registries -A registry configuration can cascade across multiple source documents. Sources are checked in order — first match wins. Entries in later sources that share a key with an earlier source are silently shadowed. +A registry configuration can cascade across multiple source documents. Sources are checked in order, first match wins. Entries in later sources that share a key with an earlier source are silently shadowed. ```toml # config.toml @@ -193,7 +214,7 @@ curl -sfS https://secretenv.io/install.sh | sh -s -- --profile acme-corp ## Writing the Registry Document Manually -For teams managing infrastructure as code, the registry document can be managed via Terraform or any tool that can write to a backend. secretenv does not require using the CLI to manage registry content — it only needs to be able to read the document at runtime. +For teams managing infrastructure as code, the registry document can be managed via Terraform or any tool that can write to a backend. secretenv does not require using the CLI to manage registry content; it only needs to be able to read the document at runtime. The registry document format is a flat TOML key-value structure: @@ -215,7 +236,7 @@ resource "aws_ssm_parameter" "secretenv_registry" { } ``` -Note: when managing the registry via Terraform, the alias-to-path mappings are in version-controlled Terraform state. This is an operational choice — the alias values (backend paths) are organizational configuration, not secret values, so this tradeoff is similar to managing Kubernetes ExternalSecrets manifests in code. +Note: when managing the registry via Terraform, the alias-to-path mappings are in version-controlled Terraform state. This is an operational choice: the alias values (backend paths) are organizational configuration, not secret values, so this tradeoff is similar to managing Kubernetes ExternalSecrets manifests in code. --- @@ -225,7 +246,7 @@ The registry document maps alias names to backend paths. It does not contain sec **Treat the registry's access controls equivalently to your most sensitive secret.** -If an attacker can read the registry, they learn your secrets topology — which backends you use, what paths your secrets live at, your naming conventions. They do not get secret values. But if they already have authenticated access to the registry backend, they likely have broader backend access anyway. +If an attacker can read the registry, they learn your secrets topology: which backends you use, what paths your secrets live at, your naming conventions. They do not get secret values. But if they already have authenticated access to the registry backend, they likely have broader backend access anyway. Recommended storage: @@ -243,6 +264,6 @@ Recommended storage: No convention is enforced by secretenv. Recommendations: - Use kebab-case: `stripe-key`, `prod-db-url`, `datadog-api-key` -- Keep alias names environment-agnostic where possible — route environments via registry selection, not alias naming -- Use consistent naming across services — if multiple services need a shared Datadog key, one alias shared via the org registry is better than `service-a-datadog-key` and `service-b-datadog-key` +- Keep alias names environment-agnostic where possible. Route environments via registry selection, not alias naming +- Use consistent naming across services. If multiple services need a shared Datadog key, one alias shared via the org registry is better than `service-a-datadog-key` and `service-b-datadog-key` - Prefix team-specific aliases if co-existing with an org registry: `payments-stripe-key` vs org-level `stripe-key` diff --git a/docs/reference/three-file-model-deep.md b/docs/reference/three-file-model-deep.md index 5494088..ed25bd6 100644 --- a/docs/reference/three-file-model-deep.md +++ b/docs/reference/three-file-model-deep.md @@ -1,4 +1,4 @@ -# The Three-File Model — Deep Reference +# The Three-File Model: Deep Reference The README has the summary. This page is the deep reference: full schemas, validation rules, lifecycle, ownership boundaries, and the exact resolution flow. @@ -12,7 +12,7 @@ The three files: --- -## File 1 — Project Manifest (`secretenv.toml`) +## File 1: Project Manifest (`secretenv.toml`) ### Discovery - Walked **upward** from CWD @@ -29,7 +29,7 @@ ENV_VAR_NAME = { default = "literal-value" } # static default ``` **Validation:** -- Two value shapes only — `{ from = "..." }` or `{ default = "..." }` +- Two value shapes only: `{ from = "..." }` or `{ default = "..." }` - `from` URIs MUST be `secretenv://` or `secretenv:///` (direct backend URIs are a hard error) - `default` values are arbitrary strings, injected as-is - Both `from` and `default` in the same entry: error @@ -39,17 +39,17 @@ ENV_VAR_NAME = { default = "literal-value" } # static default - Empty manifest: parses successfully (no secrets to inject) ### Order preservation -Entries are stored in `IndexMap` — declaration order is preserved. `doctor` and `resolve` output reflects manifest order. +Entries are stored in `IndexMap`. Declaration order is preserved. `doctor` and `resolve` output reflects manifest order. ### Lifecycle - **Created** when setting up secrets for a new project - **Committed** to git alongside other project config - **Modified** only when the project's secret requirements change (new env var needed, old one retired) -- **Never modified** when the location of a secret changes — that's the registry's job +- **Never modified** when the location of a secret changes. That's the registry's job --- -## File 2 — Machine Config (`~/.config/secretenv/config.toml`) +## File 2: Machine Config (`~/.config/secretenv/config.toml`) ### Discovery - `--config ` flag, OR @@ -60,34 +60,34 @@ Entries are stored in `IndexMap` — declaration order is preserved. `doctor` an ### Schema ```toml -# Named registries — cascade source lists +# Named registries: cascade source lists [registries.] sources = ["", ...] # first-match-wins lookup -# Named backend instances — credentials and routing +# Named backend instances: credentials and routing [backends.] type = "" # identifies factory (aws-ssm, 1password, vault, ...) # ... backend-specific fields ... ``` ### Validation -- `[registries.]` requires `sources` — non-empty list of backend URIs -- `[backends.]` requires `type` — must match a registered backend factory +- `[registries.]` requires `sources`: non-empty list of backend URIs +- `[backends.]` requires `type`: must match a registered backend factory - Backend-specific fields validated by each factory (the core stays blind to backend semantics) - Profile auto-merge: 1 MiB hard cap per profile file ### Profile merge -On load, `/profiles/*.toml` files are merged in alphabetical order. **User's config always wins** where keys overlap. Profiles only fill gaps. This makes profiles safe for organizational distribution — a bad profile cannot silently override a developer's intentional override. +On load, `/profiles/*.toml` files are merged in alphabetical order. **User's config always wins** where keys overlap. Profiles only fill gaps. This makes profiles safe for organizational distribution. A bad profile cannot silently override a developer's intentional override. ### Lifecycle - **Created** via `secretenv setup ` (interactive wizard) OR `secretenv profile install ` (pre-configured distribution) OR hand-edited -- **Updated** when backend topology changes — typically rare after initial setup -- **Per-machine** — never committed; each developer / CI runner has their own +- **Updated** when backend topology changes, typically rare after initial setup +- **Per-machine**, never committed; each developer / CI runner has their own - **Credentials** for backends are owned by the machine (AWS profiles in `~/.aws`, 1Password account via `op signin`, etc.); this file just *names* them --- -## File 3 — Alias Registry Document +## File 3: Alias Registry Document ### Where it lives Inside any backend you already control. Examples: @@ -99,7 +99,7 @@ Inside any backend you already control. Examples: ### Schema -**TOML format** (for `local`, `1password` backends — flat key-value): +**TOML format** (for `local`, `1password` backends, flat key-value): ```toml stripe-key = "1password-work://payments/stripe/api_key" @@ -107,7 +107,7 @@ db-url = "aws-ssm-dev:///myapp/dev/db_url" datadog-api-key = "1password-work://engineering/datadog/api_key" ``` -**JSON format** (for cloud backends storing as a single secret value — aws-ssm, aws-secrets, gcp, azure, vault, openbao, conjur, bitwarden-sm): +**JSON format** (for cloud backends storing as a single secret value: aws-ssm, aws-secrets, gcp, azure, vault, openbao, conjur, bitwarden-sm): ```json { @@ -119,8 +119,8 @@ datadog-api-key = "1password-work://engineering/datadog/api_key" ### Validation - Every value must parse as a valid backend URI - Every URI's scheme must match a configured backend instance -- Chained aliases are forbidden — registry values cannot be `secretenv://...` -- Writes use `BTreeMap` ordering (alphabetical) — diffs are clean and reproducible +- Chained aliases are forbidden: registry values cannot be `secretenv://...` +- Writes use `BTreeMap` ordering (alphabetical), so diffs are clean and reproducible ### Cascade @@ -129,8 +129,8 @@ A named registry can list multiple `sources`. They form a **first-match-wins cas ```toml [registries.dev] sources = [ - "aws-ssm-dev:///secretenv/dev-registry", # source 0 — checked first - "aws-ssm-platform:///secretenv/org-registry", # source 1 — fallback + "aws-ssm-dev:///secretenv/dev-registry", # source 0 (checked first) + "aws-ssm-platform:///secretenv/org-registry", # source 1 (fallback) ] ``` @@ -142,7 +142,7 @@ sources = [ ### Lifecycle - **Created** via the first `secretenv registry set ` against an empty path - **Updated** when a secret migrates between backends, or when an alias is renamed -- **Owner** is whoever owns the host backend — typically the platform / security team +- **Owner** is whoever owns the host backend, typically the platform / security team - **Scoping:** - Org-wide registry: shared across all teams (e.g., `aws-ssm-platform:///secretenv/org-registry`) - Team-specific registry: scoped to a team, can shadow org defaults @@ -154,7 +154,7 @@ sources = [ When you run `secretenv run --registry dev -- npm start`: -### Phase 1 — Registry selection +### Phase 1: Registry selection ``` 1. Explicit --registry flag (highest precedence) @@ -165,18 +165,18 @@ When you run `secretenv run --registry dev -- npm start`: If the value contains `://`, it's treated as a direct URI (single source, no cascade). Otherwise it's a name lookup against `[registries.]`. -### Phase 2 — Registry document loading +### Phase 2: Registry document loading For each source URI in the cascade: - Call the matching backend's `list()` method - Parse the result as a `Vec<(alias, target-uri)>` map - Build a layered `AliasMap` (one layer per source, in declaration order) -**All sources must succeed.** If any `list()` fails (CLI missing, NotAuth, network), the entire resolve errors. This is deliberate — fails-fast prevents silent fallthrough that would mask environment problems. +**All sources must succeed.** If any `list()` fails (CLI missing, NotAuth, network), the entire resolve errors. This is deliberate. Fails-fast prevents silent fallthrough that would mask environment problems. Each target URI is validated: parses, scheme matches a configured backend, no chained aliases. -### Phase 3 — Manifest resolution +### Phase 3: Manifest resolution For each entry in `secretenv.toml`'s `[secrets]` section: - If `from = "secretenv://alias"`: look up `alias` in the AliasMap (first-match-wins across cascade layers); get the target URI @@ -184,19 +184,19 @@ For each entry in `secretenv.toml`'s `[secrets]` section: Result: a `Vec` in manifest declaration order. -### Phase 4 — Secret fetching +### Phase 4: Secret fetching For each `ResolvedSecret`: - If `Default`: inject inline (no backend call) -- If `Uri`: call `backend.get(target-uri)` — **all fetches run in parallel** +- If `Uri`: call `backend.get(target-uri)`. **All fetches run in parallel** **Failure modes:** - Single failure: error returned with full context (alias, URI, operation) -- Multiple failures: aggregated into one report — operators see every broken alias in one pass +- Multiple failures: aggregated into one report, so operators see every broken alias in one pass If `--dry-run`: skip fetching, print resolution map (`KEY ← ` and `KEY = `), exit 0. -### Phase 5 — Inject and exec +### Phase 5: Inject and exec - Merge fetched values + static defaults into the env map - On Unix: `exec()` replaces the current process (inherits TTY, stdio, signals; secrets exist briefly in the heap before exec discards it) @@ -204,43 +204,25 @@ If `--dry-run`: skip fetching, print resolution map (`KEY ← ` and `KEY = --- -## What this decoupling solves (in detail) +## What this decoupling solves -### Secrets-in-config problem -Without decoupling, every dev's `.env` contains both values and paths: -``` -STRIPE_KEY=sk_live_abc123... # secret value -DB_URL=mydb.prod.us-east-1.rds.amazonaws.com # infrastructure path -``` -Both leak. Both go stale. Both are hard to rotate. - -With decoupling: `secretenv.toml` declares names only (safe to commit); the registry stores pointers only (safe to keep in any backend); secrets are fetched at runtime (always fresh). - -### Topology hiding -Without: new engineer reading the repo learns "Stripe is in 1Password account X, vault path Y, AWS account Z" — infrastructure topology leaks via code review. - -With: repo contains alias names; actual paths are in a registry that lives behind backend access controls. Reading the repo teaches you nothing about backend topology. - -### Team vs org scoping -Without: teams either bake env-specific logic into code, or maintain separate repos / branches per environment. - -With: one `secretenv.toml` per repo. Registry cascade routes the same alias names to env-specific backends. Environments change *how they're configured* (different registry), not *what the code knows*. - -### Credential portability -Without: migrating Stripe from 1Password to Vault means decrypting in 1Password, encrypting in Vault, updating every repo, re-inviting every team member. - -With: secrets already live in both backends (you put them there using your existing tools). One `secretenv registry set` updates the alias pointer. Every repo picks it up on the next run. - -### Offboarding -Without: departing engineer still has local `.env` files; revocation is manual and error-prone per backend. +Conflating "what a project needs" with "where the secret lives" is the root cause of most `.env` pain. Separating them produces five concrete wins: -With: revoke access to the registry backend. Engineer can no longer resolve any alias. Single operation covers every repo simultaneously. +| Problem | Without decoupling | With the three-file model | +|---|---|---| +| **Secrets in config** | `.env` holds values *and* infra paths, both leak, go stale, resist rotation | Manifest declares names only; the registry holds pointers only; values are fetched fresh at runtime | +| **Topology leaks via code review** | Reading the repo reveals "Stripe is in 1Password account X, Vault path Y" | The repo holds alias names; real paths sit behind backend access controls | +| **Per-environment config** | Env-specific logic baked into code, or separate repos/branches per env | One manifest per repo; the registry cascade routes the same aliases to env-specific backends | +| **Credential portability** | Migrating a secret means re-encrypt + update every repo + re-invite every member | One `secretenv registry migrate` (or `set`); every repo picks it up on its next run | +| **Offboarding** | Departing engineer keeps local `.env` files; revocation is manual, per-backend | Revoke registry-backend access once, covers every repo simultaneously | --- ## See also -- [Overview](/) — overview + workflows -- [Full CLI reference](cli-reference-full.md) -- [Threat model](../security.md) — full security comparison -- [Fragment vocabulary](fragment-vocabulary.md) — URI fragment grammar +- [Registry Management](registry.md): the alias registry (file 3) and its CLI +- [Configuration Reference](configuration.md): the `config.toml` schema (file 2) +- [Full CLI reference](cli-reference-full.md): every command and flag +- [Fragment vocabulary](fragment-vocabulary.md): URI fragment grammar +- [Threat model](../security.md): full security comparison +- [Overview](/): overview and workflows diff --git a/docs/security.md b/docs/security.md index 9c94e86..71ed87b 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1,47 +1,43 @@ # Security -secretenv is not a security product. It is a workflow product that eliminates a class of workflow-driven security failures. - -**secretenv does not make your secrets more secure. It makes your team less likely to handle them insecurely. For most teams, habits are where the actual breaches happen.** +**secretenv is not a security product. It is a workflow product** that eliminates a class of workflow-driven security failures. It doesn't make your secrets more secure. It makes your team less likely to handle them insecurely, and for most teams, habits are where the breaches actually happen. --- ## The Model -secretenv is a coat of paint. If the walls aren't strong, the paint is useless. The walls are your backends. - -secretenv's security posture is entirely inherited from the backends it wraps. It adds no authentication surface, stores no credentials, and makes no access control decisions. What it does is remove the workflow layer where secrets most commonly leak — `.env` files, hardcoded paths in repos, manual sharing, and offboarding gaps. +secretenv is a coat of paint: if the walls aren't strong, the paint is useless, and the walls are your backends. Its security posture is entirely inherited from the backends it wraps: it adds no authentication surface, stores no credentials, and makes no access-control decisions. What it removes is the workflow layer where secrets most commonly leak: `.env` files, hardcoded paths in repos, manual sharing, and offboarding gaps. -**secretenv doesn't replace good tools. It replaces bad habits.** +**It doesn't replace good tools. It replaces bad habits.** --- -## A Note on the fnox Comparison +## A Note on fnox -Earlier drafts of this document modelled fnox as a single-mode tool (age-encryption only). That was incomplete. **fnox is multi-mode:** it supports age-encryption (private key on disk), KMS-gated encryption (`aws-kms` / `azure-kms` / `gcp-kms` — no persistent disk key, decryption gated on IAM), and pure cloud-reference modes (`aws-sm`, `vault`, `1password`, etc. — no encryption involved). The fnox columns below are split accordingly. **In KMS modes, fnox closes the persistent-key and offboarding concerns at the KMS-key level**, and the threat model reflects that. The full mode-by-mode breakdown lives at [comparisons/vs-fnox.md](comparisons/vs-fnox.md). +**fnox is multi-mode**, and the columns below are split accordingly. It supports age-encryption (private key on disk), KMS-gated encryption (`aws-kms` / `azure-kms` / `gcp-kms`, no persistent disk key, decryption gated on IAM), and pure cloud-reference modes (`aws-sm`, `vault`, `1password`, etc., no encryption involved). **In KMS modes, fnox closes the persistent-key and offboarding concerns at the KMS-key level**, and the threat model reflects that. Full mode-by-mode breakdown: [comparisons/vs-fnox.md](comparisons/vs-fnox.md). --- ## Threat Model Comparison -The table below maps 14 threat categories across common secrets workflows. The goal is not to show secretenv wins everywhere — it doesn't. The goal is an honest map of where risks move. +The table below maps 14 threat categories across common secrets workflows. The goal is not to show secretenv wins everywhere. It doesn't. The goal is an honest map of where risks move. | Threat | **secretenv** | .env files | fnox (KMS) | direnv | op run | doppler run | fnox (age) | |---|---|---|---|---|---|---|---| -| **Secrets committed to git** | **Eliminated** — aliases only | High — files exist to be committed | Low — ciphertext or reference-only | High — `.envrc` can contain secrets | Medium — 1Password URIs committed, not values | Low — no secrets in repo | Low — encrypted at rest | -| **Secrets on disk in plaintext** | **Eliminated** — nothing written | High — that's the entire model | Low — runtime KMS decrypt or reference fetch | High — reads from local files | Low — runtime fetch | Low — runtime fetch | Medium — encrypted, key required | -| **Infrastructure paths in repos** | **Eliminated** — aliases only | High — paths are the config | High — provider + path/KMS-key-id committed | High — paths in `.envrc` | High — `op://` URIs committed | Low — project name only | Medium — paths present, encrypted | -| **New engineer onboarding** | **One command** | High — manual credential ceremony | Low — IAM grant on KMS / backend access | High — write custom `.envrc` per project | Medium — 1Password access + `op` CLI | Medium — Doppler token + CLI | Medium — age key ceremony + backend setup | -| **Offboarding a departing engineer** | **One operation** — revoke registry backend access | High — manual, slow, cached copies unknown | Low — IAM revoke on KMS key / backend | High — manual, files may be cached | Medium — remove from 1Password vault | Medium — remove from Doppler | High — re-encryption required across all repos | -| **Backend migration** | **One registry update** — all repos inherit | High — update every `.env` everywhere | High — edit every `fnox.toml` (KMS modes also re-encrypt) | High — rewrite `.envrc` everywhere | Critical — locked to 1Password | Critical — locked to Doppler | High — re-encrypt everything | -| **Machine compromise** | Active sessions exploitable — **no persistent key**, breach contained after re-image | Plaintext files directly readable | Active sessions exploitable — no persistent key; bounded by KMS / backend policy | Same as `.env` | Active sessions exploitable | Active sessions + Doppler token at risk | Active sessions + **age private key theft** — offline decryption survives re-image | -| **Registry document compromise** | **New** — path topology exposed. Requires authenticated backend access. | Does not exist | Does not exist | Does not exist | Does not exist | Does not exist | Does not exist | -| **Post-injection process exposure** | **Universal** — property of env var model, not the tool | Universal | Universal | Universal | Universal | Universal | Universal | -| **Audit trail** | **Delegated to backends** — CloudTrail, Vault audit, 1Password activity | None | KMS CloudTrail / backend audit | None | 1Password activity log | Doppler audit log | Backend-dependent | -| **Supply chain risk** | **Install script** — mitigated by signed binaries | None | fnox binary + cloud SDK trust | Low | `op` CLI binary trust | Doppler CLI + SaaS | age tooling | -| **SaaS dependency** | **None** — no secretenv service | None | Cloud KMS / backend reachability required | None | None | Hard — Doppler is the backend | None | -| **Secret rotation visibility** | **Automatic** — runtime fetch, rotation transparent | None — files go stale silently | Automatic in reference modes; manual on KMS-key rotation | None — `.envrc` goes stale | Automatic — runtime fetch | Automatic — Doppler manages rotation | Manual — re-encrypt with new value | -| **Multi-backend coordination** | **Native** — registry abstracts all backends | Manual copy-paste across tools | fnox supports many providers; per-repo config | Manual shell glue per project | Single backend only | Single backend only | Possible but complex at scale | +| **Secrets committed to git** | **Eliminated**, aliases only | High, files exist to be committed | Low, ciphertext or reference-only | High, `.envrc` can contain secrets | Medium, 1Password URIs committed, not values | Low, no secrets in repo | Low, encrypted at rest | +| **Secrets on disk in plaintext** | **Eliminated**, nothing written | High, that's the entire model | Low, runtime KMS decrypt or reference fetch | High, reads from local files | Low, runtime fetch | Low, runtime fetch | Medium, encrypted, key required | +| **Infrastructure paths in repos** | **Eliminated**, aliases only | High, paths are the config | High, provider + path/KMS-key-id committed | High, paths in `.envrc` | High, `op://` URIs committed | Low, project name only | Medium, paths present, encrypted | +| **New engineer onboarding** | **One command** | High, manual credential ceremony | Low, IAM grant on KMS / backend access | High, write custom `.envrc` per project | Medium, 1Password access + `op` CLI | Medium, Doppler token + CLI | Medium, age key ceremony + backend setup | +| **Offboarding a departing engineer** | **One operation**, revoke registry backend access | High, manual, slow, cached copies unknown | Low, IAM revoke on KMS key / backend | High, manual, files may be cached | Medium, remove from 1Password vault | Medium, remove from Doppler | High, re-encryption required across all repos | +| **Backend migration** | **One registry update**, all repos inherit | High, update every `.env` everywhere | High, edit every `fnox.toml` (KMS modes also re-encrypt) | High, rewrite `.envrc` everywhere | Critical, locked to 1Password | Critical, locked to Doppler | High, re-encrypt everything | +| **Machine compromise** | Active sessions exploitable, **no persistent key**, breach contained after re-image | Plaintext files directly readable | Active sessions exploitable, no persistent key; bounded by KMS / backend policy | Same as `.env` | Active sessions exploitable | Active sessions + Doppler token at risk | Active sessions + **age private key theft**, offline decryption survives re-image | +| **Registry document compromise** | **New**: path topology exposed. Requires authenticated backend access. | Does not exist | Does not exist | Does not exist | Does not exist | Does not exist | Does not exist | +| **Post-injection process exposure** | **Universal**, property of env var model, not the tool | Universal | Universal | Universal | Universal | Universal | Universal | +| **Audit trail** | **Delegated to backends**: CloudTrail, Vault audit, 1Password activity | None | KMS CloudTrail / backend audit | None | 1Password activity log | Doppler audit log | Backend-dependent | +| **Supply chain risk** | **Install script**, mitigated by signed binaries | None | fnox binary + cloud SDK trust | Low | `op` CLI binary trust | Doppler CLI + SaaS | age tooling | +| **SaaS dependency** | **None**, no secretenv service | None | Cloud KMS / backend reachability required | None | None | Hard, Doppler is the backend | None | +| **Secret rotation visibility** | **Automatic**, runtime fetch, rotation transparent | None, files go stale silently | Automatic in reference modes; manual on KMS-key rotation | None, `.envrc` goes stale | Automatic, runtime fetch | Automatic, Doppler manages rotation | Manual, re-encrypt with new value | +| **Multi-backend coordination** | **Native**, registry abstracts all backends | Manual copy-paste across tools | fnox supports many providers; per-repo config | Manual shell glue per project | Single backend only | Single backend only | Possible but complex at scale | --- @@ -49,30 +45,30 @@ The table below maps 14 threat categories across common secrets workflows. The g ### What secretenv Eliminates -The threats secretenv turns green are the high-frequency failures — the ones that happen not because attackers are sophisticated, but because developers are human, in a hurry, and doing the expedient thing under deadline pressure. Accidental commits. Stale files. Paths in repos. Manual offboarding. Backend lock-in. Rotation blindness. +The threats secretenv eliminates (the cells marked **Eliminated**, **One command**, or **One operation** in its column) are the high-frequency failures, the ones that happen not because attackers are sophisticated, but because developers are human, in a hurry, and doing the expedient thing under deadline pressure. Accidental commits. Stale files. Paths in repos. Manual offboarding. Backend lock-in. Rotation blindness. These are the failures that actually cost organizations. Eliminating them is real, meaningful security improvement. -### What Stays Red Everywhere +### What No Tool Solves -**Post-injection process exposure** is a property of environment variables, not of any tool. Once a secret is injected as an env var, it is readable by any process running as the same user. On Linux, `/proc//environ` exposes it to same-user processes. The fix is OS or container-level process isolation — not a different secrets tool. +**Post-injection process exposure** (marked **Universal** for every tool in the table) is a property of environment variables, not of any tool. Once a secret is injected as an env var, it is readable by any process running as the same user. On Linux, `/proc//environ` exposes it to same-user processes. The fix is OS or container-level process isolation, not a different secrets tool. -**Machine compromise** is universal. When a machine is owned, the attacker inherits whatever the user had — active cloud sessions, active Vault tokens, active 1Password sessions. The tool choice does not change this. +**Machine compromise** is universal. When a machine is owned, the attacker inherits whatever the user had: active cloud sessions, active Vault tokens, active 1Password sessions. The tool choice does not change this. What the tool choice *does* affect is the blast radius and post-incident containment: - **`.env` files:** Plaintext on disk, immediately readable, breach is permanent regardless of what you do next. - **fnox (age mode):** Active sessions inherited *plus* the age private key is now in attacker hands. That key decrypts repo ciphertext offline, after the machine is re-imaged, after credentials are rotated. The breach outlives the machine. -- **fnox (KMS / cloud-reference modes):** Active sessions inherited. No persistent decryption key on disk. Decryption (KMS modes) and reference resolution (`aws-sm`, `vault`, etc.) are gated by IAM / backend policy — re-image the machine, rotate or revoke the IAM principal, and the breach is contained. Same containment shape as secretenv. -- **secretenv:** Active sessions inherited. No persistent decryption key exists anywhere — secretenv has nothing encrypted to decrypt. Re-image the machine, rotate backend credentials — the breach is contained. It dies with the session. +- **fnox (KMS / cloud-reference modes):** Active sessions inherited. No persistent decryption key on disk. Decryption (KMS modes) and reference resolution (`aws-sm`, `vault`, etc.) are gated by IAM / backend policy. Re-image the machine, rotate or revoke the IAM principal, and the breach is contained. Same containment shape as secretenv. +- **secretenv:** Active sessions inherited. No persistent decryption key exists anywhere; secretenv has nothing encrypted to decrypt. Re-image the machine, rotate backend credentials, and the breach is contained. It dies with the session. -The real defense against machine compromise is credential scoping at the backend level — IAM policies with least privilege, Vault policies with bounded paths, short-lived session tokens. A compromised machine with narrowly scoped credentials has a bounded blast radius regardless of which secrets tool is running. If a machine is fully compromised, you have an incident response problem. The secrets tool is irrelevant at that point. +The real defense against machine compromise is credential scoping at the backend level: IAM policies with least privilege, Vault policies with bounded paths, short-lived session tokens. A compromised machine with narrowly scoped credentials has a bounded blast radius regardless of which secrets tool is running. If a machine is fully compromised, you have an incident response problem. The secrets tool is irrelevant at that point. -### The One New Yellow Cell +### The One New Risk secretenv Introduces -secretenv introduces one artifact that doesn't exist in any other workflow: the registry document. This document maps alias names to backend paths and is stored in a backend you control. +secretenv introduces one artifact that doesn't exist in any other workflow: the registry document (the **Registry document compromise** row, marked **New** in secretenv's column and "Does not exist" for every other tool). This document maps alias names to backend paths and is stored in a backend you control. -The honest characterization: if an attacker can access the registry document, they have already authenticated to your backend — the same backend your security team controls, your IAM policies govern, and your audit logs track. At that point they have demonstrated they are past your real defenses. The path topology in the registry is the least valuable thing they now have access to. If they're already inside your SSM with read permissions, the registry tells them paths they could find by listing parameters anyway. +The honest characterization: if an attacker can access the registry document, they have already authenticated to your backend, the same backend your security team controls, your IAM policies govern, and your audit logs track. At that point they have demonstrated they are past your real defenses. The path topology in the registry is the least valuable thing they now have access to. If they're already inside your SSM with read permissions, the registry tells them paths they could find by listing parameters anyway. **Treat the registry document's access controls the same as your most sensitive secret.** SSM SecureString with KMS, Vault KV with tight policy, 1Password with restricted sharing. Not a public SSM String parameter. @@ -85,11 +81,11 @@ Every backend plugin constructs CLI commands using values from the registry. If secretenv prohibits this structurally. All plugins use argument passing, not shell interpolation: ```rust -// Always — each argument is a separate string, shell never parses it +// Always: each argument is a separate string, shell never parses it Command::new("aws") .args(["ssm", "get-parameter", "--name", &uri.path]) -// Never — shell interpolation, injectable +// Never: shell interpolation, injectable Command::new("sh").arg("-c") .arg(format!("aws ssm get-parameter --name {}", uri.path)) ``` @@ -100,17 +96,11 @@ This is enforced in the plugin development guide and verified in CI for all firs ## What secretenv Does Not Solve -**Machine compromise.** If the machine is owned, the attacker runs secretenv themselves, reads the config, watches the CLI invocations. The tool is irrelevant. - -**Post-injection secret protection.** Once the process has the env var, secretenv is out of the picture. The process can log it, write it to disk, pass it in an HTTP request. This is outside secretenv's scope and always will be. - -**Production runtime security.** secretenv is a developer tool. ECS, Lambda, and Kubernetes have native secret injection mechanisms that are the right answer for production. secretenv is not a runtime secret delivery mechanism. - -**Encryption at rest.** secretenv stores no secret values, so it provides no encryption-at-rest property. That is the responsibility of whichever backend holds the value (Vault's storage encryption, SSM SecureString + KMS, 1Password's E2E vault). Tools like fnox (age + KMS modes) and sops *do* provide ciphertext-in-repo and are the right answer if that is the property you need. - -**Secret rotation enforcement.** secretenv fetches whatever the backend has, rotated or not. Rotation policy is the backend's concern. - -**Insider threats with backend access.** An authorized user with registry access and backend access can read anything they're authorized to read. secretenv doesn't add a meaningful barrier to that. +- **Machine compromise** and **post-injection secret protection**, covered under [Reading the Table](#reading-the-table). Both are universal: machine compromise inherits whatever the user had (any tool), and once a secret is an env var the process can log it, write it, or send it anywhere (a property of env vars, not the tool). +- **Production runtime security.** secretenv is a developer tool. ECS, Lambda, and Kubernetes have native secret injection that is the right answer for production. +- **Encryption at rest.** secretenv stores no secret values, so it provides none. That belongs to the backend (Vault storage encryption, SSM SecureString + KMS, 1Password's E2E vault). Tools like fnox (age + KMS modes) and sops provide ciphertext-in-repo if that's the property you need. +- **Secret rotation enforcement.** secretenv fetches whatever the backend has, rotated or not. Rotation policy is the backend's concern. +- **Insider threats with backend access.** An authorized user with registry and backend access reads anything they're authorized to read. secretenv adds no barrier there. --- @@ -122,12 +112,12 @@ secretenv has no central audit log. Audit capability depends on each backend: |---|---| | AWS SSM | CloudTrail logs every `GetParameter` and `PutParameter` call with caller identity, timestamp, and parameter name | | AWS Secrets Manager | CloudTrail, same coverage | -| HashiCorp Vault | Audit device logs every operation — configurable to file, syslog, or socket | +| HashiCorp Vault | Audit device logs every operation, configurable to file, syslog, or socket | | 1Password | Admin console activity log | | GCP Secret Manager | Cloud Audit Logs | | Azure Key Vault | Azure Monitor diagnostic logs | -For organizations that need to answer "who fetched the production database password last Tuesday" — check CloudTrail or your Vault audit log, not secretenv. The audit trail is there. It lives in your backend. +For organizations that need to answer "who fetched the production database password last Tuesday". Check CloudTrail or your Vault audit log, not secretenv. The audit trail is there. It lives in your backend. --- @@ -135,80 +125,45 @@ For organizations that need to answer "who fetched the production database passw Four backends accept a user-supplied endpoint: -- **Infisical** — `infisical_domain` (defaults to `app.infisical.com`). -- **Vault** — `vault_address` (no default; required). -- **OpenBao** — `bao_address` (no default; required). Same threat model as Vault — routes via `BAO_ADDR`. -- **CyberArk Conjur** — `conjur_url` (no default; required). Routes via `CONJUR_APPLIANCE_URL`. +- **Infisical**: `infisical_domain` (defaults to `app.infisical.com`). +- **Vault**: `vault_address` (no default; required). +- **OpenBao**: `bao_address` (no default; required). Same threat model as Vault, routes via `BAO_ADDR`. +- **CyberArk Conjur**: `conjur_url` (no default; required). Routes via `CONJUR_APPLIANCE_URL`. -The domain IS the trust boundary. A hostile endpoint receives every token and URI the backend routes through it — for Infisical that includes `$INFISICAL_TOKEN` on every CLI invocation; for Vault it includes every request to `/v1/...` carrying the client token. +The domain IS the trust boundary. A hostile endpoint receives every token and URI the backend routes through it: for Infisical that includes `$INFISICAL_TOKEN` on every CLI invocation; for Vault it includes every request to `/v1/...` carrying the client token. Discipline that applies to both: 1. **Verify the domain belongs to your organization.** Typos (`infisical.acne.com` vs. `infisical.acme.com`) + attacker-controlled lookalike registrations silently drain credentials. Compare the domain against your IaC repo / provisioning scripts, not a dashboard screenshot someone sent in chat. -2. **Pin HTTPS with a cert you trust.** `http://` is accepted by both CLIs but leaks the token to anyone on-path — only acceptable for loopback dev (`http://127.0.0.1:`). -3. **Confirm the TLS cert chain.** For BYO-CA / internal-PKI setups, the issuing CA must be in the system trust store of every machine running `secretenv`. Test with `openssl s_client -connect :443 -servername `). +3. **Confirm the TLS cert chain.** For BYO-CA / internal-PKI setups, the issuing CA must be in the system trust store of every machine running `secretenv`. Test with `openssl s_client -connect :443 -servername ` scrubs an existing file post-hoc. Both modes share the same engine (Aho-Corasick byte scanner over the set of resolved values) and substitute matches with `[redacted:]` by default. - -### What redaction catches - -- Application code that prints a resolved env-var value to stdout/stderr (the `echo "key=$STRIPE_KEY"` pattern, the most common accidental-leak class). -- Build / CI logs that capture command output and end up in shared artifact stores. -- Post-hoc scrubbing of saved log files via `secretenv redact --in-place`. - -### Limits — what redaction does NOT catch - -Redaction is **defense-in-depth**, not a complete protection. The following escape the pipe entirely or sit outside SecretEnv's view: - -- **Writes to `/dev/tty`** — bypasses the parent process's pipe entirely. -- **`syslog(3)` / `journald` / kernel logging** — kernel writes never traverse the parent stdio pipes. -- **`mmap`'d output** — file-backed shared memory; the parent never sees the bytes. -- **Core dumps + post-mortem analysis** — the process memory at fault time contains the unwrapped values. -- **Interactive TTY children** (default `Auto` mode falls back to `exec()` — see below — and forwards the original raw stdio without redaction, because a pipe would break the PTY contract). -- **Children that re-fetch values via the cloud SDK directly** — bypasses SecretEnv entirely. -- **Match-shorter-than-minimum values** — values < 8 bytes are skipped (the minimum-length filter exists because shorter substrings false-positive across normal English text, destroying log readability). 8-byte API keys are a vendor problem; rotate to a longer credential. - -The full bypass-coverage matrix lives in [[v0.14-plus/specialist-security]] §2.5 in the SecretEnv design knowledge base. - -### Modes - -**Mode A (runtime)** — `secretenv run `: - -- Default `Auto`: pipe-based redaction when the parent's stdin is non-TTY (CI, scripts); auto fall-back to `exec()` when stdin is a TTY (preserves `psql`, `vim`, `ssh`). The fallback emits a one-line stderr advisory: `secretenv: interactive TTY detected; runtime redaction disabled for this invocation. Run with --redact to force pipe-based redaction (may break PTY-bound prompts).` -- `--redact` — force pipe even on a TTY. PTY-bound commands may misbehave. -- `--no-redact --i-know` — force `exec()` path, no redaction. `--i-know` is required to prevent CI accidents where a developer typos away the protection. - -**Mode B (post-hoc)** — `secretenv redact `: - -- Default: writes scrubbed bytes to stdout. -- `--in-place [--backup=]`: atomic rename through a sibling tempfile; mode (`0o600` etc.) preserved. -- `--dry-run`: counts matches without writing. - -Both modes share these safety guards (Mode B's `--in-place` and stdout paths, plus Mode A's child-spawn): - -- **`O_NOFOLLOW`** on `open(2)` — a symlink swap between the foreign-owner stat and the open is rejected. -- **Foreign-owner refusal** — files owned by a UID other than the caller's EUID are refused unless `--allow-foreign-owner` opts in (defense against a maliciously-planted log file in a shared directory). -- **Pseudo-filesystem refusal** — `/proc`, `/sys`, `/dev` are refused outright; "scrubbing" a kernel pseudofile is meaningless. -- **64 KiB tail-window cap** (mode A only) — tainted values larger than the streaming carry-over window are refused at startup (a longer pattern cannot match mid-stream regardless of buffering). +`secretenv run` redacts resolved values from the child's stdout and stderr **by default** (an Aho-Corasick scan substitutes each match with `[redacted:]`), and `secretenv redact ` scrubs existing files post-hoc. This catches the most common accidental-leak class: an application or CI step printing a resolved env-var value into logs that land in a shared artifact store. The full operator reference (both modes, flags, safety guards, substitution token) is at [reference/redact.md](reference/redact.md). -### Substitution token +### Limits: what redaction does NOT catch -Default: `[redacted:]` — lowercase, alias-aware. The alias name is operator-chosen and treated as non-sensitive (the same name appears in your `secretenv.toml` and registry document); the substitution token gives operators a diagnostic breadcrumb in build logs without leaking the value. +Redaction is **defense-in-depth, not complete protection.** These escape the pipe entirely or sit outside SecretEnv's view: -Override: `--redact-token ''` — emits a constant substitution (e.g. `[REDACTED]` or `***`) regardless of which alias matched. Useful when alias names themselves are considered sensitive by policy. +- **Writes to `/dev/tty`**: bypass the parent process's pipe entirely. +- **`syslog(3)` / `journald` / kernel logging**: kernel writes never traverse the parent stdio pipes. +- **`mmap`'d output**: file-backed shared memory; the parent never sees the bytes. +- **Core dumps and post-mortem analysis**: process memory at fault time holds the unwrapped values. +- **Interactive TTY children**: `Auto` mode falls back to `exec()` and forwards the raw stdio without redaction, because a pipe would break the PTY contract. +- **Children that re-fetch values via a cloud SDK directly**: bypass SecretEnv entirely. +- **Values shorter than 8 bytes**: skipped, because shorter substrings false-positive across normal text and destroy log readability. An 8-byte API key is a vendor problem; rotate to a longer credential. --- diff --git a/docs/stability.md b/docs/stability.md new file mode 100644 index 0000000..21d45a6 --- /dev/null +++ b/docs/stability.md @@ -0,0 +1,48 @@ +# Stability & smoke-test history + +Every backend tool claims stability. SecretEnv proves it on every release. + +The smoke harness exercises the **real binary** against **real backend CLIs** in **real shells**, not mocks, not contract tests. Each assertion does the whole thing: spawn the CLI, route input via tempfile or stdin, parse stdout, handle stderr, observe the exit code. The harness lives at [`scripts/smoke-test/`](https://github.com/TechAlchemistX/secretenv/tree/main/scripts/smoke-test) and runs against the operator's live backend accounts before any tag is pushed. + +![Live-backend smoke assertions per release, v0.2.0 to v0.19.0](public/stability-growth.svg) + +## Why this matters + +In the v0.13 cycle the harness caught a latent pipe-deadlock in the Infisical backend that had survived **15 days and 6 release cycles** since Infisical shipped in v0.7.0. CI was green every release. Unit tests passed. Three-agent audits passed. Only the live smoke (the real binary against the real CLI in a real shell) surfaced it. The fix was one line; the lesson was the harness. + +## Assertions per release + +The integration smoke harness began at v0.2.0; v0.1.x predate it. The count tracks the **full-matrix** run across every configured backend. + +| Release | Date | Backends | Assertions | Notable addition | +|---|---|---:|---:|---| +| v0.2.0 | 2026-04-18 | 5 | ~90 | First integration smoke (local, AWS SSM, AWS Secrets, 1Password, Vault) | +| v0.3.0 | 2026-04-19 | 7 | 250 | +GCP Secret Manager, +Azure Key Vault | +| v0.4.0 | 2026-04-22 | 7 | 336 | Functionality cycle, `doctor --fix/--extensive`, `registry history/invite`, profiles | +| v0.5.0 | 2026-04-22 | 8 | 347 | +macOS Keychain | +| v0.6.0 | 2026-04-22 | 9 | 362 | +Doppler | +| v0.7.0 | 2026-04-22 | 10 | 377 | +Infisical | +| v0.8.0 | 2026-04-24 | 11 | 395 | +Keeper | +| v0.9.0 | 2026-04-25 | 12 | 419 | +Cloudflare Workers KV | +| v0.10.0 | 2026-04-27 | 13 | 452 | +OpenBao | +| v0.11.0 | 2026-04-30 | 14 | 479 | +CyberArk Conjur | +| v0.12.0 | 2026-05-05 | 15 | 508 | +Bitwarden Secrets Manager (15th backend) | +| v0.13.0 | 2026-05-06 | 15 | 508 | Hygiene cycle, caught the v0.7-era Infisical pipe-deadlock | +| v0.14.0 | 2026-05-15 | 15 | 541 | +`secretenv redact` (runtime + post-hoc) | +| v0.15.0 | 2026-05-20 | 15 | 690 | +`registry migrate` | +| v0.16.0 | 2026-05-24 | 15 | 712 | +MCP server | +| v0.17.0 | 2026-05-28 | 15 | 767 | +OpenTelemetry | +| v0.18.0 | 2026-06-04 | 15 | 796 | Hardening #1, security / telemetry / MCP closures | +| v0.19.0 | 2026-06-14 | 15 | 779 | Hardening #2, doc-vs-code audit, probe-vocabulary unification | + +Test surface grew alongside feature surface across a single-backend-per-minor-release cadence. Two reading notes: + +- **Hardening cycles add no backends, and the assertion count can move down.** v0.19 consolidated and re-scoped a handful of telemetry assertions, so its full-matrix total (779) sits just below v0.18's (796). Fewer assertions here means a tighter suite, not less coverage. +- **Patch and hygiene releases re-validate rather than extend.** Merged-not-tagged hygiene cycles (v0.7.1, v0.9.1, v0.16.1, v0.16.2, and others) re-run the prior minor's baseline matrix to confirm no regression; they don't add new assertions, so they aren't plotted above. + +## Methodology + +- **No mocks.** Every assertion runs the shipped binary end to end against a live backend account. +- **Pre-tag gate.** A green full-matrix run is required before any version tag is pushed. +- **Expected skips are explicit.** A run reports `PASS / FAIL / SKIP`; skips are pre-declared (e.g. a UUID-gated fixture, a TTY-only PTY case) and never mask a failure. +- **Environment failures are distinguished from code failures.** A first-run failure traced to an expired backend session is re-run after re-auth, not counted as a code defect. diff --git a/examples/README.md b/examples/README.md index ffec1bb..0582359 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,27 +1,18 @@ # SecretEnv Examples -Canonical configuration patterns for SecretEnv. Each subdirectory is a -realistic, minimally-complete setup you can copy, adapt, and drop onto a -machine. +Configuration patterns you can copy and adapt. Each subdirectory is a realistic, complete setup. -## ⚠️ These are NOT Cargo `examples/` +## Not Cargo `examples/` -This directory is at the workspace root, but it is **not** a Cargo -`examples/` target. It contains `config.toml` + `secretenv.toml` -fixtures, not runnable Rust binaries. `cargo build --examples` finds -nothing here (no `.rs` files), which is intentional — these are -configuration patterns that ship with the repo so users can see real, -working shapes rather than reverse-engineering them from docs. +This directory contains `config.toml` + `secretenv.toml` fixtures, not Rust binaries. `cargo build --examples` finds nothing here (no `.rs` files), intentional. These are configuration patterns for reference, not code examples. -If you want code examples (`.rs` binaries that call into -`secretenv-core`), open an issue; they'd live under -`crates/secretenv-cli/examples/` and pull in the core as a dependency. +For code examples (`.rs` binaries using `secretenv-core`), open an issue. ## The seven patterns | Directory | What it shows | |---|---| -| [`single-backend-local/`](single-backend-local/) | Simplest setup: local-file registry + local-file secrets. No cloud at all — good for a solo dev or a first taste of SecretEnv. | +| [`single-backend-local/`](single-backend-local/) | Simplest setup: local-file registry + local-file secrets. No cloud at all, good for a solo dev or a first taste of SecretEnv. | | [`single-backend-aws-ssm/`](single-backend-aws-ssm/) | Typical AWS-only team: registry + secrets both in AWS SSM Parameter Store. | | [`single-backend-keychain/`](single-backend-keychain/) | macOS Keychain as the secret store. Registry must live elsewhere (local file here) because Keychain doesn't support `list`. | | [`cascade-local-then-vault/`](cascade-local-then-vault/) | Dev-first cascade: local registry checked first, Vault fallback. Real pattern for mixed local-override + team-default workflows. | @@ -29,29 +20,25 @@ If you want code examples (`.rs` binaries that call into | [`ci-github-actions/`](ci-github-actions/) | GitHub Actions workflow snippet showing `SECRETENV_REGISTRY` injection and OIDC-assumed AWS role. | | [`secretenv-toml-canonical/`](secretenv-toml-canonical/) | Exhaustively-annotated `secretenv.toml` covering every directive, fragment, and default form. Reference, not starter. | -## How to use these +## How to use 1. Pick the pattern closest to your setup. -2. Copy its `config.toml` to `~/.config/secretenv/config.toml` (or set - `--config `). -3. Copy its `secretenv.toml` into a project repo and run - `secretenv run -- `. +2. Copy `config.toml` to `~/.config/secretenv/config.toml` (or use `--config `). +3. Copy `secretenv.toml` into your project and run `secretenv run -- `. -The `config.toml` files use placeholder names (`my-team`, `acme-corp`, -etc.) — rename to match your real infrastructure. +Rename placeholder names (`my-team`, `acme-corp`, etc.) to match your infrastructure. -## What's NOT covered here +## Not covered here -- Backend-specific auth flows (AWS SSO, Vault login, `op signin`) — - those live in each backend's doc under [`docs/backends/`](../docs/backends/). -- Distribution profiles — see [`docs/profiles.md`](../docs/profiles.md). -- Registry management commands — see [`docs/registry.md`](../docs/registry.md). -- The full threat model — see [`docs/security.md`](../docs/security.md). +- Backend auth flows: see [`docs/backends/`](../docs/backends/). +- Distribution profiles: see [`docs/reference/profiles.md`](../docs/reference/profiles.md). +- Registry management: see [`docs/reference/registry.md`](../docs/reference/registry.md). +- Threat model: see [`docs/security.md`](../docs/security.md). ## Related -- [Top-level README](../README.md) — product overview and quick start -- [`docs/configuration.md`](../docs/configuration.md) — reference for every +- [Top-level README](../README.md): product overview and quick start +- [`docs/reference/configuration.md`](../docs/reference/configuration.md): reference for every config directive -- [`docs/adding-a-backend.md`](../docs/adding-a-backend.md) — if you want +- [`docs/reference/adding-a-backend.md`](../docs/reference/adding-a-backend.md): if you want to write a new backend diff --git a/examples/cascade-local-then-vault/README.md b/examples/cascade-local-then-vault/README.md index 978e625..c698406 100644 --- a/examples/cascade-local-then-vault/README.md +++ b/examples/cascade-local-then-vault/README.md @@ -1,19 +1,12 @@ -# Cascade: local-first, Vault-fallback +# Cascade: Local → Vault -Two-source registry where a local TOML file is checked FIRST and -HashiCorp Vault provides the team-wide fallback. This is the canonical -dev-override pattern: individual developers can override production -aliases with local values (e.g. point `db-url` at their laptop's local -Postgres) without editing the team registry. +Two-source registry. Local TOML checked first, Vault fallback. Lets devs override production aliases locally (e.g. `db-url` → localhost) without editing the team registry. -## When to use this +## When to use -- Every developer needs the same base set of aliases, but individuals - want to override 2-3 per laptop (dev DB, localhost Redis, a personal - Stripe test key). -- Onboarding flow: a new engineer gets Vault access on day one and - everything works; they can add local overrides later. -- Team runs a Vault cluster already — no new infrastructure. +- All devs share a baseline, but individuals need 2-3 local overrides +- New engineers get Vault access day one; local overrides come later +- Team already runs Vault ## Precedence @@ -25,30 +18,24 @@ Postgres) without editing the team registry. If `db-url` exists in source 0, source 1 is not consulted for that alias. If `stripe-key` only exists in source 1, source 1 resolves it. -## What's in this directory +## Files -- `config.toml` — one cascading `dev` registry with local + Vault - sources, and the two backends. -- `secretenv.toml` — project manifest. -- `local-registry/registry.toml` — personal overrides; gitignored in - real use. +- `config.toml`: cascading `dev` registry with local + Vault sources +- `secretenv.toml`: project manifest +- `local-registry/registry.toml`: personal overrides (gitignored in real use) -## Running it +## Running ```sh -# Prereqs: -# vault CLI installed + VAULT_ADDR + VAULT_TOKEN set. -vault status # should report sealed=false +# Prereqs: vault CLI + VAULT_ADDR + VAULT_TOKEN +vault status # sealed=false secretenv --config examples/cascade-local-then-vault/config.toml doctor -# Run with cascade — local first, Vault fallback: +# Run with cascade: cd examples/cascade-local-then-vault secretenv run --registry dev -- npm start ``` -## Why not just two registries? +## Why cascade over two registries? -You can do that too (`--registry local`, `--registry team`). Cascade -shines when you want ONE invocation line (`secretenv run --registry -dev`) that works identically on every laptop, with per-laptop -overrides invisible to the user. +You can use `--registry local` and `--registry team` separately. Cascade shines for a single invocation (`secretenv run --registry dev`) that works identically on every laptop with per-laptop overrides invisible to users. diff --git a/examples/ci-github-actions/README.md b/examples/ci-github-actions/README.md index d09e6e3..d2120f9 100644 --- a/examples/ci-github-actions/README.md +++ b/examples/ci-github-actions/README.md @@ -1,55 +1,36 @@ # CI/CD: GitHub Actions -Reference workflow showing SecretEnv in a GitHub Actions job. Uses -OIDC-assumed AWS credentials (no long-lived secrets stored in GitHub) -and `SECRETENV_REGISTRY` to point the binary at a registry without -needing a `config.toml` on the runner. +Reference workflow. Uses OIDC-assumed AWS credentials (no long-lived secrets in GitHub) and `SECRETENV_REGISTRY` for the registry (no `config.toml` on runner). -## When to use this +## When to use -- Deploy jobs that run on ephemeral GitHub runners. -- Any pipeline that calls a command needing cloud secrets — tests, - migrations, smoke tests. -- Want to stop copy-pasting `with: env:` blocks everywhere. +- Deploy jobs on ephemeral GitHub runners +- Any pipeline calling commands with cloud secrets (tests, migrations, smoke tests) +- Stop copy-pasting `with: env:` blocks -## What's in this directory +## Files -- `deploy.yml` — canonical workflow. Copy to - `.github/workflows/deploy.yml` in your repo. -- `secretenv.toml` — matching project manifest. +- `deploy.yml`: workflow template; copy to `.github/workflows/deploy.yml` +- `secretenv.toml`: matching project manifest -## Key lines in the workflow +## Key workflow lines -1. `aws-actions/configure-aws-credentials@v4` assumes the deploy role - via OIDC. No static AWS keys stored in GitHub. -2. `curl -sfS https://secretenv.io/install.sh | sh` installs the binary - (or use `cargo install secretenv` or the prebuilt tarball from the - GH Release). -3. `SECRETENV_REGISTRY: aws-ssm:///secretenv/registry` tells SecretEnv - where the alias registry lives — no `config.toml` needed. -4. `secretenv run -- ./deploy.sh` executes your command with secrets - injected as env vars from resolved aliases. +1. `aws-actions/configure-aws-credentials@v4`: assumes deploy role via OIDC +2. `curl -sfS https://secretenv.io/install.sh | sh`: installs binary +3. `SECRETENV_REGISTRY: aws-ssm:///secretenv/registry`: registry location (no `config.toml` needed) +4. `secretenv run -- ./deploy.sh`: injects secrets as env vars from resolved aliases -## Why not just `secrets.STRIPE_KEY`? +## Why not GitHub `secrets.STRIPE_KEY`? -You can — GitHub Actions secrets work. The SecretEnv advantage shows -up when: +GitHub secrets work. SecretEnv shines when: +- Same values used locally and in CI (one source of truth) +- Multiple deploy jobs sharing 8 secrets (no `env:` copy-paste) +- Rotation in AWS/1Password (GitHub secrets don't update) -- The same secret values are used locally AND in CI (one source of - truth, not duplicated across GitHub + your laptop). -- You have three deploy jobs that all need the same 8 secrets (no more - `env:` block copy-paste). -- Rotation happens in AWS/1Password — no GitHub secret updates needed. +## IAM role permissions -## What the deploy role needs +`github-actions-deploy` needs: +- `ssm:GetParameter` / `ssm:GetParameters` on `arn:aws:ssm:*:*:parameter/secretenv/*` + prod alias paths +- Whatever your deploy script needs (ECS, S3, etc.) -The IAM role `github-actions-deploy` needs: - -- `ssm:GetParameter` / `ssm:GetParameters` on - `arn:aws:ssm:*:*:parameter/secretenv/*` + the prod paths your aliases - point to. -- Whatever your actual deploy script needs (ECS task registration, - S3 upload, etc.). - -No SecretEnv-specific IAM — the permissions are just "can this role -read the parameters the registry aliases to?". +No SecretEnv-specific IAM: just "can this role read the parameters the aliases point to?" diff --git a/examples/multi-cloud-aws-and-1password/README.md b/examples/multi-cloud-aws-and-1password/README.md index 95b669e..0640d25 100644 --- a/examples/multi-cloud-aws-and-1password/README.md +++ b/examples/multi-cloud-aws-and-1password/README.md @@ -1,34 +1,23 @@ # Multi-cloud: AWS SSM + 1Password -Real-team pattern where infrastructure secrets live in AWS SSM -(managed by ops, per-environment paths) and human-managed secrets -(third-party API tokens, shared credentials) live in 1Password -(managed by engineering). The registry lives in 1Password so the whole -team can read it without needing IAM access to the SSM registry path. +Real-team pattern. Infra secrets in AWS SSM (ops-managed), human secrets in 1Password (eng-managed). Registry in 1Password so the team reads it without SSM IAM access. -## When to use this +## When to use -- Mid-migration teams: came from 1Password, moving infra stuff into - SSM/Secrets Manager, but don't want to churn. -- Ops + dev split: separate IAM posture for "the database URL" vs "the - Figma plugin API token". -- Multiple AWS accounts (platform + dev + prod) with one 1Password - account for humans. +- Mid-migration from 1Password to SSM +- Ops/dev split (separate IAM for infra vs human secrets) +- Multiple AWS accounts with one 1Password account -## What's in this directory +## Files -- `config.toml` — three AWS SSM instances (platform/dev/prod) + two - 1Password instances (work/personal). Real orgs usually stop at 2-3 - backends total — this example shows named instances so you see the - plumbing. -- `secretenv.toml` — project manifest with infra + human-managed - aliases side by side. +- `config.toml`: three AWS SSM instances (platform/dev/prod) + two 1Password instances (work/personal). Shows the plumbing; real orgs use 2-3 backends total. +- `secretenv.toml`: project manifest with infra + human-managed aliases -## Running it +## Running ```sh # Prereqs: -aws sso login --profile platform # or whatever your auth is +aws sso login --profile platform op signin --account company.1password.com secretenv --config examples/multi-cloud-aws-and-1password/config.toml doctor @@ -37,21 +26,19 @@ cd examples/multi-cloud-aws-and-1password secretenv run -- ./app ``` -## Registry layout assumed +## Registry layout -Registry document stored in 1Password at -`1password-work://Engineering/SecretEnv Registry/notesPlain`: +Registry in 1Password at `1password-work://Engineering/SecretEnv Registry/notesPlain`: ```toml -# infra — owned by platform team, lives in SSM +# infra, owned by platform team, lives in SSM database-url = "aws-ssm-prod:///myapp/prod/database-url" redis-url = "aws-ssm-prod:///myapp/prod/redis-url" -# third-party APIs — owned by engineering, lives in 1Password +# third-party APIs, owned by engineering, lives in 1Password stripe-key = "1password-work://Engineering/Stripe/key" datadog-api-key = "1password-work://Engineering/Datadog/api-key" figma-plugin-token = "1password-work://Engineering/Figma/plugin-token" ``` -Notice the alias names are identical across environments — the registry -routing picks the right backend per env. +Alias names are identical across environments; registry routing picks the right backend per env. diff --git a/examples/secretenv-toml-canonical/README.md b/examples/secretenv-toml-canonical/README.md index b64403c..8eefe55 100644 --- a/examples/secretenv-toml-canonical/README.md +++ b/examples/secretenv-toml-canonical/README.md @@ -1,30 +1,21 @@ -# Canonical annotated `secretenv.toml` +# Canonical `secretenv.toml` Reference -Reference file — every directive SecretEnv accepts in a project -manifest, exhaustively commented. NOT a starter file; it's too noisy -to drop into a real repo. Use this to look up the syntax for a -particular directive, then copy the minimal form into your own -`secretenv.toml`. +Exhaustively-commented reference. Not a starter file, too noisy for real repos. Use to look up syntax, then copy the minimal form into your `secretenv.toml`. -## What's in this directory +## Contents -- `secretenv.toml` — the annotated manifest. +- `secretenv.toml`: annotated manifest -## What this file covers +## Covers -- The two value shapes: `{ from = "secretenv://..." }` and - `{ default = "..." }`. -- Why direct backend URIs (`aws-ssm-prod:///...`) are a hard error. -- Alias naming conventions (kebab-case in registries, SCREAMING_SNAKE - as env-var names). -- How the registry resolves an alias to its final URI. -- The resolution precedence for `--registry` and `SECRETENV_REGISTRY`. +- Value shapes: `{ from = "secretenv://..." }` and `{ default = "..." }` +- Why direct URIs like `aws-ssm-prod:///...` are errors +- Alias naming (kebab-case in registries, SCREAMING_SNAKE for env vars) +- Registry alias resolution +- `--registry` and `SECRETENV_REGISTRY` precedence -## Related references +## Related -- [`docs/configuration.md`](../../docs/configuration.md) — the - `config.toml` (machine config) counterpart. -- [`docs/registry.md`](../../docs/registry.md) — the alias registry - document shape. -- [`docs/fragment-vocabulary.md`](../../docs/fragment-vocabulary.md) — - per-backend URI fragment directives. +- [`docs/reference/configuration.md`](../../docs/reference/configuration.md): `config.toml` reference +- [`docs/reference/registry.md`](../../docs/reference/registry.md): registry document shape +- [`docs/reference/fragment-vocabulary.md`](../../docs/reference/fragment-vocabulary.md): backend URI fragments diff --git a/examples/single-backend-aws-ssm/README.md b/examples/single-backend-aws-ssm/README.md index d38e42b..8d54c22 100644 --- a/examples/single-backend-aws-ssm/README.md +++ b/examples/single-backend-aws-ssm/README.md @@ -1,41 +1,36 @@ # Single-backend: AWS SSM Parameter Store -Typical AWS-native team layout: both the alias registry and every -secret live in AWS SSM Parameter Store. One AWS account, one region, -one set of IAM permissions — no cross-vendor auth juggling. +Typical AWS-native setup. Registry and secrets in SSM. One account, one region, one IAM posture. -## When to use this +## When to use -- All-in on AWS and happy to stay there. -- Per-environment SSM paths (`/myapp/prod/`, `/myapp/staging/`) are - already your convention. -- SSM's free tier + parameter-history audit trail fits your needs. +- All-in on AWS +- Per-environment paths (`/myapp/prod/`, `/myapp/staging/`) already your pattern +- SSM free tier + parameter history fits your audit needs -## What's in this directory +## Files -- `config.toml` — one `aws-ssm` backend instance (`aws-ssm-main`), - configured with an AWS profile and region. -- `secretenv.toml` — project manifest referencing three aliases. +- `config.toml`: `aws-ssm` backend (`aws-ssm-main`) with profile and region +- `secretenv.toml`: project manifest with three aliases -## Running it +## Running ```sh -# Prerequisites: aws CLI installed + authenticated against the target account. -aws sso login --profile my-team # or whatever your auth is -aws configure list --profile my-team # verify identity +# Prerequisites: aws CLI + account auth +aws sso login --profile my-team +aws configure list --profile my-team -# Verify secretenv sees it: +# Verify secretenv: secretenv --config examples/single-backend-aws-ssm/config.toml doctor -# Run a command with secrets injected: +# Run with secrets: cd examples/single-backend-aws-ssm secretenv run -- npm start ``` -## Registry layout assumed +## Registry layout -The registry document at `aws-ssm-main:///secretenv/registry` is a -plain SSM parameter whose value is TOML: +Registry at `aws-ssm-main:///secretenv/registry` is a plain SSM parameter with TOML value: ```toml stripe-key = "aws-ssm-main:///myapp/prod/stripe-key" @@ -43,5 +38,4 @@ database-url = "aws-ssm-main:///myapp/prod/database-url" datadog-api-key = "aws-ssm-main:///myapp/prod/datadog-api-key" ``` -Manage it with `secretenv registry set/unset/list` rather than editing -the SSM parameter directly. +Use `secretenv registry set/unset/list` to manage it, not direct SSM edits. diff --git a/examples/single-backend-keychain/README.md b/examples/single-backend-keychain/README.md index d825649..e6d5442 100644 --- a/examples/single-backend-keychain/README.md +++ b/examples/single-backend-keychain/README.md @@ -1,33 +1,23 @@ # Single-backend: macOS Keychain -New in v0.5. Secrets live in the macOS login keychain; the registry -lives in a local TOML file (Keychain can't host a registry — see -[list() limitation](#why-the-registry-is-local-not-keychain)). +Secrets in the login keychain; registry in a local TOML file (Keychain can't host a registry). -## When to use this +## When to use -- Solo macOS developer who already has keychain muscle memory from - storing SSH passphrases, API tokens, etc. -- Offline-friendly: no network, no CLI auth setup, no cloud account. -- Items created by Keychain Access.app or `security add-generic-password` - are ready to use immediately. +- Solo macOS dev with keychain muscle memory +- Offline-friendly (no network, no auth setup) +- Keychain Access.app or `security` items ready immediately ## Not for you if -- Team setup: Keychain is per-user, per-machine. Use a cloud backend - for team secrets. -- Non-macOS: this backend errors cleanly on Linux/Windows. See - [`single-backend-keychain`](../single-backend-keychain/) pairs with - distro-specific backends in future releases (e.g. Secret Service for - Linux in v0.6). +- Team setup, Keychain is per-user, per-machine +- Non-macOS, future releases will add distro-specific backends -## What's in this directory +## Files -- `config.toml` — one `keychain` backend + one `local` backend for the - registry. -- `secretenv.toml` — project manifest with two aliases. -- `local-registry/registry.toml` — alias-to-URI map pointing at - Keychain items. +- `config.toml`: `keychain` backend + `local` backend for registry +- `secretenv.toml`: project manifest with two aliases +- `local-registry/registry.toml`: alias-to-URI map for Keychain items ## Creating the items @@ -44,12 +34,6 @@ secretenv --config examples/single-backend-keychain/config.toml \ run -- env | grep -E 'STRIPE_KEY|DATABASE_URL' ``` -## Why the registry is local, not Keychain +## Why registry is local -`security` has no safe list-all-items operation (the closest, `security -dump-keychain`, prompts per item and leaks every credential). SecretEnv -therefore implements Keychain as a `get`-only target — great for -storing values, not for listing aliases. Hence a local TOML registry -pointing at keychain URIs. See -[`docs/backends/keychain.md`](../../docs/backends/keychain.md) for the -full rationale. +`security` has no safe list-all operation. `security dump-keychain` prompts per item and leaks all credentials. SecretEnv implements Keychain as get-only, great for values, not listing aliases. Hence a local TOML registry pointing at keychain URIs. See [`docs/backends/keychain.md`](../../docs/backends/keychain.md) for details. diff --git a/examples/single-backend-local/README.md b/examples/single-backend-local/README.md index 983b218..6d917c9 100644 --- a/examples/single-backend-local/README.md +++ b/examples/single-backend-local/README.md @@ -1,23 +1,20 @@ # Single-backend: Local file -The simplest possible SecretEnv setup. Registry and secrets both live -in local files on disk. No cloud, no CLIs, no network. +Simplest setup. Registry and secrets in local files. No cloud, no CLIs, no network. -## When to use this +## When to use -- Trying SecretEnv for the first time with zero account setup. -- Solo project where a single developer controls every secret. -- Offline/air-gapped dev where network backends aren't available. -- Teaching SecretEnv's alias → URI model without distracting backend - auth concerns. +- First-time SecretEnv with zero account setup +- Solo projects where one dev controls all secrets +- Offline/air-gapped dev +- Learning the alias → URI model -## What's in this directory +## Files -- `config.toml` — machine config declaring one `local` backend instance - and a default registry that points at `local-registry/registry.toml`. -- `secretenv.toml` — project manifest using two aliases. -- `local-registry/registry.toml` — the alias-to-URI mapping. -- `local-secrets/` — the secret values themselves (one file per value). +- `config.toml`: machine config with `local` backend and registry path +- `secretenv.toml`: project manifest with two aliases +- `local-registry/registry.toml`: alias-to-URI mapping +- `local-secrets/`: secret values (one file per value) ## Running it @@ -30,9 +27,6 @@ cp examples/single-backend-local/config.toml ~/.config/secretenv/config.toml cd examples/single-backend-local && secretenv run -- env | grep API_KEY ``` -## What to replace for real use +## For production -Production teams don't keep secret values on disk. Move the `api_key` -and `db_url` values into AWS SSM / 1Password / Vault, update the -registry entries to point there, and the `secretenv.toml` stays -identical. +Move `api_key` and `db_url` into AWS SSM / 1Password / Vault, update registry entries to point there. The `secretenv.toml` stays identical. diff --git a/scripts/smoke-test/README.md b/scripts/smoke-test/README.md index 2e8b07c..ff788d2 100644 --- a/scripts/smoke-test/README.md +++ b/scripts/smoke-test/README.md @@ -32,13 +32,13 @@ You need authenticated CLIs for every backend the matrix touches: |-----------------|-----------|----------------------------------------------------------| | AWS SSM | `aws` | Region defaults to `us-east-1` | | AWS Secrets Mgr | `aws` | Same credentials as SSM | -| 1Password | `op` | `op signin` — vault `Private` must exist | +| 1Password | `op` | `op signin`, vault `Private` must exist | | Vault | `vault` | Dev-mode server: `vault server -dev` (KV v2 mounted at `secret/`) | | GCP Secret Mgr | `gcloud` | `gcloud auth application-default login` | | Azure Key Vault | `az` | `az login` + Key Vault Secrets Officer role on the vault | | macOS Keychain | `security`| macOS only; test keychain created per-run under `$RUNTIME_DIR` | -| Doppler | `doppler` | `doppler login` — project `secretenv-validation` / config `dev` (provisioned) | -| Infisical | `infisical`| `infisical login` — account-specific project UUID in `$SECRETENV_INFISICAL_PROJECT_ID` (default: CI smoke account) | +| Doppler | `doppler` | `doppler login`, project `secretenv-validation` / config `dev` (provisioned) | +| Infisical | `infisical`| `infisical login`, account-specific project UUID in `$SECRETENV_INFISICAL_PROJECT_ID` (default: CI smoke account) | Plus a Rust toolchain to build the release binary. @@ -62,7 +62,7 @@ export SECRETENV_TEST_AWS_REGION=us-east-1 # default # OpenBao export SECRETENV_TEST_BAO_ADDR=http://127.0.0.1:8300 # Bitwarden Secrets Manager (UUIDs from your bws workspace) -export BWS_ACCESS_TOKEN=0..: # NO surrounding quotes — bws v2 misreads them +export BWS_ACCESS_TOKEN=0..: # NO surrounding quotes; bws v2 misreads them export SECRETENV_TEST_BWS_SERVER_URL=https://api.bitwarden.com export SECRETENV_TEST_BWS_SCALAR_UUID= export SECRETENV_TEST_BWS_JSON_UUID= @@ -91,7 +91,7 @@ export SECRETENV_INFISICAL_PROJECT_ID= # override the Infisical pro # 1. build the release binary cargo build --release -# 2. seed cloud + local fixtures (idempotent — safe to re-run) +# 2. seed cloud + local fixtures (idempotent, safe to re-run) ./scripts/smoke-test/provision.sh # 3. run the full matrix (~5 minutes wall-clock) @@ -121,7 +121,7 @@ usually want a subset. for: a contributor onboarding loop, a CI pre-check on every PR, or testing CLI ergonomics changes that don't touch backend code. -`--sections N,M-P` runs the listed sections only — useful when iterating on a +`--sections N,M-P` runs the listed sections only, useful when iterating on a specific surface (e.g. `--sections 17` while debugging registry history). The full matrix remains the default and is the **only** mode that gates a @@ -145,26 +145,26 @@ release tag. | 12 | no | Shell completions | | 13 | yes | v0.2.1 canonical fragment grammar | | 14 | yes | v0.2.6 cross-backend matrix | -| 15 | yes | v0.3 Phase 1 — GCP Secret Manager | -| 16 | yes | v0.3 Phase 2 — Azure Key Vault | -| 17 | yes | v0.4 Phase 2a — `registry history` | -| 18 | no | v0.4 Phase 2b — `registry invite` (offline) | -| 19 | yes | v0.4 Phase 1 — `doctor --fix` + `--extensive` | -| 20 | yes | v0.4 Phase 3 — `timeout_secs` (offline) + `op_unsafe_set` (needs `op`) | -| 21 | yes | v0.5 — macOS Keychain backend (macOS hosts only; SKIP on Linux) | -| 22 | yes | v0.6 — Doppler backend (skips if not authenticated) | -| 23 | yes | v0.7 — Infisical backend (skips if not authenticated) | -| 24 | yes | v0.8 — Keeper backend (skips if persistent-login not set) | -| 25 | yes | v0.9 — Cloudflare Workers KV (skips if `wrangler` not authenticated) | -| 26 | yes | v0.10 — OpenBao backend (skips if `bao` server unreachable / sealed) | -| 27 | yes | v0.11 — CyberArk Conjur backend (skips if `docker` / Conjur unreachable) | -| 28 | yes | v0.12 — Bitwarden Secrets Manager backend (skips if `bws` / no token) | -| 29 | yes | v0.14 Mode A — runtime stdout/stderr redaction | -| 30 | yes | v0.14 Mode B — post-hoc file scrubber | -| 31 | yes | v0.14 Mode B — safety guards (special-path, foreign-owner, O_NOFOLLOW) | -| 32 | no | v0.15 — `secretenv registry migrate` local-only semantics + JSON wire-format | -| 33 | yes | v0.15 — `secretenv registry migrate` live per-backend matrix (15 backends; SKIP-aware per-backend) | -| 34 | no | v0.15 — `secretenv registry migrate` `--delete-source` flow + SEC-INV-08 second-prompt lock | +| 15 | yes | v0.3 Phase 1, GCP Secret Manager | +| 16 | yes | v0.3 Phase 2, Azure Key Vault | +| 17 | yes | v0.4 Phase 2a, `registry history` | +| 18 | no | v0.4 Phase 2b, `registry invite` (offline) | +| 19 | yes | v0.4 Phase 1, `doctor --fix` + `--extensive` | +| 20 | yes | v0.4 Phase 3, `timeout_secs` (offline) + `op_unsafe_set` (needs `op`) | +| 21 | yes | v0.5, macOS Keychain backend (macOS hosts only; SKIP on Linux) | +| 22 | yes | v0.6, Doppler backend (skips if not authenticated) | +| 23 | yes | v0.7, Infisical backend (skips if not authenticated) | +| 24 | yes | v0.8, Keeper backend (skips if persistent-login not set) | +| 25 | yes | v0.9, Cloudflare Workers KV (skips if `wrangler` not authenticated) | +| 26 | yes | v0.10, OpenBao backend (skips if `bao` server unreachable / sealed) | +| 27 | yes | v0.11, CyberArk Conjur backend (skips if `docker` / Conjur unreachable) | +| 28 | yes | v0.12, Bitwarden Secrets Manager backend (skips if `bws` / no token) | +| 29 | yes | v0.14 Mode A, runtime stdout/stderr redaction | +| 30 | yes | v0.14 Mode B, post-hoc file scrubber | +| 31 | yes | v0.14 Mode B, safety guards (special-path, foreign-owner, O_NOFOLLOW) | +| 32 | no | v0.15, `secretenv registry migrate` local-only semantics + JSON wire-format | +| 33 | yes | v0.15, `secretenv registry migrate` live per-backend matrix (15 backends; SKIP-aware per-backend) | +| 34 | no | v0.15, `secretenv registry migrate` `--delete-source` flow + SEC-INV-08 second-prompt lock | Run `./run-tests.sh --list-sections` for the live inventory. @@ -177,12 +177,12 @@ The runner exits non-zero if any test failed. ## Troubleshooting -- **"secretenv binary not found"** — run `cargo build --release` from the repo +- **"secretenv binary not found"**: run `cargo build --release` from the repo root, or set `SECRETENV_BIN` to an explicit path. -- **"SECRETENV_TEST_GCP_PROJECT is not set"** — export the cloud env vars (see +- **"SECRETENV_TEST_GCP_PROJECT is not set"**: export the cloud env vars (see above). `--local-only` skips this requirement entirely. -- **AWS Secrets Manager `InvalidRequestException` on first run** — the fixture +- **AWS Secrets Manager `InvalidRequestException` on first run**: the fixture may be stuck in soft-deleted state from a previous run. Wait the recovery window or use `aws secretsmanager restore-secret` then re-run `provision.sh`. -- **1Password `op` session expired mid-run** — re-auth (`eval $(op signin)`) +- **1Password `op` session expired mid-run**: re-auth (`eval $(op signin)`) and re-run; `provision.sh` is idempotent. diff --git a/scripts/smoke-test/fixtures/vscode-mcp-copilot/README.md b/scripts/smoke-test/fixtures/vscode-mcp-copilot/README.md index 22b824a..9774d3c 100644 --- a/scripts/smoke-test/fixtures/vscode-mcp-copilot/README.md +++ b/scripts/smoke-test/fixtures/vscode-mcp-copilot/README.md @@ -15,8 +15,8 @@ Mutation calls time out after 30s server-side; the audit log records `decision: "timeout"`. The v0.16.2 F-11 cycle is set up to A/B test the leading -hypothesis — adding a single no-op `confirm: bool` field to the -schema — against the 6 elicitation-capable IDEs (Claude Code, +hypothesis (adding a single no-op `confirm: bool` field to the +schema) against the 6 elicitation-capable IDEs (Claude Code, Gemini, Cline, Codex, OpenCode, Copilot) to verify whether option (a) unlocks Copilot without regressing the 5 IDEs that currently work.