Skip to content

Commit ac22b40

Browse files
author
Mark IJbema
authored
Merge pull request #11218 from sylwester-liljegren/feat/file-links-2-fs-validation
feat(vscode): add filesystem validation protocol for file links
2 parents f90ea35 + 8cebfb4 commit ac22b40

9 files changed

Lines changed: 231 additions & 15 deletions

File tree

packages/kilo-vscode/src/KiloProvider.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1417,6 +1417,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
14171417
dir: () => this.getWorkspaceDirectory(this.currentSession?.id),
14181418
diff: this.diffVirtualProvider,
14191419
storage: this.extensionContext?.globalStorageUri,
1420+
post: (msg) => this.postMessage(msg),
14201421
})
14211422
}
14221423

packages/kilo-vscode/src/kilo-provider/editor-actions.ts

Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as vscode from "vscode"
22
import { buildPreviewPath, getPreviewCommand, getPreviewDir, parseImage, trimEntries } from "../image-preview"
3-
import { isAbsolutePath } from "../path-utils"
3+
import { escapeGlob, isAbsolutePath } from "../path-utils"
4+
import { validateFiles } from "./file-links"
45
import type { DiffVirtualFile, DiffVirtualProvider } from "../DiffVirtualProvider"
56

67
type EditorOpenMessage = {
@@ -68,11 +69,14 @@ export function handleEditorAction(
6869
initialDiffStyle?: unknown
6970
dataUrl?: string
7071
filename?: string
72+
id?: string
73+
paths?: string[]
7174
},
7275
opts: {
7376
dir: () => string
7477
diff?: DiffVirtualProvider
7578
storage?: vscode.Uri
79+
post?: (msg: unknown) => void
7680
},
7781
): boolean {
7882
if (message.type === "openFile") {
@@ -83,6 +87,18 @@ export function handleEditorAction(
8387
if (message.content) openContent(message.content, message.language)
8488
return true
8589
}
90+
if (message.type === "validateFiles") {
91+
const id = message.id
92+
const paths = message.paths
93+
if (id && paths && opts.post) {
94+
const post = opts.post
95+
validateFiles(opts.dir(), paths).then(
96+
(existing) => post({ type: "validateFilesResult", id, existing }),
97+
(err) => console.error("[Kilo New] KiloProvider: validateFiles failed:", err),
98+
)
99+
}
100+
return true
101+
}
86102
if (message.type === "openExternal") {
87103
openExternal(message.url)
88104
return true
@@ -105,6 +121,56 @@ function openContent(content: string, language?: string): void {
105121
)
106122
}
107123

124+
function show(uri: vscode.Uri, line?: number, column?: number): void {
125+
vscode.workspace.openTextDocument(uri).then(
126+
(doc) => {
127+
const options: vscode.TextDocumentShowOptions = { preview: true }
128+
if (line !== undefined && line > 0) {
129+
const col = column !== undefined && column > 0 ? column - 1 : 0
130+
const pos = new vscode.Position(line - 1, col)
131+
options.selection = new vscode.Range(pos, pos)
132+
}
133+
vscode.window
134+
.showTextDocument(doc, options)
135+
.then(undefined, (err) => console.error("[Kilo New] KiloProvider: Failed to show document:", uri.fsPath, err))
136+
},
137+
(err) => console.error("[Kilo New] KiloProvider: Failed to open file:", uri.fsPath, err),
138+
)
139+
}
140+
141+
/**
142+
* Fallback when the exact path does not exist: search the session directory by
143+
* filename. Opens the file directly on a single match, prompts on multiple,
144+
* warns on none. The search is scoped to `dir` (the active session's directory)
145+
* via a RelativePattern so it can't cross into another worktree/branch.
146+
*/
147+
function findFallback(dir: string, filePath: string, line?: number, column?: number): void {
148+
const name = filePath.split(/[\\/]/).pop() || filePath
149+
// VS Code globs don't honor backslash escapes, so bracket-escape metacharacters
150+
// (e.g. `[id].tsx`) instead — otherwise such names never match.
151+
const pattern = new vscode.RelativePattern(vscode.Uri.file(dir), `**/${escapeGlob(name)}`)
152+
Promise.resolve(vscode.workspace.findFiles(pattern, "**/node_modules/**", 5)).then(
153+
(matches) => {
154+
if (matches.length === 1) {
155+
show(matches[0], line, column)
156+
return
157+
}
158+
if (matches.length > 1) {
159+
const items = matches.map((m) => ({ label: vscode.workspace.asRelativePath(m), uri: m }))
160+
vscode.window.showQuickPick(items, { placeHolder: `Multiple matches for "${name}"` }).then(
161+
(pick) => {
162+
if (pick) show(pick.uri, line, column)
163+
},
164+
(err) => console.error("[Kilo New] KiloProvider: showQuickPick failed:", err),
165+
)
166+
return
167+
}
168+
vscode.window.showWarningMessage(`File not found: ${filePath}`)
169+
},
170+
(err: unknown) => console.error("[Kilo New] KiloProvider: findFiles failed:", err),
171+
)
172+
}
173+
108174
function openFile(dir: string, filePath: string, line?: number, column?: number): void {
109175
const uri = isAbsolutePath(filePath) ? vscode.Uri.file(filePath) : vscode.Uri.joinPath(vscode.Uri.file(dir), filePath)
110176
vscode.workspace.fs.stat(uri).then(
@@ -113,19 +179,8 @@ function openFile(dir: string, filePath: string, line?: number, column?: number)
113179
vscode.commands.executeCommand("revealInExplorer", uri)
114180
return
115181
}
116-
vscode.workspace.openTextDocument(uri).then(
117-
(doc) => {
118-
const options: vscode.TextDocumentShowOptions = { preview: true }
119-
if (line !== undefined && line > 0) {
120-
const col = column !== undefined && column > 0 ? column - 1 : 0
121-
const pos = new vscode.Position(line - 1, col)
122-
options.selection = new vscode.Range(pos, pos)
123-
}
124-
vscode.window.showTextDocument(doc, options)
125-
},
126-
(err) => console.error("[Kilo New] KiloProvider: Failed to open file:", uri.fsPath, err),
127-
)
182+
show(uri, line, column)
128183
},
129-
(err) => console.error("[Kilo New] KiloProvider: Path does not exist:", uri.fsPath, err),
184+
() => findFallback(dir, filePath, line, column),
130185
)
131186
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { realpath } from "node:fs/promises"
2+
import * as path from "node:path"
3+
import * as vscode from "vscode"
4+
import { contains } from "../path-utils"
5+
6+
/**
7+
* Stat-check candidate paths and return which ones are actual files (not directories).
8+
*
9+
* The webview marks every inline code span as a file-link candidate; this confirms
10+
* which of those candidates resolve to a real file so the webview can promote them
11+
* to clickable links and leave the rest as plain code.
12+
*
13+
* Containment is enforced twice so auto-validated model output can't probe host
14+
* files outside the session `root`:
15+
* 1. a lexical check rejects absolute paths elsewhere, UNC paths, and `../`
16+
* traversal before touching the filesystem at all;
17+
* 2. the candidate's real path (symlinks resolved) must still be inside the
18+
* real root, so a checked-in symlink can't escape the root either.
19+
*/
20+
export function validateFiles(root: string, paths: string[]): Promise<string[]> {
21+
return Promise.resolve(realpath(root)).then(
22+
(realRoot) => {
23+
const check = (p: string): Promise<string | null> => {
24+
if (!contains(root, p)) return Promise.resolve(null)
25+
return Promise.resolve(realpath(path.resolve(root, p))).then(
26+
(real) => {
27+
if (!contains(realRoot, real)) return null
28+
return Promise.resolve(vscode.workspace.fs.stat(vscode.Uri.file(real))).then(
29+
(s) => (s.type & vscode.FileType.File ? p : null),
30+
() => null,
31+
)
32+
},
33+
() => null,
34+
)
35+
}
36+
return Promise.all(paths.map(check)).then((r) => r.filter((x): x is string => x !== null))
37+
},
38+
() => [],
39+
)
40+
}

packages/kilo-vscode/src/path-utils.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import * as path from "node:path"
2+
13
/**
24
* Check whether a file path is absolute.
35
*
@@ -25,3 +27,29 @@ export function isAbsolutePath(filePath: string): boolean {
2527
return true
2628
return false
2729
}
30+
31+
/**
32+
* Whether `candidate` resolves to a location inside `root`.
33+
*
34+
* Rejects UNC candidates, absolute paths outside the root, and `../` traversal
35+
* that escapes the root. Used to keep filesystem probes scoped to the trusted
36+
* session directory so model-generated paths can't reach arbitrary host files.
37+
*/
38+
export function contains(root: string, candidate: string): boolean {
39+
if (!root || !candidate) return false
40+
// UNC candidates can trigger outbound filesystem requests on Windows — never allow them.
41+
if (candidate.startsWith("\\\\") || candidate.startsWith("//")) return false
42+
const base = path.resolve(root)
43+
const rel = path.relative(base, path.resolve(base, candidate))
44+
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)
45+
}
46+
47+
/**
48+
* Escape glob metacharacters so a literal filename can be embedded in a VS Code
49+
* glob pattern. VS Code globs do not honor backslash escapes, so each special
50+
* character is wrapped in a single-character bracket expression — e.g.
51+
* `[id].tsx` becomes `[[]id[]].tsx`.
52+
*/
53+
export function escapeGlob(name: string): string {
54+
return name.replace(/[*?{}[\]]/g, (c) => (c === "]" ? "[]]" : `[${c}]`))
55+
}

packages/kilo-vscode/tests/unit/path-utils.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "bun:test"
2-
import { isAbsolutePath } from "../../src/path-utils"
2+
import { contains, escapeGlob, isAbsolutePath } from "../../src/path-utils"
33

44
describe("isAbsolutePath", () => {
55
// ── Unix absolute paths ──────────────────────────────────────────────
@@ -163,3 +163,47 @@ describe("isAbsolutePath", () => {
163163
})
164164
})
165165
})
166+
167+
describe("contains", () => {
168+
it("accepts relative paths inside the root", () => {
169+
expect(contains("/work", "src/a.ts")).toBe(true)
170+
expect(contains("/work", "./src/a.ts")).toBe(true)
171+
expect(contains("/work", "a.ts")).toBe(true)
172+
})
173+
174+
it("rejects parent traversal that escapes the root", () => {
175+
expect(contains("/work", "../etc/passwd")).toBe(false)
176+
expect(contains("/work", "../../secret.ts")).toBe(false)
177+
})
178+
179+
it("rejects absolute paths outside the root", () => {
180+
expect(contains("/work", "/etc/passwd")).toBe(false)
181+
})
182+
183+
it("rejects UNC candidates", () => {
184+
expect(contains("/work", "\\\\server\\share\\file.ts")).toBe(false)
185+
expect(contains("/work", "//server/share/file.ts")).toBe(false)
186+
})
187+
188+
it("rejects empty inputs", () => {
189+
expect(contains("", "a.ts")).toBe(false)
190+
expect(contains("/work", "")).toBe(false)
191+
})
192+
})
193+
194+
describe("escapeGlob", () => {
195+
it("bracket-escapes dynamic-route filenames", () => {
196+
expect(escapeGlob("[id].tsx")).toBe("[[]id[]].tsx")
197+
expect(escapeGlob("[...slug].tsx")).toBe("[[]...slug[]].tsx")
198+
})
199+
200+
it("escapes wildcard and brace metacharacters", () => {
201+
expect(escapeGlob("a*b?.ts")).toBe("a[*]b[?].ts")
202+
expect(escapeGlob("{x,y}.ts")).toBe("[{]x,y[}].ts")
203+
})
204+
205+
it("leaves ordinary filenames unchanged", () => {
206+
expect(escapeGlob("index.ts")).toBe("index.ts")
207+
expect(escapeGlob("file-path.test.ts")).toBe("file-path.test.ts")
208+
})
209+
})

packages/kilo-vscode/webview-ui/src/App.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,35 @@ export const DataBridge: Component<{ children: any }> = (props) => {
150150
vscode.postMessage({ type: "openContent", content, language })
151151
}
152152

153+
// File existence validation for code span candidates
154+
const pending = new Map<string, (existing: string[]) => void>()
155+
const counter = { n: 0 }
156+
const validateFiles = (paths: string[]): Promise<string[]> => {
157+
const id = `vf-${++counter.n}`
158+
return new Promise((resolve) => {
159+
pending.set(id, resolve)
160+
vscode.postMessage({ type: "validateFiles", id, paths })
161+
setTimeout(() => {
162+
if (pending.has(id)) {
163+
pending.delete(id)
164+
resolve([])
165+
}
166+
}, 3000)
167+
})
168+
}
169+
const handler = (event: MessageEvent) => {
170+
const msg = event.data
171+
if (msg?.type === "validateFilesResult" && msg.id) {
172+
const cb = pending.get(msg.id)
173+
if (cb) {
174+
pending.delete(msg.id)
175+
cb(msg.existing ?? [])
176+
}
177+
}
178+
}
179+
onMount(() => window.addEventListener("message", handler))
180+
onCleanup(() => window.removeEventListener("message", handler))
181+
153182
const directory = () => {
154183
const dir = server.workspaceDirectory()
155184
if (!dir) return ""
@@ -168,6 +197,7 @@ export const DataBridge: Component<{ children: any }> = (props) => {
168197
onOpenDiff={openDiff}
169198
onOpenUrl={openUrl}
170199
onOpenContent={openContent}
200+
onValidateFiles={validateFiles}
171201
>
172202
{props.children}
173203
</DataProvider>

packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1013,6 +1013,12 @@ export interface RemoteStatusMessage {
10131013
connected: boolean
10141014
}
10151015

1016+
export interface ValidateFilesResultMessage {
1017+
type: "validateFilesResult"
1018+
id: string
1019+
existing: string[]
1020+
}
1021+
10161022
export type ExtensionMessage =
10171023
| ReadyMessage
10181024
| FontSizeChangedMessage
@@ -1167,3 +1173,4 @@ export type ExtensionMessage =
11671173
| ExtensionDataReadyMessage
11681174
| TelemetryStateMessage
11691175
| RemoteStatusMessage
1176+
| ValidateFilesResultMessage

packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,12 @@ export interface OpenContentRequest {
143143
language?: string
144144
}
145145

146+
export interface ValidateFilesRequest {
147+
type: "validateFiles"
148+
id: string
149+
paths: string[]
150+
}
151+
146152
export interface CancelLoginRequest {
147153
type: "cancelLogin"
148154
}
@@ -1167,6 +1173,7 @@ export type WebviewMessage =
11671173
| OpenAgentManagerRequest
11681174
| OpenAdvancedWorktreeRequest
11691175
| OpenFileRequest
1176+
| ValidateFilesRequest
11701177
| CancelLoginRequest
11711178
| SetOrganizationRequest
11721179
| WebviewReadyRequest

packages/ui/src/context/data.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ export type OpenDiffFn = (diff: {
5656
export type OpenUrlFn = (url: string) => void
5757

5858
export type OpenContentFn = (content: string, language?: string) => void // kilocode_change
59+
60+
export type ValidateFilesFn = (paths: string[]) => Promise<string[]> // kilocode_change
5961
// kilocode_change end
6062

6163
export const { use: useData, provider: DataProvider } = createSimpleContext({
@@ -69,6 +71,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
6971
onOpenDiff?: OpenDiffFn // kilocode_change
7072
onOpenUrl?: OpenUrlFn // kilocode_change
7173
onOpenContent?: OpenContentFn // kilocode_change
74+
onValidateFiles?: ValidateFilesFn // kilocode_change
7275
}) => {
7376
return {
7477
get store() {
@@ -83,6 +86,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
8386
openDiff: props.onOpenDiff, // kilocode_change
8487
openUrl: props.onOpenUrl, // kilocode_change
8588
openContent: props.onOpenContent, // kilocode_change
89+
validateFiles: props.onValidateFiles, // kilocode_change
8690
}
8791
},
8892
})

0 commit comments

Comments
 (0)