Files
scadaproj/docs/plans/2026-07-19-secrets-interactive-cli.md
T

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.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 <!-- 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_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):

/// <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.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):

/// <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, 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):

/// <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_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):

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_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):

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_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<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 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):

/// <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_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 '<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_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-<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 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

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 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.)