diff --git a/src/main/ipc/filesystem-auth.ts b/src/main/ipc/filesystem-auth.ts index 2782fd5c10d..546c611e50b 100644 --- a/src/main/ipc/filesystem-auth.ts +++ b/src/main/ipc/filesystem-auth.ts @@ -83,24 +83,45 @@ async function normalizeExistingPath(targetPath: string): Promise { } } +/** + * Resolve and verify that a worktree path belongs to a registered repo. + * + * Why this doesn't use resolveAuthorizedPath: linked worktrees can live + * anywhere on disk (e.g. ~/.codex/worktrees/), far outside the repo root + * and workspaceDir that resolveAuthorizedPath allows. The security boundary + * for git operations is *worktree registration* — the path must match a + * worktree reported by `git worktree list` for a known repo — not + * directory containment within allowed roots. + */ export async function resolveRegisteredWorktreePath( worktreePath: string, store: Store ): Promise { - const resolvedPath = await resolveAuthorizedPath(worktreePath, store) + // Reject obviously malformed paths early — mirrors the null-byte check in + // validateGitRelativeFilePath and prevents probing via realpath. + if (!worktreePath || worktreePath.includes('\0')) { + throw new Error('Access denied: invalid worktree path') + } + + const resolvedTarget = resolve(worktreePath) + + // Resolve through symlinks when the path exists on disk, so that we + // compare canonical paths on both sides (git worktree list also resolves + // symlinks). + const normalizedTarget = await normalizeExistingPath(resolvedTarget) for (const repo of store.getRepos()) { const normalizedRepoPath = await normalizeExistingPath(repo.path) - if (resolvedPath === normalizedRepoPath) { - return resolvedPath + if (normalizedTarget === normalizedRepoPath) { + return normalizedTarget } const worktrees = await listWorktrees(repo.path) for (const worktree of worktrees) { const normalizedWorktreePath = await normalizeExistingPath(worktree.path) - if (resolvedPath === normalizedWorktreePath) { - return resolvedPath + if (normalizedTarget === normalizedWorktreePath) { + return normalizedTarget } } } diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 7d310130523..7f8b7687f22 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -275,6 +275,52 @@ describe('registerFilesystemHandlers', () => { expect(getBranchCompareMock).toHaveBeenCalledWith('/workspace/repo-feature', 'origin/main') }) + it('allows git operations on worktrees outside repo/workspace roots', async () => { + // Linked worktrees can live anywhere on disk (e.g. ~/.codex/worktrees/). + // As long as the path matches a worktree reported by `git worktree list` + // for a registered repo, it should be allowed — the security boundary is + // worktree registration, not directory containment. + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/repo', + head: 'abc', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/external/worktrees/feature', + head: 'def', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + + getBranchCompareMock.mockResolvedValue({ + summary: { + baseRef: 'origin/main', + baseOid: 'base-oid', + compareRef: 'feature', + headOid: 'head-oid', + mergeBase: 'merge-base-oid', + changedFiles: 0, + status: 'ready' + }, + entries: [] + }) + + registerFilesystemHandlers(store as never) + + // /external/worktrees/feature is outside both /workspace/repo and /workspace + await handlers.get('git:branchCompare')!(null, { + worktreePath: '/external/worktrees/feature', + baseRef: 'origin/main' + }) + + expect(getBranchCompareMock).toHaveBeenCalledWith('/external/worktrees/feature', 'origin/main') + }) + it('routes branch diff queries through the pinned branch diff helper', async () => { getBranchDiffMock.mockResolvedValue({ kind: 'text',