mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
fix(filesystem-auth): allow worktree access through macOS /var canonical alias (#1589)
#1524 stopped realpath'ing every worktree root during background refreshes to avoid TCC prompts, which broke File Explorer for worktrees registered under /var/folders when realpath canonicalizes them to /private/var/folders. Lazily canonicalize the registered root only when the user actively touches it: pass the pre-realpath source path through resolveAuthorizedPath so isPathAllowedIncludingRegisteredWorktrees can match the canonical target against the source's registered ancestor and cache the canonical root for future lookups. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -197,7 +197,11 @@ export async function resolveAuthorizedPath(
|
||||
// (delete/rename) act on the link itself.
|
||||
const realParent = await realpath(dirname(resolvedTarget))
|
||||
const candidateTarget = resolve(realParent, basename(resolvedTarget))
|
||||
if (!(await isPathAllowedIncludingRegisteredWorktrees(candidateTarget, store))) {
|
||||
if (
|
||||
!(await isPathAllowedIncludingRegisteredWorktrees(candidateTarget, store, {
|
||||
canonicalSourcePath: resolvedTarget
|
||||
}))
|
||||
) {
|
||||
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
|
||||
}
|
||||
return candidateTarget
|
||||
@@ -205,7 +209,11 @@ export async function resolveAuthorizedPath(
|
||||
|
||||
try {
|
||||
const realTarget = await realpath(resolvedTarget)
|
||||
if (!(await isPathAllowedIncludingRegisteredWorktrees(realTarget, store))) {
|
||||
if (
|
||||
!(await isPathAllowedIncludingRegisteredWorktrees(realTarget, store, {
|
||||
canonicalSourcePath: resolvedTarget
|
||||
}))
|
||||
) {
|
||||
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
|
||||
}
|
||||
return realTarget
|
||||
@@ -216,7 +224,11 @@ export async function resolveAuthorizedPath(
|
||||
|
||||
const realParent = await realpath(dirname(resolvedTarget))
|
||||
const candidateTarget = resolve(realParent, basename(resolvedTarget))
|
||||
if (!(await isPathAllowedIncludingRegisteredWorktrees(candidateTarget, store))) {
|
||||
if (
|
||||
!(await isPathAllowedIncludingRegisteredWorktrees(candidateTarget, store, {
|
||||
canonicalSourcePath: resolvedTarget
|
||||
}))
|
||||
) {
|
||||
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
|
||||
}
|
||||
return candidateTarget
|
||||
@@ -225,7 +237,8 @@ export async function resolveAuthorizedPath(
|
||||
|
||||
async function isPathAllowedIncludingRegisteredWorktrees(
|
||||
targetPath: string,
|
||||
store: Store
|
||||
store: Store,
|
||||
options: { canonicalSourcePath?: string } = {}
|
||||
): Promise<boolean> {
|
||||
if (isPathAllowed(targetPath, store)) {
|
||||
return true
|
||||
@@ -235,12 +248,19 @@ async function isPathAllowedIncludingRegisteredWorktrees(
|
||||
return true
|
||||
}
|
||||
|
||||
if (await isPathAllowedByCanonicalRegisteredRoot(targetPath, options.canonicalSourcePath)) {
|
||||
return true
|
||||
}
|
||||
|
||||
await ensureAuthorizedRootsCache(store)
|
||||
|
||||
// Why: external linked worktrees are already trusted for git operations.
|
||||
// Cache their normalized roots once and reuse that index so quick-open and
|
||||
// file explorer do not spawn `git worktree list` on every filesystem read.
|
||||
return isRegisteredWorktreePath(targetPath)
|
||||
return (
|
||||
isRegisteredWorktreePath(targetPath) ||
|
||||
(await isPathAllowedByCanonicalRegisteredRoot(targetPath, options.canonicalSourcePath))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -314,6 +334,41 @@ function isRegisteredWorktreePath(targetPath: string): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
async function isPathAllowedByCanonicalRegisteredRoot(
|
||||
targetPath: string,
|
||||
sourcePath: string | undefined
|
||||
): Promise<boolean> {
|
||||
if (!sourcePath) {
|
||||
return false
|
||||
}
|
||||
const textualRoot = findRegisteredWorktreeRoot(sourcePath)
|
||||
if (!textualRoot) {
|
||||
return false
|
||||
}
|
||||
const canonicalRoot = await normalizeExistingPath(textualRoot)
|
||||
if (!isDescendantOrEqual(targetPath, canonicalRoot)) {
|
||||
return false
|
||||
}
|
||||
// Why: #1524 stopped realpath'ing every worktree root during background
|
||||
// refreshes to avoid macOS privacy prompts. Cache only the root the user is
|
||||
// actively accessing so /var→/private/var aliases work without broad probes.
|
||||
registeredWorktreeRoots.add(canonicalRoot)
|
||||
return true
|
||||
}
|
||||
|
||||
function findRegisteredWorktreeRoot(targetPath: string): string | null {
|
||||
let bestRoot: string | null = null
|
||||
for (const root of registeredWorktreeRoots) {
|
||||
if (!isDescendantOrEqual(targetPath, root)) {
|
||||
continue
|
||||
}
|
||||
if (!bestRoot || root.length > bestRoot.length) {
|
||||
bestRoot = root
|
||||
}
|
||||
}
|
||||
return bestRoot
|
||||
}
|
||||
|
||||
async function normalizeExistingPath(resolvedPath: string): Promise<string> {
|
||||
try {
|
||||
return resolve(await realpath(resolvedPath))
|
||||
|
||||
@@ -219,6 +219,73 @@ describe('registerFilesystemHandlers', () => {
|
||||
expect(readFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows readDir when a registered worktree resolves to a macOS canonical alias', async () => {
|
||||
const aliasWorktreePath = path.resolve('/var/folders/orca/worktrees/feature')
|
||||
const canonicalWorktreePath = path.resolve('/private/var/folders/orca/worktrees/feature')
|
||||
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, aliasWorktreePath])
|
||||
realpathMock.mockImplementation(async (targetPath: string) => {
|
||||
if (targetPath === aliasWorktreePath) {
|
||||
return canonicalWorktreePath
|
||||
}
|
||||
return targetPath
|
||||
})
|
||||
readdirMock.mockResolvedValue([dirEntry({ name: 'README.md', file: true })])
|
||||
|
||||
registerFilesystemHandlers(store as never)
|
||||
|
||||
await expect(
|
||||
handlers.get('fs:readDir')!(null, { dirPath: aliasWorktreePath })
|
||||
).resolves.toEqual([{ name: 'README.md', isDirectory: false, isSymlink: false }])
|
||||
|
||||
expect(readdirMock).toHaveBeenCalledWith(canonicalWorktreePath, { withFileTypes: true })
|
||||
expect(listWorktreesMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows deletePath when a registered worktree parent resolves to a macOS canonical alias', async () => {
|
||||
const aliasWorktreePath = path.resolve('/var/folders/orca/worktrees/feature')
|
||||
const canonicalWorktreePath = path.resolve('/private/var/folders/orca/worktrees/feature')
|
||||
const aliasFilePath = path.join(aliasWorktreePath, 'README.md')
|
||||
const canonicalFilePath = path.join(canonicalWorktreePath, 'README.md')
|
||||
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, aliasWorktreePath])
|
||||
realpathMock.mockImplementation(async (targetPath: string) => {
|
||||
if (targetPath === aliasWorktreePath) {
|
||||
return canonicalWorktreePath
|
||||
}
|
||||
return targetPath
|
||||
})
|
||||
|
||||
registerFilesystemHandlers(store as never)
|
||||
|
||||
await handlers.get('fs:deletePath')!(null, { targetPath: aliasFilePath })
|
||||
|
||||
expect(trashItemMock).toHaveBeenCalledWith(canonicalFilePath)
|
||||
expect(listWorktreesMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects readFile when a symlink in a canonical alias worktree escapes the registered root', async () => {
|
||||
const aliasWorktreePath = path.resolve('/var/folders/orca/worktrees/feature')
|
||||
const canonicalWorktreePath = path.resolve('/private/var/folders/orca/worktrees/feature')
|
||||
const aliasLinkPath = path.join(aliasWorktreePath, 'link.txt')
|
||||
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, aliasWorktreePath])
|
||||
realpathMock.mockImplementation(async (targetPath: string) => {
|
||||
if (targetPath === aliasWorktreePath) {
|
||||
return canonicalWorktreePath
|
||||
}
|
||||
if (targetPath === aliasLinkPath) {
|
||||
return path.resolve('/private/secret.txt')
|
||||
}
|
||||
return targetPath
|
||||
})
|
||||
|
||||
registerFilesystemHandlers(store as never)
|
||||
|
||||
await expect(handlers.get('fs:readFile')!(null, { filePath: aliasLinkPath })).rejects.toThrow(
|
||||
'Access denied: path resolves outside allowed directories'
|
||||
)
|
||||
|
||||
expect(readFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not enumerate worktrees when filesystem handlers register', () => {
|
||||
registerFilesystemHandlers(store as never)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user