fix(cli): create the connector key file atomically with owner-only permissions - #2665
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Review disposition (beta-platform-integrity lane, T3 gate)Fresh-context adversarial review at 21697ef: SHIP. No CRITICAL or HIGH. The reviewer compared both helper sources line by line, confirmed the Tracked, not fixed here (law 2: MEDIUM and LOW become issues)All four are in #2667:
Verified by the coordinator
Verification recorded in the PR bodyRed first on the unmodified bootstrapper (structural check and the Windows DACL assertion both failed), then 5/5 focused, Cli.Tests 228/228, Architecture.Tests 28/28 (1 pre-existing skip), solution build 0 errors, the full serialized backend solution 8886 passed / 0 failed / 34 pre-existing skips, |
Summary
The CLI's connector encryption key file is now created atomically with owner-only permissions
instead of being written first and restricted afterwards.
CliFirstRunBootstrapper.PersistKeystages the generated key in a sibling temp file and thenFile.Moves it into place. It used to callFile.WriteAllText(tempPath, payload)and only thenFile.SetUnixFileMode(tempPath, ...), and only on Unix. This change routes the staged write througha new CLI-local helper,
backend/src/Taskdeck.Cli/RestrictedFileWriter.cs, which creates the filewith
FileMode.CreateNewandFileShare.None, supplies the owner-only permissions to the createcall itself, verifies them through the still-open handle, and writes the payload through that same
handle. The existing
MoveWithRetryinto place is unchanged.RestrictedFileWriteris a deliberate, faithful copy of the API-side helperTaskdeck.Api.FirstRun.FirstRunBootstrapper.WriteRestrictedFile/CreateRestrictedNewFile/CreateOwnerOnlyFileWindows/BuildOwnerOnlyFileSecurity, shipped by PR #1267 for #1264. The CLIcannot reference the API project (
Taskdeck.Clireferences Application and Infrastructure only, andTaskdeck.Architecture.Testsenforces that), so the code is duplicated rather than shared. The classcomment names the API original and PR #1267, and the API helper itself is not moved or edited.
Root cause
PersistKeycreated the temp file withFile.WriteAllText, which applies whatever permissions theplatform defaults give it, and only afterwards narrowed them.
On Unix that leaves a window between the write and the
SetUnixFileModecall in which the base64256-bit connector key sits on disk at the umask-derived mode. Under the common umask 022 that is
0644, readable by every local user.
File.Movepreserves the mode, so the exposure is the windowrather than the final file.
On Windows nothing narrowed the permissions at all. The
SetUnixFileModecall was inside anif (!OperatingSystem.IsWindows())guard, so the temp file simply inherited the containingdirectory's DACL, which typically grants
BUILTIN\Usersread.File.Movepreserves what wasinherited, so the persisted key file stayed readable by other local users for its whole life, not
just for a window.
The fix removes both problems by never letting the file exist with wider permissions than intended:
on Unix the file is born 0600 via
FileStreamOptions.UnixCreateModeand the exact mode is thenpinned through the open handle with
File.SetUnixFileMode(SafeFileHandle), which is umask-proof andfails on filesystems that cannot store the mode; on Windows a protected owner-only
FileSecurity(inheritance disabled, a single ACE for the current user's SID) is passed to
FileInfo.Createandthe resulting DACL is read back through the same handle, so FAT32, exFAT and SMB shares that
silently ignore the security descriptor fail closed rather than persist the key unprotected.
FileMode.CreateNewalso refuses to adopt a file someone pre-created at the temp path, and anyfailure is normalized to
IOExceptionso the existingcatch (Exception ex) when (ex is IOException or UnauthorizedAccessException)inEnsureKeyOnDiskstill degrades to a transient in-memory key instead of crashing the CLI.
Verification
All commands run in the worktree at
.worktrees/codex-1262-cli-key-atomic-create, Windows 11.Red first, against the unmodified
CliFirstRunBootstrapper.csat base 86ca907, with only the newtest file added:
dotnet test backend/tests/Taskdeck.Cli.Tests/Taskdeck.Cli.Tests.csproj -c Release -m:1 --filter "FullyQualifiedName~CliRestrictedFileWriterTests"The second failure is the Windows half of the bug observed end to end: the key file that
EnsureConnectorEncryptionKeypersists had an inherited, unprotected DACL.Green after the fix, same filter, with the three helper-level tests added:
Whole CLI test project:
dotnet test backend/tests/Taskdeck.Cli.Tests/Taskdeck.Cli.Tests.csproj -c Release -m:1Layer purity, since the fix adds a file to the Cli project:
dotnet test backend/tests/Taskdeck.Architecture.Tests/Taskdeck.Architecture.Tests.csproj -c Release -m:1Full solution build:
dotnet build backend/Taskdeck.sln -c ReleaseAll 12 warnings are pre-existing CS8602/CS8603/CS1998 in
Taskdeck.Api.TestsandTaskdeck.Application.Testsfiles this PR does not touch. No warning comes fromTaskdeck.CliorTaskdeck.Cli.Testssources changed here.Required backend check from
backend/AGENTS.md:dotnet test backend/Taskdeck.sln -c Release -m:1Exit code 0, all six projects green:
Total 8886 passed, 0 failed, 34 skipped. The skips are the suites' own pre-existing conditional
skips, not anything this PR disabled.
Parity with the API original. Extracting both helper bodies and stripping comments and blank lines,
diffreports only two differences: the API's additionalRestrictFileToCurrentUsermethod, whichis its forward-remediation path for files that already exist and has no caller in the CLI, and the
closing method boundary. The four copied methods (
WriteRestrictedFilex2,CreateRestrictedNewFile,CreateOwnerOnlyFileWindows,BuildOwnerOnlyFileSecurity) are character-identical.Whitespace:
git diff --check 86ca9078c92d8b7da4fb98d53dcbd0b88c2fe4a1...HEAD— clean, exit 0.node scripts/check-docs-governance.mjswas not run because no documentation file changed. I readdocs/security/BETA_THREAT_MODEL.md,docs/platform/CONFIGURATION_REFERENCE.mdanddocs/decisions/ADR-0041-desktop-connector-key-autogeneration.mdlooking for a sentence thatdescribes the CLI key file's protection as write-then-chmod or as inheritance-based. There is none.
ADR-0041 documents the API and desktop-exe half and already states the file is created owner-only
before the secret is written, which stays true;
CONFIGURATION_REFERENCE.md:197describes the API'slocal-config copies. Nothing became false, so no doc sentence was edited.
Not verified
CreateOwnerOnlyFileWindowsand theWindows DACL assertions are what actually ran; the
FileStreamOptions.UnixCreateModepath and the0600 assertions are covered only by the structural test here and by the Linux CI runner. The Unix
code is a character-level copy of the API helper that has been running on the Linux runner since
PR fix(security): create secret files atomically with owner-only permissions (#1264) #1267.
It is pinned structurally (
AreAccessRulesProtected,File.SetUnixFileMode(stream.SafeFileHandle)rather than behaviourally, the same way the API suite pins it.
FileMode.CreateNewnow refuses; the temp name already carries a fresh GUID per call.backend/src/Taskdeck.Clichanged.
Risk notes
would have meant touching the API helper, whose
FirstRunBootstrapperTestsassert its source textstructurally, so the duplication is deliberate. It carries a comment naming the original and
PR fix(security): create secret files atomically with owner-only permissions (#1264) #1267; a future change to the lockdown contract has to be made in both places, and the two
structural tests will not catch a drift between them.
IOExceptionin cases that previously succeeded: a filesystem thatcannot store the owner-only permissions, and a pre-existing file at the temp path. Both surface as
the existing "Could not persist connector encryption key" stderr warning and a transient in-memory
key for that run, so the CLI still starts. On such a volume the key stops being persisted at all,
which is the intended fail-closed trade and matches the API's behaviour since FirstRunBootstrapper: create secret temp file atomically with owner-only ACL (close empty-file handle race) #1264.
EnsureKeyOnDisk's existing catch clause coversIOExceptionandUnauthorizedAccessException;the helper normalizes everything else to
IOException, so no new exception type escapes tostartup.
the read-modify-write merge of
appsettings.local.json, andMoveWithRetryare untouched.Closes #1262. Refs #1241, #1264, PR #1267.