Skip to content

Sanitize standalone CLI unexpected failures - #2466

Merged
Chris0Jeky merged 6 commits into
mainfrom
issue-2351/cli-unexpected-error-safety
Sep 4, 2026
Merged

Sanitize standalone CLI unexpected failures#2466
Chris0Jeky merged 6 commits into
mainfrom
issue-2351/cli-unexpected-error-safety

Conversation

@Chris0Jeky

Copy link
Copy Markdown
Owner

Summary

Closes the "standalone CLI unexpected errors" residual of #2351. backend/src/Taskdeck.Cli had
no top-level exception boundary at all: any exception escaping a command handler reached the .NET
runtime, which printed the raw message and full stack trace to stderr (paths, SQLite constraint text,
provider URLs, tokens included) and exited with a runtime crash code rather than the CLI's documented
failure code.

This PR adds CliUnexpectedFailure, wraps the entry point in it, and keeps the full exception exactly
once in the protected startup-trace sink.

Surface inventory (every path an exception can reach user-visible CLI output)

Surface Class Disposition
Program.cs top level (host build, SerializedMigrator.Migrate, ContextFabricBootstrap, dispatch, disposal) (b) no boundary existed — raw message + stack trace to stderr Fixed: whole run wrapped in CliUnexpectedFailure.Handle
BoardsCommandHandler, ColumnsCommandHandler, CardsCommandHandler (b) no catch; service/EF/SQLite exceptions propagated to the runtime Fixed by the top-level boundary (handlers deliberately keep no catch; there is one boundary, not one per handler)
ApiKeysCommandHandler, InvitesCommandHandler catch (DomainException ex)PrintFailure(ex.ErrorCode, ex.Message) (a) deliberate Unchanged
*CommandHandler failed-ResultPrintFailure(result.ErrorCode, result.ErrorMessage) (a) deliberate domain result Unchanged
ArgParser / PrintUsageError validation and parse messages (a) deliberate Unchanged
CommandDispatcher unknown group / help (a) deliberate Unchanged
ConnectorVerificationCommand catch (Exception)CONNECTOR_DATABASE_UNAVAILABLE, CONNECTOR_KEY_INVALID already generic Unchanged (already correct)
DatabaseRecoveryCommand catch (Exception)RESTORE_FAILED / BACKUP_FAILED, ConnectorRestoreVerificationException already generic / deliberate Unchanged (already correct)
CliFirstRunBootstrapper IOException / UnauthorizedAccessException / JsonException stderr guidance quoting ex.Message and the operator's own local config path (a) deliberate — first-run operator guidance about the operator's own machine Unchanged, and now recorded as a deliberate decision in the policy doc. Bootstrap behavior itself is #1131 and out of scope.

Behavior

  • Unknown exception → stderr gets exactly Error [UnexpectedError]: Unexpected processing error. Check server logs with the correlation ID. plus (trace correlation: <id>) when a CliStartupTrace
    correlation exists for the run; exit code stays ExitCodes.Failure (1).
  • CliUnexpectedFailure.Message is SensitiveDataRedactor.GenericUnexpectedFailureMessage and
    CliUnexpectedFailure.ErrorCode is ErrorCodes.UnexpectedError, so the CLI cannot drift from the
    HTTP surface (asserted by a test).
  • Diagnostics are not suppressed and not double-logged: CliStartupTrace.TryRecordUnexpectedFailure
    writes the full exception.ToString() once to a companion startup-<correlation>.failure file in
    the same guarded directory and marks the trace stream with a new allow-listed unexpected-failure
    phase. The CLI has no logger (Logging.ClearProviders() keeps stdout clean JSON), so when no trace
    sink is enabled the boundary prints a stable notice saying diagnostics were not captured rather than
    silently dropping them.

Note: the AddInfrastructure Connectors:EncryptionKey fail-fast message is now covered by the
generic boundary too. In practice it is unreachable from the CLI — CliFirstRunBootstrapper runs
immediately before it and always supplies a key (persisted or transient), printing its own guidance on
failure.

Verification

From the worktree root, all green:

  1. dotnet test backend/tests/Taskdeck.Cli.Tests/Taskdeck.Cli.Tests.csproj -c Release -m:1188 passed, 0 failed, 0 skipped (3 m 45 s), including the 8 new tests.
  2. dotnet test backend/tests/Taskdeck.Architecture.Tests/Taskdeck.Architecture.Tests.csproj -c Release -m:128 passed, 0 failed, 1 skipped (pre-existing Invariant09 skip). No new layer references.
  3. node scripts/check-docs-governance.mjs — passed.
  4. git diff --check — clean.

New tests (backend/tests/Taskdeck.Cli.Tests/CliUnexpectedErrorSafetyTests.cs) use a synthetic
exception whose message and inner message carry a secret-like token (sk-live-ABC123), a Windows path,
SQLite UNIQUE constraint failed text, and a provider URL, and assert:

  • none of those fragments (nor the exception type name nor a stack frame) appear on stderr;
  • the stable generic line and the failure exit code do appear;
  • with a trace enabled, the correlation reference is shown and the protected .failure file contains
    the full text exactly once (occurrence counts asserted), with the trace's last phase
    unexpected-failure and zero malformed records;
  • without a trace, the "diagnostics were not captured" notice is printed;
  • regressions: a deliberate DomainException message and a usage/validation message still print
    unchanged and are not replaced by the generic line;
  • end to end: the real CLI process launched against an unopenable database exits 1 with a safe stderr
    (proves the boundary is wired into Program.cs, not just unit-reachable).

Documentation

One appended bullet in the "Runtime Enforcement" section of docs/security/SECURITY_LOGGING_REDACTION.md
recording the CLI boundary and the explicit decision that the bootstrapper's local-path operator
guidance is deliberate. Append-only; no other docs touched.

Not verified

  • No full-solution backend run (dotnet test backend/Taskdeck.sln); the change is confined to
    Taskdeck.Cli plus one doc line, and the two projects that compile against it were run in full.
  • No manual run of a real operator failure on a fresh machine.
  • Behavior of the AddInfrastructure fail-fast under the new boundary was reasoned about, not executed.
  • CI results (checks were not polled).

Part of #2351. This PR intentionally does not close the umbrella issue.

Wrap the CLI entry point in a generic failure boundary so an unexpected exception can no longer print its raw message, stack trace, paths, SQL text, provider URLs or tokens to stderr. Full exception text goes once to the protected startup-trace companion file.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

Create the startup-<correlation>.failure companion file owner-read/write only on POSIX; stop reporting a correlation reference once tracing has disabled itself; treat PreMigrationBackupException as a deliberate operator message (redacted, no stack trace). Rewrite the redaction-policy bullet to state that the CLI has no always-on diagnostic sink (#2468).
@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Review round 1 addressed in one batch. New head: e6bbcbb686ef3534eaffbaba2b89131c0ea5345e.

  1. (MEDIUM) Doc bullet overstated the guarantee — fixed. The Runtime Enforcement entry now says
    plainly that the CLI has no always-on diagnostic sink (it clears all logging providers to keep
    stdout clean JSON), that the startup-<correlation>.failure companion file is written only when
    the harness trace is enabled via TASKDECK_CLI_TEST_TRACE_CORRELATION, and that in an ordinary
    operator run the CLI prints the generic line plus the diagnostics-not-captured notice and the
    exception is not retained anywhere. Adding an always-on sink is tracked in [CLI][Security] Give the standalone CLI a bounded always-on diagnostic sink for unexpected failures #2468 and is not built
    here. The stderr notice itself now reads "the CLI has no local diagnostic sink, and the startup
    trace is not enabled for this run."
  2. (MEDIUM) .failure file mode — fixed. CliStartupTrace.TryRecordUnexpectedFailure now creates
    the companion file explicitly and applies File.SetUnixFileMode(..., UserRead | UserWrite) before
    any content is written, following the existing CliFirstRunBootstrapper pattern (no-op on Windows).
    Covered by FailureSink_IsOwnerReadWriteOnly_OnPosix, which returns early on Windows.
  3. (LOW) CorrelationId while disabled — fixed. It now returns null when _disabled, so the CLI
    never shows a reference to a record it did not keep. Covered by
    Handle_WhenTraceDisabledItself_DoesNotShowACorrelationReference.
  4. (Deliberate operator messages) PreMigrationBackupException — fixed. CliUnexpectedFailure.Handle
    treats it as class (a): it prints Error [PRE_MIGRATION_BACKUP_FAILED]: <message redacted through SensitiveDataRedactor.Redact> with no stack trace and returns ExitCodes.Failure. No new project
    reference was needed — Taskdeck.Cli already references Taskdeck.Infrastructure. Covered by
    Handle_WithPreMigrationBackupFailure_KeepsItsDeliberateOperatorMessage.

Declined, per review: the Program.cs CWD read outside the try — unchanged.

Verification at the new head, all green:

  • dotnet test backend/tests/Taskdeck.Cli.Tests/Taskdeck.Cli.Tests.csproj -c Release -m:1191 passed, 0 failed, 0 skipped (2 m 43 s), up from 188 (3 new tests).
  • dotnet test backend/tests/Taskdeck.Architecture.Tests/Taskdeck.Architecture.Tests.csproj -c Release -m:128 passed, 0 failed, 1 skipped (pre-existing Invariant09 skip).
  • node scripts/check-docs-governance.mjs — passed.
  • git diff --check — clean.

Not verified: CI at this head (not polled), full-solution backend run, and the POSIX file-mode assertion
was skipped locally because this box is Windows — it will execute on the Linux CI leg.

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Fresh-context independent review, round 1 at ed1697d and a scoped verification pass on the fix diff ed1697d..e6bbcbb.

Verdict: no CRITICAL or HIGH correctness, security, or data-loss finding at either head. Merge-blocking: none. Round count: one review round, one fix round.

Round 1 findings and disposition:

  • MEDIUM, fixed: the doc bullet claimed the full exception is retained in a protected companion file, but in a real operator run no sink exists (the trace is enabled only by the harness env var). The bullet and the stderr notice now state that truthfully; the always-on sink is tracked in [CLI][Security] Give the standalone CLI a bounded always-on diagnostic sink for unexpected failures #2468.
  • MEDIUM, fixed: the companion failure file was written world-readable on POSIX while carrying full exception text. It is now created explicitly with owner read/write before content is written.
  • LOW, fixed: CorrelationId no longer reports an id after the trace disabled itself.
  • Deliberate operator message, fixed: PreMigrationBackupException keeps its actionable, redacted message and the failure exit code, without a stack trace, as it did before the boundary existed.
  • LOW, declined: the CWD read in Program.cs before the try (no secrets, marginal).

Fix-diff verification: the new PreMigrationBackupException branch prints only the redacted Message (no ToString, stack, or inner-exception traversal beyond what the Infrastructure builder already bakes in, which is paths, never credentials); create, then set mode, then write, so no content exists during a permissive window; the Windows guard is required, not cosmetic; nothing changed beyond the four items; the three new tests exercise the three new branches. Worker-run proof at e6bbcbb: CLI tests 191/191, Architecture 28 passed with the pre-existing single skip, docs governance and diff check clean.

LOW, tracked on #2468 rather than fixed here: if a stale companion file already exists at the path, the mode fix is skipped and text is appended to it. Harness-only path.

Remaining gate: exact-head hosted ci-required green and the three-minute aging floor, then a merge commit. Part of #2351; the umbrella stays open.

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Current-base refresh: merged origin/main (65abe3e and later) once at exact head a7aca21. The only conflict was the appended bullet in docs/security/SECURITY_LOGGING_REDACTION.md against the MCP write-tools bullet PR #2467 landed; both bullets are kept, MCP first. Effective diff is unchanged (five files); docs governance and diff check pass. No logic changed, so the round-1 review and the fix-diff verification stand; exact-head CI is the remaining gate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant