From b2831c20d9c39aaec053672161f9ce4791374f43 Mon Sep 17 00:00:00 2001 From: Sylwester Liljegren Date: Wed, 8 Jul 2026 02:12:15 +0200 Subject: [PATCH 1/5] feat(vscode): add file picker to @ mention dropdown --- .changeset/vscode-file-picker-mention.md | 5 + packages/kilo-vscode/src/KiloProvider.ts | 13 + .../tests/unit/file-mention-utils.test.ts | 33 ++- .../tests/unit/use-file-mention.test.ts | 236 +++++++++++++++++- .../src/components/chat/PromptInput.tsx | 83 +++--- .../src/hooks/file-mention-utils.ts | 17 +- .../webview-ui/src/hooks/useFileMention.ts | 50 +++- .../src/styles/prompt-dropdowns.css | 7 + .../src/types/messages/extension-messages.ts | 6 + .../src/types/messages/webview-messages.ts | 5 + 10 files changed, 407 insertions(+), 48 deletions(-) create mode 100644 .changeset/vscode-file-picker-mention.md diff --git a/.changeset/vscode-file-picker-mention.md b/.changeset/vscode-file-picker-mention.md new file mode 100644 index 00000000000..760863bcef5 --- /dev/null +++ b/.changeset/vscode-file-picker-mention.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add a file picker option to the @ mention dropdown in the VS Code extension prompt input, allowing users to attach files from outside the current workspace. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 2d85c39ac24..9801d43ba3e 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -1258,6 +1258,19 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper post: (msg) => this.postMessage(msg), }) break + case "requestFilePicker": { + const uri = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + openLabel: "Select file", + }) + this.postMessage({ + type: "filePickerResult", + path: uri && uri[0] ? uri[0].fsPath : "", + }) + break + } case "requestTerminalContext": void this.handleTerminalContext(message.requestId) break diff --git a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts index 6d5083bd663..8b7befb9e5d 100644 --- a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts @@ -9,6 +9,7 @@ import { getMentionRemovalRange, isCursorAtMentionEnd, findMentionRange, + FILE_PICKER_RESULT, } from "../../webview-ui/src/hooks/file-mention-utils" describe("AT_PATTERN", () => { @@ -53,32 +54,37 @@ describe("buildMentionResults", () => { it("includes terminal for matching prefix", () => { const result = buildMentionResults("term", ["src/terminal.ts"]) - expect(result.map((item) => item.type)).toEqual(["terminal", "file"]) + expect(result.map((item) => item.type)).toEqual(["terminal", "file-picker", "file"]) }) it("includes git changes for matching prefix", () => { const result = buildMentionResults("git", ["src/git.ts"]) - expect(result.map((item) => item.type)).toEqual(["git-changes", "file"]) + expect(result.map((item) => item.type)).toEqual(["git-changes", "file-picker", "file"]) }) it("omits special mentions for unrelated query", () => { const result = buildMentionResults("src", ["src/index.ts"]) - expect(result.map((item) => item.type)).toEqual(["file"]) + expect(result.map((item) => item.type)).toEqual(["file-picker", "file"]) }) it("omits git changes when git is unavailable", () => { const result = buildMentionResults("git", ["src/git.ts"], false) - expect(result.map((item) => item.type)).toEqual(["file"]) + expect(result.map((item) => item.type)).toEqual(["file-picker", "file"]) }) it("includes folder results", () => { const result = buildMentionResults("src", [{ path: "src", type: "folder" }]) - expect(result).toEqual([{ type: "folder", value: "src" }]) + expect(result).toEqual([FILE_PICKER_RESULT, { type: "folder", value: "src" }]) }) it("preserves opened file result type", () => { const result = buildMentionResults("src", [{ path: "src/index.ts", type: "opened-file" }]) - expect(result).toEqual([{ type: "opened-file", value: "src/index.ts" }]) + expect(result).toEqual([FILE_PICKER_RESULT, { type: "opened-file", value: "src/index.ts" }]) + }) + + it("always includes file picker result", () => { + const result = buildMentionResults("", []) + expect(result[result.length - 1]).toEqual(FILE_PICKER_RESULT) }) }) @@ -87,8 +93,14 @@ describe("filterMentionResults", () => { const result = filterMentionResults("gi", [ { type: "file", value: "README.md" }, { type: "file", value: "src/git.ts" }, + FILE_PICKER_RESULT, ]) - expect(result).toEqual([{ type: "file", value: "src/git.ts" }]) + expect(result).toEqual([{ type: "file", value: "src/git.ts" }, FILE_PICKER_RESULT]) + }) + + it("always preserves file picker result regardless of query", () => { + const result = filterMentionResults("zz", [FILE_PICKER_RESULT]) + expect(result).toEqual([FILE_PICKER_RESULT]) }) }) @@ -242,6 +254,13 @@ describe("buildFileAttachments", () => { const result = buildFileAttachments("@foo.ts", paths, "C:\\Users\\workspace") expect(result[0]!.url).not.toContain("\\") }) + + it("handles Windows absolute paths directly", () => { + const paths = new Set(["C:/Users/file.ts"]) + const result = buildFileAttachments("@C:/Users/file.ts", paths, "/workspace") + expect(result).toHaveLength(1) + expect(result[0]!.url).toContain("C:/Users/file.ts") + }) }) describe("getMentionRemovalRange", () => { diff --git a/packages/kilo-vscode/tests/unit/use-file-mention.test.ts b/packages/kilo-vscode/tests/unit/use-file-mention.test.ts index cb06763a7d8..6d8d1401537 100644 --- a/packages/kilo-vscode/tests/unit/use-file-mention.test.ts +++ b/packages/kilo-vscode/tests/unit/use-file-mention.test.ts @@ -1,8 +1,30 @@ import { describe, expect, it } from "bun:test" import { createRoot } from "solid-js" import { useFileMention } from "../../webview-ui/src/hooks/useFileMention" +import { FILE_PICKER_RESULT } from "../../webview-ui/src/hooks/file-mention-utils" import type { ExtensionMessage, WebviewMessage } from "../../webview-ui/src/types/messages" +declare global { + // eslint-disable-next-line no-var + var document: { execCommand: (commandId: string, showUI?: boolean, value?: string) => boolean } +} + +const hadDoc = "document" in globalThis +const originalDoc = hadDoc ? globalThis.document : undefined + +function mockDocument() { + globalThis.document = { execCommand: () => true } +} + +function restoreDocument() { + if (hadDoc && originalDoc) { + globalThis.document = originalDoc + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (globalThis as any).document + } +} + const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) function textarea(value: string, cursor: number, dir: "ltr" | "rtl") { @@ -68,11 +90,17 @@ describe("useFileMention", () => { }) } - expect(mention.mentionResults()).toEqual([{ type: "opened-file", value: "packages/kilo-vscode/src/extension.ts" }]) + expect(mention.mentionResults()).toEqual([ + FILE_PICKER_RESULT, + { type: "opened-file", value: "packages/kilo-vscode/src/extension.ts" }, + ]) mention.onInput("@ex", 3) - expect(mention.mentionResults()).toEqual([{ type: "opened-file", value: "packages/kilo-vscode/src/extension.ts" }]) + expect(mention.mentionResults()).toEqual([ + FILE_PICKER_RESULT, + { type: "opened-file", value: "packages/kilo-vscode/src/extension.ts" }, + ]) dispose.fn?.() }) @@ -109,7 +137,7 @@ describe("useFileMention", () => { mention.onInput("@zz", 3) - expect(mention.mentionResults()).toEqual([]) + expect(mention.mentionResults()).toEqual([FILE_PICKER_RESULT]) dispose.fn?.() }) @@ -210,7 +238,7 @@ describe("useFileMention", () => { mention.onInput("@gi", 3) - expect(mention.mentionResults()).toEqual([{ type: "file", value: "src/git.ts" }]) + expect(mention.mentionResults()).toEqual([FILE_PICKER_RESULT, { type: "file", value: "src/git.ts" }]) dispose.fn?.() }) @@ -272,4 +300,204 @@ describe("useFileMention", () => { dispose.fn?.() }) + + it("selecting file picker sends requestFilePicker and stores state", async () => { + const posted: WebviewMessage[] = [] + const ctx = { + postMessage: (message: WebviewMessage) => posted.push(message), + onMessage: () => () => {}, + } + + const dispose: { fn?: () => void } = {} + const mention = createRoot((root) => { + dispose.fn = root + return useFileMention(ctx, undefined, () => false) + }) + + const state = { value: "hello @b", cursor: 8 } + const input = { + value: state.value, + get selectionStart() { + return state.cursor + }, + get selectionEnd() { + return state.cursor + }, + isConnected: true, + setSelectionRange: (start: number, end: number) => { + state.cursor = end + }, + focus: () => {}, + } as unknown as HTMLTextAreaElement + + let execCalled = false + mockDocument() + globalThis.document.execCommand = () => { + execCalled = true + return true + } + + try { + mention.selectMention( + { type: "file-picker", value: "file-picker", label: "Browse", description: "" }, + input, + () => {}, + ) + } finally { + restoreDocument() + } + + expect(posted).toEqual([{ type: "requestFilePicker" }]) + expect(execCalled).toBe(false) + + dispose.fn?.() + }) + + it("insertFilePickerResult inserts the path at the stored position", () => { + const ctx = { + postMessage: () => {}, + onMessage: () => () => {}, + } + + const dispose: { fn?: () => void } = {} + const mention = createRoot((root) => { + dispose.fn = root + return useFileMention(ctx, undefined, () => false) + }) + + const state = { value: "hello @b", cursor: 8, textSet: "" } + const input = { + get value() { + return state.value + }, + get selectionStart() { + return state.cursor + }, + get selectionEnd() { + return state.cursor + }, + isConnected: true, + setSelectionRange: (start: number, end: number) => { + state.value = state.value.slice(0, start) + state.value.slice(end) + state.cursor = start + }, + focus: () => {}, + } as unknown as HTMLTextAreaElement + + mockDocument() + globalThis.document.execCommand = (_cmd: string, _show: boolean, val: string) => { + state.value = state.value.slice(0, state.cursor) + val + state.value.slice(state.cursor) + state.cursor = state.cursor + val.length + return true + } + + try { + mention.selectMention( + { type: "file-picker", value: "file-picker", label: "Browse", description: "" }, + input, + (text: string) => { + state.textSet = text + }, + ) + mention.insertFilePickerResult("/outside/file.ts") + } finally { + restoreDocument() + } + + expect(state.value).toBe("hello @/outside/file.ts ") + expect(mention.mentionedPaths().has("/outside/file.ts")).toBe(true) + expect(state.textSet).toBe("hello @/outside/file.ts ") + + dispose.fn?.() + }) + + it("insertFilePickerResult normalizes Windows backslashes to forward slashes", () => { + const ctx = { + postMessage: () => {}, + onMessage: () => () => {}, + } + + const dispose: { fn?: () => void } = {} + const mention = createRoot((root) => { + dispose.fn = root + return useFileMention(ctx, undefined, () => false) + }) + + const state = { value: "hello @b", cursor: 8, textSet: "" } + const input = { + get value() { + return state.value + }, + get selectionStart() { + return state.cursor + }, + get selectionEnd() { + return state.cursor + }, + isConnected: true, + setSelectionRange: (start: number, end: number) => { + state.value = state.value.slice(0, start) + state.value.slice(end) + state.cursor = start + }, + focus: () => {}, + } as unknown as HTMLTextAreaElement + + mockDocument() + globalThis.document.execCommand = (_cmd: string, _show: boolean, val: string) => { + state.value = state.value.slice(0, state.cursor) + val + state.value.slice(state.cursor) + state.cursor = state.cursor + val.length + return true + } + + try { + mention.selectMention( + { type: "file-picker", value: "file-picker", label: "Browse", description: "" }, + input, + (text: string) => { + state.textSet = text + }, + ) + mention.insertFilePickerResult("C:\\Users\\file.ts") + } finally { + restoreDocument() + } + + expect(state.value).toBe("hello @C:/Users/file.ts ") + expect(mention.mentionedPaths().has("C:/Users/file.ts")).toBe(true) + + dispose.fn?.() + }) + + it("insertFilePickerResult with empty path cleans up state", () => { + const ctx = { + postMessage: () => {}, + onMessage: () => () => {}, + } + + const dispose: { fn?: () => void } = {} + const mention = createRoot((root) => { + dispose.fn = root + return useFileMention(ctx, undefined, () => false) + }) + + const input = { + value: "hello @b", + selectionStart: 8, + selectionEnd: 8, + isConnected: true, + setSelectionRange: () => {}, + focus: () => {}, + } as unknown as HTMLTextAreaElement + + mention.selectMention( + { type: "file-picker", value: "file-picker", label: "Browse", description: "" }, + input, + () => {}, + ) + mention.insertFilePickerResult("") + + expect(input.value).toBe("hello @b") + + dispose.fn?.() + }) }) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index d9c346cb4b8..aa6e19da5a2 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -695,6 +695,10 @@ export const PromptInput: Component = (props) => { setEnhancing(false) } } + + if (message.type === "filePickerResult") { + mention.insertFilePickerResult(message.path) + } }) vscode.postMessage({ type: "requestAutoApproveState" }) @@ -1058,40 +1062,51 @@ export const PromptInput: Component = (props) => { > {(item, index) => ( -
{ - e.preventDefault() - if (textareaRef) mention.selectMention(item, textareaRef, setText, adjustHeight) - }} - onMouseEnter={() => mention.setMentionIndex(index())} - > - {item.type === "terminal" ? ( - <> - - {item.label} - {item.description} - - ) : item.type === "git-changes" ? ( - <> - - {item.label} - {item.description} - - ) : ( - <> - - - {item.type === "folder" ? `${fileName(item.value)}/` : fileName(item.value)} - - {dirName(item.value)} - - )} -
+ <> +
{ + e.preventDefault() + if (textareaRef) mention.selectMention(item, textareaRef, setText, adjustHeight) + }} + onMouseEnter={() => mention.setMentionIndex(index())} + > + {item.type === "terminal" ? ( + <> + + {item.label} + {item.description} + + ) : item.type === "git-changes" ? ( + <> + + {item.label} + {item.description} + + ) : item.type === "file-picker" ? ( + <> + + {item.label} + {item.description} + + ) : ( + <> + + + {item.type === "folder" ? `${fileName(item.value)}/` : fileName(item.value)} + + {dirName(item.value)} + + )} +
+ 1}> +
+ + )} diff --git a/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts b/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts index 6de5511815b..b166c11507e 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts @@ -10,6 +10,7 @@ export type MentionResult = | { type: "file"; value: string } | { type: "opened-file"; value: string } | { type: "folder"; value: string } + | { type: "file-picker"; value: "file-picker"; label: string; description: string } export const TERMINAL_RESULT: MentionResult = { type: "terminal", @@ -25,6 +26,13 @@ export const GIT_CHANGES_RESULT: MentionResult = { description: "Current session/worktree changes", } +export const FILE_PICKER_RESULT: MentionResult = { + type: "file-picker", + value: "file-picker", + label: "Browse files...", + description: "Select a file outside the workspace", +} + /** * Escape special regex characters in a string so it can be used in a RegExp. */ @@ -51,7 +59,7 @@ export function buildMentionResults(query: string, items: Array { if (item.type === "terminal") return TERMINAL_MENTION.startsWith(value) if (item.type === "git-changes") return GIT_CHANGES_MENTION.startsWith(value) || "git".startsWith(value) + if (item.type === "file-picker") return true return item.value.toLowerCase().includes(value) }) } @@ -166,6 +175,10 @@ export function findMentionRange( return null } +function isAbsolutePath(path: string): boolean { + return path.startsWith("/") || /^[A-Za-z]:[\\\/]/.test(path) +} + /** * Build FileAttachment objects from currently mentioned paths in the text. */ @@ -178,7 +191,7 @@ export function buildFileAttachments( const dir = workspaceDir.replaceAll("\\", "/") for (const path of mentionedPaths) { if (text.includes(`@${path}`)) { - const abs = path.startsWith("/") ? path : `${dir}/${path}` + const abs = isAbsolutePath(path) ? path : `${dir}/${path}` const url = new URL("file://") url.pathname = abs.startsWith("/") ? abs : `/${abs}` result.push({ mime: "text/plain", url: url.href }) diff --git a/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts b/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts index cc9ca92def5..22030910157 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts @@ -10,6 +10,7 @@ import { isCursorAtMentionEnd, getMentionRemovalRange, findMentionRange, + FILE_PICKER_RESULT, type MentionResult, } from "./file-mention-utils" @@ -71,6 +72,8 @@ export interface FileMention { snapSelection: (textarea: HTMLTextAreaElement) => void /** Seed known paths from existing text (e.g. after undo restores a draft). */ seedFromText: (text: string) => void + /** Insert a file-picker result at the stored cursor position. */ + insertFilePickerResult: (path: string) => void } export function useFileMention( @@ -89,6 +92,13 @@ export function useFileMention( let fileSearchTimer: ReturnType | undefined let fileSearchCounter = 0 + let pickerState: { + textarea: HTMLTextAreaElement + atStart: number + atEnd: number + setText: (text: string) => void + onSelect?: () => void + } | null = null const showMention = () => mentionQuery() !== null @@ -145,6 +155,16 @@ export function useFileMention( const before = val.substring(0, cursor) const after = val.substring(cursor) + if (result.type === "file-picker") { + const match = before.match(AT_PATTERN)! + const prefix = /^\s/.test(match[0]) ? 1 : 0 + const atPos = match.index! + prefix + pickerState = { textarea, atStart: atPos, atEnd: cursor, setText: _setText, onSelect } + closeMention() + vscode.postMessage({ type: "requestFilePicker" }) + return + } + // Add to knownPaths BEFORE execCommand so syncMentionedPaths (triggered // by the input event) can discover the new path. if (result.type === "file" || result.type === "folder" || result.type === "opened-file") @@ -324,7 +344,7 @@ export function useFileMention( } const seedFromText = (text: string) => { - const re = /@([\w./-]+\.[\w]+|[\w.-]+\/[\w./-]+)/g + const re = /@([\w.:/-]+\.[\w]+|[\w.:-]+\/[\w.:/-]+)/g let m: RegExpExecArray | null while ((m = re.exec(text))) { knownPaths.add(m[1]) @@ -332,6 +352,33 @@ export function useFileMention( syncMentionedPaths(text) } + const insertFilePickerResult = (path: string) => { + if (!path) { + pickerState = null + return + } + const norm = path.replaceAll("\\", "/") + const state = pickerState + if (!state) return + pickerState = null + const textarea = state.textarea + if (!textarea.isConnected) return + const after = textarea.value.substring(state.atEnd) + const suffix = /^\s/.test(after) ? "" : " " + suppress = true + try { + textarea.setSelectionRange(state.atStart, state.atEnd) + document.execCommand("insertText", false, `@${norm}${suffix}`) + } finally { + suppress = false + } + knownPaths.add(norm) + setMentionedPaths((prev) => new Set([...prev, norm])) + syncMentionedPaths(textarea.value) + state.setText(textarea.value) + state.onSelect?.() + } + return { mentionedPaths, mentionResults, @@ -348,5 +395,6 @@ export function useFileMention( handleArrowKey, snapSelection, seedFromText, + insertFilePickerResult, } } diff --git a/packages/kilo-vscode/webview-ui/src/styles/prompt-dropdowns.css b/packages/kilo-vscode/webview-ui/src/styles/prompt-dropdowns.css index 70a9385bd31..59049f53d50 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/prompt-dropdowns.css +++ b/packages/kilo-vscode/webview-ui/src/styles/prompt-dropdowns.css @@ -64,6 +64,13 @@ text-align: center; } +.file-mention-separator { + height: 1px; + margin: 4px 10px; + background: var(--vscode-editorWidget-border, var(--vscode-input-border)); + opacity: 0.5; +} + /* ============================================ Slash Command Dropdown ============================================ */ diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index 75aa20b8aaa..3158a5fb59e 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -431,6 +431,11 @@ export interface FileSearchResultMessage { requestId: string } +export interface FilePickerResultMessage { + type: "filePickerResult" + path: string +} + export interface TerminalContextResultMessage { type: "terminalContextResult" requestId: string @@ -1110,6 +1115,7 @@ export type ExtensionMessage = | SpeechToTextResultMessage | SpeechToTextErrorMessage | FileSearchResultMessage + | FilePickerResultMessage | TerminalContextResultMessage | TerminalContextErrorMessage | GitChangesContextResultMessage diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index 6bf6ea39e9a..aa8199e95d0 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -396,6 +396,10 @@ export interface RequestFileSearchMessage { sessionID?: string } +export interface RequestFilePickerMessage { + type: "requestFilePicker" +} + export interface RequestTerminalContextMessage { type: "requestTerminalContext" requestId: string @@ -1236,6 +1240,7 @@ export type WebviewMessage = | SpeechToTextStopMessage | SpeechToTextCancelMessage | RequestFileSearchMessage + | RequestFilePickerMessage | RequestTerminalContextMessage | RequestGitChangesContextMessage | ChatCompletionAcceptedMessage From 4b7fcee2f413c448b671b7485ffb82d524ea99d6 Mon Sep 17 00:00:00 2001 From: Sylwester Liljegren Date: Wed, 8 Jul 2026 02:39:38 +0200 Subject: [PATCH 2/5] fix(vscode): address file-picker mention review feedback --- packages/kilo-vscode/src/KiloProvider.ts | 2 +- .../src/kilo-provider/file-picker.ts | 7 ++- .../tests/unit/file-mention-utils.test.ts | 13 ++++- .../tests/unit/use-file-mention.test.ts | 55 ++++++++++++++++--- .../src/components/chat/PromptInput.tsx | 4 +- .../src/hooks/file-mention-utils.ts | 7 ++- .../webview-ui/src/hooks/useFileMention.ts | 24 +++++--- .../src/types/messages/extension-messages.ts | 1 + .../src/types/messages/webview-messages.ts | 1 + 9 files changed, 91 insertions(+), 23 deletions(-) diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 0c946ef38c9..f3f8d7e0157 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -1273,7 +1273,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper }) break case "requestFilePicker": - await handleFilePicker({ post: (msg) => this.postMessage(msg) }) + await handleFilePicker({ requestId: message.requestId, post: (msg) => this.postMessage(msg) }) break case "requestTerminalContext": void this.handleTerminalContext(message.requestId) diff --git a/packages/kilo-vscode/src/kilo-provider/file-picker.ts b/packages/kilo-vscode/src/kilo-provider/file-picker.ts index 4ad45ab36e3..7256fe1ecdb 100644 --- a/packages/kilo-vscode/src/kilo-provider/file-picker.ts +++ b/packages/kilo-vscode/src/kilo-provider/file-picker.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode" type Input = { + requestId: string post: (message: unknown) => void } @@ -11,5 +12,9 @@ export async function handleFilePicker(input: Input): Promise { canSelectMany: false, openLabel: "Select file", }) - input.post({ type: "filePickerResult", path: uri && uri[0] ? uri[0].fsPath : "" }) + input.post({ + type: "filePickerResult", + path: uri && uri[0] ? uri[0].fsPath : "", + requestId: input.requestId, + }) } diff --git a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts index 8b7befb9e5d..c4fa2c34235 100644 --- a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts @@ -10,6 +10,8 @@ import { isCursorAtMentionEnd, findMentionRange, FILE_PICKER_RESULT, + TERMINAL_RESULT, + GIT_CHANGES_RESULT, } from "../../webview-ui/src/hooks/file-mention-utils" describe("AT_PATTERN", () => { @@ -82,9 +84,14 @@ describe("buildMentionResults", () => { expect(result).toEqual([FILE_PICKER_RESULT, { type: "opened-file", value: "src/index.ts" }]) }) - it("always includes file picker result", () => { - const result = buildMentionResults("", []) - expect(result[result.length - 1]).toEqual(FILE_PICKER_RESULT) + it("always includes file picker result, placed after terminal/git-changes and before file results", () => { + const result = buildMentionResults("", ["src/index.ts"]) + expect(result).toEqual([ + TERMINAL_RESULT, + GIT_CHANGES_RESULT, + FILE_PICKER_RESULT, + { type: "file", value: "src/index.ts" }, + ]) }) }) diff --git a/packages/kilo-vscode/tests/unit/use-file-mention.test.ts b/packages/kilo-vscode/tests/unit/use-file-mention.test.ts index 04cfcb75a92..06364592ee0 100644 --- a/packages/kilo-vscode/tests/unit/use-file-mention.test.ts +++ b/packages/kilo-vscode/tests/unit/use-file-mention.test.ts @@ -470,15 +470,16 @@ describe("useFileMention", () => { restoreDocument() } - expect(posted).toEqual([{ type: "requestFilePicker" }]) + expect(posted).toEqual([{ type: "requestFilePicker", requestId: expect.any(String) }]) expect(execCalled).toBe(false) dispose.fn?.() }) it("insertFilePickerResult inserts the path at the stored position", () => { + const posted: WebviewMessage[] = [] const ctx = { - postMessage: () => {}, + postMessage: (message: WebviewMessage) => posted.push(message), onMessage: () => () => {}, } @@ -522,7 +523,8 @@ describe("useFileMention", () => { state.textSet = text }, ) - mention.insertFilePickerResult("/outside/file.ts") + const requestId = (posted.at(-1) as { requestId: string }).requestId + mention.insertFilePickerResult("/outside/file.ts", requestId) } finally { restoreDocument() } @@ -535,8 +537,9 @@ describe("useFileMention", () => { }) it("insertFilePickerResult normalizes Windows backslashes to forward slashes", () => { + const posted: WebviewMessage[] = [] const ctx = { - postMessage: () => {}, + postMessage: (message: WebviewMessage) => posted.push(message), onMessage: () => () => {}, } @@ -580,7 +583,8 @@ describe("useFileMention", () => { state.textSet = text }, ) - mention.insertFilePickerResult("C:\\Users\\file.ts") + const requestId = (posted.at(-1) as { requestId: string }).requestId + mention.insertFilePickerResult("C:\\Users\\file.ts", requestId) } finally { restoreDocument() } @@ -592,8 +596,44 @@ describe("useFileMention", () => { }) it("insertFilePickerResult with empty path cleans up state", () => { + const posted: WebviewMessage[] = [] const ctx = { - postMessage: () => {}, + postMessage: (message: WebviewMessage) => posted.push(message), + onMessage: () => () => {}, + } + + const dispose: { fn?: () => void } = {} + const mention = createRoot((root) => { + dispose.fn = root + return useFileMention(ctx, undefined, () => false) + }) + + const input = { + value: "hello @b", + selectionStart: 8, + selectionEnd: 8, + isConnected: true, + setSelectionRange: () => {}, + focus: () => {}, + } as unknown as HTMLTextAreaElement + + mention.selectMention( + { type: "file-picker", value: "file-picker", label: "Browse", description: "" }, + input, + () => {}, + ) + const requestId = (posted.at(-1) as { requestId: string }).requestId + mention.insertFilePickerResult("", requestId) + + expect(input.value).toBe("hello @b") + + dispose.fn?.() + }) + + it("insertFilePickerResult ignores a result whose requestId doesn't match the pending request", () => { + const posted: WebviewMessage[] = [] + const ctx = { + postMessage: (message: WebviewMessage) => posted.push(message), onMessage: () => () => {}, } @@ -617,9 +657,10 @@ describe("useFileMention", () => { input, () => {}, ) - mention.insertFilePickerResult("") + mention.insertFilePickerResult("/outside/file.ts", "stale-request-id") expect(input.value).toBe("hello @b") + expect(mention.mentionedPaths().has("/outside/file.ts")).toBe(false) dispose.fn?.() }) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index db4b769fd3a..89e6a6edc27 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -700,7 +700,7 @@ export const PromptInput: Component = (props) => { } if (message.type === "filePickerResult") { - mention.insertFilePickerResult(message.path) + mention.insertFilePickerResult(message.path, message.requestId) } }) vscode.postMessage({ type: "requestAutoApproveState" }) @@ -1158,7 +1158,7 @@ export const PromptInput: Component = (props) => { )}
- 1}> +
diff --git a/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts b/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts index b166c11507e..3beca7245ab 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts @@ -59,7 +59,12 @@ export function buildMentionResults(query: string, items: Array void /** Seed known paths from existing text (e.g. after undo restores a draft). */ seedFromText: (text: string) => void - /** Insert a file-picker result at the stored cursor position. */ - insertFilePickerResult: (path: string) => void + /** Insert a file-picker result at the stored cursor position. Ignored unless requestId matches the pending request. */ + insertFilePickerResult: (path: string, requestId: string) => void } export function useFileMention( @@ -86,7 +86,9 @@ export function useFileMention( let fileSearchTimer: ReturnType | undefined let fileSearchCounter = 0 + let filePickerCounter = 0 let pickerState: { + requestId: string textarea: HTMLTextAreaElement atStart: number atEnd: number @@ -155,9 +157,11 @@ export function useFileMention( const match = before.match(AT_PATTERN)! const prefix = /^\s/.test(match[0]) ? 1 : 0 const atPos = match.index! + prefix - pickerState = { textarea, atStart: atPos, atEnd: cursor, setText: _setText, onSelect } + filePickerCounter++ + const requestId = `file-picker-${filePickerCounter}` + pickerState = { requestId, textarea, atStart: atPos, atEnd: cursor, setText: _setText, onSelect } closeMention() - vscode.postMessage({ type: "requestFilePicker" }) + vscode.postMessage({ type: "requestFilePicker", requestId }) return } @@ -377,7 +381,10 @@ export function useFileMention( } const seedFromText = (text: string) => { - const re = /@([\w.:/-]+\.[\w]+|[\w.:-]+\/[\w.:/-]+)/g + // The optional drive-letter prefix is scoped to a single letter directly after + // @ (e.g. "C:") so a colon elsewhere in the match (as in "@https://example.com") + // doesn't get mistaken for a Windows path. + const re = /@((?:[A-Za-z]:)?(?:[\w./-]+\.[\w]+|[\w.-]+\/[\w./-]+))/g let m: RegExpExecArray | null while ((m = re.exec(text))) { knownPaths.add(m[1]) @@ -385,14 +392,14 @@ export function useFileMention( syncMentionedPaths(text) } - const insertFilePickerResult = (path: string) => { + const insertFilePickerResult = (path: string, requestId: string) => { + const state = pickerState + if (!state || state.requestId !== requestId) return if (!path) { pickerState = null return } const norm = path.replaceAll("\\", "/") - const state = pickerState - if (!state) return pickerState = null const textarea = state.textarea if (!textarea.isConnected) return @@ -405,6 +412,7 @@ export function useFileMention( } finally { suppress = false } + textarea.focus() knownPaths.add(norm) setMentionedPaths((prev) => new Set([...prev, norm])) syncMentionedPaths(textarea.value) diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index 76320fc232a..35dad356d83 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -435,6 +435,7 @@ export interface FileSearchResultMessage { export interface FilePickerResultMessage { type: "filePickerResult" path: string + requestId: string } export interface TerminalContextResultMessage { diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index cc7a8dfc9f9..360a3c312d0 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -403,6 +403,7 @@ export interface RequestFileSearchMessage { export interface RequestFilePickerMessage { type: "requestFilePicker" + requestId: string } export interface RequestTerminalContextMessage { From c382cc38f9383eedd1059901c4352f6e4b11e4d0 Mon Sep 17 00:00:00 2001 From: Sylwester Liljegren Date: Wed, 8 Jul 2026 02:58:18 +0200 Subject: [PATCH 3/5] fix(vscode): focus textarea before execCommand in insertFilePickerResult --- packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts b/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts index c3b4c3f64eb..fb598d66729 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts @@ -405,6 +405,10 @@ export function useFileMention( if (!textarea.isConnected) return const after = textarea.value.substring(state.atEnd) const suffix = /^\s/.test(after) ? "" : " " + // Restore focus to the textarea before execCommand: after the native file-picker + // dialog closes, the textarea is no longer the active element, and execCommand + // operates on the currently focused element, so it would otherwise silently no-op. + textarea.focus() suppress = true try { textarea.setSelectionRange(state.atStart, state.atEnd) @@ -412,7 +416,6 @@ export function useFileMention( } finally { suppress = false } - textarea.focus() knownPaths.add(norm) setMentionedPaths((prev) => new Set([...prev, norm])) syncMentionedPaths(textarea.value) From a41b81c0a5d153f8064c4574323e6601efda0327 Mon Sep 17 00:00:00 2001 From: Sylwester Liljegren Date: Wed, 8 Jul 2026 16:36:00 +0200 Subject: [PATCH 4/5] fix(vscode): don't auto-attach out-of-workspace files picked via the file picker Attaching a file reads its content on the backend through a code path that bypasses the permission system entirely, including any prior 'deny' decision for that file/pattern. The file picker made this easy to trigger for arbitrary files outside the workspace. buildFileAttachments now only turns an absolute path (Unix, Windows, or UNC) into an auto-attached FileAttachment when it resolves inside the workspace. Paths outside the workspace remain a normal styled, clickable @mention (styling and preview-on-click are independent of attachment building), but their content is not silently read - the model must call the Read tool, which enforces the real external-directory permission checks and respects a prior denial. --- .changeset/vscode-file-picker-mention.md | 2 +- .../tests/unit/file-mention-utils.test.ts | 31 +++++++++++++------ .../src/hooks/file-mention-utils.ts | 19 ++++++++++-- .../webview-ui/src/hooks/useFileMention.ts | 13 ++++++-- 4 files changed, 48 insertions(+), 17 deletions(-) diff --git a/.changeset/vscode-file-picker-mention.md b/.changeset/vscode-file-picker-mention.md index 760863bcef5..673b8887507 100644 --- a/.changeset/vscode-file-picker-mention.md +++ b/.changeset/vscode-file-picker-mention.md @@ -2,4 +2,4 @@ "kilo-code": minor --- -Add a file picker option to the @ mention dropdown in the VS Code extension prompt input, allowing users to attach files from outside the current workspace. +Add a "Browse files..." option to the @ mention dropdown in the VS Code extension prompt input. Selecting it opens a native file picker and mentions the chosen file, so you can point Kilo Code at files outside the current workspace. Files outside the workspace are not auto-attached; Kilo Code reads them on request through the normal Read tool, respecting your file access permissions. diff --git a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts index c4fa2c34235..d003bcb263f 100644 --- a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts @@ -249,11 +249,29 @@ describe("buildFileAttachments", () => { expect(result[0]!.url).toContain("foo.ts") }) - it("handles absolute paths directly", () => { + it("attaches an absolute path that lives inside the workspace", () => { + const paths = new Set(["/workspace/src/file.ts"]) + const result = buildFileAttachments("@/workspace/src/file.ts", paths, "/workspace") + expect(result).toHaveLength(1) + expect(result[0]!.url).toContain("/workspace/src/file.ts") + }) + + it("does not attach an absolute Unix path outside the workspace", () => { const paths = new Set(["/abs/path/file.ts"]) const result = buildFileAttachments("@/abs/path/file.ts", paths, "/workspace") - expect(result).toHaveLength(1) - expect(result[0]!.url).toContain("/abs/path/file.ts") + expect(result).toEqual([]) + }) + + it("does not attach an absolute Windows path outside the workspace", () => { + const paths = new Set(["C:/Users/file.ts"]) + const result = buildFileAttachments("@C:/Users/file.ts", paths, "/workspace") + expect(result).toEqual([]) + }) + + it("does not attach a UNC path outside the workspace", () => { + const paths = new Set(["\\\\server\\share\\file.ts"]) + const result = buildFileAttachments("@\\\\server\\share\\file.ts", paths, "/workspace") + expect(result).toEqual([]) }) it("normalizes Windows backslashes in workspaceDir", () => { @@ -261,13 +279,6 @@ describe("buildFileAttachments", () => { const result = buildFileAttachments("@foo.ts", paths, "C:\\Users\\workspace") expect(result[0]!.url).not.toContain("\\") }) - - it("handles Windows absolute paths directly", () => { - const paths = new Set(["C:/Users/file.ts"]) - const result = buildFileAttachments("@C:/Users/file.ts", paths, "/workspace") - expect(result).toHaveLength(1) - expect(result[0]!.url).toContain("C:/Users/file.ts") - }) }) describe("getMentionRemovalRange", () => { diff --git a/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts b/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts index 3beca7245ab..6f9554c9189 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts @@ -181,11 +181,23 @@ export function findMentionRange( } function isAbsolutePath(path: string): boolean { - return path.startsWith("/") || /^[A-Za-z]:[\\\/]/.test(path) + return path.startsWith("/") || /^[A-Za-z]:[\\\/]/.test(path) || path.startsWith("\\\\") +} + +/** Whether `abs` is the workspace root or lives under it (both normalized to forward slashes). */ +function isInsideWorkspace(abs: string, dir: string): boolean { + return abs === dir || abs.startsWith(`${dir}/`) } /** * Build FileAttachment objects from currently mentioned paths in the text. + * + * Paths outside the workspace (e.g. picked via the file picker) are deliberately + * excluded: attaching a file reads its content on the backend through a path that + * bypasses the permission system, including any prior "deny" decision for that + * file. Such paths remain visible and clickable as a styled mention in the UI, but + * are not auto-attached — if the model needs their contents it must call the Read + * tool, which enforces the normal external-directory permission checks. */ export function buildFileAttachments( text: string, @@ -193,10 +205,11 @@ export function buildFileAttachments( workspaceDir: string, ): FileAttachment[] { const result: FileAttachment[] = [] - const dir = workspaceDir.replaceAll("\\", "/") + const dir = workspaceDir.replaceAll("\\", "/").replace(/\/+$/, "") for (const path of mentionedPaths) { if (text.includes(`@${path}`)) { - const abs = isAbsolutePath(path) ? path : `${dir}/${path}` + const abs = isAbsolutePath(path) ? path.replaceAll("\\", "/") : `${dir}/${path}` + if (isAbsolutePath(path) && !isInsideWorkspace(abs, dir)) continue const url = new URL("file://") url.pathname = abs.startsWith("/") ? abs : `/${abs}` result.push({ mime: "text/plain", url: url.href }) diff --git a/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts b/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts index fb598d66729..eec16d67afd 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts @@ -405,9 +405,16 @@ export function useFileMention( if (!textarea.isConnected) return const after = textarea.value.substring(state.atEnd) const suffix = /^\s/.test(after) ? "" : " " - // Restore focus to the textarea before execCommand: after the native file-picker - // dialog closes, the textarea is no longer the active element, and execCommand - // operates on the currently focused element, so it would otherwise silently no-op. + // Insert as a styled @mention so it renders like any other file reference and + // is clickable to preview (openFile is a plain editor action on the user's own + // disk, unrelated to the AI permission system). The actual security boundary + // lives in buildFileAttachments: paths outside the workspace are never turned + // into an auto-read FileAttachment, regardless of how they were mentioned, so + // a prior "deny" decision can't be bypassed by picking/attaching this way. If + // the model wants the file's contents it must call the Read tool, which + // enforces the normal external-directory permission checks. + // Restore focus before execCommand: after the native dialog closes the textarea + // is no longer the active element, so execCommand would otherwise silently no-op. textarea.focus() suppress = true try { From 80d2109bfb8c391d850990704242796258cb2f24 Mon Sep 17 00:00:00 2001 From: Sylwester Liljegren Date: Wed, 8 Jul 2026 16:57:33 +0200 Subject: [PATCH 5/5] fix(vscode): normalize path traversal before workspace-containment check isInsideWorkspace did a literal string-prefix comparison, so an absolute path like "/workspace/../../etc/passwd" passed the check purely because it starts with "/workspace/" as a string, even though it actually resolves outside the workspace. The same gap applied to a relative- looking mention seeded from raw draft text (seedFromText doesn't validate containment either), e.g. "../../etc/passwd". Add normalizeAbsolutePath to collapse "." and ".." segments (preserving a drive letter and distinguishing a UNC root from a plain root) before comparing against the workspace directory, for every resolved path (relative or absolute), not just ones that were already absolute. --- .../tests/unit/file-mention-utils.test.ts | 21 ++++++++ .../src/hooks/file-mention-utils.ts | 51 +++++++++++++++---- 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts index d003bcb263f..cd5a1903b34 100644 --- a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts @@ -274,6 +274,27 @@ describe("buildFileAttachments", () => { expect(result).toEqual([]) }) + it("does not attach an absolute path that escapes the workspace via ../ segments", () => { + const paths = new Set(["/workspace/../../etc/passwd"]) + const result = buildFileAttachments("@/workspace/../../etc/passwd", paths, "/workspace") + expect(result).toEqual([]) + }) + + it("does not attach a relative-looking mention that escapes the workspace via ../ segments", () => { + // Simulates a path seeded from raw text (e.g. seedFromText) rather than the + // file picker or file search, which never produce a leading "../". + const paths = new Set(["../../etc/passwd"]) + const result = buildFileAttachments("@../../etc/passwd", paths, "/workspace") + expect(result).toEqual([]) + }) + + it("attaches a relative mention with ../ segments that still resolves inside the workspace", () => { + const paths = new Set(["sub/../foo.ts"]) + const result = buildFileAttachments("@sub/../foo.ts", paths, "/workspace") + expect(result).toHaveLength(1) + expect(result[0]!.url).toContain("/workspace/foo.ts") + }) + it("normalizes Windows backslashes in workspaceDir", () => { const paths = new Set(["foo.ts"]) const result = buildFileAttachments("@foo.ts", paths, "C:\\Users\\workspace") diff --git a/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts b/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts index 6f9554c9189..c2d9ecb1273 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts @@ -184,7 +184,34 @@ function isAbsolutePath(path: string): boolean { return path.startsWith("/") || /^[A-Za-z]:[\\\/]/.test(path) || path.startsWith("\\\\") } -/** Whether `abs` is the workspace root or lives under it (both normalized to forward slashes). */ +/** + * Collapse "." and ".." segments in a forward-slash path so a traversal like + * "/workspace/../../etc/passwd" resolves to its real location ("/etc/passwd") + * before any workspace-containment check runs. Preserves a leading drive + * letter (`C:`) and distinguishes a UNC root ("//server") from a plain root + * ("/"). ".." segments that would go above the root are dropped rather than + * kept, matching filesystem semantics for an absolute path. + */ +function normalizeAbsolutePath(input: string): string { + const drive = input.match(/^[A-Za-z]:/)?.[0] ?? "" + const rest = drive ? input.slice(drive.length) : input + const root = rest.startsWith("//") ? "//" : rest.startsWith("/") ? "/" : "" + const segments = rest + .slice(root.length) + .split("/") + .filter((s) => s.length > 0 && s !== ".") + const stack: string[] = [] + for (const seg of segments) { + if (seg === "..") { + if (stack.length > 0) stack.pop() + continue + } + stack.push(seg) + } + return `${drive}${root}${stack.join("/")}` +} + +/** Whether `abs` is the workspace root or lives under it (both already normalized). */ function isInsideWorkspace(abs: string, dir: string): boolean { return abs === dir || abs.startsWith(`${dir}/`) } @@ -192,12 +219,15 @@ function isInsideWorkspace(abs: string, dir: string): boolean { /** * Build FileAttachment objects from currently mentioned paths in the text. * - * Paths outside the workspace (e.g. picked via the file picker) are deliberately - * excluded: attaching a file reads its content on the backend through a path that - * bypasses the permission system, including any prior "deny" decision for that - * file. Such paths remain visible and clickable as a styled mention in the UI, but - * are not auto-attached — if the model needs their contents it must call the Read - * tool, which enforces the normal external-directory permission checks. + * Paths outside the workspace (e.g. picked via the file picker, or seeded from + * raw draft text via a "../.." traversal) are deliberately excluded: attaching a + * file reads its content on the backend through a path that bypasses the + * permission system, including any prior "deny" decision for that file. Such + * paths remain visible and clickable as a styled mention in the UI, but are not + * auto-attached — if the model needs their contents it must call the Read tool, + * which enforces the normal external-directory permission checks. Every + * resolved path (relative or absolute) is normalized before the containment + * check so a "../" sequence can't slip past a literal string-prefix match. */ export function buildFileAttachments( text: string, @@ -205,11 +235,12 @@ export function buildFileAttachments( workspaceDir: string, ): FileAttachment[] { const result: FileAttachment[] = [] - const dir = workspaceDir.replaceAll("\\", "/").replace(/\/+$/, "") + const dir = normalizeAbsolutePath(workspaceDir.replaceAll("\\", "/")).replace(/\/+$/, "") for (const path of mentionedPaths) { if (text.includes(`@${path}`)) { - const abs = isAbsolutePath(path) ? path.replaceAll("\\", "/") : `${dir}/${path}` - if (isAbsolutePath(path) && !isInsideWorkspace(abs, dir)) continue + const raw = isAbsolutePath(path) ? path.replaceAll("\\", "/") : `${dir}/${path}` + const abs = normalizeAbsolutePath(raw) + if (!isInsideWorkspace(abs, dir)) continue const url = new URL("file://") url.pathname = abs.startsWith("/") ? abs : `/${abs}` result.push({ mime: "text/plain", url: url.href })