From f94b15ec85b0fb118a4801e3d75ebd7072edf2c0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:05:58 -0700 Subject: [PATCH] fix(cli): a sandbox that denies chmod no longer breaks reading Orca's state files (#20937) (#33662) Orca CLI commands inside a sandbox that blocks chmod (e.g. Claude Code's Bash sandbox) no longer fail with a misleading "file is invalid": the pre-read hardening of existing secure files ignores EPERM/EACCES/EROFS; writers still create files 0600. Fixes #20937 --- src/shared/secure-file.test.ts | 35 +++++++++++++++++++++++++++++++++- src/shared/secure-file.ts | 24 +++++++++++++++++------ 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/shared/secure-file.test.ts b/src/shared/secure-file.test.ts index f9d2791eb42..0cb16407cdd 100644 --- a/src/shared/secure-file.test.ts +++ b/src/shared/secure-file.test.ts @@ -1,7 +1,8 @@ import { chmodSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' +import type * as NodeFs from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { __getSecureFileHardeningCacheStateForTests, __resetSecureFileHardenedPathsForTests, @@ -15,6 +16,21 @@ import { } from './windows-security-addon' import { createFakeWindowsSecurity } from './windows-security-addon.test-fixture' +const chmodFailure = vi.hoisted((): { code: string | null } => ({ code: null })) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + chmodSync: (...args: Parameters) => { + if (chmodFailure.code) { + throw Object.assign(new Error(chmodFailure.code), { code: chmodFailure.code }) + } + actual.chmodSync(...args) + } + } +}) + const posixModeIt = process.platform === 'win32' ? it.skip : it describe('POSIX secure files', () => { @@ -27,6 +43,7 @@ describe('POSIX secure files', () => { }) afterEach(() => { + chmodFailure.code = null resetWindowsSecurityLoadForTests() __resetSecureFileHardenedPathsForTests() setWindowsSecurityForTests(null) @@ -82,6 +99,22 @@ describe('POSIX secure files', () => { expect(statMode(userDataPath)).toBe(0o700) }) + /** Claude Code's Bash sandbox denies chmod on userData; `orca worktree list` failed reading it (#20937). */ + posixModeIt( + 'lets readers proceed when a sandbox denies hardening, but not on other errors', + () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + const targetPath = join(tempDir(), 'secret.json') + writeFileSync(targetPath, '{}') + + chmodFailure.code = 'EPERM' + expect(() => hardenExistingSecureFile(targetPath)).not.toThrow() + + chmodFailure.code = 'EIO' + expect(() => hardenExistingSecureFile(targetPath)).toThrow('EIO') + } + ) + posixModeIt('LRU-bounds POSIX hardening entries while keeping recent paths cached', () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) __resetSecureFileHardenedPathsForTests({ diff --git a/src/shared/secure-file.ts b/src/shared/secure-file.ts index d7379a9bc04..756ecbf442d 100644 --- a/src/shared/secure-file.ts +++ b/src/shared/secure-file.ts @@ -207,16 +207,28 @@ export function isUnreadableError(error: unknown): boolean { ) } +/** Readers tighten modes opportunistically; a sandbox that denies chmod must not fail the read (#20937). */ export function hardenExistingSecureFile(targetPath: string): void { - const dir = dirname(targetPath) - if (existsSync(dir)) { - hardenSecurePathOnce(dir, true) - } - if (existsSync(targetPath)) { - hardenSecurePathOnce(targetPath, false) + try { + const dir = dirname(targetPath) + if (existsSync(dir)) { + hardenSecurePathOnce(dir, true) + } + if (existsSync(targetPath)) { + hardenSecurePathOnce(targetPath, false) + } + } catch (error) { + if (!isPermissionDeniedError(error)) { + throw error + } } } +function isPermissionDeniedError(error: unknown): boolean { + const code = error instanceof Error && 'code' in error ? error.code : undefined + return code === 'EPERM' || code === 'EACCES' || code === 'EROFS' +} + /** Caches the current metadata snapshot for a just-hardened path, or clears it if the path is gone. */ function rememberHardenedPath(targetPath: string, isDirectory: boolean): void { const entry = getHardenedPathCacheEntry(targetPath, isDirectory)