Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1376,6 +1376,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),
})
}

Expand Down
83 changes: 69 additions & 14 deletions packages/kilo-vscode/src/kilo-provider/editor-actions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
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"

type EditorOpenMessage = {
Expand Down Expand Up @@ -68,11 +69,14 @@ export function handleEditorAction(
initialDiffStyle?: unknown
dataUrl?: string
filename?: string
id?: string
paths?: string[]
},
opts: {
dir: () => string
diff?: DiffVirtualProvider
storage?: vscode.Uri
post?: (msg: unknown) => void
},
): boolean {
if (message.type === "openFile") {
Expand All @@ -83,6 +87,18 @@ export function handleEditorAction(
if (message.content) openContent(message.content, message.language)
return true
}
if (message.type === "validateFiles") {
const id = message.id
const paths = message.paths
if (id && paths && opts.post) {
const post = opts.post
validateFiles(opts.dir(), paths).then(
(existing) => 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
Expand All @@ -105,6 +121,56 @@ 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)
.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),
)
}

/**
* 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(dir: string, filePath: string, line?: number, column?: number): void {
const name = filePath.split(/[\\/]/).pop() || filePath
// 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)
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)
},
(err) => console.error("[Kilo New] KiloProvider: showQuickPick failed:", err),
)
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(
Expand All @@ -113,19 +179,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(dir, filePath, line, column),
)
}
40 changes: 40 additions & 0 deletions packages/kilo-vscode/src/kilo-provider/file-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { realpath } from "node:fs/promises"
import * as path from "node:path"
import * as vscode from "vscode"
import { contains } 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.
*
* 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<string[]> {
return Promise.resolve(realpath(root)).then(
(realRoot) => {
const check = (p: string): Promise<string | null> => {
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))
},
() => [],
)
}
28 changes: 28 additions & 0 deletions packages/kilo-vscode/src/path-utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import * as path from "node:path"

/**
* Check whether a file path is absolute.
*
Expand Down Expand Up @@ -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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: contains() only enforces lexical containment, so symlink escapes can still probe files outside the session root.

path.resolve() / path.relative() treat root/link/passwd as in-tree even when link is a symlink to /etc, so validateFiles() will still call workspace.fs.stat() on an external target through any checked-in symlink. Once the stacked UI auto-validates code spans, model output can still confirm arbitrary host files this way. Consider resolving the real path before the containment check, or rejecting symlinked path segments entirely.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

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}]`))
}
46 changes: 45 additions & 1 deletion packages/kilo-vscode/tests/unit/path-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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 ──────────────────────────────────────────────
Expand Down Expand Up @@ -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")
})
})
30 changes: 30 additions & 0 deletions packages/kilo-vscode/webview-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, (existing: string[]) => void>()
const counter = { n: 0 }
const validateFiles = (paths: string[]): Promise<string[]> => {
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 ""
Expand All @@ -167,6 +196,7 @@ export const DataBridge: Component<{ children: any }> = (props) => {
onOpenDiff={openDiff}
onOpenUrl={openUrl}
onOpenContent={openContent}
onValidateFiles={validateFiles}
>
{props.children}
</DataProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1004,6 +1004,12 @@ export interface RemoteStatusMessage {
connected: boolean
}

export interface ValidateFilesResultMessage {
type: "validateFilesResult"
id: string
existing: string[]
}

export type ExtensionMessage =
| ReadyMessage
| FontSizeChangedMessage
Expand Down Expand Up @@ -1156,3 +1162,4 @@ export type ExtensionMessage =
| ExtensionDataReadyMessage
| TelemetryStateMessage
| RemoteStatusMessage
| ValidateFilesResultMessage
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,12 @@ export interface OpenContentRequest {
language?: string
}

export interface ValidateFilesRequest {
type: "validateFiles"
id: string
paths: string[]
}

export interface CancelLoginRequest {
type: "cancelLogin"
}
Expand Down Expand Up @@ -1158,6 +1164,7 @@ export type WebviewMessage =
| OpenAgentManagerRequest
| OpenAdvancedWorktreeRequest
| OpenFileRequest
| ValidateFilesRequest
| CancelLoginRequest
| SetOrganizationRequest
| WebviewReadyRequest
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/context/data.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,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<string[]> // kilocode_change
// kilocode_change end

export const { use: useData, provider: DataProvider } = createSimpleContext({
Expand All @@ -69,6 +71,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() {
Expand All @@ -83,6 +86,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
}
},
})
Loading