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
This commit is contained in:
ka2umaz
2026-10-26 11:18:50 -07:00
committed by GitHub
parent 66fbd81994
commit cb6aa82791
5 changed files with 44 additions and 7 deletions
+7 -4
View File
@@ -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<void> {
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<boolean> {
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
}
@@ -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 })
}
}
)
+3 -1
View File
@@ -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
)
}
}
@@ -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' }
+2 -1
View File
@@ -78,7 +78,8 @@ export async function writeUsageSourceCacheData(
await writeFileDurable(
durableWriteTempPath(ref.path, threadId > 0 ? String(threadId) : undefined),
ref.path,
data
data,
0o600
)
}