30 KiB
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.mdExecute in worktree branchworktree-secrets-interactive-cli(root:/Users/dohertj2/Desktop/scadaproj/.claude/worktrees/secrets-interactive-cli). All paths below are relative toZB.MOM.WW.Secrets/inside that worktree. Run all commands from thatZB.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,
sealedclasses, 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 <!-- Interactive console --> group next to the Test group):
<PackageVersion Include="Spectre.Console" Version="0.50.0" />
<PackageVersion Include="Spectre.Console.Testing" Version="0.50.0" />
(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 <PackageReference Include="Spectre.Console" /> 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 <PackageReference Include="Spectre.Console.Testing" />.
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_missingTouch_then_load_roundtrips_name_and_pathTouch_existing_path_moves_it_first_and_updates_stamp(inject aTimeProvider)Load_tolerates_corrupt_json_by_returning_emptyTouch_caps_list_at_ten_entriesSaved_json_contains_no_key_material_fields(serialize, assert raw text has onlyname/appSettingsPath/lastUsedUtcproperties)
Step 2: Run dotnet test ZB.MOM.WW.Secrets.slnx --filter "FullyQualifiedName~RecentTargetsStoreTests" → FAIL (type missing).
Step 3 (implement):
/// <summary>Persists the operator's recently-used store targets (names + appsettings paths ONLY — never key material).</summary>
public sealed class RecentTargetsStore
{
public sealed record RecentTarget(string Name, string AppSettingsPath, DateTimeOffset LastUsedUtc);
public RecentTargetsStore(string filePath, TimeProvider? timeProvider = null) { ... }
public IReadOnlyList<RecentTarget> 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.jsonoverrides SqlitePath whenenvironment: "Production"passed)Binds_sqlserver_options_when_section_present(Secrets:SqlServer:ConnectionString)SqlitePath_is_resolved_relative_to_the_app_directory(relativesecrets.db→ absolute under the appsettings dir — the app's CWD at runtime; this is the honest-store guarantee)Missing_file_throws_FileNotFoundException_with_pathCustom_section_path_is_honored(e.g.MySecrets)
Step 2: Run filter TargetConfigReaderTests → FAIL.
Step 3 (implement):
/// <summary>A resolved deployment store target: the app's composed Secrets options + where they came from.</summary>
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
{
/// <summary>Loads the target app's config exactly the way the app would (base json + optional env overlay + env vars).</summary>
public StoreTarget Read(string appSettingsPath, string? environment = null, string sectionPath = "Secrets") { ... }
/// <summary>Builds a manual target from explicit db path + master-key options (no appsettings).</summary>
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, compareKekId— 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 aset-style upsert roundtrips)Opens_degraded_session_when_env_var_unset(KekAvailable == false,Storestill lists,Cipheris null)Override_kek_upgrades_degraded_session(create withLiteralMasterKeyProvideroverride → full)SqlServer_target_with_unresolved_secret_ref_connstr_falls_back_to_sqlite_with_warning(connstr containing${secret:→session.Warningsnon-empty, store is the SQLite one)
Step 2: filter SecretsSessionFactoryTests|LiteralMasterKeyProviderTests → FAIL.
Step 3 (implement):
/// <summary>An open store session for one target. Degraded when no KEK is available (metadata ops only).</summary>
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<string> Warnings { get; init; } = [];
public bool KekAvailable => Cipher is not null;
}
public sealed class SecretsSessionFactory
{
/// <summary>Builds the store stack for a target. KEK resolution failure yields a DEGRADED session, never a throw.</summary>
public async Task<SecretsSession> 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_OkClassifies_absent_secret_as_MissingClassifies_tombstoned_secret_as_TombstonedClassifies_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 becomesPresentUnverified)
Step 2: filter → FAIL. Step 3 (implement):
public enum ReferenceStatus { Ok, PresentUnverified, Missing, Tombstoned, Undecryptable }
public sealed record ReferenceFinding(string SecretName, ReferenceStatus Status, IReadOnlyList<string> ConfigPaths);
/// <summary>Scans a target app's composed configuration for ${secret:NAME} tokens and checks each against the store.</summary>
public sealed class ReferenceAuditor
{
public async Task<IReadOnlyList<ReferenceFinding>> 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_kekDiagnose_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(tamperWrapTagbyte)Diagnose_throws_on_degraded_session(InvalidOperationException— doctor needs a KEK)RewrapAll_moves_wrong_kek_rows_onto_session_kek(delegate toKekRotationService; assert report counts + rows now decrypt)
Step 2: FAIL. Step 3 (implement):
public enum RowKekStatus { Ok, WrongKek, Corrupt }
public sealed record KekDiagnosis(string SecretName, RowKekStatus Status, string RowKekId);
public sealed record KekDoctorReport(string SessionKekId, IReadOnlyList<KekDiagnosis> Rows)
{ public bool Healthy => Rows.All(r => r.Status == RowKekStatus.Ok); }
/// <summary>Lockout triage: verifies the session KEK actually opens every stored row and drives the rewrap remedy.</summary>
public sealed class KekDoctor
{
public async Task<KekDoctorReport> DiagnoseAsync(SecretsSession session, CancellationToken ct) { ... }
public Task<RewrapReport> 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_identicalBundle_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<BundleEntry> Entries; } where BundleEntry mirrors every StoredSecret field (byte[] as base64, SecretName as string). BundleService:
public sealed class BundleService
{
public async Task<int> ExportAsync(SecretsSession session, string path, bool includeDeleted, CancellationToken ct) { ... }
public async Task<BundleImportReport> ImportAsync(
SecretsSession session, string path, IMasterKeyProvider? sourceKek,
Func<StoredSecret /*existing*/, StoredSecret /*incoming*/, bool /*takeIncoming*/>? 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 flowRequiresKek = 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 throwsSecretDecryptionException→ output contains remedy hint textKEK doctor, shell loops, next Quit exits cleanly)Ctrl_c_at_menu_exits_zero
Step 2: FAIL. Step 3 (implement):
/// <summary>One menu action of the interactive console. Implementations are UI-owning but store-logic-free.</summary>
public interface IInteractiveFlow
{
string Title { get; }
bool RequiresKek { get; }
Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct);
}
/// <summary>The menu loop: target selection, session lifecycle, flow dispatch, error containment.</summary>
public sealed class InteractiveShell
{
public InteractiveShell(IAnsiConsole console, RecentTargetsStore recents, TargetConfigReader reader,
SecretsSessionFactory sessions, IReadOnlyList<IInteractiveFlow> flows) { ... }
public async Task<int> 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<string>().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<string> (validated through SecretName), value via .Secret(), content type via SelectionPrompt<SecretContentType>, 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_referencesOffers_to_seed_each_non_ok_reference_and_seeds_accepted_ones(masked prompts; afterwards re-audit reports all Ok)Skipped_references_are_left_untouchedManual_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 '<name>' 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_summaryWrong_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_countImport_reports_counts_and_applies_lwwImport_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-<target>-<yyyyMMdd>.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 theargs.Length == 0branch + 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
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(<Version>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 ofdocs/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.)