From ed5fc5a45fb7dca6437d0aff67bc6b651c7505d5 Mon Sep 17 00:00:00 2001 From: Sylwester Liljegren Date: Sun, 14 Jun 2026 15:08:01 +0200 Subject: [PATCH 1/4] feat(vscode): add filesystem validation protocol for file links Add a validateFiles request/response round-trip between the webview and the extension so the webview can confirm which inline code-span candidates are real files before promoting them to clickable links. The extension stat-checks candidate paths (new file-links.ts) and replies with the subset that exist. Routing lives in editor-actions alongside the other editor open actions, and openFile now falls back to a workspace filename search (single match opens, multiple prompts) with a "File not found" warning when a clicked path cannot be resolved. --- packages/kilo-vscode/src/KiloProvider.ts | 1 + .../src/kilo-provider/editor-actions.ts | 68 +++++++++++++++---- .../src/kilo-provider/file-links.ts | 22 ++++++ packages/kilo-vscode/webview-ui/src/App.tsx | 30 ++++++++ .../src/types/messages/extension-messages.ts | 7 ++ .../src/types/messages/webview-messages.ts | 7 ++ packages/ui/src/context/data.tsx | 4 ++ 7 files changed, 126 insertions(+), 13 deletions(-) create mode 100644 packages/kilo-vscode/src/kilo-provider/file-links.ts diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index b117b8c14ee..9d995106609 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -1233,6 +1233,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper dir: () => this.getWorkspaceDirectory(this.currentSession?.id), diff: this.diffVirtualProvider, storage: this.extensionContext?.globalStorageUri, + post: (msg) => this.postMessage(msg), }) } diff --git a/packages/kilo-vscode/src/kilo-provider/editor-actions.ts b/packages/kilo-vscode/src/kilo-provider/editor-actions.ts index 1c55c65ec6b..d5dfd072db8 100644 --- a/packages/kilo-vscode/src/kilo-provider/editor-actions.ts +++ b/packages/kilo-vscode/src/kilo-provider/editor-actions.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode" import { buildPreviewPath, getPreviewCommand, getPreviewDir, parseImage, trimEntries } from "../image-preview" import { isAbsolutePath } from "../path-utils" +import { validateFiles } from "./file-links" import type { DiffVirtualFile, DiffVirtualProvider } from "../DiffVirtualProvider" type EditorOpenMessage = { @@ -73,6 +74,7 @@ export function handleEditorAction( dir: () => string diff?: DiffVirtualProvider storage?: vscode.Uri + post?: (msg: unknown) => void }, ): boolean { if (message.type === "openFile") { @@ -83,6 +85,17 @@ export function handleEditorAction( if (message.content) openContent(message.content, message.language) return true } + if (message.type === "validateFiles") { + const id = (message as { id?: string }).id + const paths = (message as { paths?: string[] }).paths + if (id && paths && opts.post) { + validateFiles(opts.dir(), paths).then( + (existing) => opts.post!({ type: "validateFilesResult", id, existing }), + (err) => console.error("[Kilo New] KiloProvider: validateFiles failed:", err), + ) + } + return true + } if (message.type === "openExternal") { openExternal(message.url) return true @@ -105,6 +118,46 @@ function openContent(content: string, language?: string): void { ) } +function show(uri: vscode.Uri, line?: number, column?: number): void { + vscode.workspace.openTextDocument(uri).then( + (doc) => { + const options: vscode.TextDocumentShowOptions = { preview: true } + if (line !== undefined && line > 0) { + const col = column !== undefined && column > 0 ? column - 1 : 0 + const pos = new vscode.Position(line - 1, col) + options.selection = new vscode.Range(pos, pos) + } + vscode.window.showTextDocument(doc, options) + }, + (err) => console.error("[Kilo New] KiloProvider: Failed to open file:", uri.fsPath, err), + ) +} + +/** + * Fallback when the exact path does not exist: search the workspace by filename. + * Opens the file directly on a single match, prompts on multiple, warns on none. + */ +function findFallback(filePath: string, line?: number, column?: number): void { + const name = filePath.split(/[\\/]/).pop() || filePath + Promise.resolve(vscode.workspace.findFiles(`**/${name}`, "**/node_modules/**", 5)).then( + (matches) => { + if (matches.length === 1) { + show(matches[0], line, column) + return + } + if (matches.length > 1) { + const items = matches.map((m) => ({ label: vscode.workspace.asRelativePath(m), uri: m })) + vscode.window.showQuickPick(items, { placeHolder: `Multiple matches for "${name}"` }).then((pick) => { + if (pick) show(pick.uri, line, column) + }) + return + } + vscode.window.showWarningMessage(`File not found: ${filePath}`) + }, + (err: unknown) => console.error("[Kilo New] KiloProvider: findFiles failed:", err), + ) +} + function openFile(dir: string, filePath: string, line?: number, column?: number): void { const uri = isAbsolutePath(filePath) ? vscode.Uri.file(filePath) : vscode.Uri.joinPath(vscode.Uri.file(dir), filePath) vscode.workspace.fs.stat(uri).then( @@ -113,19 +166,8 @@ function openFile(dir: string, filePath: string, line?: number, column?: number) vscode.commands.executeCommand("revealInExplorer", uri) return } - vscode.workspace.openTextDocument(uri).then( - (doc) => { - const options: vscode.TextDocumentShowOptions = { preview: true } - if (line !== undefined && line > 0) { - const col = column !== undefined && column > 0 ? column - 1 : 0 - const pos = new vscode.Position(line - 1, col) - options.selection = new vscode.Range(pos, pos) - } - vscode.window.showTextDocument(doc, options) - }, - (err) => console.error("[Kilo New] KiloProvider: Failed to open file:", uri.fsPath, err), - ) + show(uri, line, column) }, - (err) => console.error("[Kilo New] KiloProvider: Path does not exist:", uri.fsPath, err), + () => findFallback(filePath, line, column), ) } diff --git a/packages/kilo-vscode/src/kilo-provider/file-links.ts b/packages/kilo-vscode/src/kilo-provider/file-links.ts new file mode 100644 index 00000000000..738e9f89e2b --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/file-links.ts @@ -0,0 +1,22 @@ +import * as vscode from "vscode" +import { isAbsolutePath } from "../path-utils" + +/** + * Stat-check candidate paths and return which ones are actual files (not directories). + * + * The webview marks every inline code span as a file-link candidate; this confirms + * which of those candidates resolve to a real file so the webview can promote them + * to clickable links and leave the rest as plain code. + */ +export function validateFiles(root: string, paths: string[]): Promise { + const resolve = (p: string) => + isAbsolutePath(p) ? vscode.Uri.file(p) : vscode.Uri.joinPath(vscode.Uri.file(root), p) + return Promise.all( + paths.map((p) => + Promise.resolve(vscode.workspace.fs.stat(resolve(p))).then( + (s) => (s.type & vscode.FileType.File ? p : null), + () => null, + ), + ), + ).then((r) => r.filter((x): x is string => x !== null)) +} diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index 26f775a8bf5..3208ee4852e 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -149,6 +149,35 @@ export const DataBridge: Component<{ children: any }> = (props) => { vscode.postMessage({ type: "openContent", content, language }) } + // File existence validation for code span candidates + const pending = new Map void>() + const counter = { n: 0 } + const validateFiles = (paths: string[]): Promise => { + const id = `vf-${++counter.n}` + return new Promise((resolve) => { + pending.set(id, resolve) + vscode.postMessage({ type: "validateFiles", id, paths }) + setTimeout(() => { + if (pending.has(id)) { + pending.delete(id) + resolve([]) + } + }, 3000) + }) + } + const handler = (event: MessageEvent) => { + const msg = event.data + if (msg?.type === "validateFilesResult" && msg.id) { + const cb = pending.get(msg.id) + if (cb) { + pending.delete(msg.id) + cb(msg.existing ?? []) + } + } + } + onMount(() => window.addEventListener("message", handler)) + onCleanup(() => window.removeEventListener("message", handler)) + const directory = () => { const dir = server.workspaceDirectory() if (!dir) return "" @@ -167,6 +196,7 @@ export const DataBridge: Component<{ children: any }> = (props) => { onOpenDiff={openDiff} onOpenUrl={openUrl} onOpenContent={openContent} + onValidateFiles={validateFiles} > {props.children} 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 95d64f15dcb..e9f8d2fd410 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 @@ -962,6 +962,12 @@ export interface RemoteStatusMessage { connected: boolean } +export interface ValidateFilesResultMessage { + type: "validateFilesResult" + id: string + existing: string[] +} + export type ExtensionMessage = | ReadyMessage | FontSizeChangedMessage @@ -1108,3 +1114,4 @@ export type ExtensionMessage = | ExtensionDataReadyMessage | TelemetryStateMessage | RemoteStatusMessage + | ValidateFilesResultMessage 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 ee7ffcc40c0..1ad9b654d49 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 @@ -142,6 +142,12 @@ export interface OpenContentRequest { language?: string } +export interface ValidateFilesRequest { + type: "validateFiles" + id: string + paths: string[] +} + export interface CancelLoginRequest { type: "cancelLogin" } @@ -1107,6 +1113,7 @@ export type WebviewMessage = | OpenAgentManagerRequest | OpenAdvancedWorktreeRequest | OpenFileRequest + | ValidateFilesRequest | CancelLoginRequest | SetOrganizationRequest | WebviewReadyRequest diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index dbb2ccb908e..08e82f4f4e3 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -48,6 +48,8 @@ export type OpenDiffFn = (diff: { export type OpenUrlFn = (url: string) => void export type OpenContentFn = (content: string, language?: string) => void // kilocode_change + +export type ValidateFilesFn = (paths: string[]) => Promise // kilocode_change // kilocode_change end export const { use: useData, provider: DataProvider } = createSimpleContext({ @@ -61,6 +63,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ onOpenDiff?: OpenDiffFn // kilocode_change onOpenUrl?: OpenUrlFn // kilocode_change onOpenContent?: OpenContentFn // kilocode_change + onValidateFiles?: ValidateFilesFn // kilocode_change }) => { return { get store() { @@ -75,6 +78,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ openDiff: props.onOpenDiff, // kilocode_change openUrl: props.onOpenUrl, // kilocode_change openContent: props.onOpenContent, // kilocode_change + validateFiles: props.onValidateFiles, // kilocode_change } }, }) From 81aad79f98c51c0eee95a9ec2794292a77475559 Mon Sep 17 00:00:00 2001 From: Sylwester Liljegren Date: Sun, 14 Jun 2026 23:46:37 +0200 Subject: [PATCH 2/4] fix(vscode): harden file-link fallback and type validateFiles message Address kilo-code-bot review on #11218: - escape glob metacharacters in the workspace filename search so names like [id].tsx / [...slug].tsx resolve instead of falling through to the "File not found" warning - add rejection handlers to showTextDocument (in show()) and showQuickPick so VS Code API errors surface instead of being silently swallowed - type the validateFiles message fields on the handleEditorAction parameter instead of inline casts --- .../src/kilo-provider/editor-actions.ts | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/kilo-vscode/src/kilo-provider/editor-actions.ts b/packages/kilo-vscode/src/kilo-provider/editor-actions.ts index d5dfd072db8..535c74faecd 100644 --- a/packages/kilo-vscode/src/kilo-provider/editor-actions.ts +++ b/packages/kilo-vscode/src/kilo-provider/editor-actions.ts @@ -69,6 +69,8 @@ export function handleEditorAction( initialDiffStyle?: unknown dataUrl?: string filename?: string + id?: string + paths?: string[] }, opts: { dir: () => string @@ -86,11 +88,12 @@ export function handleEditorAction( return true } if (message.type === "validateFiles") { - const id = (message as { id?: string }).id - const paths = (message as { paths?: string[] }).paths + const id = message.id + const paths = message.paths if (id && paths && opts.post) { + const post = opts.post validateFiles(opts.dir(), paths).then( - (existing) => opts.post!({ type: "validateFilesResult", id, existing }), + (existing) => post({ type: "validateFilesResult", id, existing }), (err) => console.error("[Kilo New] KiloProvider: validateFiles failed:", err), ) } @@ -127,7 +130,9 @@ function show(uri: vscode.Uri, line?: number, column?: number): void { const pos = new vscode.Position(line - 1, col) options.selection = new vscode.Range(pos, pos) } - vscode.window.showTextDocument(doc, options) + vscode.window + .showTextDocument(doc, options) + .then(undefined, (err) => console.error("[Kilo New] KiloProvider: Failed to show document:", uri.fsPath, err)) }, (err) => console.error("[Kilo New] KiloProvider: Failed to open file:", uri.fsPath, err), ) @@ -139,7 +144,9 @@ function show(uri: vscode.Uri, line?: number, column?: number): void { */ function findFallback(filePath: string, line?: number, column?: number): void { const name = filePath.split(/[\\/]/).pop() || filePath - Promise.resolve(vscode.workspace.findFiles(`**/${name}`, "**/node_modules/**", 5)).then( + // Escape glob metacharacters so filenames like `[id].tsx` or `[...slug].tsx` resolve correctly. + const escaped = name.replace(/[\[\]{}?*!()]/g, "\\$&") + Promise.resolve(vscode.workspace.findFiles(`**/${escaped}`, "**/node_modules/**", 5)).then( (matches) => { if (matches.length === 1) { show(matches[0], line, column) @@ -147,9 +154,12 @@ function findFallback(filePath: string, line?: number, column?: number): void { } if (matches.length > 1) { const items = matches.map((m) => ({ label: vscode.workspace.asRelativePath(m), uri: m })) - vscode.window.showQuickPick(items, { placeHolder: `Multiple matches for "${name}"` }).then((pick) => { - if (pick) show(pick.uri, line, column) - }) + vscode.window.showQuickPick(items, { placeHolder: `Multiple matches for "${name}"` }).then( + (pick) => { + if (pick) show(pick.uri, line, column) + }, + (err) => console.error("[Kilo New] KiloProvider: showQuickPick failed:", err), + ) return } vscode.window.showWarningMessage(`File not found: ${filePath}`) From d497be6311b46503ccf8724305971e764e80f2ee Mon Sep 17 00:00:00 2001 From: Sylwester Liljegren Date: Thu, 18 Jun 2026 20:15:49 +0200 Subject: [PATCH 3/4] fix(vscode): scope file-link validation and fallback to the session root Address markijbema review on #11218: - validateFiles now rejects candidates that resolve outside the session root (absolute paths elsewhere, UNC paths, ../ traversal) before any fs.stat, so auto-validated model output can't probe arbitrary host paths - the openFile dead-link fallback searches the session dir via a RelativePattern instead of the whole opened workspace, so it can't cross into another worktree/branch - use VS Code-compatible bracket glob escaping (`[id].tsx` -> `[[]id[]].tsx`) so dynamic-route filenames resolve instead of falling through Adds focused unit tests for the new vscode-free `contains` and `escapeGlob` helpers in path-utils. --- .../src/kilo-provider/editor-actions.ts | 19 ++++---- .../src/kilo-provider/file-links.ts | 25 +++++----- packages/kilo-vscode/src/path-utils.ts | 28 +++++++++++ .../kilo-vscode/tests/unit/path-utils.test.ts | 46 ++++++++++++++++++- 4 files changed, 98 insertions(+), 20 deletions(-) diff --git a/packages/kilo-vscode/src/kilo-provider/editor-actions.ts b/packages/kilo-vscode/src/kilo-provider/editor-actions.ts index 535c74faecd..c92ede42873 100644 --- a/packages/kilo-vscode/src/kilo-provider/editor-actions.ts +++ b/packages/kilo-vscode/src/kilo-provider/editor-actions.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode" import { buildPreviewPath, getPreviewCommand, getPreviewDir, parseImage, trimEntries } from "../image-preview" -import { isAbsolutePath } from "../path-utils" +import { escapeGlob, isAbsolutePath } from "../path-utils" import { validateFiles } from "./file-links" import type { DiffVirtualFile, DiffVirtualProvider } from "../DiffVirtualProvider" @@ -139,14 +139,17 @@ function show(uri: vscode.Uri, line?: number, column?: number): void { } /** - * Fallback when the exact path does not exist: search the workspace by filename. - * Opens the file directly on a single match, prompts on multiple, warns on none. + * Fallback when the exact path does not exist: search the session directory by + * filename. Opens the file directly on a single match, prompts on multiple, + * warns on none. The search is scoped to `dir` (the active session's directory) + * via a RelativePattern so it can't cross into another worktree/branch. */ -function findFallback(filePath: string, line?: number, column?: number): void { +function findFallback(dir: string, filePath: string, line?: number, column?: number): void { const name = filePath.split(/[\\/]/).pop() || filePath - // Escape glob metacharacters so filenames like `[id].tsx` or `[...slug].tsx` resolve correctly. - const escaped = name.replace(/[\[\]{}?*!()]/g, "\\$&") - Promise.resolve(vscode.workspace.findFiles(`**/${escaped}`, "**/node_modules/**", 5)).then( + // VS Code globs don't honor backslash escapes, so bracket-escape metacharacters + // (e.g. `[id].tsx`) instead — otherwise such names never match. + const pattern = new vscode.RelativePattern(vscode.Uri.file(dir), `**/${escapeGlob(name)}`) + Promise.resolve(vscode.workspace.findFiles(pattern, "**/node_modules/**", 5)).then( (matches) => { if (matches.length === 1) { show(matches[0], line, column) @@ -178,6 +181,6 @@ function openFile(dir: string, filePath: string, line?: number, column?: number) } show(uri, line, column) }, - () => findFallback(filePath, line, column), + () => findFallback(dir, filePath, line, column), ) } diff --git a/packages/kilo-vscode/src/kilo-provider/file-links.ts b/packages/kilo-vscode/src/kilo-provider/file-links.ts index 738e9f89e2b..b4a6a2551a5 100644 --- a/packages/kilo-vscode/src/kilo-provider/file-links.ts +++ b/packages/kilo-vscode/src/kilo-provider/file-links.ts @@ -1,5 +1,5 @@ import * as vscode from "vscode" -import { isAbsolutePath } from "../path-utils" +import { contains, isAbsolutePath } from "../path-utils" /** * Stat-check candidate paths and return which ones are actual files (not directories). @@ -7,16 +7,19 @@ import { isAbsolutePath } from "../path-utils" * The webview marks every inline code span as a file-link candidate; this confirms * which of those candidates resolve to a real file so the webview can promote them * to clickable links and leave the rest as plain code. + * + * Candidates that resolve outside the session `root` (absolute paths elsewhere, + * UNC paths, or `../` traversal) are rejected without touching the filesystem, so + * auto-validated model output can't probe arbitrary host paths. */ export function validateFiles(root: string, paths: string[]): Promise { - const resolve = (p: string) => - isAbsolutePath(p) ? vscode.Uri.file(p) : vscode.Uri.joinPath(vscode.Uri.file(root), p) - return Promise.all( - paths.map((p) => - Promise.resolve(vscode.workspace.fs.stat(resolve(p))).then( - (s) => (s.type & vscode.FileType.File ? p : null), - () => null, - ), - ), - ).then((r) => r.filter((x): x is string => x !== null)) + const check = (p: string): Promise => { + if (!contains(root, p)) return Promise.resolve(null) + const uri = isAbsolutePath(p) ? vscode.Uri.file(p) : vscode.Uri.joinPath(vscode.Uri.file(root), p) + return Promise.resolve(vscode.workspace.fs.stat(uri)).then( + (s) => (s.type & vscode.FileType.File ? p : null), + () => null, + ) + } + return Promise.all(paths.map(check)).then((r) => r.filter((x): x is string => x !== null)) } diff --git a/packages/kilo-vscode/src/path-utils.ts b/packages/kilo-vscode/src/path-utils.ts index 856a76038a2..ab3f829abb4 100644 --- a/packages/kilo-vscode/src/path-utils.ts +++ b/packages/kilo-vscode/src/path-utils.ts @@ -1,3 +1,5 @@ +import * as path from "node:path" + /** * Check whether a file path is absolute. * @@ -25,3 +27,29 @@ export function isAbsolutePath(filePath: string): boolean { return true return false } + +/** + * Whether `candidate` resolves to a location inside `root`. + * + * Rejects UNC candidates, absolute paths outside the root, and `../` traversal + * that escapes the root. Used to keep filesystem probes scoped to the trusted + * session directory so model-generated paths can't reach arbitrary host files. + */ +export function contains(root: string, candidate: string): boolean { + if (!root || !candidate) return false + // UNC candidates can trigger outbound filesystem requests on Windows — never allow them. + if (candidate.startsWith("\\\\") || candidate.startsWith("//")) return false + const base = path.resolve(root) + const rel = path.relative(base, path.resolve(base, candidate)) + return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel) +} + +/** + * Escape glob metacharacters so a literal filename can be embedded in a VS Code + * glob pattern. VS Code globs do not honor backslash escapes, so each special + * character is wrapped in a single-character bracket expression — e.g. + * `[id].tsx` becomes `[[]id[]].tsx`. + */ +export function escapeGlob(name: string): string { + return name.replace(/[*?{}[\]]/g, (c) => (c === "]" ? "[]]" : `[${c}]`)) +} diff --git a/packages/kilo-vscode/tests/unit/path-utils.test.ts b/packages/kilo-vscode/tests/unit/path-utils.test.ts index 517e89eded9..0c9dfc3082d 100644 --- a/packages/kilo-vscode/tests/unit/path-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/path-utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test" -import { isAbsolutePath } from "../../src/path-utils" +import { contains, escapeGlob, isAbsolutePath } from "../../src/path-utils" describe("isAbsolutePath", () => { // ── Unix absolute paths ────────────────────────────────────────────── @@ -163,3 +163,47 @@ describe("isAbsolutePath", () => { }) }) }) + +describe("contains", () => { + it("accepts relative paths inside the root", () => { + expect(contains("/work", "src/a.ts")).toBe(true) + expect(contains("/work", "./src/a.ts")).toBe(true) + expect(contains("/work", "a.ts")).toBe(true) + }) + + it("rejects parent traversal that escapes the root", () => { + expect(contains("/work", "../etc/passwd")).toBe(false) + expect(contains("/work", "../../secret.ts")).toBe(false) + }) + + it("rejects absolute paths outside the root", () => { + expect(contains("/work", "/etc/passwd")).toBe(false) + }) + + it("rejects UNC candidates", () => { + expect(contains("/work", "\\\\server\\share\\file.ts")).toBe(false) + expect(contains("/work", "//server/share/file.ts")).toBe(false) + }) + + it("rejects empty inputs", () => { + expect(contains("", "a.ts")).toBe(false) + expect(contains("/work", "")).toBe(false) + }) +}) + +describe("escapeGlob", () => { + it("bracket-escapes dynamic-route filenames", () => { + expect(escapeGlob("[id].tsx")).toBe("[[]id[]].tsx") + expect(escapeGlob("[...slug].tsx")).toBe("[[]...slug[]].tsx") + }) + + it("escapes wildcard and brace metacharacters", () => { + expect(escapeGlob("a*b?.ts")).toBe("a[*]b[?].ts") + expect(escapeGlob("{x,y}.ts")).toBe("[{]x,y[}].ts") + }) + + it("leaves ordinary filenames unchanged", () => { + expect(escapeGlob("index.ts")).toBe("index.ts") + expect(escapeGlob("file-path.test.ts")).toBe("file-path.test.ts") + }) +}) From c2076c8429199f1de2aa645501f1fb577525b4c8 Mon Sep 17 00:00:00 2001 From: Sylwester Liljegren Date: Thu, 18 Jun 2026 20:26:03 +0200 Subject: [PATCH 4/4] fix(vscode): resolve real path before validating file-link containment Address kilo-code-bot review on #11218: contains() is purely lexical, so a checked-in symlink (e.g. root/link -> /etc) would pass the check and let validateFiles stat an external target. validateFiles now resolves each candidate's real path (symlinks included) and requires it to stay inside the real session root before any stat, on top of the existing lexical pre-check that still rejects UNC / absolute-outside / ../ without touching the disk. --- .../src/kilo-provider/file-links.ts | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/packages/kilo-vscode/src/kilo-provider/file-links.ts b/packages/kilo-vscode/src/kilo-provider/file-links.ts index b4a6a2551a5..d038005bea3 100644 --- a/packages/kilo-vscode/src/kilo-provider/file-links.ts +++ b/packages/kilo-vscode/src/kilo-provider/file-links.ts @@ -1,5 +1,7 @@ +import { realpath } from "node:fs/promises" +import * as path from "node:path" import * as vscode from "vscode" -import { contains, isAbsolutePath } from "../path-utils" +import { contains } from "../path-utils" /** * Stat-check candidate paths and return which ones are actual files (not directories). @@ -8,18 +10,31 @@ import { contains, isAbsolutePath } from "../path-utils" * which of those candidates resolve to a real file so the webview can promote them * to clickable links and leave the rest as plain code. * - * Candidates that resolve outside the session `root` (absolute paths elsewhere, - * UNC paths, or `../` traversal) are rejected without touching the filesystem, so - * auto-validated model output can't probe arbitrary host paths. + * Containment is enforced twice so auto-validated model output can't probe host + * files outside the session `root`: + * 1. a lexical check rejects absolute paths elsewhere, UNC paths, and `../` + * traversal before touching the filesystem at all; + * 2. the candidate's real path (symlinks resolved) must still be inside the + * real root, so a checked-in symlink can't escape the root either. */ export function validateFiles(root: string, paths: string[]): Promise { - const check = (p: string): Promise => { - if (!contains(root, p)) return Promise.resolve(null) - const uri = isAbsolutePath(p) ? vscode.Uri.file(p) : vscode.Uri.joinPath(vscode.Uri.file(root), p) - return Promise.resolve(vscode.workspace.fs.stat(uri)).then( - (s) => (s.type & vscode.FileType.File ? p : null), - () => null, - ) - } - return Promise.all(paths.map(check)).then((r) => r.filter((x): x is string => x !== null)) + return Promise.resolve(realpath(root)).then( + (realRoot) => { + const check = (p: string): Promise => { + if (!contains(root, p)) return Promise.resolve(null) + return Promise.resolve(realpath(path.resolve(root, p))).then( + (real) => { + if (!contains(realRoot, real)) return null + return Promise.resolve(vscode.workspace.fs.stat(vscode.Uri.file(real))).then( + (s) => (s.type & vscode.FileType.File ? p : null), + () => null, + ) + }, + () => null, + ) + } + return Promise.all(paths.map(check)).then((r) => r.filter((x): x is string => x !== null)) + }, + () => [], + ) }