fix(vscode): fail closed for next edit file access

This commit is contained in:
Mark IJbema
2026-05-27 13:29:01 +02:00
parent 63fedf42a5
commit fadf72bdbf
5 changed files with 74 additions and 11 deletions
@@ -116,10 +116,11 @@ export class AutocompleteServiceManager {
getRecentlyViewedSnippets: () => {
// Reuse the LRU populated by the classic provider — keeps a single
// RecentlyVisitedRangesService instance instead of double-tracking.
// Snippets are filtered against the ignore controller before sending.
// Suppress snippets until access checks are available, then include
// only content explicitly approved by the ignore controller.
const raw = this.inlineCompletionProvider.recentlyVisitedRangesService.getSnippets()
const ignore = this.ignoreControllerSync
const allowed = ignore ? raw.filter((s) => ignore.validateAccess(s.filepath)) : raw
const allowed = ignore ? raw.filter((s) => ignore.validateAccess(s.filepath)) : []
return toMercuryRecentSnippets(allowed)
},
onFatalError: (status) => this.handleFatalAutocompleteError(status),
@@ -16,7 +16,7 @@ export interface NextEditProviderDeps {
/** Optional source of recently-viewed snippets (kilocode's VisibleCodeTracker can adapt to this). */
getRecentlyViewedSnippets?: (document: vscode.TextDocument) => MercuryRecentSnippet[]
/** Returns false for files that must not be sent to a server (.env etc). */
isFileAllowed?: (fsPath: string) => Promise<boolean>
isFileAllowed: (fsPath: string) => Promise<boolean>
/** Telemetry hook fired on every suggestion result. */
onSuggestion?: (event: NextEditSuggestionEvent) => void
onFatalError?: (status: number | null) => void
@@ -67,8 +67,8 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion
if (document.uri.scheme !== "file") return undefined
if (this.deps.suggestionManager?.isPending()) return undefined
// Never send an ignored file (.env, secrets, etc.) to the model.
if (this.deps.isFileAllowed && !(await this.deps.isFileAllowed(document.uri.fsPath))) return undefined
// Never send a file unless the access policy explicitly approves it.
if (!(await this.allowed(document.uri.fsPath))) return undefined
const isExplicit = context.triggerKind === vscode.InlineCompletionTriggerKind.Invoke
if (!isExplicit) {
@@ -95,6 +95,12 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion
}
}
private async allowed(path: string): Promise<boolean> {
const allow = this.deps.isFileAllowed
if (!allow) return false
return allow(path).catch(() => false)
}
private swapAbortController(token: vscode.CancellationToken): AbortController {
this.currentAbort?.abort()
const abort = new AbortController()
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest"
import * as vscode from "vscode"
import type { KiloConnectionService } from "../../../cli-backend"
import { NextEditInlineCompletionProvider } from "../NextEditInlineCompletionProvider"
import { NextEditInlineCompletionProvider, type NextEditProviderDeps } from "../NextEditInlineCompletionProvider"
import type { NextEditSuggestionManager } from "../NextEditSuggestionManager"
vi.mock("vscode", () => {
@@ -55,14 +55,51 @@ function doc(text: string): vscode.TextDocument {
range: { end: new vscode.Position(line, lines[line].length) },
}),
getText: () => text,
uri: { fsPath: "/workspace/test.ts", scheme: "file" },
} as unknown as vscode.TextDocument
}
describe("NextEditInlineCompletionProvider", () => {
it("does not send a document when the access policy is missing at runtime", async () => {
const connection = { getClientAsync: vi.fn() }
const provider = new NextEditInlineCompletionProvider({ connectionService: connection } as unknown as NextEditProviderDeps)
const out = await provider.provideInlineCompletionItems(
doc("const value = 1"),
new vscode.Position(0, 0),
{} as vscode.InlineCompletionContext,
{} as vscode.CancellationToken,
)
expect(out).toBeUndefined()
expect(connection.getClientAsync).not.toHaveBeenCalled()
provider.dispose()
})
it("does not send a document when the access policy fails", async () => {
const connection = { getClientAsync: vi.fn() }
const provider = new NextEditInlineCompletionProvider({
connectionService: connection as unknown as KiloConnectionService,
isFileAllowed: async () => Promise.reject(new Error("unavailable")),
})
const out = await provider.provideInlineCompletionItems(
doc("const value = 1"),
new vscode.Position(0, 0),
{} as vscode.InlineCompletionContext,
{} as vscode.CancellationToken,
)
expect(out).toBeUndefined()
expect(connection.getClientAsync).not.toHaveBeenCalled()
provider.dispose()
})
it("stashes same-line rewrites before the cursor for decorated acceptance", () => {
const mgr = { clear: vi.fn(), setPending: vi.fn() }
const provider = new NextEditInlineCompletionProvider({
connectionService: {} as KiloConnectionService,
isFileAllowed: async () => true,
suggestionManager: mgr as unknown as NextEditSuggestionManager,
})
const text = "const oldName = make()"
@@ -90,6 +127,7 @@ describe("NextEditInlineCompletionProvider", () => {
const mgr = { clear: vi.fn(), setPending: vi.fn() }
const provider = new NextEditInlineCompletionProvider({
connectionService: {} as KiloConnectionService,
isFileAllowed: async () => true,
suggestionManager: mgr as unknown as NextEditSuggestionManager,
})
@@ -111,6 +149,7 @@ describe("NextEditInlineCompletionProvider", () => {
const mgr = { clear: vi.fn(), setPending: vi.fn() }
const provider = new NextEditInlineCompletionProvider({
connectionService: {} as KiloConnectionService,
isFileAllowed: async () => true,
suggestionManager: mgr as unknown as NextEditSuggestionManager,
})
@@ -34,7 +34,7 @@ function doc(path: string, initial: string): Doc {
describe("EditHistoryTracker", () => {
it("retains chronological edits across files for Mercury context", async () => {
const tracker = new EditHistoryTracker()
const tracker = new EditHistoryTracker({ isFileAllowed: async () => true })
const a = doc("/workspace/a.ts", "const a = 1\n")
const b = doc("/workspace/b.ts", "const b = 1\n")
const open = (vscode.workspace as unknown as { open(doc: vscode.TextDocument): void }).open
@@ -43,6 +43,7 @@ describe("EditHistoryTracker", () => {
open(b)
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
a.setText("const a = 2\n")
await tracker.flush(a)
b.setText("const b = 2\n")
@@ -58,6 +59,21 @@ describe("EditHistoryTracker", () => {
tracker.dispose()
})
it("does not retain edits when the access policy is missing at runtime", async () => {
const tracker = new EditHistoryTracker({} as { isFileAllowed: (path: string) => Promise<boolean> })
const a = doc("/workspace/a.ts", "const a = 1\n")
const open = (vscode.workspace as unknown as { open(doc: vscode.TextDocument): void }).open
open(a)
await Promise.resolve()
await Promise.resolve()
a.setText("const a = 2\n")
await tracker.flush(a)
expect(await tracker.getRecentDiffs()).toEqual([])
tracker.dispose()
})
it("never returns edits from denied documents", async () => {
const denied = new Set(["/workspace/.env"])
const tracker = new EditHistoryTracker({ isFileAllowed: async (path) => !denied.has(path) })
@@ -7,7 +7,7 @@ const DEFAULT_MAX_DIFFS = 5
type Options = {
debounceMs?: number
maxDiffs?: number
isFileAllowed?: (fsPath: string) => Promise<boolean>
isFileAllowed: (fsPath: string) => Promise<boolean>
}
type Diff = {
@@ -30,7 +30,7 @@ export class EditHistoryTracker implements vscode.Disposable {
private readonly diffs: Diff[] = []
private readonly subscriptions: vscode.Disposable[] = []
constructor(private readonly options: Options = {}) {
constructor(private readonly options: Options) {
const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS
// Seed snapshots on open so the FIRST edit in a freshly-opened file is
@@ -152,8 +152,9 @@ export class EditHistoryTracker implements vscode.Disposable {
}
private async allowed(key: string): Promise<boolean> {
if (!this.options.isFileAllowed) return true
return this.options.isFileAllowed(key).catch(() => false)
const allow = this.options.isFileAllowed
if (!allow) return false
return allow(key).catch(() => false)
}
private reject(key: string): void {