mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 01:57:57 +00:00
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
174 lines
6.0 KiB
TypeScript
174 lines
6.0 KiB
TypeScript
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, vi } from 'vitest'
|
|
import {
|
|
__getSecureFileHardeningCacheStateForTests,
|
|
__resetSecureFileHardenedPathsForTests,
|
|
hardenExistingSecureFile,
|
|
isUnreadableError,
|
|
writeSecureFile
|
|
} from './secure-file'
|
|
import {
|
|
setWindowsSecurityForTests,
|
|
resetWindowsSecurityLoadForTests
|
|
} 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<typeof NodeFs>()
|
|
return {
|
|
...actual,
|
|
chmodSync: (...args: Parameters<typeof actual.chmodSync>) => {
|
|
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', () => {
|
|
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
|
const tempDirs: string[] = []
|
|
|
|
beforeEach(() => {
|
|
resetWindowsSecurityLoadForTests()
|
|
__resetSecureFileHardenedPathsForTests()
|
|
})
|
|
|
|
afterEach(() => {
|
|
chmodFailure.code = null
|
|
resetWindowsSecurityLoadForTests()
|
|
__resetSecureFileHardenedPathsForTests()
|
|
setWindowsSecurityForTests(null)
|
|
if (originalPlatform) {
|
|
Object.defineProperty(process, 'platform', originalPlatform)
|
|
}
|
|
for (const dir of tempDirs.splice(0)) {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
function tempDir(): string {
|
|
const dir = mkdtempSync(join(tmpdir(), 'orca-secure-file-'))
|
|
tempDirs.push(dir)
|
|
return dir
|
|
}
|
|
|
|
it('never touches the Windows ACL addon off win32', () => {
|
|
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
|
|
const fake = createFakeWindowsSecurity()
|
|
setWindowsSecurityForTests(fake)
|
|
const targetPath = join(tempDir(), 'secret.json')
|
|
|
|
writeSecureFile(targetPath, 'contents')
|
|
hardenExistingSecureFile(targetPath)
|
|
|
|
expect(fake.calls).toEqual([])
|
|
})
|
|
|
|
posixModeIt('restricts the published file and its directory by mode', () => {
|
|
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
|
|
const userDataPath = tempDir()
|
|
const targetPath = join(userDataPath, 'secret.json')
|
|
|
|
expect(writeSecureFile(targetPath, 'contents')).toBe(true)
|
|
|
|
expect(statMode(targetPath)).toBe(0o600)
|
|
expect(statMode(userDataPath)).toBe(0o700)
|
|
})
|
|
|
|
posixModeIt('re-hardens a POSIX directory when its metadata changes after caching', () => {
|
|
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
|
|
const userDataPath = tempDir()
|
|
const targetPath = join(userDataPath, 'secret.json')
|
|
writeFileSync(targetPath, '{}')
|
|
|
|
hardenExistingSecureFile(targetPath)
|
|
expect(statMode(userDataPath)).toBe(0o700)
|
|
|
|
chmodSync(userDataPath, 0o755)
|
|
hardenExistingSecureFile(targetPath)
|
|
|
|
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({
|
|
maxEntries: 2,
|
|
maxKeyBytes: 4096,
|
|
maxTotalKeyBytes: 8192
|
|
})
|
|
const userDataPath = tempDir()
|
|
const firstPath = join(userDataPath, 'first.json')
|
|
const secondPath = join(userDataPath, 'second.json')
|
|
writeFileSync(firstPath, '{}')
|
|
writeFileSync(secondPath, '{}')
|
|
|
|
hardenExistingSecureFile(firstPath)
|
|
hardenExistingSecureFile(secondPath)
|
|
expect(__getSecureFileHardeningCacheStateForTests().paths.paths).toEqual([
|
|
userDataPath,
|
|
secondPath
|
|
])
|
|
|
|
hardenExistingSecureFile(firstPath)
|
|
expect(__getSecureFileHardeningCacheStateForTests().paths.paths).toEqual([
|
|
userDataPath,
|
|
firstPath
|
|
])
|
|
})
|
|
})
|
|
|
|
function statMode(path: string): number {
|
|
return statSync(path).mode & 0o777
|
|
}
|
|
|
|
describe('isUnreadableError', () => {
|
|
const withCode = (code: string): NodeJS.ErrnoException => Object.assign(new Error(code), { code })
|
|
|
|
it('reports a denied read', () => {
|
|
expect(isUnreadableError(withCode('EPERM'))).toBe(true)
|
|
expect(isUnreadableError(withCode('EACCES'))).toBe(true)
|
|
})
|
|
|
|
/** AV locks (EBUSY) or fd exhaustion (EMFILE) reveal no contents and must never authorize regenerate-and-overwrite. */
|
|
it('reports a read that never reached the contents for any other reason', () => {
|
|
expect(isUnreadableError(withCode('EBUSY'))).toBe(true)
|
|
expect(isUnreadableError(withCode('EMFILE'))).toBe(true)
|
|
expect(isUnreadableError(withCode('ENFILE'))).toBe(true)
|
|
expect(isUnreadableError(withCode('EIO'))).toBe(true)
|
|
})
|
|
|
|
/** Missing files permit creation; parsed garbage permits repair. Neither is an unreadable-content failure. */
|
|
it('does not report a missing file or a parse failure', () => {
|
|
expect(isUnreadableError(withCode('ENOENT'))).toBe(false)
|
|
expect(isUnreadableError(new SyntaxError('Unexpected end of JSON input'))).toBe(false)
|
|
expect(isUnreadableError(withCode('EISDIR'))).toBe(false)
|
|
expect(isUnreadableError(undefined)).toBe(false)
|
|
})
|
|
})
|