From cb6aa82791e4e957ed70dc05cd9f7612405ee08e Mon Sep 17 00:00:00 2001 From: ka2umaz <250477926+ka2umaz@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:18:50 +0900 Subject: [PATCH] fix(usage): write usage cache snapshots and sidecars owner-only (0600) (#31027) Usage caches and their source sidecars hold session ids and project paths, but their durable temp files were created with the process default mode, so a replace under umask 022 published them as 0644. Thread an optional creation mode through writeFileDurable / writeFileDurableIfCurrent and pass 0o600 from UsageCacheSnapshotWriter and the usage source sidecar writer. Other durable writers are unchanged. Fixes #31026 --- src/main/durable-file-write.ts | 11 +++++--- src/main/usage-cache-snapshot-writer.test.ts | 25 +++++++++++++++++++ src/main/usage-cache-snapshot-writer.ts | 4 ++- .../usage/usage-source-cache-file.test.ts | 8 +++++- src/main/usage/usage-source-cache-file.ts | 3 ++- 5 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 src/main/usage-cache-snapshot-writer.test.ts diff --git a/src/main/durable-file-write.ts b/src/main/durable-file-write.ts index abd7fba2534..047e7df3263 100644 --- a/src/main/durable-file-write.ts +++ b/src/main/durable-file-write.ts @@ -79,9 +79,10 @@ export async function writeTempFileDurable( export async function writeFileDurable( tmpPath: string, finalPath: string, - payload: string | Uint8Array + payload: string | Uint8Array, + mode?: number ): Promise { - await writeFileDurableIfCurrent(tmpPath, finalPath, payload, () => true) + await writeFileDurableIfCurrent(tmpPath, finalPath, payload, () => true, mode) } /** Veto superseded writes before rename and remove their temp files on every uncommitted path. */ @@ -89,12 +90,14 @@ export async function writeFileDurableIfCurrent( tmpPath: string, finalPath: string, payload: string | Uint8Array, - isCurrent: () => boolean + isCurrent: () => boolean, + /** Creation mode for the new temp file, e.g. 0o600 for state other users must not read. */ + mode?: number ): Promise { let renamed = false try { // Fsync before publication so power loss cannot expose an empty file. - await writeTempFileDurable(tmpPath, payload) + await writeTempFileDurable(tmpPath, payload, mode) if (!(await renameFileWithWindowsRetryAsync(tmpPath, finalPath, isCurrent))) { return false } diff --git a/src/main/usage-cache-snapshot-writer.test.ts b/src/main/usage-cache-snapshot-writer.test.ts new file mode 100644 index 00000000000..959a9264c14 --- /dev/null +++ b/src/main/usage-cache-snapshot-writer.test.ts @@ -0,0 +1,25 @@ +import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { UsageCacheSnapshotWriter } from './usage-cache-snapshot-writer' + +it.skipIf(process.platform === 'win32')( + 'replaces a world-readable usage cache with an owner-only snapshot', + async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-usage-cache-mode-')) + try { + const file = join(directory, 'orca-claude-usage.json') + writeFileSync(file, '{}') + chmodSync(file, 0o644) + const writer = new UsageCacheSnapshotWriter('[test]', () => file) + + await writer.write(() => '{"sessionId":"s"}') + + expect(readFileSync(file, 'utf8')).toBe('{"sessionId":"s"}') + expect(statSync(file).mode & 0o777).toBe(0o600) + } finally { + rmSync(directory, { recursive: true, force: true }) + } + } +) diff --git a/src/main/usage-cache-snapshot-writer.ts b/src/main/usage-cache-snapshot-writer.ts index 8b442952336..08faf228351 100644 --- a/src/main/usage-cache-snapshot-writer.ts +++ b/src/main/usage-cache-snapshot-writer.ts @@ -47,7 +47,9 @@ export class UsageCacheSnapshotWriter { durableWriteTempPath(usageFile), usageFile, payload, - () => generation === this.generation + () => generation === this.generation, + // Usage caches hold session ids and project paths. + 0o600 ) } } diff --git a/src/main/usage/usage-source-cache-file.test.ts b/src/main/usage/usage-source-cache-file.test.ts index ab4acc5484e..5169ebf6d28 100644 --- a/src/main/usage/usage-source-cache-file.test.ts +++ b/src/main/usage/usage-source-cache-file.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -40,6 +40,12 @@ describe('usage source cache file', () => { await expect(readUsageSourceCache(ref)).resolves.toEqual([{ path: 'a.jsonl' }]) }) + it.skipIf(process.platform === 'win32')('writes the sidecar owner-only', async () => { + await writeUsageSourceCache(ref, [{ path: 'a.jsonl' }]) + + expect(statSync(ref.path).mode & 0o777).toBe(0o600) + }) + it('rejects attribution from a sidecar committed ahead of its report', async () => { await writeUsageSourceCache({ ...ref, worktreeFingerprint: 'new-worktrees' }, [ { path: 'a.jsonl', repoId: 'new-repo' } diff --git a/src/main/usage/usage-source-cache-file.ts b/src/main/usage/usage-source-cache-file.ts index 848abb701c5..ae66cd36310 100644 --- a/src/main/usage/usage-source-cache-file.ts +++ b/src/main/usage/usage-source-cache-file.ts @@ -78,7 +78,8 @@ export async function writeUsageSourceCacheData( await writeFileDurable( durableWriteTempPath(ref.path, threadId > 0 ? String(threadId) : undefined), ref.path, - data + data, + 0o600 ) }