Skip to content

Commit b5ae75a

Browse files
committed
fix(desktop): cancel pending file consent and recheck access
1 parent 9965e85 commit b5ae75a

9 files changed

Lines changed: 284 additions & 59 deletions

File tree

‎apps/desktop/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ Copilot can inspect user-selected local directories through the ordinary VFS too
188188
- **Bound to a live Copilot call:** before a native read/search or browser action, Electron asks the authenticated Sim origin for the pending tool-call record. Local requests must exactly match its persisted operation, path, and options; browser actions run with the persisted arguments rather than renderer-supplied ones. Completed, failed, and aborted runs are rejected.
189189
- **Abort-aware and bounded:** stop/cancel propagates to active native scans and reads. File size, aggregate grep bytes, line, result, traversal-depth, and scan-count limits remain enforced in Electron, and unsafe regular expressions are rejected before execution.
190190

191-
The native `read_local_file` and `import_local_files` tools also accept absolute or `~/` paths. They reuse the same remembered folder grants as the VFS tools. For an unapproved path, Electron displays a bundled, isolated dialog showing the canonical folder and connected server. **Allow folder** grants read and import access to that folder and its subfolders across chats and normal app restarts. A file request proposes its containing folder explicitly; no wider folder is approved silently. Closing or declining the dialog returns no contents. Users can add or forget folders through **File → Folder Access**. As with VFS grants, sign-out and server changes clear access, and unavailable secure storage limits persistence to the app session. New consent prompts are serialized, but reads of approved folders proceed independently.
191+
The native `read_local_file` and `import_local_files` tools also accept absolute or `~/` paths. They reuse the same remembered folder grants as the VFS tools. For an unapproved path, Electron displays a bundled, isolated dialog showing the canonical folder and connected server. **Allow folder** grants read and import access to that folder and its subfolders across chats and normal app restarts. A file request proposes its containing folder explicitly; no wider folder is approved silently. Closing or declining the dialog returns no contents. Users can add or forget folders through **File → Folder Access**. As with VFS grants, sign-out and server changes clear access, and unavailable secure storage limits persistence to the app session. New consent prompts are serialized, but reads of approved folders proceed independently. Existing encrypted path-based approvals retain their scope and acquire folder-identity metadata on their first restore.
192192

193193
Approved native reads can return bounded text, directory listings, images, or PDFs to the chat. Approved imports transfer file bytes to Workspace Files. Electron revalidates every pending call before using a grant, including remembered grants, checks canonical containment and grant identity throughout the operation, and opens files with no-follow and descriptor identity checks. A model or hosted renderer cannot answer the local consent dialog. These permissions govern the native file tools; the separately enabled terminal still runs with the user's OS privileges.
194194

‎apps/desktop/e2e/local-files.spec.ts‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,98 @@ test('native file tools remember folder consent across chats and restarts until
222222
expect(await stale.result).toMatchObject({ ok: false })
223223
}
224224
})
225+
await test.step('a symlink replacement cannot redirect an inspected file', async () => {
226+
const file = realpathSync(join(source, 'report.txt'))
227+
const backup = join(source, 'original-report.txt')
228+
const other = join(source, 'other.txt')
229+
writeFileSync(other, 'different file contents')
230+
await app?.evaluate(
231+
(_electron, paths) => {
232+
const fs = process.getBuiltinModule(
233+
'node:fs/promises'
234+
) as typeof import('node:fs/promises')
235+
const original = fs.stat
236+
fs.stat = (async (...args: Parameters<typeof fs.stat>) => {
237+
const result = await original(...args)
238+
if (args[0] === paths.file) {
239+
fs.stat = original
240+
await fs.rename(paths.file, paths.backup)
241+
await fs.symlink(paths.other, paths.file)
242+
}
243+
return result
244+
}) as typeof fs.stat
245+
},
246+
{ file, backup, other }
247+
)
248+
try {
249+
expect(await invoke({ operation: 'read', toolCallId: 'text' })).toMatchObject({ ok: false })
250+
} finally {
251+
rmSync(file)
252+
renameSync(backup, file)
253+
rmSync(other)
254+
}
255+
})
256+
await test.step('a directory swapped out and back cannot leak outside entries', async () => {
257+
await app?.evaluate(
258+
(_electron, paths) => {
259+
const fs = process.getBuiltinModule(
260+
'node:fs/promises'
261+
) as typeof import('node:fs/promises')
262+
const originalOpen = fs.opendir
263+
const originalRead = fs.readdir
264+
const swapped = async <T>(operation: () => Promise<T>): Promise<T> => {
265+
fs.opendir = originalOpen
266+
fs.readdir = originalRead
267+
await fs.rename(paths.source, paths.backup)
268+
await fs.symlink(paths.outside, paths.source)
269+
try {
270+
return await operation()
271+
} finally {
272+
await fs.rm(paths.source)
273+
await fs.rename(paths.backup, paths.source)
274+
}
275+
}
276+
fs.opendir = (path, options) =>
277+
path === paths.source
278+
? swapped(() => originalOpen(path, options))
279+
: originalOpen(path, options)
280+
fs.readdir = ((...args: Parameters<typeof fs.readdir>) =>
281+
args[0] === paths.source
282+
? swapped(() => originalRead(...args))
283+
: originalRead(...args)) as typeof fs.readdir
284+
},
285+
{ source: realpathSync(source), backup: join(root, 'reports-backup'), outside }
286+
)
287+
expect(await invoke({ operation: 'read', toolCallId: 'directory' })).toMatchObject({
288+
ok: false,
289+
})
290+
})
291+
await test.step('cancelling in the renderer closes consent without remembering access', async () => {
292+
calls.cancelled = {
293+
toolName: 'read_local_file',
294+
args: { path: join(outside, 'private.txt') },
295+
}
296+
const cancelled = await requestPermission({ operation: 'read', toolCallId: 'cancelled' })
297+
const closed = cancelled.prompt.waitForEvent('close', { timeout: 5000 })
298+
await window.evaluate(async () => {
299+
const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
300+
await api.localFiles?.({ operation: 'cancel', toolCallId: 'cancelled' })
301+
})
302+
await closed
303+
expect(await cancelled.result).toMatchObject({ ok: false })
304+
const again = await requestPermission({ operation: 'read', toolCallId: 'cancelled' })
305+
await again.prompt.getByRole('button', { name: "Don't allow", exact: true }).click()
306+
expect(await again.result).toMatchObject({ ok: false })
307+
})
308+
await test.step('consent escapes direction controls in folder names', async () => {
309+
const folder = join(root, 'Bidi\u061c\u200e\u200f')
310+
mkdirSync(folder)
311+
calls.bidi = { toolName: 'read_local_file', args: { path: folder } }
312+
const bidi = await requestPermission({ operation: 'read', toolCallId: 'bidi' })
313+
await expect(bidi.prompt.getByRole('dialog')).toContainText('Bidi\\u061c\\u200e\\u200f')
314+
await bidi.prompt.getByRole('button', { name: "Don't allow", exact: true }).click()
315+
expect(await bidi.result).toMatchObject({ ok: false })
316+
})
225317
await test.step('an unanswered prompt does not block approved folders', async () => {
226318
calls.blocker = { toolName: 'read_local_file', args: { path: join(outside, 'private.txt') } }
227319
const blocker = await requestPermission({ operation: 'read', toolCallId: 'blocker' })

‎apps/desktop/src/main/ipc.ts‎

Lines changed: 52 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,8 @@ async function authorizeLocalFilesystemTool(
598598
*/
599599
export function registerIpcHandlers(deps: IpcDeps): void {
600600
const localFilePermissions = new LocalFilePermissions(deps.localFilesystem)
601+
const activeLocalFiles = new Map<string, Set<AbortController>>()
602+
let activeLocalFileCount = 0
601603
const browserScopeBySender = new WeakMap<WebContents, string>()
602604
const terminalScopeBySender = new WeakMap<WebContents, string>()
603605
const browserPendingScopesBySender = new WeakMap<WebContents, Set<string>>()
@@ -2087,42 +2089,59 @@ export function registerIpcHandlers(deps: IpcDeps): void {
20872089
const generation = captureAccountDataGeneration()
20882090
const origin = deps.appOrigin()
20892091
const request = args[0]
2090-
if (!isRecordLike(request)) return { ok: false, error: 'Invalid local file request.' }
2091-
let failureStatus: number | undefined
2092-
const authorization = await fetchDesktopToolAuthorization(
2093-
event,
2094-
deps,
2095-
request.toolCallId,
2096-
request.operation === 'manifest',
2097-
(status) => {
2098-
failureStatus = status
2099-
}
2100-
)
2101-
if (failureStatus === 409)
2092+
if (!isRecordLike(request) || !isDesktopToolCallId(request.toolCallId))
2093+
return { ok: false, error: 'Invalid local file request.' }
2094+
const key = JSON.stringify([event.sender.id, request.toolCallId])
2095+
if (request.operation === 'cancel') {
2096+
for (const pending of activeLocalFiles.get(key) ?? []) pending.abort()
2097+
return { ok: false, error: 'Local file operation cancelled.' }
2098+
}
2099+
if (activeLocalFileCount >= 128)
21022100
return {
21032101
ok: false,
2104-
code: 'ALREADY_STARTED',
2105-
error: 'This import is already running or was already started.',
2102+
error: 'Too many local file operations are running. Try again later.',
21062103
}
2107-
if (
2108-
!authorization ||
2109-
!['read_local_file', 'import_local_files'].includes(authorization.toolName)
2110-
)
2111-
return { ok: false, error: 'This is not an authorized pending local file tool call.' }
2112-
if (
2113-
authorization.toolName === 'read_local_file'
2114-
? request.operation !== 'read'
2115-
: request.operation !== 'manifest' && request.operation !== 'chunk'
2116-
)
2117-
return { ok: false, error: 'The operation does not match the pending tool call.' }
2118-
const parent = deps.getWindowForContents(event.sender)
2119-
if (!parent)
2120-
return { ok: false, error: 'A desktop window is required to approve file access.' }
2104+
const controller = new AbortController()
2105+
const controllers = activeLocalFiles.get(key) ?? new Set<AbortController>()
2106+
controllers.add(controller)
2107+
activeLocalFiles.set(key, controllers)
2108+
activeLocalFileCount++
21212109
try {
2110+
let failureStatus: number | undefined
2111+
const authorization = await fetchDesktopToolAuthorization(
2112+
event,
2113+
deps,
2114+
request.toolCallId,
2115+
request.operation === 'manifest',
2116+
(status) => {
2117+
failureStatus = status
2118+
}
2119+
)
2120+
if (failureStatus === 409)
2121+
return {
2122+
ok: false,
2123+
code: 'ALREADY_STARTED',
2124+
error: 'This import is already running or was already started.',
2125+
}
2126+
if (
2127+
!authorization ||
2128+
!['read_local_file', 'import_local_files'].includes(authorization.toolName)
2129+
)
2130+
return { ok: false, error: 'This is not an authorized pending local file tool call.' }
2131+
if (
2132+
authorization.toolName === 'read_local_file'
2133+
? request.operation !== 'read'
2134+
: request.operation !== 'manifest' && request.operation !== 'chunk'
2135+
)
2136+
return { ok: false, error: 'The operation does not match the pending tool call.' }
2137+
const parent = deps.getWindowForContents(event.sender)
2138+
if (!parent)
2139+
return { ok: false, error: 'A desktop window is required to approve file access.' }
21222140
const access = await localFilePermissions.authorize(authorization, {
21232141
parent,
21242142
origin,
21252143
generation,
2144+
signal: controller.signal,
21262145
isCurrent: () =>
21272146
isAccountDataGenerationCurrent(generation) &&
21282147
deps.accountDataAvailable() &&
@@ -2134,9 +2153,13 @@ export function registerIpcHandlers(deps: IpcDeps): void {
21342153
await fetchDesktopToolAuthorization(event, deps, request.toolCallId)
21352154
),
21362155
})
2137-
handlerArgs = [request, authorization, access]
2156+
return await spec.handler(event.sender, request, authorization, access)
21382157
} catch (error) {
21392158
return { ok: false, error: getErrorMessage(error) }
2159+
} finally {
2160+
controllers.delete(controller)
2161+
if (controllers.size === 0) activeLocalFiles.delete(key)
2162+
activeLocalFileCount--
21402163
}
21412164
}
21422165
if (spec.passSender) {

‎apps/desktop/src/main/local-file-permissions.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ interface LocalFilePermissionContext {
1313
parent: BrowserWindow
1414
origin: string
1515
generation: number
16+
signal: AbortSignal
1617
isCurrent: () => boolean
1718
revalidate: () => Promise<boolean>
1819
}
@@ -27,6 +28,7 @@ function nativePath(value: unknown): string {
2728
}
2829

2930
function assertCurrent(context: LocalFilePermissionContext): void {
31+
context.signal.throwIfAborted()
3032
if (context.parent.isDestroyed() || !context.isCurrent())
3133
throw new Error('This local file request expired. Ask again in the current chat.')
3234
}
@@ -89,11 +91,12 @@ export class LocalFilePermissions {
8991
const root = await lstat(folder)
9092
if (!root.isDirectory()) throw new Error('The folder is no longer available.')
9193
const displayedPath = JSON.stringify(folder).replace(
92-
/[\u202a-\u202e\u2066-\u2069]/g,
94+
/\p{Bidi_Control}/gu,
9395
(character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`
9496
)
9597
assertCurrent(context)
9698
const result = await showShellDialog(context.parent, {
99+
signal: context.signal,
97100
title: 'Allow access to this folder?',
98101
message: displayedPath,
99102
detail: `Sim can read files in this folder and its subfolders, use them across chats, and import them into your workspaces on ${context.origin}.\n\nManage or remove access in File → Folder Access.`,

0 commit comments

Comments
 (0)