Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
19fdcf6
fix(editor): read files outside projects without a grant a restart loses
brennanb2025 Oct 2, 2026
3ecb4a1
Merge remote-tracking branch 'origin/main' into brennanb2025/editor-r…
brennanb2025 Oct 2, 2026
9e47d99
fix(editor): re-grant restored floating-workspace tabs by owner, not …
brennanb2025 Oct 2, 2026
ff79c83
Merge remote-tracking branch 'origin/main' into brennanb2025/editor-r…
brennanb2025 Oct 2, 2026
303b7ab
fix(editor): let main decide which restored-tab paths a project alrea…
brennanb2025 Oct 2, 2026
7c8350c
fix(fs): decide a restored tab's project membership from every ancest…
brennanb2025 Oct 2, 2026
2038347
Merge remote-tracking branch 'origin/main' into brennanb2025/editor-r…
brennanb2025 Oct 2, 2026
3046896
fix(fs): read local files as regular files only, from one bounded handle
brennanb2025 Oct 2, 2026
8c064cb
feat(fs): let desktop file requests declare their shape
brennanb2025 Oct 2, 2026
9a44e6a
feat(editor): send each local file request's shape from the renderer
brennanb2025 Oct 2, 2026
01efba5
refactor(fs): delete the in-memory path grant system
brennanb2025 Oct 2, 2026
04c86c3
feat(editor): open project links that lead outside the project as nam…
brennanb2025 Oct 2, 2026
f8b42ff
chore: drop the casts the changed-code quality gate flags on this branch
brennanb2025 Oct 2, 2026
af65b5a
Merge remote-tracking branch 'origin/main' into brennanb2025/editor-r…
brennanb2025 Oct 2, 2026
bd9a043
fix(fs): load chat images by type, and keep escaping project links re…
brennanb2025 Oct 2, 2026
607b757
Merge remote-tracking branch 'origin/main' into brennanb2025/editor-r…
brennanb2025 Oct 2, 2026
9f45e17
Merge remote-tracking branch 'origin/main' into brennanb2025/editor-r…
brennanb2025 Oct 2, 2026
d925b86
fix(fs): tighten automatic image loads and the project-link check
brennanb2025 Oct 2, 2026
921953c
Merge remote-tracking branch 'origin/main' into brennanb2025/editor-r…
brennanb2025 Oct 2, 2026
1fae989
refactor(fs): name file access by its role, not its structure
brennanb2025 Oct 2, 2026
d0555ba
Merge remote-tracking branch 'origin/main' into brennanb2025/editor-r…
brennanb2025 Oct 2, 2026
d62f1c9
fix(fs): refuse every Windows device-name spelling in automatic image…
brennanb2025 Oct 2, 2026
c4f9a6a
Merge remote-tracking branch 'origin/main' into brennanb2025/editor-r…
brennanb2025 Oct 2, 2026
8bb88b9
Merge remote-tracking branch 'origin/main' into brennanb2025/editor-r…
brennanb2025 Oct 2, 2026
67bb9ea
fix(editor): let users rename and insert images into files opened out…
brennanb2025 Oct 2, 2026
f0d898c
Merge remote-tracking branch 'origin/main' into brennanb2025/editor-r…
brennanb2025 Oct 2, 2026
d7c34be
fix(fs): a declared file-access kind never refuses what the project c…
brennanb2025 Oct 2, 2026
02f0290
Merge remote-tracking branch 'origin/main' into brennanb2025/editor-r…
brennanb2025 Oct 2, 2026
be3f9bb
fix(fs): symmetric outside-project renames, notebook real folder, sam…
brennanb2025 Oct 2, 2026
41450da
Merge remote-tracking branch 'origin/main' into brennanb2025/editor-r…
brennanb2025 Oct 2, 2026
c2e253d
fix(fs): refuse share images outside projects again; keep renames and…
brennanb2025 Oct 2, 2026
99b1aa4
Merge remote-tracking branch 'origin/main' into brennanb2025/editor-r…
brennanb2025 Oct 2, 2026
0d3db6d
Merge remote-tracking branch 'origin/main' into brennanb2025/editor-r…
brennanb2025 Oct 2, 2026
bbe1afe
fix(fs): a file opened outside every project renames to any path, and…
brennanb2025 Oct 3, 2026
b00383c
Merge remote-tracking branch 'origin/main' into brennanb2025/editor-r…
brennanb2025 Oct 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/main/ai-vault/local-log-tail-reader.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { open } from 'node:fs/promises'
import { LOCAL_READ_OPEN_FLAGS } from '../ipc/filesystem/local-regular-file-read'
import {
LOCAL_LOG_TAIL_CHUNK_BYTES,
type LocalLogTailReadResult
Expand All @@ -23,7 +24,7 @@ export async function readLocalLogTailRange(
throw new Error('Invalid local log tail byte offset')
}

const handle = await open(filePath, 'r')
const handle = await open(filePath, LOCAL_READ_OPEN_FLAGS)
try {
const initialStats = await handle.stat()
if (!initialStats.isFile()) {
Expand Down
1 change: 1 addition & 0 deletions src/main/destination-serialized-local-rename.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const { copyFileMock, handleMock, lstatMock, realpathMock, renameMock } = vi.hoi
const handlers = new Map<string, (_event: unknown, args: unknown) => Promise<unknown>>()

vi.mock('electron', () => ({
app: { getPath: () => '/orca-test-user-data' },
ipcMain: { handle: handleMock }
}))

Expand Down
10 changes: 5 additions & 5 deletions src/main/ipc/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const {
destroySystemTrayMock,
relaunchAppMock,
showOpenDialogMock,
grantFloatingWorkspaceDirectoryMock,
trustFloatingWorkspaceDirectoryMock,
registerRendererShutdownCheckpointHandlerMock,
registerMacKeyboardLayoutChangeNotificationsMock
} = vi.hoisted(() => ({
Expand All @@ -22,7 +22,7 @@ const {
destroySystemTrayMock: vi.fn(),
relaunchAppMock: vi.fn(),
showOpenDialogMock: vi.fn(),
grantFloatingWorkspaceDirectoryMock: vi.fn(),
trustFloatingWorkspaceDirectoryMock: vi.fn(),
registerRendererShutdownCheckpointHandlerMock: vi.fn(),
registerMacKeyboardLayoutChangeNotificationsMock: vi.fn()
}))
Expand Down Expand Up @@ -103,7 +103,7 @@ vi.mock('../app-relaunch', () => ({

vi.mock('./floating-workspace-directory', () => ({
ensureDefaultFloatingWorkspacePath: vi.fn(),
grantFloatingWorkspaceDirectory: grantFloatingWorkspaceDirectoryMock,
trustFloatingWorkspaceDirectory: trustFloatingWorkspaceDirectoryMock,
resolveFloatingTerminalCwd: vi.fn()
}))

Expand Down Expand Up @@ -155,7 +155,7 @@ describe('registerAppHandlers', () => {
relaunchAppMock.mockReset()
relaunchAppMock.mockImplementation(() => appRelaunchMock())
showOpenDialogMock.mockReset()
grantFloatingWorkspaceDirectoryMock.mockReset()
trustFloatingWorkspaceDirectoryMock.mockReset()
registerRendererShutdownCheckpointHandlerMock.mockReset()
registerMacKeyboardLayoutChangeNotificationsMock.mockReset()
for (const probe of Object.values(windowsProbes)) {
Expand Down Expand Up @@ -416,7 +416,7 @@ describe('registerAppHandlers', () => {
expect(showOpenDialogMock).toHaveBeenCalledWith({
properties: ['openDirectory']
})
expect(grantFloatingWorkspaceDirectoryMock).toHaveBeenCalledWith(store, '/Users/kaylee/notes')
expect(trustFloatingWorkspaceDirectoryMock).toHaveBeenCalledWith(store, '/Users/kaylee/notes')
})

// Why: the renderer reads these on every Windows capability refresh; the sync probes
Expand Down
10 changes: 4 additions & 6 deletions src/main/ipc/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,9 @@ import { isWslAvailableAsync, listWslDistrosAsync } from '../wsl'
import { isGitBashAvailable } from '../git-bash'
import { setUnreadDockBadgeCount } from '../dock/unread-badge'
import { destroySystemTray } from '../tray/system-tray'
import { authorizeExternalPath } from './filesystem-auth'
import {
ensureDefaultFloatingWorkspacePath,
grantFloatingWorkspaceDirectory,
trustFloatingWorkspaceDirectory,
resolveFloatingTerminalCwd
} from './floating-workspace-directory'
import { isMarkdownDocumentName, markdownDocumentFromFilePath } from './markdown-documents'
Expand Down Expand Up @@ -60,7 +59,6 @@ async function pickFloatingMarkdownDocument(
if (!isMarkdownDocumentName(filePath)) {
throw new Error('Selected file is not a markdown document.')
}
authorizeExternalPath(filePath)
return markdownDocumentFromFilePath(cwd, filePath, { outsideRootRelativePath: 'basename' })
}

Expand All @@ -70,7 +68,7 @@ async function pickFloatingWorkspaceDirectory(
): Promise<string | null> {
const parentWindow = BrowserWindow.fromWebContents(event.sender)
const options = {
// Why: this picker only grants access to an existing directory; creation belongs to explicit file actions.
// Why: this picker only chooses an existing directory; creation belongs to explicit file actions.
properties: ['openDirectory']
} satisfies Electron.OpenDialogOptions
const result = parentWindow
Expand All @@ -80,8 +78,8 @@ async function pickFloatingWorkspaceDirectory(
return null
}
const selectedDir = result.filePaths[0]
// Why: a user-approved picker selection is a trust grant for later markdown creation, unlike typed settings text.
await grantFloatingWorkspaceDirectory(store, selectedDir)
// Why: only a user-approved picker selection may become the floating terminal's cwd, unlike typed settings text.
await trustFloatingWorkspaceDirectory(store, selectedDir)
return selectedDir
}

Expand Down
47 changes: 47 additions & 0 deletions src/main/ipc/automatic-load-path-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { basename, sep } from 'node:path'
import { parseWslUncPath } from '../../shared/wsl-paths'

// Why the path flavour, not the OS: these are Windows path rules, and tests exercise them with
// path.win32 on any host.
function usesWindowsPaths(): boolean {
return sep === '\\'
}

const WINDOWS_RESERVED_DEVICE_STEM =
/^(?:con|prn|aux|nul|conin\$|conout\$|clock\$|com[0-9¹²³]|lpt[0-9¹²³])$/i

/**
* `NUL.png`, `com1 .jpg`, `Aux.`, `NUL:stream.png` name a Windows device, not a file, whatever the
* extension or alternate data stream (`:`).
*/
export function isWindowsReservedDeviceName(filePath: string): boolean {
if (!usesWindowsPaths()) {
return false
}
const stem =
basename(filePath)
.replace(/[. ]+$/, '')
.split(/[.:]/)[0] ?? ''
return WINDOWS_RESERVED_DEVICE_STEM.test(stem.replace(/ +$/, ''))
}

function toBackslashes(filePath: string): string {
return filePath.replace(/\//g, '\\')
}

/** A Windows device-namespace path (`\\?\`, `\\.\`), which can name devices and shares alike. */
export function isDeviceNamespacePath(filePath: string): boolean {
return usesWindowsPaths() && /^\\\\[?.]\\/.test(toBackslashes(filePath))
}

/**
* A network share (`\\host\share`). WSL paths are UNC in form but stay on this machine, so they
* are not network paths.
*/
export function isNetworkSharePath(filePath: string): boolean {
if (!usesWindowsPaths() || isDeviceNamespacePath(filePath)) {
return false
}
const normalized = toBackslashes(filePath)
return normalized.startsWith('\\\\') && parseWslUncPath(normalized) === null
}
21 changes: 1 addition & 20 deletions src/main/ipc/filesystem-allowed-roots.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import type { ProjectGroup } from '../../shared/project-group-types'
import type { Project } from '../../shared/project-types'
import type { Repo } from '../../shared/repo-types'
import { getAllowedRoots } from './filesystem-allowed-roots'
import { authorizeExternalPath, resolveAuthorizedPath } from './filesystem-auth'
import { resolveAuthorizedPath } from './filesystem-auth'
import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache'
import { computeWorkspaceRoot, getWorktreePathSettings } from './worktree-logic'

Expand Down Expand Up @@ -358,25 +358,6 @@ describe('resolveAuthorizedPath allowed-root reuse', () => {
}
)

it('builds no allowed-root list at all for a granted external path', async () => {
const external = join(outsideRoot, 'external.md')
await writeFile(external, 'notes\n')
authorizeExternalPath(external)
counts.getRepos = 0
counts.getProjects = 0
counts.getFolderWorkspaces = 0

for (let index = 0; index < 5; index += 1) {
await expect(resolveAuthorizedPath(external, store)).resolves.toBe(external)
}

// The grant answers on its own; hoisting the snapshot must not turn zero builds into one per read.
expect.soft(counts.getRepos).toBe(0)
expect.soft(counts.getProjects).toBe(0)
expect.soft(counts.getFolderWorkspaces).toBe(0)
expect.soft(vi.mocked(buildProjectGroupChildIndex)).not.toHaveBeenCalled()
})

it.skipIf(process.platform === 'win32')(
'still refuses a directory symlink that escapes every allowed root',
async () => {
Expand Down
46 changes: 1 addition & 45 deletions src/main/ipc/filesystem-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,7 @@ import type { FolderWorkspace } from '../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../shared/project-group-types'
import type { Repo } from '../../shared/repo-types'
import type { GitWorktreeInfo } from '../../shared/worktree/types'
import {
AUTHORIZED_EXTERNAL_PATHS_MAX,
authorizeExternalPath,
isPathAllowed,
resolveAuthorizedPath
} from './filesystem-auth'
import { resolveAuthorizedPath } from './filesystem-auth'
import { isDescendantOrEqual, validateGitRelativeFilePath } from './filesystem-path-containment'
import {
__resetCreatedWorktreeRootsForTests,
Expand Down Expand Up @@ -419,42 +414,3 @@ describe('filesystem-auth path containment', () => {
}
})
})

describe('filesystem-auth authorized external path bound', () => {
// Empty allow-list store, so a path is allowed only if it (or an ancestor) is
// in the session-authorized external-path set.
const emptyStore = makeStore([])
const flood = (n: number): string =>
resolve(`/leak-audit-ext/flood-${String(n).padStart(6, '0')}`)

it('bounds the authorized external path set with LRU eviction', () => {
const keep = resolve('/leak-audit-ext/keep')
authorizeExternalPath(keep)

// Flood past the cap with distinct external paths, re-authorizing `keep`
// periodically so LRU keeps it hot.
const total = AUTHORIZED_EXTERNAL_PATHS_MAX + 200
for (let i = 0; i < total; i += 1) {
authorizeExternalPath(flood(i))
if (i % 250 === 0) {
authorizeExternalPath(keep)
}
}

// The oldest never-re-touched entries fell out of the bounded set...
expect(isPathAllowed(flood(0), emptyStore)).toBe(false)
// ...while the periodically re-authorized path and the most recent survive.
expect(isPathAllowed(keep, emptyStore)).toBe(true)
expect(isPathAllowed(flood(total - 1), emptyStore)).toBe(true)
})

it('re-authorizes an evicted path on next use (self-healing)', () => {
const path = resolve('/leak-audit-ext/evicted-then-reused')
for (let i = 0; i < AUTHORIZED_EXTERNAL_PATHS_MAX + 50; i += 1) {
authorizeExternalPath(flood(100_000 + i))
}
expect(isPathAllowed(path, emptyStore)).toBe(false)
authorizeExternalPath(path)
expect(isPathAllowed(path, emptyStore)).toBe(true)
})
})
57 changes: 11 additions & 46 deletions src/main/ipc/filesystem-auth.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { resolve, dirname, basename } from 'node:path'
import { realpathSync } from 'node:fs'
import { realpath } from 'node:fs/promises'
import type { Store } from '../persistence'
import { PATH_OUTSIDE_ALLOWED_DIRECTORIES } from '../../shared/local-file-access'
import { getAllowedRoots } from './filesystem-allowed-roots'
import { isDescendantOrEqual, isENOENT, normalizeExistingPath } from './filesystem-path-containment'
import {
Expand All @@ -16,45 +16,16 @@ export { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cach
export { invalidateAuthorizedRootsCacheForRepo } from './registered-worktree-roots-scoped-invalidation'
export { isENOENT } from './filesystem-path-containment'

export const PATH_ACCESS_DENIED_MESSAGE =
'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.'
// Why: authorized external paths accumulate all session; LRU-bound the set. Safe to evict because every caller re-authorizes before operating.
export const AUTHORIZED_EXTERNAL_PATHS_MAX = 4096
const authorizedExternalPaths = new Set<string>()

function rememberAuthorizedExternalPath(path: string): void {
// Delete-then-add makes re-authorized paths most-recent so LRU eviction sheds only the oldest untouched entries.
authorizedExternalPaths.delete(path)
authorizedExternalPaths.add(path)
while (authorizedExternalPaths.size > AUTHORIZED_EXTERNAL_PATHS_MAX) {
const oldest = authorizedExternalPaths.keys().next().value
if (oldest === undefined) {
break
}
authorizedExternalPaths.delete(oldest)
}
}

export function authorizeExternalPath(targetPath: string): void {
const resolvedTarget = resolve(targetPath)
rememberAuthorizedExternalPath(resolvedTarget)
try {
// Why: macOS canonicalizes /tmp to /private/tmp during read authorization.
rememberAuthorizedExternalPath(realpathSync(resolvedTarget))
} catch {}
}

/**
* One allowed-root list shared by every check in a single authorization.
*
* Lazy so a path already covered by an external grant still builds nothing at all, the way it did
* before the list was hoisted out of the individual checks.
*/
export const PATH_ACCESS_DENIED_MESSAGE = `${PATH_OUTSIDE_ALLOWED_DIRECTORIES}. If this blocks a legitimate workflow, please file a GitHub issue.`
/** One allowed-root list shared by every check in a single authorization, built on first use. */
type AllowedRootsSnapshot = { get: () => readonly string[] }

function createAllowedRootsSnapshot(store: Store): AllowedRootsSnapshot {
function createAllowedRootsSnapshot(
store: Store,
extraRoots: readonly string[] = []
): AllowedRootsSnapshot {
let roots: readonly string[] | undefined
return { get: () => (roots ??= getAllowedRoots(store)) }
return { get: () => (roots ??= [...getAllowedRoots(store), ...extraRoots]) }
}

export function isPathAllowed(
Expand All @@ -63,14 +34,6 @@ export function isPathAllowed(
allowedRoots?: AllowedRootsSnapshot
): boolean {
const resolvedTarget = resolve(targetPath)
if (authorizedExternalPaths.has(resolvedTarget)) {
return true
}
for (const authorizedPath of authorizedExternalPaths) {
if (isDescendantOrEqual(resolvedTarget, authorizedPath)) {
return true
}
}
return (allowedRoots?.get() ?? getAllowedRoots(store)).some((root) =>
isDescendantOrEqual(resolvedTarget, root)
)
Expand All @@ -81,6 +44,8 @@ export type ResolveAuthorizedPathOptions = {
* Canonicalize the parent but preserve the leaf so delete/rename target the symlink itself, not its destination (which may live outside allowed roots).
*/
preserveSymlink?: boolean
/** Roots only the desktop window may use (never runtime RPC), checked like any other root. */
extraRoots?: readonly string[]
}

export async function resolveAuthorizedPath(
Expand All @@ -91,7 +56,7 @@ export async function resolveAuthorizedPath(
const resolvedTarget = resolve(targetPath)
// Why: the roots depend only on store state, not on the candidate path, so one snapshot serves
// every authorization below; each candidate is still checked against it in full.
const allowedRoots = createAllowedRootsSnapshot(store)
const allowedRoots = createAllowedRootsSnapshot(store, options.extraRoots)
if (!(await isPathAllowedIncludingRegisteredWorktrees(resolvedTarget, store, { allowedRoots }))) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
}
Expand Down
Loading
Loading