fix(cli): harden the failure sink (evict after write, hex reference, argv policy) - #2619
Conversation
Adds the three regressions for #2577 before the fix: a failed write at the retention cap must delete no older record, TryRecord must reject a reference that is not lowercase hex of 12 or 32 characters, and the argv line must keep only command and flag names. All three fail against the current sink (7 failed, 17 passed).
…argv policy) Eviction now runs after the new record's stream is closed, skipping the record just written, so a create that fails deletes nothing. TryRecord accepts only a lowercase-hex reference of 12 or 32 characters, the two shapes the callers produce, and fails open otherwise. The argv line keeps the leading command words and the flag names and replaces every other token with [value], so a space-separated secret or a card title never reaches disk; the redactor still runs over the result for the key=value forms.
Records the conservative option chosen for #2577 item 3 and the two hardening changes beside it: eviction only after a successful write, and the lowercase-hex reference check.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Three defects the review found in the #2577 hardening, each pinned red-first: - Eviction ran inside the write try block, so an enumeration failure made TryRecord return false for a record that was already closed on disk: the CLI then printed the "diagnostics were not captured" notice for a record that exists, and the 20-record cap was silently suspended. Eviction now has its own catch and can no longer change the reported outcome. An internal record-lister seam on ForDataDirectory lets the test make the enumeration throw. - A '-'-prefixed token was kept whole, so an attached value whose key is not in SensitiveDataRedactor's keyword list (--title=..., --description=...) was written verbatim. Only the flag name up to its first '=' is kept now; the attached value becomes the same [value] placeholder a separate one gets, and Redact still runs over the result. - IsAcceptedReference took lowercase hex only while CliStartupTrace.IsCorrelationId takes either case, so the sink could refuse a reference the CLI itself printed. Both now accept hex in either case, and the 32 length lives once, as CliStartupTrace.CorrelationLength. Also adds the missing test for the self-eviction skip branch: a record written with the oldest timestamp at the cap survives, and the next-oldest goes instead. The truncation test's argv fixture moved its bulk from attached values to flag names, since values no longer reach the record and the payload has to exceed the 8 KB bound for the test to mean anything.
The argv paragraph overstated the guarantee: it said a flag name is kept and left the impression that an attached value was covered by Redact, which only masks the keys it knows. Say what is kept (the leading command words and the flag names, up to the first '=') and what is replaced (every value, attached or separate), and record that the reference check now takes hex in either case and that a failed eviction never changes the reported outcome.
Fix roundHead
1 — eviction can no longer downgrade a written record
New test 2 — attached values are replaced tooA New test Collateral: 3 — hex in either case, one length constant
New tests: 4 — the self-eviction skip branchNew test Verification (all run in the PR worktree, at head
|
|
Review (agent half of the gate; Codex credits exhausted, SC-9). Two fresh-context reviewer passes (read-only, Opus 5). Round 1 at 7dcf503: verdict SHIP, no CRITICAL/HIGH. Confirmed the load-bearing property: a traversal-shaped reference could previously steer a record to Fix round at 1e1c600 (all four fixed, none declined): eviction runs in its own try/catch after the stream is closed, so an enumeration failure can no longer report a durable record as not captured (test through an internal record-lister seam); an attached Round 2 (scoped to the fix diff) at 1e1c600: verdict SHIP. Each finding traced closed; every attached-value shape ( Closed in c7ca196 (a shape rule, no new review round owed): a flag name is a dash-prefixed token whose name part (before any Merge gate: hosted ci-required green at c7ca196 plus the aging floor. |
Summary
Takes all three failure-sink follow-ups the #2573 review recorded, each pinned by a regression that
fails against the current sink first.
CliFailureSink.TryRecordnow evicts old records only after the new record's stream has beenwritten and closed, and never evicts the record it just wrote. A create that fails deletes
nothing.
TryRecordvalidates the correlation reference itself: lowercase hex of exactly 12 characters(the generated reference) or 32 (the harness trace correlation). Anything else fails open,
returning false with nothing written and nothing printed.
keeps the command grammar and drops the values: a token is written verbatim only when it starts
with
-(a flag name) or is one of the at most two leading command words, and every other tokenbecomes the fixed placeholder
[value].SensitiveDataRedactor.Redactstill runs over theresult for the attached
key=valueforms. The policy is written into the sink paragraph ofdocs/security/SECURITY_LOGGING_REDACTION.md.The #2466 stderr constants, the record's other fields, the 8 KB and 20-record bounds, the
FileMode.CreateNewwrite and the POSIX 0600 creation mode are unchanged. No file outsidebackend/src/Taskdeck.Cli,backend/tests/Taskdeck.Cli.Testsand that one doc paragraph istouched.
Root cause
Item 1 was an ordering bug. Eviction ran before the write and sized itself for the record about to
be added (
existing.Length - MaximumRecordCount + 1), so a create that then failed left thedirectory short by up to six records with nothing written in their place: net diagnostic loss
instead of the fail-open-with-no-change the sink documents. Eviction now runs after the stream is
closed, sizes itself against what is actually on disk (
existing.Length - MaximumRecordCount) andskips the just-written path by an ordinal comparison so a record can never evict itself.
Item 2 was an invariant that lived only in the callers. The reference is interpolated into the file
name, and
TryRecordchecked only for null or whitespace, so a reference that contained pathsegments could steer the write out of the diagnostics directory. Both current callers happen to
produce a safe shape; the check now holds regardless of what a future caller does.
Item 3 was a retention question rather than a defect. The redactor only masks the
key=valueandkey: valueforms, so--token abc123would have been retained verbatim, and ordinary usercontent such as a card title reached disk on failure where nothing was retained before the sink
existed.
Verification
All commands run from the worktree at
C:/Users/jekyt/source/Taskdeck-Beta/.worktrees/codex-2577-sink-followups.Red, before the fix (the tests were committed first, against the unchanged sink):
dotnet test backend/tests/Taskdeck.Cli.Tests/Taskdeck.Cli.Tests.csproj -c Release -m:1 --filter "FullyQualifiedName~CliFailureSinkTests"->
Failed: 7, Passed: 17, Skipped: 0, Total: 24. The seven failures were:TryRecord_WhenTheWriteFailsAtTheCap_DeletesNoOlderRecord-> "Expected remaining to be acollection with 20 item(s), but ... contains 1 item(s) less than", the missing entry being
cli-failure-20260904T112214Z-aaaaaaaaaaaa.txt, deleted by the pre-write eviction for a writethat then failed on the planted file.
TryRecord_WithAReferenceThatIsNotLowercaseHex_FailsOpenAndWritesNothingfor the referencesa/../../x,0A1B2C3D4E5F,0a1b2c3d4e5,0a1b2c3d4e5f0andzzzzzzzzzzzz-> "Expectedcaptured to be False, but found True". The
a/../../xcase is the load-bearing one: the old codereturned true after writing the record outside the diagnostics directory. The
../x, empty andwhitespace cases already passed, since Windows path normalisation and the old whitespace guard
happened to reject them; they stay in the theory as regression cover.
TryRecord_KeepsCommandAndFlagNamesButNoArgumentValues-> the record containedargv: cards add --title Secret plan --token abc123instead ofargv: cards add --title [value] --token [value].Green, after the fix:
dotnet test backend/tests/Taskdeck.Cli.Tests/Taskdeck.Cli.Tests.csproj -c Release -m:1 --filter "FullyQualifiedName~CliFailureSink|FullyQualifiedName~CliUnexpectedError"->
Failed: 0, Passed: 37, Skipped: 0, Total: 37.dotnet test backend/tests/Taskdeck.Cli.Tests/Taskdeck.Cli.Tests.csproj -c Release -m:1(wholeproject) ->
Failed: 0, Passed: 217, Skipped: 0, Total: 217, duration 3 m 30 s.dotnet build backend/Taskdeck.sln -c Release->12 Warning(s), 0 Error(s). All twelvewarnings are pre-existing nullable warnings in
Taskdeck.Api.TestsandCliStartupTraceTests,none in a file this PR touches.
node scripts/check-docs-governance.mjs-> "Docs governance check passed."git diff --check-> clean.Not verified
dotnet test backend/Taskdeck.sln -c Release -m:1) was not run here;ci-requiredrepeats it on this push.Record_IsOwnerReadWriteOnly_OnPosixself-skips on Windows, so it wasnot exercised on this box. The Linux CI leg is what proves the creation mode, and this PR does not
change it.
directory ACL; the planted-file case is the stand-in for that whole class.
Risk notes
CliStartupTrace.IsCorrelationIdusesUri.IsHexDigit, which also accepts uppercase. A harness that setsTASKDECK_CLI_TEST_TRACE_CORRELATIONto an uppercase 32-hex value would now be refused by thesink. The consequence is bounded: the startup trace still records the failure, so the run still
prints its correlation and no "diagnostics were not captured" notice appears; only the sink's
extra copy is skipped. The in-repo harness uses
Guid.NewGuid().ToString("N"), which islowercase.
play but not the values, so a failure that depends on a specific value (a path, an id) is harder
to reproduce from the record alone. That is the trade the issue asked for.
digits and hyphens, and only in the first two positions and only before the first flag. A
positional value that happens to have that exact shape and sits in one of those two positions
would still be retained. Every command group and command the dispatcher routes fits inside the
two-token depth, so this is the narrowest rule that keeps records readable.
MaximumRecordCount + 1records: the moment between the new record closing and the trim completing. If the process dies in
that window the next successful write trims it back.
Closes #2577