diff --git a/docs/plans/2026-07-19-secrets-interactive-cli.md b/docs/plans/2026-07-19-secrets-interactive-cli.md new file mode 100644 index 0000000..4350f1f --- /dev/null +++ b/docs/plans/2026-07-19-secrets-interactive-cli.md @@ -0,0 +1,505 @@ +# `secret` Interactive Console Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. +> Design (approved): `docs/plans/2026-07-19-secrets-interactive-cli-design.md` +> Execute in worktree branch `worktree-secrets-interactive-cli` (root: `/Users/dohertj2/Desktop/scadaproj/.claude/worktrees/secrets-interactive-cli`). All paths below are relative to `ZB.MOM.WW.Secrets/` inside that worktree. Run all commands from that `ZB.MOM.WW.Secrets/` directory. + +**Goal:** Menu-driven Spectre.Console TUI inside the existing `secret` CLI for deployment secret-seeding and lockout recovery, launched by no-args-in-a-TTY; all headless verbs unchanged. + +**Architecture:** New `Interactive/` layer in `src/ZB.MOM.WW.Secrets.Cli`: UI-free services (`TargetConfigReader`, `SecretsSessionFactory`, `ReferenceAuditor`, `KekDoctor`, `BundleService`, `RecentTargetsStore`) + an `InteractiveShell` that owns `IAnsiConsole` and dispatches to `IInteractiveFlow` implementations (one file per flow, so flows parallelize). Sessions are built per target by constructing the store stack directly (SQLite or SQL-Server) — no per-target DI host. Degraded mode when the KEK is unavailable in the CLI's shell. + +**Tech Stack:** .NET 10, Spectre.Console (+ Spectre.Console.Testing), xunit 2.9.3, existing core seams (`ISecretStore`, `ISecretCipher`, `IMasterKeyProvider`, `KekRotationService`, `SecretLastWriterWins`). + +**Global rules for every task:** +- TDD: write the failing test first, see it fail, implement, see it pass, commit. +- Test command: `dotnet test ZB.MOM.WW.Secrets.slnx` (fully offline; must end 0-warning — the repo treats warnings as errors in CI posture). +- Secret values must NEVER appear in recents JSON, bundles, logs, or non-reveal screen output. XML-doc every public member (family CommentChecker convention). +- Match surrounding code style: file-scoped namespaces, `sealed` classes, records for data, `ConfigureAwait(false)` in library-ish code. + +--- + +### Task 1: Package + project wiring + +**Classification:** trivial +**Estimated implement time:** ~3 min +**Parallelizable with:** none (everything depends on it) + +**Files:** +- Modify: `Directory.Packages.props` +- Modify: `src/ZB.MOM.WW.Secrets.Cli/ZB.MOM.WW.Secrets.Cli.csproj` +- Modify: `tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj` + +**Step 1:** Add to `Directory.Packages.props` (new `` group next to the Test group): + +```xml + + +``` + +(If restore says 0.50.0 doesn't exist on the feed, use the latest stable `dotnet package search Spectre.Console` reports and keep both packages on the same version.) + +**Step 2:** In the Cli csproj add `` to its ItemGroup and a ProjectReference to `..\ZB.MOM.WW.Secrets.Replicator.SqlServer\ZB.MOM.WW.Secrets.Replicator.SqlServer.csproj` (SQL-hub targets like ScadaBridge central). + +**Step 3:** In the Tests csproj add ``. + +**Step 4:** Run: `dotnet build ZB.MOM.WW.Secrets.slnx` → Expected: Build succeeded, 0 warnings. + +**Step 5:** Commit: `git add -A && git commit -m "feat(secrets-cli): wire Spectre.Console + SqlServer store refs for interactive console"` + +--- + +### Task 2: RecentTargetsStore + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 3 + +**Files:** +- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/RecentTargetsStore.cs` +- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/RecentTargetsStoreTests.cs` + +**Step 1 (failing tests):** `RecentTargetsStoreTests` against a temp file path (constructor takes the JSON file path; production default `Path.Combine(Environment.GetFolderPath(SpecialFolder.UserProfile), ".zb-secrets", "recent-targets.json")` supplied by the caller, tests pass a temp path): +- `Load_returns_empty_when_file_missing` +- `Touch_then_load_roundtrips_name_and_path` +- `Touch_existing_path_moves_it_first_and_updates_stamp` (inject a `TimeProvider`) +- `Load_tolerates_corrupt_json_by_returning_empty` +- `Touch_caps_list_at_ten_entries` +- `Saved_json_contains_no_key_material_fields` (serialize, assert raw text has only `name`/`appSettingsPath`/`lastUsedUtc` properties) + +**Step 2:** Run `dotnet test ZB.MOM.WW.Secrets.slnx --filter "FullyQualifiedName~RecentTargetsStoreTests"` → FAIL (type missing). + +**Step 3 (implement):** + +```csharp +/// Persists the operator's recently-used store targets (names + appsettings paths ONLY — never key material). +public sealed class RecentTargetsStore +{ + public sealed record RecentTarget(string Name, string AppSettingsPath, DateTimeOffset LastUsedUtc); + + public RecentTargetsStore(string filePath, TimeProvider? timeProvider = null) { ... } + public IReadOnlyList Load() { ... } // missing/corrupt file => [] + public void Touch(string name, string appSettingsPath) { ... } // upsert by path, newest-first, cap 10, CreateDirectory, atomic write (temp+move) +} +``` + +**Step 4:** Re-run the filter → PASS. **Step 5:** Commit `feat(secrets-cli): recent-targets store for the interactive console`. + +--- + +### Task 3: TargetConfigReader + StoreTarget + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 2 + +**Files:** +- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/StoreTarget.cs` +- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/TargetConfigReader.cs` +- Read for reference: `src/ZB.MOM.WW.Secrets/SecretsOptions.cs`, `src/ZB.MOM.WW.Secrets.Replicator.SqlServer/SqlServerSecretsOptions.cs` +- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/TargetConfigReaderTests.cs` + +**Step 1 (failing tests):** temp dir fixtures writing real `appsettings.json` files: +- `Reads_secrets_section_and_binds_options` (SqlitePath, MasterKey source/env-var) +- `Environment_overlay_wins_over_base` (`appsettings.Production.json` overrides SqlitePath when `environment: "Production"` passed) +- `Binds_sqlserver_options_when_section_present` (`Secrets:SqlServer:ConnectionString`) +- `SqlitePath_is_resolved_relative_to_the_app_directory` (relative `secrets.db` → absolute under the appsettings dir — the app's CWD at runtime; this is the honest-store guarantee) +- `Missing_file_throws_FileNotFoundException_with_path` +- `Custom_section_path_is_honored` (e.g. `MySecrets`) + +**Step 2:** Run filter `TargetConfigReaderTests` → FAIL. + +**Step 3 (implement):** + +```csharp +/// A resolved deployment store target: the app's composed Secrets options + where they came from. +public sealed record StoreTarget( + string Name, // display name (defaults to appsettings parent dir name) + string? AppSettingsPath, // null for manual-entry targets + SecretsOptions Secrets, // SqlitePath already made absolute + SqlServerSecretsOptions? SqlServer, + IConfigurationRoot Configuration); // full composed config, consumed by ReferenceAuditor + +public sealed class TargetConfigReader +{ + /// Loads the target app's config exactly the way the app would (base json + optional env overlay + env vars). + public StoreTarget Read(string appSettingsPath, string? environment = null, string sectionPath = "Secrets") { ... } + /// Builds a manual target from explicit db path + master-key options (no appsettings). + public StoreTarget Manual(string sqlitePath, MasterKeyOptions masterKey) { ... } +} +``` + +`Read` uses `new ConfigurationBuilder().SetBasePath(dir).AddJsonFile(fileName).AddJsonFile($"appsettings.{environment}.json", optional: true).AddEnvironmentVariables().Build()`, binds `SecretsOptions` from `sectionPath`, binds `SqlServerSecretsOptions` from `sectionPath + ":SqlServer"` only when that section has children, and absolutizes `SqlitePath` against `dir`. + +**Step 4:** filter → PASS. **Step 5:** Commit `feat(secrets-cli): target config reader — operate on the app's own composed Secrets config`. + +--- + +### Task 4: SecretsSession + SecretsSessionFactory + LiteralMasterKeyProvider + +**Classification:** high-risk (crypto/KEK wiring; degraded-mode correctness is the lockout path) +**Estimated implement time:** ~5 min +**Parallelizable with:** none (Tasks 5-12 depend on it) + +**Files:** +- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretsSession.cs` +- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretsSessionFactory.cs` +- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/LiteralMasterKeyProvider.cs` +- Read for reference: `src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyProviderBase.cs` (KekId derivation to mirror), `src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyProviderFactory.cs`, `src/ZB.MOM.WW.Secrets.Replicator.SqlServer/DependencyInjection/` (how SqlServer store + migrator are constructed), `src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs` +- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/SecretsSessionFactoryTests.cs`, `.../LiteralMasterKeyProviderTests.cs` + +**Step 1 (failing tests):** +- `LiteralMasterKeyProvider`: `Accepts_base64_32_byte_key`, `Rejects_wrong_length`, `Derives_same_kekid_as_environment_provider_for_same_material` (set a temp env var, compare `KekId` — pins the sha256 derivation match so rewraps interoperate), `Explicit_kekid_wins`. +- `SecretsSessionFactory` (temp SQLite targets): + - `Opens_full_session_when_env_kek_present` (`KekAvailable == true`, cipher + store usable, schema migrated — assert a `set`-style upsert roundtrips) + - `Opens_degraded_session_when_env_var_unset` (`KekAvailable == false`, `Store` still lists, `Cipher` is null) + - `Override_kek_upgrades_degraded_session` (create with `LiteralMasterKeyProvider` override → full) + - `SqlServer_target_with_unresolved_secret_ref_connstr_falls_back_to_sqlite_with_warning` (connstr containing `${secret:` → `session.Warnings` non-empty, store is the SQLite one) + +**Step 2:** filter `SecretsSessionFactoryTests|LiteralMasterKeyProviderTests` → FAIL. + +**Step 3 (implement):** + +```csharp +/// An open store session for one target. Degraded when no KEK is available (metadata ops only). +public sealed class SecretsSession +{ + public required StoreTarget Target { get; init; } + public required ISecretStore Store { get; init; } + public required ISecretsStoreMigrator Migrator { get; init; } + public IMasterKeyProvider? MasterKey { get; init; } // null => degraded + public ISecretCipher? Cipher { get; init; } // null => degraded + public required string StoreKind { get; init; } // "sqlite" | "sqlserver" + public IReadOnlyList Warnings { get; init; } = []; + public bool KekAvailable => Cipher is not null; +} + +public sealed class SecretsSessionFactory +{ + /// Builds the store stack for a target. KEK resolution failure yields a DEGRADED session, never a throw. + public async Task OpenAsync(StoreTarget target, IMasterKeyProvider? overrideKek, CancellationToken ct) { ... } +} +``` + +Factory logic: pick SqlServer store when `target.SqlServer` is set AND its `ConnectionString` doesn't contain `${secret:` (else warn + SQLite fallback); construct exactly what the DI extensions construct (`SecretsSqliteConnectionFactory`/`SqliteSecretStore`/`SqliteSecretsStoreMigrator`, or the SqlServer equivalents); KEK = `overrideKek ?? try MasterKeyProviderFactory.Create(...)` — wrap creation AND a probe call (compute `KekId`) in try/catch, catching only the provider's documented failure exceptions; on failure record the reason in `Warnings` and open degraded. Always `await migrator.MigrateAsync(ct)` before returning. `LiteralMasterKeyProvider` holds the 32 raw bytes pasted by the operator, mirrors the base class's derived-KekId scheme. + +**Step 4:** filter → PASS. **Step 5:** Commit `feat(secrets-cli): per-target session factory with degraded-KEK mode + literal KEK provider`. + +--- + +### Task 5: ReferenceAuditor + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 6, Task 7 + +**Files:** +- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/ReferenceAuditor.cs` +- Read for reference: `src/ZB.MOM.WW.Secrets/Configuration/SecretReferenceExpander.cs` (token regex — reuse the exact pattern `\$\{secret:([^}]+)\}`) +- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/ReferenceAuditorTests.cs` + +**Step 1 (failing tests):** in-memory `IConfigurationRoot` + temp SQLite session: +- `Finds_all_distinct_secret_references_with_their_config_paths` (two keys referencing the same name → one finding, two paths) +- `Classifies_present_decryptable_secret_as_Ok` +- `Classifies_absent_secret_as_Missing` +- `Classifies_tombstoned_secret_as_Tombstoned` +- `Classifies_wrong_kek_row_as_Undecryptable` (seed row via a cipher on KEK-A, audit with session on KEK-B) +- `Degraded_session_reports_PresenceOnly_status` (KEK unavailable → Ok becomes `PresentUnverified`) + +**Step 2:** filter → FAIL. **Step 3 (implement):** + +```csharp +public enum ReferenceStatus { Ok, PresentUnverified, Missing, Tombstoned, Undecryptable } +public sealed record ReferenceFinding(string SecretName, ReferenceStatus Status, IReadOnlyList ConfigPaths); + +/// Scans a target app's composed configuration for ${secret:NAME} tokens and checks each against the store. +public sealed class ReferenceAuditor +{ + public async Task> AuditAsync(SecretsSession session, CancellationToken ct) { ... } +} +``` + +Walk `session.Target.Configuration.AsEnumerable()` values with the regex; for each distinct name: `store.GetAsync` → null ⇒ Missing; `IsDeleted` ⇒ Tombstoned; degraded ⇒ PresentUnverified; else `cipher.Decrypt` in try/catch(`SecretDecryptionException`) ⇒ Ok/Undecryptable (discard plaintext immediately; never store it in the finding). + +**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): reference auditor — classify every ${secret:} the target app needs`. + +--- + +### Task 6: KekDoctor + +**Classification:** high-risk (drives rewrap remediation) +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 5, Task 7 + +**Files:** +- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/KekDoctor.cs` +- Read for reference: `src/ZB.MOM.WW.Secrets/Rotation/KekRotationService.cs`, `src/ZB.MOM.WW.Secrets/Rotation/RewrapReport.cs` +- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/KekDoctorTests.cs` + +**Step 1 (failing tests):** temp store seeded under KEK-A and KEK-B: +- `Diagnose_reports_ok_rows_under_session_kek` +- `Diagnose_reports_wrong_kek_rows_with_their_kekid` (session on B, rows on A → `WrongKek`, foreign KekId surfaced so the operator knows which key to hunt for) +- `Diagnose_reports_corrupt_row_when_kekid_matches_but_unwrap_fails` (tamper `WrapTag` byte) +- `Diagnose_throws_on_degraded_session` (`InvalidOperationException` — doctor needs a KEK) +- `RewrapAll_moves_wrong_kek_rows_onto_session_kek` (delegate to `KekRotationService`; assert report counts + rows now decrypt) + +**Step 2:** FAIL. **Step 3 (implement):** + +```csharp +public enum RowKekStatus { Ok, WrongKek, Corrupt } +public sealed record KekDiagnosis(string SecretName, RowKekStatus Status, string RowKekId); +public sealed record KekDoctorReport(string SessionKekId, IReadOnlyList Rows) +{ public bool Healthy => Rows.All(r => r.Status == RowKekStatus.Ok); } + +/// Lockout triage: verifies the session KEK actually opens every stored row and drives the rewrap remedy. +public sealed class KekDoctor +{ + public async Task DiagnoseAsync(SecretsSession session, CancellationToken ct) { ... } + public Task RewrapAllAsync(SecretsSession session, IMasterKeyProvider oldKek, CancellationToken ct) + => new KekRotationService(session.Store, session.Cipher!).RewrapAllAsync(oldKek, session.MasterKey!, ct); +} +``` + +Diagnosis probe: KekId mismatch ⇒ WrongKek (no decrypt attempt); match ⇒ `cipher.Decrypt` try/catch ⇒ Ok/Corrupt. Include tombstoned rows (they still block rewrap-all). + +**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): KEK doctor — per-row diagnosis + guided rewrap remedy`. + +--- + +### Task 7: SecretBundle export/import + +**Classification:** high-risk (data movement between stores, LWW + cross-KEK rewrap) +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 5, Task 6 + +**Files:** +- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretBundle.cs` (DTOs + codec) +- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/BundleService.cs` +- Read for reference: `src/ZB.MOM.WW.Secrets.Abstractions/SecretLastWriterWins.cs`, `src/ZB.MOM.WW.Secrets.Abstractions/StoredSecret.cs` +- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/BundleServiceTests.cs` + +**Step 1 (failing tests):** +- `Export_then_import_into_empty_store_roundtrips_rows_byte_identical` +- `Bundle_json_contains_no_plaintext` (export a known value; assert raw file text doesn't contain it) +- `Import_conflict_resolved_by_last_writer_wins` (newer local row survives; newer bundle row replaces) +- `Import_conflict_callback_can_force_bundle_row` (per-row override delegate) +- `Cross_kek_import_rewraps_when_source_kek_supplied` (bundle from KEK-A into session on KEK-B + `oldKek` → rows land on B and decrypt) +- `Cross_kek_import_without_source_kek_skips_rows_and_reports_them` + +**Step 2:** FAIL. **Step 3 (implement):** DTO `SecretBundle { int FormatVersion = 1; DateTimeOffset ExportedUtc; string SourceKekId; List Entries; }` where `BundleEntry` mirrors every `StoredSecret` field (byte[] as base64, `SecretName` as string). `BundleService`: + +```csharp +public sealed class BundleService +{ + public async Task ExportAsync(SecretsSession session, string path, bool includeDeleted, CancellationToken ct) { ... } + public async Task ImportAsync( + SecretsSession session, string path, IMasterKeyProvider? sourceKek, + Func? conflictOverride, + CancellationToken ct) { ... } +} +public sealed record BundleImportReport(int Imported, int SkippedOlder, int SkippedForeignKek, int Conflicts); +``` + +Import per entry: rewrap via `session.Cipher.Rewrap(row, sourceKek, session.MasterKey)` when `row.KekId != session.MasterKey.KekId` and `sourceKek` matches, else count `SkippedForeignKek`; conflict when the store already has the name → default `SecretLastWriterWins`, `conflictOverride` (when supplied) decides instead; upsert winners. `ExportedUtc` from an injected `TimeProvider`. + +**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): ciphertext-only bundle export/import with LWW + cross-KEK rewrap`. + +--- + +### Task 8: InteractiveShell skeleton + IInteractiveFlow + target picker + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none (Tasks 9-12 depend on the flow seam) + +**Files:** +- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/IInteractiveFlow.cs` +- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/InteractiveShell.cs` +- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/InteractiveShellTests.cs` + +**Step 1 (failing tests):** with `Spectre.Console.Testing.TestConsole` (`Interactive()` profile, `PushKey`/`PushTextWithEnter` scripted input) and a fake flow: +- `Shell_shows_target_picker_then_menu_and_quits` (recents + manual entry path; select Quit → returns 0) +- `Menu_lists_registered_flows_by_title_plus_builtins` (Switch target, Quit) +- `Degraded_session_flow_requiring_kek_prompts_for_key_first` (fake flow `RequiresKek = true`, degraded session → shell asks masked key/file prompt, upgrades session via factory override, then runs the flow) +- `Flow_exception_renders_error_panel_and_returns_to_menu` (fake flow throws `SecretDecryptionException` → output contains remedy hint text `KEK doctor`, shell loops, next Quit exits cleanly) +- `Ctrl_c_at_menu_exits_zero` + +**Step 2:** FAIL. **Step 3 (implement):** + +```csharp +/// One menu action of the interactive console. Implementations are UI-owning but store-logic-free. +public interface IInteractiveFlow +{ + string Title { get; } + bool RequiresKek { get; } + Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct); +} + +/// The menu loop: target selection, session lifecycle, flow dispatch, error containment. +public sealed class InteractiveShell +{ + public InteractiveShell(IAnsiConsole console, RecentTargetsStore recents, TargetConfigReader reader, + SecretsSessionFactory sessions, IReadOnlyList flows) { ... } + public async Task RunAsync(CancellationToken ct) { ... } +} +``` + +Header panel per loop: target name, store kind, `KEK: OK` / `KEK: UNAVAILABLE (degraded)`. Menu = flows' titles + `Switch store target` + `Quit`. Degraded + `RequiresKek` ⇒ prompt (choice: base64 paste via `TextPrompt().Secret()`, or key-file path) → `LiteralMasterKeyProvider`/file provider → reopen session with override. Every flow call wrapped in try/catch rendering `new Panel(...)` in red with the remedy hint; loop continues. + +**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): interactive shell skeleton — target picker, degraded-KEK upgrade, flow dispatch`. + +--- + +### Task 9: CRUD flows (list / set-rotate / reveal / delete) + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 10, Task 11, Task 12 + +**Files:** +- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/ListSecretsFlow.cs` +- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/SetSecretFlow.cs` +- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/RevealSecretFlow.cs` +- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/DeleteSecretFlow.cs` +- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/CrudFlowTests.cs` + +**Step 1 (failing tests):** each flow run against a TestConsole + real temp-SQLite session: +- List: `Renders_metadata_table_never_values` (name/contentType/kekId/revision/updated columns; `RequiresKek == false`) +- Set: `Masked_prompt_seals_and_upserts` (typed value lands decryptable; typed value NOT in console output), `Existing_name_confirms_overwrite` +- Reveal: `Confirm_then_prints_value_once`, `Decline_prints_nothing` +- Delete: `Confirm_tombstones_row`, `Decline_leaves_row` + +**Step 2:** FAIL. **Step 3:** Implement the four flows (each ~40 lines): name via `TextPrompt` (validated through `SecretName`), value via `.Secret()`, content type via `SelectionPrompt`, description optional; seal with `session.Cipher.Encrypt` + actor `Environment.UserName` (same stamps as `SecretCommands.UpsertSealedAsync`). Reveal decrypts via `session.Cipher.Decrypt(store.GetAsync(...))` — no resolver, no cache. + +**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): CRUD flows for the interactive console`. + +--- + +### Task 10: Reference-audit + seeding flow + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 9, Task 11, Task 12 + +**Files:** +- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/ReferenceAuditFlow.cs` +- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/ReferenceAuditFlowTests.cs` + +**Step 1 (failing tests):** TestConsole + session whose target config references three secrets (one Ok, one Missing, one Tombstoned): +- `Renders_status_table_for_all_references` +- `Offers_to_seed_each_non_ok_reference_and_seeds_accepted_ones` (masked prompts; afterwards re-audit reports all Ok) +- `Skipped_references_are_left_untouched` +- `Manual_target_without_appsettings_reports_nothing_to_audit` + +**Step 2:** FAIL. **Step 3:** Implement: run `ReferenceAuditor.AuditAsync`, render table (status color-coded: green Ok / yellow PresentUnverified / red Missing/Tombstoned/Undecryptable + the config paths), then for each non-Ok finding `ConfirmationPrompt("Seed '' now?")` → masked value + content-type prompts → seal + upsert (tombstoned: upsert revives), finish with a re-audit summary line. `RequiresKek == true`. + +**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): deployment seeding flow — audit ${secret:} refs and fill the gaps`. + +--- + +### Task 11: KEK-doctor flow + +**Classification:** high-risk +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 9, Task 10, Task 12 + +**Files:** +- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/KekDoctorFlow.cs` +- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/KekDoctorFlowTests.cs` + +**Step 1 (failing tests):** +- `Healthy_store_renders_all_ok_summary` +- `Wrong_kek_rows_offer_rewrap_and_rewrap_succeeds` (script: choose "Rewrap from old KEK", choose env-var source pointing at KEK-A, confirm → rows decrypt under session KEK; output shows report counts) +- `Rewrap_requires_explicit_confirmation` (decline → store untouched) +- `Lost_kek_path_offers_reset_of_affected_secrets` (choose "Old KEK is lost" → per-row confirm+masked re-set; declined rows stay wrong-KEK) + +**Step 2:** FAIL. **Step 3:** Implement: `DiagnoseAsync` → summary table (session KekId, per-status counts, the foreign KekIds seen); if unhealthy, `SelectionPrompt`: *Rewrap from old KEK* (old key via env-var name / file path / masked base64 → provider; `ConfirmationPrompt` showing exactly what will happen; then `KekDoctor.RewrapAllAsync`, render `RewrapReport` counts) / *Old KEK is lost — re-set affected secrets* (per wrong-KEK row: confirm + masked new value → seal under session KEK, overwriting) / *Back*. `RequiresKek == true`. + +**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): lockout-recovery flow — KEK diagnosis, guided rewrap, lost-KEK reset`. + +--- + +### Task 12: Bundle flows (export / import) + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 9, Task 10, Task 11 + +**Files:** +- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/BundleExportFlow.cs` +- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/BundleImportFlow.cs` +- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/BundleFlowTests.cs` + +**Step 1 (failing tests):** +- `Export_prompts_for_path_and_reports_row_count` +- `Import_reports_counts_and_applies_lww` +- `Import_conflict_prompt_lets_operator_pick_per_row` (script both choices) +- `Import_foreign_kek_prompts_for_source_key_then_rewraps` + +**Step 2:** FAIL. **Step 3:** Implement over `BundleService`; export path prompt defaults to `secrets-bundle--.json`; import wires the flow's per-row `ConfirmationPrompt` into `conflictOverride` and, when `SkippedForeignKek` would be non-zero (bundle `SourceKekId` ≠ session KekId), first offers the source-key prompt (env/file/masked). Render final `BundleImportReport` as a table. + +**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): bundle export/import flows`. + +--- + +### Task 13: Program.cs entry point + non-TTY pin + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** none (integrates everything) + +**Files:** +- Modify: `src/ZB.MOM.WW.Secrets.Cli/Program.cs` (ONLY the `args.Length == 0` branch + composition) +- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/InteractiveEntry.cs` +- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/InteractiveEntryTests.cs` + +**Step 1 (failing tests):** +- `ShouldEnterInteractive_true_only_for_no_args_and_real_tty` (pure static: `(args, inputRedirected, outputRedirected)` truth table — pins that any redirection keeps usage/exit-2) +- `CreateShell_composes_all_five_flows_plus_builtins` (flow titles present exactly once: List, Set/rotate, Reveal, Delete, Reference audit & seed, KEK doctor, Bundle export, Bundle import) + +**Step 2:** FAIL. **Step 3:** `InteractiveEntry` exposes `ShouldEnterInteractive(...)` and `CreateShell(IAnsiConsole console)` (news up recents store at `~/.zb-secrets/recent-targets.json`, reader, factory, all flows). `Program.cs`: replace the bare `return Usage();` with + +```csharp +if (args.Length == 0) +{ + if (InteractiveEntry.ShouldEnterInteractive(args, Console.IsInputRedirected, Console.IsOutputRedirected)) + return await InteractiveEntry.CreateShell(AnsiConsole.Console).RunAsync(ct); + return Usage(); +} +``` + +(The interactive path builds its own sessions per target, so move the host-based migration call so it only runs for the headless verbs — verify with the existing headless tests.) + +**Step 4:** `dotnet test ZB.MOM.WW.Secrets.slnx` (FULL suite — protects the headless verbs) → all pass. Manual smoke: `dotnet run --project src/ZB.MOM.WW.Secrets.Cli < /dev/null` → usage, exit 2. **Step 5:** Commit `feat(secrets-cli): launch interactive console on bare secret in a TTY`. + +--- + +### Task 14: Version bump, docs, final green run + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** none + +**Files:** +- Modify: `Directory.Build.props` (`` 0.2.3 → 0.3.0) +- Modify: `README.md` (CLI section: interactive console paragraph + screenshot-style text sample) +- Create: `docs/operations/interactive-console.md` (operator runbook: launching, deployment-seeding walk-through, lockout playbook incl. degraded mode + rewrap, bundle notes; mirror the tone of `docs/operations/kek-rotation.md`) + +**Step 1:** Make the edits. **Step 2:** `dotnet build ZB.MOM.WW.Secrets.slnx` → 0 warnings; `dotnet test ZB.MOM.WW.Secrets.slnx` → full suite green. **Step 3:** Commit `docs(secrets): interactive console runbook + 0.3.0 version bump`. + +**Step 4 (handoff, not merge):** Do NOT merge to `main` from inside this task — another agent is active on `main`. Leave the worktree branch `worktree-secrets-interactive-cli` fully committed; merging/publishing is a separate user-decision step (family convention: publish the 5 packages + app bumps only on explicit rollout). + +--- + +## Dependency graph + +``` +1 ─┬─ 2 ─┐ + └─ 3 ─┴─ 4 ─┬─ 5 ─┐ + ├─ 6 ─┤ + ├─ 7 ─┤ + └─ 8 ─┼─ 9 ──┐ + ├─ 10 ─┤ + ├─ 11 ─┼─ 13 ── 14 + └─ 12 ─┘ +``` +(9-12 also depend on their service tasks: 10→5, 11→6, 12→7.) diff --git a/docs/plans/2026-07-19-secrets-interactive-cli.md.tasks.json b/docs/plans/2026-07-19-secrets-interactive-cli.md.tasks.json new file mode 100644 index 0000000..74008ab --- /dev/null +++ b/docs/plans/2026-07-19-secrets-interactive-cli.md.tasks.json @@ -0,0 +1,20 @@ +{ + "planPath": "docs/plans/2026-07-19-secrets-interactive-cli.md", + "tasks": [ + {"id": 1, "subject": "Task 1: Package + project wiring", "status": "pending"}, + {"id": 2, "subject": "Task 2: RecentTargetsStore", "status": "pending", "blockedBy": [1]}, + {"id": 3, "subject": "Task 3: TargetConfigReader + StoreTarget", "status": "pending", "blockedBy": [1]}, + {"id": 4, "subject": "Task 4: SecretsSession + SecretsSessionFactory + LiteralMasterKeyProvider", "status": "pending", "blockedBy": [2, 3]}, + {"id": 5, "subject": "Task 5: ReferenceAuditor", "status": "pending", "blockedBy": [4]}, + {"id": 6, "subject": "Task 6: KekDoctor", "status": "pending", "blockedBy": [4]}, + {"id": 7, "subject": "Task 7: SecretBundle export/import", "status": "pending", "blockedBy": [4]}, + {"id": 8, "subject": "Task 8: InteractiveShell skeleton + IInteractiveFlow + target picker", "status": "pending", "blockedBy": [4]}, + {"id": 9, "subject": "Task 9: CRUD flows (list / set-rotate / reveal / delete)", "status": "pending", "blockedBy": [8]}, + {"id": 10, "subject": "Task 10: Reference-audit + seeding flow", "status": "pending", "blockedBy": [5, 8]}, + {"id": 11, "subject": "Task 11: KEK-doctor flow", "status": "pending", "blockedBy": [6, 8]}, + {"id": 12, "subject": "Task 12: Bundle flows (export / import)", "status": "pending", "blockedBy": [7, 8]}, + {"id": 13, "subject": "Task 13: Program.cs entry point + non-TTY pin", "status": "pending", "blockedBy": [9, 10, 11, 12]}, + {"id": 14, "subject": "Task 14: Version bump, docs, final green run", "status": "pending", "blockedBy": [13]} + ], + "lastUpdated": "2026-07-19T00:00:00Z" +}