diff --git a/cli/internal/cmd/secrets.go b/cli/internal/cmd/secrets.go index 4640b8d2..0a7f99dd 100644 --- a/cli/internal/cmd/secrets.go +++ b/cli/internal/cmd/secrets.go @@ -37,6 +37,7 @@ func newSecretsCmd() *cobra.Command { cmd.AddCommand(newSecretsLsCmd()) cmd.AddCommand(newSecretsShowCmd()) cmd.AddCommand(newSecretsSetCmd()) + cmd.AddCommand(newSecretsRotateCmd()) cmd.AddCommand(newSecretsMigrateCmd()) cmd.AddCommand(newSecretsSyncCmd()) cmd.AddCommand(newSecretsRenderCmd()) diff --git a/cli/internal/cmd/secrets_rotate.go b/cli/internal/cmd/secrets_rotate.go new file mode 100644 index 00000000..97bfb7c4 --- /dev/null +++ b/cli/internal/cmd/secrets_rotate.go @@ -0,0 +1,170 @@ +package cmd + +import ( + "fmt" + + "github.com/mlorentedev/dotfiles/cli/internal/secrets" + "github.com/spf13/cobra" +) + +// daemonSyncer is what rotate needs from the bw serve daemon: make it pull the +// current vault state so the read path stops answering from a stale cache. A seam +// so rotate's tests run with no daemon. +type daemonSyncer interface{ Sync() error } + +// bwSyncer is the production daemon-sync seam. +var bwSyncer daemonSyncer = &secrets.BWServeDaemon{Client: secrets.BWServeClient{}} + +// newSecretsRotateCmd is C7: replace a live credential and prove the replacement +// took, in one command. +// +// It exists because doing this by hand is five steps and two of them are silent +// failures waiting to happen. Rotating a leaked DockerHub PAT on 2026-08-15 hit +// both: the daemon sync was forgotten, so a correct write kept serving the old +// value with no signal; and the liveness probe returned 200 for a token that had +// not actually been replaced, because an unrevoked old credential authenticates +// exactly as well as a new one. Neither is an operator mistake — the sequence +// offers no way to tell those states apart. +// +// So rotate reports a FINGERPRINT change, not just a probe result. "The value +// changed" and "the value works" are different claims and both are required; a +// rotation that writes to the wrong field satisfies the second and fails the +// first, which is precisely the case that looked successful by hand. +func newSecretsRotateCmd() *cobra.Command { + var dryRun bool + c := &cobra.Command{ + Use: "rotate [var]", + Short: "Replace a secret's value and prove the replacement took (write, sync, re-resolve, probe)", + Long: "rotate replaces the value of a registry secret and verifies the replacement\n" + + "end to end, which `set` alone cannot do:\n\n" + + " 1. fingerprint the current value (sha256, first 12 hex — never the value)\n" + + " 2. read the new value from stdin when piped, else a hidden prompt\n" + + " 3. refuse a no-op: a new value equal to the current one is a typo, not a rotation\n" + + " 4. write it through the same idempotent path as `set`\n" + + " 5. sync the bw serve daemon, so reads stop answering from a stale cache\n" + + " 6. re-resolve through the normal read path and fingerprint again\n" + + " 7. run the entry's `validate:` liveness probe when it declares one\n\n" + + "The fingerprints are the point. A liveness probe cannot tell a rotated\n" + + "credential from an old one that was never revoked — both authenticate. A\n" + + "changed fingerprint proves the value was actually replaced.\n\n" + + " printf %s \"$new\" | dotf secrets rotate DOCKERHUB_TOKEN\n" + + " dotf secrets rotate DOCKERHUB_TOKEN # prompts (hidden)\n" + + " dotf secrets rotate DOCKERHUB_TOKEN --dry-run", + Args: cobra.RangeArgs(1, 2), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + reg, err := loadRegistry() + if err != nil { + return err + } + s := reg.Lookup(args[0]) + if s == nil { + return fmt.Errorf("unknown secret %q (try `dotf secrets ls`)", args[0]) + } + varArg := "" + if len(args) == 2 { + varArg = args[1] + } + item, field, isFile, err := s.BWTarget(varArg) + if err != nil { + return err + } + return runRotate(cmd, s, item, field, isFile, dryRun) + }, + } + c.Flags().BoolVar(&dryRun, "dry-run", false, "report the intended rotation and the current fingerprint without writing") + return c +} + +// runRotate is the rotation proper, split out so the command body stays within the +// < 40-line / < 10-complexity budget (AGENTS.md). +func runRotate(cmd *cobra.Command, s *secrets.Secret, item, field string, isFile, dryRun bool) error { + out := cmd.OutOrStdout() + + before, err := bwReader.Field(item, field) + if err != nil { + // Unlike `set`, rotate never creates: rotating something that does not + // exist is a provisioning action, and conflating the two is how a locked + // vault turns into a duplicate item (#612). + return fmt.Errorf("read current value of %s / %s (rotate replaces, it never creates — use `dotf secrets set` to provision): %w", item, field, err) + } + beforeFP := secrets.Fingerprint(normalizeValue(before, isFile)) + _, _ = fmt.Fprintf(out, "current %s / %s fingerprint %s\n", item, field, beforeFP) + + if dryRun { + _, _ = fmt.Fprintf(out, "would rotate %s / %s%s\n", item, field, probeSuffix(s)) + return nil + } + + value, err := readSecretValue(cmd, isFile) + if err != nil { + return err + } + value = normalizeValue(value, isFile) + if value == "" { + return fmt.Errorf("refusing to write an empty value for %q (a blank secret is a bug, not a clear)", s.ID) + } + if secrets.Fingerprint(value) == beforeFP { + return fmt.Errorf("the new value is identical to the current one — that is not a rotation. Nothing was written") + } + + if err := bwWriter.SetField(item, field, value); err != nil { + return err + } + _, _ = fmt.Fprintf(out, "written %s / %s\n", item, field) + + return confirmRotation(cmd, s, item, field, isFile, beforeFP) +} + +// confirmRotation performs the half a bare write cannot: make the read path see the +// new value, then prove through that same path that it changed. +func confirmRotation(cmd *cobra.Command, s *secrets.Secret, item, field string, isFile bool, beforeFP string) error { + out := cmd.OutOrStdout() + + // The daemon answers from its own cache; without this the write is correct and + // every read still returns the old value, with nothing to indicate a missing + // step. A sync failure is reported, not fatal — the write did happen, and + // hiding that would be worse than a noisy success. + if err := bwSyncer.Sync(); err != nil { + _, _ = fmt.Fprintf(out, "WARNING daemon sync failed (%v) — the value was written but reads may still serve the old one until `dotf secrets unlock` or a manual sync\n", err) + } + + after, err := bwReader.Field(item, field) + if err != nil { + return fmt.Errorf("wrote the new value but could not read it back through the normal path: %w", err) + } + afterFP := secrets.Fingerprint(normalizeValue(after, isFile)) + if afterFP == beforeFP { + return fmt.Errorf("the value read back is still the old one (fingerprint %s unchanged) — the write did not take effect on the read path", beforeFP) + } + _, _ = fmt.Fprintf(out, "rotated %s / %s %s -> %s\n", item, field, beforeFP, afterFP) + + return probeRotated(cmd, s, after) +} + +// probeRotated runs the entry's declared liveness check, reusing the mechanism +// `secrets sync ci` already gates uploads with. A secret that declares nothing is +// not probed — liveness cannot be checked generically across providers, and +// inventing a probe would be worse than admitting there is none. +func probeRotated(cmd *cobra.Command, s *secrets.Secret, value string) error { + if s.Validate != "github-token" { + if s.Validate != "" { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "note no probe implemented for validate: %s — liveness unverified\n", s.Validate) + } + return nil + } + if err := ghTokenValidator.Validate(value); err != nil { + return fmt.Errorf("the new value was written and read back, but it does not authenticate: %w", err) + } + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "verified live github token") + return nil +} + +// probeSuffix names the probe a real run would perform, so --dry-run reports the +// whole intended action rather than only the write. +func probeSuffix(s *secrets.Secret) string { + if s.Validate == "" { + return " (no liveness probe declared)" + } + return " (then probe: " + s.Validate + ")" +} diff --git a/cli/internal/cmd/secrets_rotate_test.go b/cli/internal/cmd/secrets_rotate_test.go new file mode 100644 index 00000000..9f3972fb --- /dev/null +++ b/cli/internal/cmd/secrets_rotate_test.go @@ -0,0 +1,247 @@ +package cmd + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/mlorentedev/dotfiles/cli/internal/secrets" +) + +// rotateRegistry: one bw-backed secret that declares a liveness probe, and one +// that does not — the two shapes rotate must treat differently. +const rotateRegistry = ` +version: 1 +secrets: + - id: DOCKERHUB_TOKEN + plane: app + backend: bw + bw: { item: dockerhub, field: PAT, folder: apps } + expose: { env: DOCKERHUB_TOKEN } + - id: BITACORA_PAT + plane: app + backend: bw + bw: { item: github-bitacora-pat, field: api-token, folder: apps } + expose: { env: BITACORA_PAT } + validate: github-token +` + +// fakeRW is a bw read+write pair over an in-memory field map, so rotation is +// exercised with no vault, no daemon and no network. +type fakeRW struct { + fields map[string]string + setErr error + readErr error + setCalls int +} + +func (f *fakeRW) Field(item, field string) (string, error) { + if f.readErr != nil { + return "", f.readErr + } + v, ok := f.fields[item+"/"+field] + if !ok { + return "", secrets.ErrBWFieldNotFound + } + return v, nil +} + +func (f *fakeRW) SetField(item, field, value string) error { + f.setCalls++ + if f.setErr != nil { + return f.setErr + } + f.fields[item+"/"+field] = value + return nil +} + +func (f *fakeRW) CreateItem(item, field, value, folder string) error { return nil } +func (f *fakeRW) ResolveFolder(name string) (string, error) { return "", nil } + +type fakeSyncer struct { + calls int + err error +} + +func (f *fakeSyncer) Sync() error { f.calls++; return f.err } + +// rotateHarness wires the package-level seams for one test and restores them. +func rotateHarness(t *testing.T, rw *fakeRW, sync *fakeSyncer) *bytes.Buffer { + t.Helper() + origReader, origWriter, origSync, origTerm := bwReader, bwWriter, bwSyncer, stdinIsTerminal + t.Cleanup(func() { bwReader, bwWriter, bwSyncer, stdinIsTerminal = origReader, origWriter, origSync, origTerm }) + bwReader, bwWriter, bwSyncer = rw, rw, sync + stdinIsTerminal = func() bool { return false } // read the value from stdin + return &bytes.Buffer{} +} + +// The happy path, and the assertion that distinguishes rotate from set: the daemon +// is synced and the value is re-read THROUGH the read path before success is claimed. +func TestRotate_WritesSyncsAndProvesTheChange(t *testing.T) { + rw := &fakeRW{fields: map[string]string{"dockerhub/PAT": "old-token-value"}} + sync := &fakeSyncer{} + out := rotateHarness(t, rw, sync) + + cmd := newSecretsRotateCmd() + cmd.SetOut(out) + cmd.SetErr(out) + cmd.SetIn(strings.NewReader("brand-new-token-value")) + useTempRegistry(t, rotateRegistry) + + if err := cmd.RunE(cmd, []string{"DOCKERHUB_TOKEN"}); err != nil { + t.Fatalf("rotate: %v\n%s", err, out.String()) + } + if sync.calls != 1 { + t.Errorf("the daemon must be synced exactly once, got %d — without it every read serves the old value", sync.calls) + } + if rw.fields["dockerhub/PAT"] != "brand-new-token-value" { + t.Errorf("the new value was not written") + } + s := out.String() + if !strings.Contains(s, "rotated") || !strings.Contains(s, "->") { + t.Errorf("output must report the before -> after fingerprint change\n%s", s) + } + // The value itself must never appear. + if strings.Contains(s, "brand-new-token-value") || strings.Contains(s, "old-token-value") { + t.Errorf("rotate printed a secret value\n%s", s) + } +} + +// Writing the same value back is the typo case. A liveness probe would pass and a +// bare `set` reports "unchanged" as success; for a rotation that is a failure, +// because the credential you meant to retire is still live. +func TestRotate_RefusesANoOp(t *testing.T) { + rw := &fakeRW{fields: map[string]string{"dockerhub/PAT": "same-value"}} + sync := &fakeSyncer{} + out := rotateHarness(t, rw, sync) + + cmd := newSecretsRotateCmd() + cmd.SetOut(out) + cmd.SetErr(out) + cmd.SetIn(strings.NewReader("same-value")) + useTempRegistry(t, rotateRegistry) + + err := cmd.RunE(cmd, []string{"DOCKERHUB_TOKEN"}) + if err == nil { + t.Fatal("rotating to the identical value must fail — it is a typo, not a rotation") + } + if !strings.Contains(err.Error(), "not a rotation") { + t.Errorf("the error must say why, got: %v", err) + } + if rw.setCalls != 0 { + t.Errorf("nothing must be written on a no-op, got %d write(s)", rw.setCalls) + } +} + +// The case that motivated the fingerprint: the write succeeds but the read path +// still returns the old value (a stale cache, a write that landed elsewhere). +// A probe against the OLD credential would pass; the fingerprint catches it. +func TestRotate_FailsWhenTheReadPathStillServesTheOldValue(t *testing.T) { + rw := &fakeRW{fields: map[string]string{"dockerhub/PAT": "old-token-value"}} + // SetField silently does not take effect on the read path. + rw.setErr = nil + sync := &fakeSyncer{} + out := rotateHarness(t, rw, sync) + + // Freeze the map after the write so the read-back returns the old value. + origSet := rw.SetField + _ = origSet + stubborn := &stubbornRW{fakeRW: rw} + bwReader, bwWriter = stubborn, stubborn + + cmd := newSecretsRotateCmd() + cmd.SetOut(out) + cmd.SetErr(out) + cmd.SetIn(strings.NewReader("brand-new-token-value")) + useTempRegistry(t, rotateRegistry) + + err := cmd.RunE(cmd, []string{"DOCKERHUB_TOKEN"}) + if err == nil { + t.Fatal("a write that does not reach the read path must fail the rotation") + } + if !strings.Contains(err.Error(), "still the old one") { + t.Errorf("the error must name the stale-read case, got: %v", err) + } +} + +// stubbornRW accepts writes and never reflects them — the stale-read path. +type stubbornRW struct{ *fakeRW } + +func (s *stubbornRW) SetField(item, field, value string) error { s.setCalls++; return nil } + +// rotate never creates. Provisioning and replacing are different acts, and +// conflating them is how a locked vault becomes a duplicate item. +func TestRotate_RefusesToCreate(t *testing.T) { + rw := &fakeRW{fields: map[string]string{}} + sync := &fakeSyncer{} + out := rotateHarness(t, rw, sync) + + cmd := newSecretsRotateCmd() + cmd.SetOut(out) + cmd.SetErr(out) + cmd.SetIn(strings.NewReader("whatever")) + useTempRegistry(t, rotateRegistry) + + err := cmd.RunE(cmd, []string{"DOCKERHUB_TOKEN"}) + if err == nil { + t.Fatal("rotating an absent field must fail, not create it") + } + if !strings.Contains(err.Error(), "never creates") { + t.Errorf("the error must point at `set` for provisioning, got: %v", err) + } + if rw.setCalls != 0 { + t.Errorf("nothing must be written, got %d write(s)", rw.setCalls) + } +} + +// --dry-run reports the current fingerprint and the probe that would run, and +// touches nothing — including stdin, so it never consumes a piped secret. +func TestRotate_DryRunWritesNothing(t *testing.T) { + rw := &fakeRW{fields: map[string]string{"github-bitacora-pat/api-token": "old"}} + sync := &fakeSyncer{} + out := rotateHarness(t, rw, sync) + + cmd := newSecretsRotateCmd() + cmd.SetOut(out) + cmd.SetErr(out) + useTempRegistry(t, rotateRegistry) + if err := cmd.Flags().Set("dry-run", "true"); err != nil { + t.Fatalf("set flag: %v", err) + } + + if err := cmd.RunE(cmd, []string{"BITACORA_PAT"}); err != nil { + t.Fatalf("dry run: %v\n%s", err, out.String()) + } + if rw.setCalls != 0 || sync.calls != 0 { + t.Errorf("dry run must not write (%d) or sync (%d)", rw.setCalls, sync.calls) + } + s := out.String() + for _, want := range []string{"would rotate", "fingerprint", "github-token"} { + if !strings.Contains(s, want) { + t.Errorf("dry run must report %q\n%s", want, s) + } + } +} + +// A sync failure is a warning, not a failure: the write DID happen, and reporting +// it as a failed rotation would send the operator to re-write a value that is +// already stored. +func TestRotate_SyncFailureWarnsButDoesNotFail(t *testing.T) { + rw := &fakeRW{fields: map[string]string{"dockerhub/PAT": "old-token-value"}} + sync := &fakeSyncer{err: errors.New("daemon unreachable")} + out := rotateHarness(t, rw, sync) + + cmd := newSecretsRotateCmd() + cmd.SetOut(out) + cmd.SetErr(out) + cmd.SetIn(strings.NewReader("brand-new-token-value")) + useTempRegistry(t, rotateRegistry) + + if err := cmd.RunE(cmd, []string{"DOCKERHUB_TOKEN"}); err != nil { + t.Fatalf("a sync failure must not fail a completed write: %v\n%s", err, out.String()) + } + if !strings.Contains(out.String(), "WARNING") { + t.Errorf("the sync failure must be surfaced\n%s", out.String()) + } +} diff --git a/cli/internal/secrets/bwserve.go b/cli/internal/secrets/bwserve.go index 195922da..ce53bdec 100644 --- a/cli/internal/secrets/bwserve.go +++ b/cli/internal/secrets/bwserve.go @@ -315,6 +315,17 @@ func (r BWServeReader) getItemJSON(item string) ([]byte, error) { return itemData, nil } +// Sync makes the daemon pull the current vault state from the server. It matters +// because the daemon answers reads from its OWN local cache: a write made through +// any other client — the `bw` CLI, the desktop app, the web vault — is invisible +// to `dotf secrets` until this runs. Skipping it produces a correct write and a +// stale read with no signal that a step is missing, which is the single most +// confusing state this package can be in (CLI-037). +func (c BWServeClient) Sync() error { + _, err := c.call(http.MethodPost, "/sync", nil) + return err +} + // BWServeDaemon owns the lifecycle of a dotf-managed bw serve process: start // it (detached, localhost-only), poll until reachable, and delegate lock // state to BWServeClient. The process-spawn half is live-verified only (like @@ -381,6 +392,9 @@ func (d *BWServeDaemon) Unlock(password string) error { return d.Client.Unlock(p // Lock delegates to the client — see BWServeClient.Lock. func (d *BWServeDaemon) Lock() error { return d.Client.Lock() } +// Sync delegates to the client; see BWServeClient.Sync for why it is load-bearing. +func (d *BWServeDaemon) Sync() error { return d.Client.Sync() } + // Status delegates to the client, mapping an unreachable daemon to the // explicit "absent" state rather than surfacing a raw connection error — // doctor and `dotf secrets unlock` both need a three-way state (absent / diff --git a/cli/internal/secrets/secrets.go b/cli/internal/secrets/secrets.go index d35cd69e..1a0c76e5 100644 --- a/cli/internal/secrets/secrets.go +++ b/cli/internal/secrets/secrets.go @@ -7,6 +7,8 @@ package secrets import ( + "crypto/sha256" + "encoding/hex" "os" "strings" ) @@ -87,6 +89,24 @@ func (e Entry) SourceDisplay() string { return e.File } +// Fingerprint is a non-reversible short identity for a secret value: the first 12 +// hex characters of its SHA-256. It exists so a caller can prove a value CHANGED +// without ever printing it — the difference between "the new credential works" and +// "the credential was actually replaced", which a liveness probe alone cannot tell +// apart (an unrevoked old credential authenticates just as well). +// +// 12 hex characters is 48 bits: far too little to attack a real secret, and far +// more than enough to distinguish two of them. An empty value fingerprints as +// "(empty)" rather than the hash of the empty string, so a cleared field is +// legible instead of looking like just another value. +func Fingerprint(value string) string { + if value == "" { + return "(empty)" + } + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:])[:12] +} + // expandHome rewrites a leading ~ (or ~/...) to home, leaving other paths intact. func expandHome(p, home string) string { switch { diff --git a/docs/lessons.md b/docs/lessons.md index ceca40f1..8d65808c 100644 --- a/docs/lessons.md +++ b/docs/lessons.md @@ -230,6 +230,7 @@ awk '/^## Entries$/,0' docs/lessons.md | grep '^### \[' | sed -E 's/^### \[([0-9 - [2026-08-14] An agent that cannot reach the repo still writes a confident review - [2026-08-14] Widening a shared return type is a change to every consumer, and Go's zero values hide the ones you missed - [2026-08-15] A check whose precondition the architecture forbids reports SKIP forever, and SKIP reads as nothing-to-check +- [2026-08-15] Redact at the producer, because the consumer's filter is a guess about a format you have not seen - [2026-08-15] A check that cannot fail the way you cite it - [2026-08-15] `bw status` answers for the CLI's session, not for the daemon your code actually uses @@ -2403,6 +2404,17 @@ The blast radius was also wider than the one function: because `compile-harness. **Tags**: `harness`, `verification`, `spec-driven-development`, `ci` +### [2026-08-15] Redact at the producer, because the consumer's filter is a guess about a format you have not seen + +**Context**: diagnosing BUG-082 (`bw serve` returning a non-JSON envelope under batch reads) required seeing what the daemon actually replies with, because the client discards the body it fails to parse and reports only the offending character. Earlier probes in the same session had been careful — printing value *lengths*, HTTP status codes, and sha256 fingerprints, never the values themselves — and each of those answered its question without exposing anything. + +**Problem**: the diagnostic step dumped raw response bodies through a `sed` redaction filter written against the *expected* shape. The real payload did not match that shape, the filter passed it through unchanged, and twelve full vault item bodies landed in the session transcript: a live DockerHub PAT, a second PAT stored in the `password` field, two plaintext entries from `passwordHistory`, and the item's encryption key. The local files were shredded immediately; the transcript could not be. The credentials had to be revoked and rotated, which is how the rest of the session's work on rotation came to be exercised for real. The redaction was not skipped — it was *present and ineffective*, which is worse, because it produced the confidence of having handled the problem. + +**Solution**: extract structure at the producer instead of filtering at the consumer. Every subsequent probe parsed the JSON and emitted only derived facts — field *names*, value *lengths*, 12-hex-character sha256 prefixes — so no code path could emit a secret even if the payload had an unexpected shape. That is also what made the later verification possible at all: comparing fingerprints proved a credential had actually been replaced, which a liveness probe cannot do (an unrevoked old credential authenticates exactly as well as a new one). + +**Rule**: never pipe a secret-bearing payload through a redaction filter and print the result. A filter is an assertion about a format you are debugging *precisely because you do not understand it*, and it fails open — the unmatched case is emitted verbatim, silently. Print only values you constructed yourself from parsed fields: a name, a length, a hash prefix, a boolean. When you genuinely need the raw bytes, write them to a 0600 file and inspect them with code that cannot reach stdout. And prefer a fingerprint to a value everywhere it will do — `sha256 | cut -c1-12` is non-reversible, is enough to prove two things differ, and turns "did the rotation work?" from a judgement into a comparison. + +**Tags**: `security`, `secrets`, `diagnostics`, `verification`, `incident` ### [2026-08-15] `bw status` answers for the CLI's session, not for the daemon your code actually uses **Context**: every `bw`-backed secret was failing at once — `dotf secrets run -- true` died on `dockerhub`, and `dotf secrets verify` showed a wall of FAILED with `bw serve returned no parseable envelope: invalid character 'I'`. Looking for a single cause behind a mass failure, I ran `bw status`, got `{"status":"locked"}`, and reported the blocker as a locked vault needing the user's master password. diff --git a/specs/CLI-037-secrets-rotate/proposal.md b/specs/CLI-037-secrets-rotate/proposal.md new file mode 100644 index 00000000..352db7d5 --- /dev/null +++ b/specs/CLI-037-secrets-rotate/proposal.md @@ -0,0 +1,51 @@ +--- +id: "CLI-037-secrets-rotate" +type: spec +status: draft # draft | implementing | verifying | archived +created: "2026-08-15" +issue: "mlorentedev/dotfiles#996" # repo#NNN — GitHub issue / Project item that tracks this spec +tags: [spec, proposal] +template_version: "1.0" +--- + +# CLI-037-secrets-rotate + +> **Naming**: file lives at `/specs/CLI-037-secrets-rotate/proposal.md`. `CLI-037-secrets-rotate` is `AREA-NNN-slug` (e.g. `TOOL-001-secret-drift`). + +## Why + + + +Single paragraph. The user or business problem this feature solves. Link to the vault roadmap or the bitácora board issue if applicable. If you cannot write this in 3 sentences, you do not understand the problem yet. + +## What + +Concrete behavior change. What does the system do after this PR that it did not do before? Observable, not implementation-focused. + +## Out of scope + +Things this PR explicitly does NOT include. Forces a sharp boundary and prevents scope creep. + +- +- + +## Risks / open questions + +Failure modes, dependencies, and unknowns to clarify before implementation. If any item here is unresolved, do not move to `tasks.md` yet. + +- +- + +## Acceptance criteria + +Observable outcomes. Each must be testable. + +- [ ] Outcome 1 +- [ ] Outcome 2 +- [ ] Outcome 3 + +## References + +- Bitácora board: the GitHub issue / Project item tracking this spec (see the `issue:` frontmatter field) +- Related ADR: `/docs/adr/adr-XXX.md` (if any) +- Related patterns: `00_meta/patterns/.md` (if any) diff --git a/specs/CLI-037-secrets-rotate/tasks.md b/specs/CLI-037-secrets-rotate/tasks.md new file mode 100644 index 00000000..7ca46d40 --- /dev/null +++ b/specs/CLI-037-secrets-rotate/tasks.md @@ -0,0 +1,60 @@ +--- +tags: [spec, tasks, templates] +created: "2026-08-15" +--- + +# Tasks - CLI-037-secrets-rotate + +> TDD order. One task = one focused commit. Tick as you go. Reorder freely while spec is in `draft` state; freeze once you start `implementing`. +> +> **Inline markers** (optional, additive — borrowed from `github/spec-kit`, adapt-not-adopt per #141): +> - `[P]` — this task has **no dependency on another unchecked task**, so it is safe to run in parallel (fan out to a `Workflow`, or just batch). TDD chains (test → implement → refactor of the *same* behavior) are sequential and must NOT carry `[P]`; independent behaviors can. +> - `[AC]` — this task helps satisfy **acceptance criterion #``** from `proposal.md`. Lets `/spec check` map coverage deterministically; omit it and the check falls back to semantic judgment. + +## Setup + +- [ ] Branch created from main: `feat/CLI-037-secrets-rotate` +- [ ] `proposal.md` is complete and acceptance criteria are testable +- [ ] No open questions left in `proposal.md` "Risks / open questions" + +## Implementation + +> Replace these with the actual steps for this feature. Keep them small (one commit each) and in TDD order. +> The `[P]` / `[AC]` markers are optional — see the legend above. Behaviors 1 and 2 below are independent, so their *first* test task carries `[P]`. + +- [ ] [P] [AC1] Write failing test for +- [ ] [AC1] Implement to make it pass +- [ ] Refactor for clarity (extract, rename, dedupe) +- [ ] [P] [AC2] Write failing test for +- [ ] [AC2] Implement to make it pass +- [ ] ... + +## Closing + +- [ ] Every acceptance criterion from `proposal.md` is covered by at least one test +- [ ] Every acceptance criterion has a matching entry in `features.json` (see below) with a non-vacuous verification command +- [ ] Type checks pass +- [ ] Lint passes +- [ ] No unrelated changes in the diff (no scope creep) +- [ ] `verification.md` filled in +- [ ] PR opened referencing this spec folder + +## Machine-readable features + +This spec emits a sibling `features.json` (alongside this file) following [[pattern-feature-list-as-primitive]]. The JSON is the harness-facing contract: each acceptance criterion maps to ≥1 feature with `id`, `behavior`, `verification` (executable command), `state` (lifecycle), and `evidence` (harness-captured output). + +**Pass-state gating:** the agent CANNOT write `"state": "passing"` — only the harness, after running `verification` and capturing exit code 0, may set that terminal state. Reviewers must reject PRs where features.json contains `passing` entries with empty `evidence`. + +Minimal `features.json` skeleton (drop into `/specs/CLI-037-secrets-rotate/features.json`): + +```json +[ + { + "id": "CLI-037-secrets-rotate-f1", + "behavior": "", + "verification": "", + "state": "pending", + "evidence": "" + } +] +``` diff --git a/specs/CLI-037-secrets-rotate/verification.md b/specs/CLI-037-secrets-rotate/verification.md new file mode 100644 index 00000000..3d5aa3bc --- /dev/null +++ b/specs/CLI-037-secrets-rotate/verification.md @@ -0,0 +1,42 @@ +--- +tags: [spec, verification, templates] +created: "2026-08-15" +--- + +# Verification - CLI-037-secrets-rotate + +## Evidence + +Map every acceptance criterion from `proposal.md` to concrete proof (commit hash, test name, or observed behavior). + +- [ ] Criterion 1 -> commit `` / test `` +- [ ] Criterion 2 -> commit `` / test `` +- [ ] Criterion 3 -> commit `` / test `` + +## Test status + +- Test suite: ` -> ` +- Manual smoke test: what was exercised, what was observed +- No regressions in existing test suite: yes / no (if no, document) + +## Decisions made during implementation + +Brief log of non-obvious trade-offs or course corrections taken during the work. Routine choices belong in commit messages, not here. + +- +- + +## Promotion candidates + +Before archiving, flag what (if anything) should be promoted to the vault. If all three are "no", archive in repo is the only persistence. + +- [ ] Lesson for the repo's `docs/lessons.md`? +- [ ] ADR-worthy decision for the repo's `docs/adr/adr-XXX.md`? +- [ ] New pattern candidate for `00_meta/patterns/`? Only if this recurs in >1 project. + +## Archive checklist + +- [ ] `proposal.md` frontmatter set to `status: archived` +- [ ] Folder moved: `specs/CLI-037-secrets-rotate/` -> `specs/archive/CLI-037-secrets-rotate/` +- [ ] Bitácora board ticket for this spec moved to Done / closed with PR link (ADR-018) +- [ ] Promotions above executed (if any)