Skip to content

Commit ce6f05b

Browse files
committed
fix(desktop): reject replaced directory listings
1 parent 94a8dad commit ce6f05b

3 files changed

Lines changed: 39 additions & 2 deletions

File tree

‎apps/desktop/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ Copilot can inspect user-selected local directories through the ordinary VFS too
190190

191191
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

193-
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. Directory enumeration uses `fdopendir` on the verified descriptor, so replacing a parent path cannot redirect the listing. 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.
193+
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. Directory enumeration uses `fdopendir` on the verified descriptor, so replacing a parent path cannot redirect the listing. Listings scan at most 1,001 entries and return up to 1,000 sorted names with an explicit truncation flag; the cap bounds both memory and filesystem work, rather than promising the globally first 1,000 names in an arbitrarily large directory. Imports reject truncated listings. 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

195195
## Auto-update, channels, rollout, rollback
196196

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,40 @@ test('native file tools remember folder consent across chats and restarts until
317317
rmSync(otherParent, { recursive: true, force: true })
318318
}
319319
})
320+
await test.step('a replaced directory cannot return a listing for its old contents', async () => {
321+
const directory = join(realpathSync(source), 'replace-during-read')
322+
const backup = join(realpathSync(source), 'previous-directory')
323+
mkdirSync(directory)
324+
writeFileSync(join(directory, 'old.txt'), 'old contents')
325+
calls.directoryReplaced = { toolName: 'read_local_file', args: { path: directory } }
326+
await app?.evaluate(
327+
(_electron, paths) => {
328+
const fs = process.getBuiltinModule(
329+
'node:fs/promises'
330+
) as typeof import('node:fs/promises')
331+
const original = fs.lstat
332+
fs.lstat = (async (...args: Parameters<typeof fs.lstat>) => {
333+
const result = await original(...args)
334+
if (args[0] === paths.directory) {
335+
fs.lstat = original
336+
await fs.rename(paths.directory, paths.backup)
337+
await fs.mkdir(paths.directory)
338+
await fs.writeFile(`${paths.directory}/new.txt`, 'new contents')
339+
}
340+
return result
341+
}) as typeof fs.lstat
342+
},
343+
{ directory, backup }
344+
)
345+
try {
346+
expect(await invoke({ operation: 'read', toolCallId: 'directoryReplaced' })).toMatchObject({
347+
ok: false,
348+
})
349+
} finally {
350+
rmSync(directory, { recursive: true, force: true })
351+
rmSync(backup, { recursive: true, force: true })
352+
}
353+
})
320354
await test.step('cancelling after a file opens prevents its contents from returning', async () => {
321355
await app?.evaluate(
322356
(_electron, path) => {

‎apps/desktop/src/main/local-files.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,10 @@ async function readApprovedDirectory(path: string, access: LocalFileAccess) {
7474
const handle = await openApprovedPath(path, access, true)
7575
try {
7676
const listing = await readNativeDirectory(handle.fd, MAX_ENTRIES)
77-
if ((await access.resolve(path)) !== path)
77+
const canonical = await access.resolve(path)
78+
const current = await lstat(canonical)
79+
const opened = await handle.stat()
80+
if (canonical !== path || current.dev !== opened.dev || current.ino !== opened.ino)
7881
throw new Error('The local directory changed while it was being read. Try again.')
7982
listing.entries.sort((left, right) => compareStrings(left.name, right.name))
8083
return listing

0 commit comments

Comments
 (0)