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:
Jinjing
2026-05-08 10:22:15 -07:00
committed by GitHub
co-authored by Orca
parent 884feb42b2
commit e400191cf0
2 changed files with 127 additions and 5 deletions
+60 -5
View File
@@ -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))
+67
View File
@@ -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)