fix(cli): restrict pre-existing connector key files and pin API/CLI helper parity - #2671
Conversation
…t user EnsureKeyOnDisk returned as soon as it found a persisted key, so a key file written by a build older than #1262 kept the directory's inherited Windows DACL or its umask-derived Unix mode forever. Copy the API's RestrictFileToCurrentUser helper into RestrictedFileWriter and call it, best-effort, on the paths that leave an existing file on disk.
…LI copy RestrictedFileWriter is a duplicate of the API FirstRunBootstrapper helpers because the CLI cannot reference the API project. Compare the shared method bodies, comments and whitespace normalized away, so a one-sided fix fails the build.
The CLI writes its connector encryption key next to the data directory and needs a filesystem that stores owner-only permissions; on FAT32, exFAT and some SMB shares it refuses to persist and uses a per-run transient key.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
The second RestrictExistingKeyFileAt call site had no test. Adds a structural pin on the PreserveFile branch (same style as the existing-key pin) plus a Windows-only behavioural test that reaches the branch by holding the key file open with FileShare.None and asserts the file survives with a transient-key warning.
…d runs EnsureConnectorEncryptionKey returns before resolving a path when Connectors:EncryptionKey is configured, so the re-lockdown does not run then; also records that an existing key file on a FAT32/exFAT/SMB volume keeps being used with a per-run restriction warning.
…ion warning site The PR shifted CliFirstRunBootstrapper.cs line offsets; re-read at this head and adds the new IOException/UnauthorizedAccessException stderr warning at :294-301 with its bounded classification.
Fix roundHead after the round: 1. MEDIUM — second
|
| Command | Result |
|---|---|
dotnet test backend/tests/Taskdeck.Cli.Tests/Taskdeck.Cli.Tests.csproj -c Release -m:1 --filter "FullyQualifiedName~CliKeyFileRemediationTests" |
Passed — 7/7, 0 failed (was 5 before this round) |
dotnet test backend/tests/Taskdeck.Cli.Tests/Taskdeck.Cli.Tests.csproj -c Release -m:1 |
Passed — 241/241, 0 failed, 3 m 20 s |
node scripts/check-docs-governance.mjs |
Docs governance check passed. (exit 0) |
node scripts/check-unknown-exception-boundary.mjs |
Unknown-exception boundary check passed. (exit 0) |
git diff --check |
exit 0 (only a CRLF-normalization notice) |
Red-first proof for finding 1: deleting the RestrictExistingKeyFileAt(localConfigPath); line from the PreserveFile branch turns the run red — EnsureKeyOnDisk_PreserveFilePath_CallsTheRemediation_StructuralCheck fails with Assert.Contains() Failure: Sub-string not found, 1 failed / 6 passed. The behavioural test stays green under that mutation, which is the limit named above. The line was restored (git checkout --) and the green run above is from the restored source. Note the structural pin, like the existing one it mirrors, matches source text and would still pass against a commented-out call.
No backend solution rerun: no non-test source line changed in this round, so the previously-run solution evidence still applies to the shipped code. NOT verified: the Unix branch of the new behavioural test (skipped by design on non-Windows) and the CI runners' own results for this head.
…tered catch in the bootstrapper row
Review disposition (beta-platform-integrity lane, T3 gate)Round 1 (fresh-context, read-only reviewer at 7d73011): SHIP, no CRITICAL or HIGH; two MEDIUM, four LOW. The reviewer diffed the CLI copy of Fixed in the fix round (79a8d42)
Fixed after round 2 (afbd16b, docs only)
Declined, with reasoning
VerificationRound 1 head: red first on the unmodified source (DACL assertion, structural pin, and one-character parity mutation all red; the other five parity cases green in the same run), Cli.Tests 239/239, Architecture.Tests 28/28 (one pre-existing skip), solution build 0 errors, full serialized backend solution 0 failed, docs governance, diff check. Fix round: Cli.Tests 241/241, boundary guard and docs governance green; no non-test source line changed after 7d73011 (the bootstrapper is byte-identical to the round-1 head), so no solution rerun. Final commit is docs only. Merge once exact-head |
Summary
Closes the three implementable gaps left by PR #2665 on the CLI connector key file.
Forward remediation.
CliFirstRunBootstrapper.EnsureKeyOnDiskreturned as soon as it found a persisted key, so a key file created before fix(cli): create the connector key file atomically with owner-only permissions #2665 kept whatever permissions it was born with. The CLI now re-restricts that file to the current user before returning the key.RestrictedFileWritergainsRestrictFileToCurrentUser, a verbatim copy of the API helper of the same name, and a new best-effort wrapperCliFirstRunBootstrapper.RestrictExistingKeyFileAtcalls it. The wrapper does nothing when the file is absent, never reads, rewrites or deletes content, and turns anyIOExceptionorUnauthorizedAccessExceptioninto one[CliFirstRun] WARNING:line on stderr that names the path, after which the run continues with the key it already has.Parity guard.
CliRestrictedFileWriterParityTestsreadsbackend/src/Taskdeck.Api/FirstRun/FirstRunBootstrapper.csandbackend/src/Taskdeck.Cli/RestrictedFileWriter.cs, extracts the bodies of the six shared methods by signature, strips//,///and/* */comments, drops blank lines and collapses whitespace, and asserts each pair is identical. A mismatch names the method and prints the first differing normalized line from each side. A source file that cannot be located raisesFileNotFoundException, so the test fails rather than passing quietly. Paths are built withPath.Combineonly.Doc. One paragraph in
docs/platform/CONFIGURATION_REFERENCE.md, at the end of the "Generated local configuration file" section, on the filesystem requirement for the CLI key file.Decisions taken inside the task's scope:
RestrictFileToCurrentUserhas no "already restricted" fast path, so there was none to mirror. The call runs on every run; it is oneSetAccessControlor onechmod.existing.PreserveFilebranch is treated the same way. That branch is reached when the file exists but could not be read, so the file survives the run and deserves the same lockdown; changing the DACL or mode does not read or rewrite content, so it is safe on a file we could not read. The one path left uncovered is "bootstrap lock unavailable and the file exists but is unreadable", which returns before thePreserveFilebranch is reached.EnsureConnectorEncryptionKeyreturns before it resolves any path whenConnectors:EncryptionKeyis already in configuration, and that no-file-IO early return is a documented contract with an existing test behind it (EnsureConnectorEncryptionKey_WhenAlreadyConfigured_IsNoOp). Remediating there would mean resolving the data directory and touching a file the run never uses.Root cause
EnsureKeyOnDiskhad a single early return for the already-provisioned case:PR #2665 changed only the creation path (
PersistKeynow stages the key throughRestrictedFileWriter.WriteRestrictedFile). Nothing on the read path touched permissions, so an install that already hadappsettings.local.jsonfrom an older build kept the directory's inherited Windows DACL, typically includingBUILTIN\Usersread, or the umask-derived Unix mode, typically 0644, on every subsequent run.Verification
All commands run from the worktree root with
-m:1, one test process at a time.Red first, against the pre-fix source. The remediation call was removed from the existing-key path and one character was changed in the CLI copy of
RestrictFileToCurrentUser("current user" to "current usEr"), then:The three failures:
EnsureKeyOnDisk_ExistingUnrestrictedKeyFile_IsRestrictedToCurrentUser: "inheritance should be disabled so the directory's default ACEs (e.g. BUILTIN\Users read) do not apply" atCliRestrictedFileWriterTests.AssertOwnerOnly.EnsureKeyOnDisk_ExistingKeyPath_CallsTheRemediation_StructuralCheck: "Assert.Contains() Failure: Sub-string not found. Not found: RestrictExistingKeyFileAt(localConfigPath...".SharedLockdownHelper_HasIdenticalBodiesOnBothSides(method: "RestrictFileToCurrentUser", ...): "RestrictFileToCurrentUser has drifted between the API original and the CLI copy. First difference at normalized line 18: API ... to the current user; ... CLI ... to the current usEr; ...". The other five parity cases passed in the same run, which is what shows the guard discriminates rather than failing on everything.Both temporary changes were reverted, then:
The 14 warnings are all pre-existing and all in files this PR does not touch (
Taskdeck.Api.Tests,Taskdeck.Application.Tests, andCliStartupTraceTests.cs). No new warning.Not verified
RestrictFileToCurrentUserand ofAssertOwnerOnly(0600 viaFile.SetUnixFileMode) were not executed locally; the Ubuntu CI leg covers them. The parity test itself is platform independent and passed locally.restrictFiledelegate intoRestrictExistingKeyFileAtand asserting the stderr warning and that nothing is thrown. There is no test that drives a real permission failure throughEnsureKeyOnDiskend to end, because no deterministic way to forceSetAccessControlorchmodto fail was available in-process. ThatEnsureKeyOnDiskstill returns the key follows from the wrapper never throwing.RestrictedFileWriter(AreAccessRulesProtectedread-back and theSetUnixFileModehandle pin), which have their own structural coverage.docs/COURSE_CORRECTION.mdand stays open.Risk notes
chmodon Unix, on a file the process is about to read anyway.Connectors__EncryptionKeyon every host.Refs #2667 (items 1 to 3; item 4 stays open for maintainer wording). Refs #1262, PR #2665, #1241, #1264, PR #1267.