From 2e0c0c033265fb2abf4a743f3ad2dff8d4c7fce9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:13:38 -0700 Subject: [PATCH] fix(sidebar): reject stale folder owners --- src/main/ipc/filesystem-auth.test.ts | 25 +++++++++++++++++++ .../project-group-sidebar-identity.test.ts | 7 ++++++ .../sidebar/project-group-sidebar-identity.ts | 5 +--- src/shared/folder-workspaces.ts | 14 +++++------ src/shared/project-groups.test.ts | 23 +++++++++++++++++ src/shared/project-groups.ts | 14 ++++++----- 6 files changed, 70 insertions(+), 18 deletions(-) diff --git a/src/main/ipc/filesystem-auth.test.ts b/src/main/ipc/filesystem-auth.test.ts index c1113426f37..c610d5097f9 100644 --- a/src/main/ipc/filesystem-auth.test.ts +++ b/src/main/ipc/filesystem-auth.test.ts @@ -302,6 +302,31 @@ describe('filesystem-auth path containment', () => { } }) + it('rejects a foreign-stamped folder whose group exists only locally', async () => { + const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-stale-folder-owner-')) + try { + const localPath = join(tempRoot, 'local') + const foreignPath = join(tempRoot, 'foreign') + await mkdir(localPath) + await mkdir(foreignPath) + const store = makeStore([], { + projectGroups: [makeProjectGroup({ id: 'same-id', parentPath: localPath })], + folderWorkspaces: [ + makeFolderWorkspace({ + id: 'foreign-folder', + projectGroupId: 'same-id', + folderPath: foreignPath, + connectionId: 'missing' + }) + ] + }) + + await expect(resolveAuthorizedPath(foreignPath, store)).rejects.toThrow('Access denied') + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } + }) + it('keeps large multi-owner folder authorization linear in catalog size', () => { const groupCount = 2_048 const repoCount = 2_048 diff --git a/src/renderer/src/components/sidebar/project-group-sidebar-identity.test.ts b/src/renderer/src/components/sidebar/project-group-sidebar-identity.test.ts index 86e09d24a7c..bb9e5d5e276 100644 --- a/src/renderer/src/components/sidebar/project-group-sidebar-identity.test.ts +++ b/src/renderer/src/components/sidebar/project-group-sidebar-identity.test.ts @@ -35,6 +35,13 @@ describe('project-group sidebar identity', () => { expect(getProjectGroupSidebarIdentity(local)).not.toBe(getProjectGroupSidebarIdentity(runtime)) }) + it('does not fall back to a foreign group for an explicit stale owner', () => { + const local = group('same-id', 'local') + const index = buildProjectGroupSidebarIndex([local]) + + expect(findProjectGroupForSidebarOwner(index, 'same-id', 'runtime:missing')).toBeUndefined() + }) + it('disables focused-host mutations for mixed-owner and mismatched lists', () => { const localA = group('a', 'local') const localB = group('b', 'local') diff --git a/src/renderer/src/components/sidebar/project-group-sidebar-identity.ts b/src/renderer/src/components/sidebar/project-group-sidebar-identity.ts index 1015c679e09..938ed9a0625 100644 --- a/src/renderer/src/components/sidebar/project-group-sidebar-identity.ts +++ b/src/renderer/src/components/sidebar/project-group-sidebar-identity.ts @@ -103,10 +103,7 @@ export function findProjectGroupForSidebarOwner( return undefined } if (ownerHostId) { - const exact = index.byIdentity.get(JSON.stringify([ownerHostId, groupId])) - if (exact) { - return exact - } + return index.byIdentity.get(JSON.stringify([ownerHostId, groupId])) } return index.byUnambiguousId.get(groupId) } diff --git a/src/shared/folder-workspaces.ts b/src/shared/folder-workspaces.ts index 26260212cf6..8c595f0a4cf 100644 --- a/src/shared/folder-workspaces.ts +++ b/src/shared/folder-workspaces.ts @@ -7,8 +7,7 @@ import { import { buildProjectGroupOwnerIndex, getFolderWorkspaceProjectGroupOwnerHostId, - resolveFolderWorkspaceProjectGroup, - resolveProjectGroupOwner + resolveFolderWorkspaceProjectGroup } from './project-groups' import type { FolderWorkspace, ProjectGroup } from './types' import { isTuiAgent } from './tui-agent-config' @@ -107,12 +106,11 @@ export function normalizeFolderWorkspaces( continue } const executionHostId = normalizeExecutionHostId(raw.executionHostId) - const group = - resolveFolderWorkspaceProjectGroup(projectGroupIndex, { - projectGroupId: raw.projectGroupId, - connectionId: raw.connectionId, - executionHostId - }) ?? resolveProjectGroupOwner(projectGroupIndex, raw.projectGroupId) + const group = resolveFolderWorkspaceProjectGroup(projectGroupIndex, { + projectGroupId: raw.projectGroupId, + connectionId: raw.connectionId, + executionHostId + }) if (!group?.parentPath) { continue } diff --git a/src/shared/project-groups.test.ts b/src/shared/project-groups.test.ts index 9537caf3c5a..613ec58f931 100644 --- a/src/shared/project-groups.test.ts +++ b/src/shared/project-groups.test.ts @@ -13,6 +13,7 @@ import { resolveFolderWorkspaceProjectGroup, resolveProjectGroupOwner } from './project-groups' +import { normalizeFolderWorkspaces } from './folder-workspaces' import type { ProjectGroup, Repo } from './types' function repo(overrides: Partial): Repo { @@ -162,6 +163,28 @@ describe('project-groups', () => { expect(resolveFolderWorkspaceProjectGroup(index, { projectGroupId: 'same-id' })).toBeNull() }) + it('rejects folder workspaces stamped for missing foreign owners', () => { + const local = projectGroup({ id: 'same-id', name: 'Local', parentPath: '/local' }) + + expect( + normalizeFolderWorkspaces( + [ + { + id: 'runtime-folder', + projectGroupId: 'same-id', + executionHostId: 'runtime:missing' + }, + { + id: 'ssh-folder', + projectGroupId: 'same-id', + connectionId: 'missing' + } + ], + [local] + ) + ).toEqual([]) + }) + it('collects descendants only from the selected owner hierarchy', () => { const localRoot = projectGroup({ id: 'root', name: 'Local root' }) const localChild = projectGroup({ id: 'child', parentGroupId: 'root' }) diff --git a/src/shared/project-groups.ts b/src/shared/project-groups.ts index b779e95c87c..99e093612b6 100644 --- a/src/shared/project-groups.ts +++ b/src/shared/project-groups.ts @@ -17,11 +17,10 @@ export type ProjectGroupOwnerIndex = { export function getProjectGroupOwnerHostId( group: Pick ): ExecutionHostId { - const executionHostId = normalizeExecutionHostId(group.executionHostId) - if (executionHostId) { - return executionHostId - } - return group.connectionId ? toSshExecutionHostId(group.connectionId) : LOCAL_EXECUTION_HOST_ID + return ( + normalizeExecutionHostId(group.executionHostId) ?? + (group.connectionId ? toSshExecutionHostId(group.connectionId) : LOCAL_EXECUTION_HOST_ID) + ) } export function getProjectGroupOwnerIdentity( @@ -99,7 +98,10 @@ export function resolveFolderWorkspaceProjectGroup( ? toSshExecutionHostId(workspace.connectionId) : LOCAL_EXECUTION_HOST_ID const group = resolveProjectGroupMembership(index, workspace.projectGroupId, ownerHostId) - return group ?? resolveProjectGroupOwner(index, workspace.projectGroupId) + // Why: null predates owner stamps; a named SSH owner is authoritative. + return workspace.connectionId === null + ? (group ?? resolveProjectGroupOwner(index, workspace.projectGroupId)) + : group } return resolveProjectGroupOwner(index, workspace.projectGroupId) }