diff --git a/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts b/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts index 0f2709ff896..23e1dcf2979 100644 --- a/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts +++ b/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts @@ -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), diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts index 1e1695c1b4b..e1d426579ab 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts @@ -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 + isFileAllowed: (fsPath: string) => Promise /** 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 { + 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() diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/NextEditInlineCompletionProvider.spec.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/NextEditInlineCompletionProvider.spec.ts index f33cf819392..3c12778974b 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/NextEditInlineCompletionProvider.spec.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/NextEditInlineCompletionProvider.spec.ts @@ -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, }) diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editHistoryTracker.spec.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editHistoryTracker.spec.ts index 67515b654db..00f22b85b2a 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editHistoryTracker.spec.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/editHistoryTracker.spec.ts @@ -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 }) + 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) }) diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts index 2d1baa6dd72..9edebc298f6 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/editHistoryTracker.ts @@ -7,7 +7,7 @@ const DEFAULT_MAX_DIFFS = 5 type Options = { debounceMs?: number maxDiffs?: number - isFileAllowed?: (fsPath: string) => Promise + isFileAllowed: (fsPath: string) => Promise } 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 { - 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 {