Sanitize standalone CLI unexpected failures - #2466
Conversation
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.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
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).
|
Review round 1 addressed in one batch. New head:
Declined, per review: the Verification at the new head, all green:
Not verified: CI at this head (not polled), full-solution backend run, and the POSIX file-mode assertion |
|
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:
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. |
|
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. |
Summary
Closes the "standalone CLI unexpected errors" residual of #2351.
backend/src/Taskdeck.Clihadno 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 exactlyonce in the protected startup-trace sink.
Surface inventory (every path an exception can reach user-visible CLI output)
Program.cstop level (host build,SerializedMigrator.Migrate,ContextFabricBootstrap, dispatch, disposal)CliUnexpectedFailure.HandleBoardsCommandHandler,ColumnsCommandHandler,CardsCommandHandlercatch; service/EF/SQLite exceptions propagated to the runtimeApiKeysCommandHandler,InvitesCommandHandlercatch (DomainException ex)→PrintFailure(ex.ErrorCode, ex.Message)*CommandHandlerfailed-Result→PrintFailure(result.ErrorCode, result.ErrorMessage)ArgParser/PrintUsageErrorvalidation and parse messagesCommandDispatcherunknown group / helpConnectorVerificationCommandcatch (Exception)→CONNECTOR_DATABASE_UNAVAILABLE,CONNECTOR_KEY_INVALIDDatabaseRecoveryCommandcatch (Exception)→RESTORE_FAILED/BACKUP_FAILED,ConnectorRestoreVerificationExceptionCliFirstRunBootstrapperIOException/UnauthorizedAccessException/JsonExceptionstderr guidance quotingex.Messageand the operator's own local config pathBehavior
Error [UnexpectedError]: Unexpected processing error. Check server logs with the correlation ID.plus(trace correlation: <id>)when aCliStartupTracecorrelation exists for the run; exit code stays
ExitCodes.Failure(1).CliUnexpectedFailure.MessageisSensitiveDataRedactor.GenericUnexpectedFailureMessageandCliUnexpectedFailure.ErrorCodeisErrorCodes.UnexpectedError, so the CLI cannot drift from theHTTP surface (asserted by a test).
CliStartupTrace.TryRecordUnexpectedFailurewrites the full
exception.ToString()once to a companionstartup-<correlation>.failurefile inthe same guarded directory and marks the trace stream with a new allow-listed
unexpected-failurephase. The CLI has no logger (
Logging.ClearProviders()keeps stdout clean JSON), so when no tracesink is enabled the boundary prints a stable notice saying diagnostics were not captured rather than
silently dropping them.
Note: the
AddInfrastructureConnectors:EncryptionKeyfail-fast message is now covered by thegeneric boundary too. In practice it is unreachable from the CLI —
CliFirstRunBootstrapperrunsimmediately before it and always supplies a key (persisted or transient), printing its own guidance on
failure.
Verification
From the worktree root, all green:
dotnet test backend/tests/Taskdeck.Cli.Tests/Taskdeck.Cli.Tests.csproj -c Release -m:1— 188 passed, 0 failed, 0 skipped (3 m 45 s), including the 8 new tests.dotnet test backend/tests/Taskdeck.Architecture.Tests/Taskdeck.Architecture.Tests.csproj -c Release -m:1— 28 passed, 0 failed, 1 skipped (pre-existingInvariant09skip). No new layer references.node scripts/check-docs-governance.mjs— passed.git diff --check— clean.New tests (
backend/tests/Taskdeck.Cli.Tests/CliUnexpectedErrorSafetyTests.cs) use a syntheticexception whose message and inner message carry a secret-like token (
sk-live-ABC123), a Windows path,SQLite
UNIQUE constraint failedtext, and a provider URL, and assert:.failurefile containsthe full text exactly once (occurrence counts asserted), with the trace's last phase
unexpected-failureand zero malformed records;DomainExceptionmessage and a usage/validation message still printunchanged and are not replaced by the generic line;
(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.mdrecording 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
dotnet test backend/Taskdeck.sln); the change is confined toTaskdeck.Cliplus one doc line, and the two projects that compile against it were run in full.AddInfrastructurefail-fast under the new boundary was reasoned about, not executed.Part of #2351. This PR intentionally does not close the umbrella issue.