From e400191cf01e8f9672289fac96e8c084e49dff1b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 8 May 2026 10:22:15 -0700 Subject: [PATCH] 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 --- src/main/ipc/filesystem-auth.ts | 65 +++++++++++++++++++++++++++++--- src/main/ipc/filesystem.test.ts | 67 +++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 5 deletions(-) diff --git a/src/main/ipc/filesystem-auth.ts b/src/main/ipc/filesystem-auth.ts index 88856eefde6..2118be37ed6 100644 --- a/src/main/ipc/filesystem-auth.ts +++ b/src/main/ipc/filesystem-auth.ts @@ -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 { 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 { + 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 { try { return resolve(await realpath(resolvedPath)) diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index de89fcb2da1..d7c2107a72b 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -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)