fix(cli): prevent filesystem root indexing

This commit is contained in:
marius-kilocode
2026-08-26 22:02:42 +02:00
parent 20b594b051
commit 6e05f48fb8
6 changed files with 274 additions and 45 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Prevent filesystem-root workspaces from starting FFF and codebase indexing.
+35 -18
View File
@@ -13,7 +13,7 @@ import { RelativePath } from "../schema"
import { Flag } from "../flag/flag"
// kilocode_change start
import * as SearchTarget from "../kilocode/search-target"
import { scanning } from "../kilocode/fff"
import { allowed, scanning } from "../kilocode/fff"
// kilocode_change end
export interface Interface {
@@ -48,20 +48,25 @@ export const ripgrepLayer = Layer.effect(
directories: [] as string[],
}
const directories = new Set<string>()
yield* ripgrep
.find({
cwd: location.directory,
pattern: "*",
limit: location.vcs ? Number.MAX_SAFE_INTEGER : 100_000,
onEntry: (entry) =>
Effect.sync(() => {
state.files.push(entry.path)
const parts = entry.path.split("/")
parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep))
state.directories = Array.from(directories)
}),
})
.pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope))
// kilocode_change start - never eagerly enumerate a filesystem root.
const real = yield* fs.realPath(location.directory).pipe(Effect.catch(() => Effect.succeed(location.directory)))
if (allowed(real)) {
yield* ripgrep
.find({
cwd: real,
pattern: "*",
limit: location.vcs ? Number.MAX_SAFE_INTEGER : 100_000,
onEntry: (entry) =>
Effect.sync(() => {
state.files.push(entry.path)
const parts = entry.path.split("/")
parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep))
state.directories = Array.from(directories)
}),
})
.pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope))
}
// kilocode_change end
return Service.of({
glob: (input) =>
Effect.gen(function* () {
@@ -176,14 +181,16 @@ export const fffLayer = Layer.effect(
}).pipe(Effect.ignore)
const make = Effect.uninterruptible(
Effect.gen(function* () {
const real = yield* fs.realPath(location.directory).pipe(Effect.orDie)
if (!allowed(real)) return yield* Effect.die(new Error("FFF indexing is disabled for filesystem roots."))
const result = yield* Effect.try({
try: () =>
Fff.create({
basePath: location.directory,
basePath: real,
aiMode: true,
disableMmapCache: true,
disableContentIndexing: true,
...scanning(location.directory),
...scanning(real),
}),
catch: (cause) => cause,
}).pipe(Effect.orDie)
@@ -329,7 +336,17 @@ export const fffLayer = Layer.effect(
}),
)
const layer = Layer.unwrap(Effect.sync(() => (Flag.KILO_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)))
// kilocode_change start - FFF owns an initial scan and watcher, so roots must use the non-indexing fallback.
const layer = Layer.unwrap(
Effect.gen(function* () {
if (Flag.KILO_DISABLE_FFF || !Fff.available()) return ripgrepLayer
const location = yield* Location.Service
const fs = yield* FSUtil.Service
const real = yield* fs.realPath(location.directory).pipe(Effect.catch(() => Effect.succeed(location.directory)))
return allowed(real) ? fffLayer : ripgrepLayer
}),
)
// kilocode_change end
export const locationLayer = layer
+14 -5
View File
@@ -1,9 +1,18 @@
import os from "os"
import path from "path"
export function scanning(directory: string) {
return {
enableFsRootScanning: directory === path.parse(directory).root,
enableHomeDirScanning: directory === os.homedir(),
}
function root(directory: string, api: typeof path.posix) {
if (!api.isAbsolute(directory)) return false
return api.normalize(directory) === api.normalize(api.parse(directory).root)
}
export function allowed(directory: string) {
const value = path.win32.normalize(directory)
const prefix = "\\\\?\\UNC\\"
const windows = value.toUpperCase().startsWith(prefix.toUpperCase()) ? `\\\\${value.slice(prefix.length)}` : value
return !root(directory, path.posix) && !root(windows, path.win32)
}
export function scanning(directory: string) {
return { enableHomeDirScanning: directory === os.homedir() }
}
+140 -19
View File
@@ -3,43 +3,162 @@ import { FileFinder, type InitOptions } from "@ff-labs/fff-bun"
import "@opencode-ai/core/filesystem"
import { Fff } from "@opencode-ai/core/filesystem/fff.bun"
import { FSUtil } from "@opencode-ai/core/fs-util"
import fs from "node:fs/promises"
import os from "os"
import path from "path"
import { Context, Effect, Layer, Scope } from "effect"
import { scanning } from "@opencode-ai/core/kilocode/fff"
import { Cause, Context, Effect, Layer, Scope } from "effect"
import { allowed, scanning } from "@opencode-ai/core/kilocode/fff"
import { Location } from "@opencode-ai/core/location"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
describe("FFF scanning boundaries", () => {
test("enables filesystem-root scanning only at the exact root", () => {
const root = path.parse(process.cwd()).root
expect(scanning(root)).toEqual({ enableFsRootScanning: true, enableHomeDirScanning: root === os.homedir() })
expect(scanning(path.join(root, "workspace"))).toEqual({
enableFsRootScanning: false,
enableHomeDirScanning: false,
})
test("rejects POSIX, Windows drive, and UNC roots", () => {
expect(allowed("/")).toBe(false)
expect(allowed("C:\\")).toBe(false)
expect(allowed("D:/")).toBe(false)
expect(allowed("\\\\server\\share\\")).toBe(false)
expect(allowed("\\\\?\\C:\\workspace\\..")).toBe(false)
expect(allowed("\\\\?\\UNC\\server\\share\\")).toBe(false)
})
test("enables home scanning only at the exact home directory", () => {
const home = os.homedir()
expect(scanning(home)).toEqual({
enableFsRootScanning: home === path.parse(home).root,
enableHomeDirScanning: true,
})
expect(scanning(path.join(home, "workspace"))).toEqual({
enableFsRootScanning: false,
enableHomeDirScanning: false,
})
test("allows ordinary project directories", () => {
expect(allowed("/workspace")).toBe(true)
expect(allowed("C:\\workspace")).toBe(true)
expect(allowed("D:/workspace")).toBe(true)
expect(allowed("\\\\server\\share\\workspace")).toBe(true)
})
test("keeps explicit home scanning without opting into filesystem-root scanning", () => {
expect(scanning(os.homedir())).toEqual({ enableHomeDirScanning: true })
expect(scanning(path.join(os.homedir(), "workspace"))).toEqual({ enableHomeDirScanning: false })
expect(scanning(os.homedir())).not.toHaveProperty("enableFsRootScanning")
})
test("does not start FFF or fallback indexing at a filesystem root", async () => {
const root = path.parse(process.cwd()).root
const tmp = process.platform === "win32" ? undefined : await tmpdir()
const link = tmp ? path.join(tmp.path, "root") : undefined
if (link) await fs.symlink(root, link)
const create = FileFinder.create
const calls = { fff: 0, ripgrep: 0 }
FileFinder.create = () => {
calls.fff++
return { ok: false, error: "FFF must not start at a filesystem root" }
}
const ripgrep = Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
find: () =>
Effect.sync(() => {
calls.ripgrep++
return []
}),
glob: () => Effect.succeed({ items: [], truncated: false, partial: false }),
grep: () => Effect.succeed({ items: [], truncated: false, partial: false }),
}),
)
try {
for (const directory of [root, link].filter((item): item is string => item !== undefined)) {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const { FileSystemSearch } = yield* Effect.promise(() => import("@opencode-ai/core/filesystem/search"))
const layer = FileSystemSearch.locationLayer.pipe(
Layer.provide(FSUtil.defaultLayer),
Layer.provide(ripgrep),
Layer.provide(
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
),
),
)
const context = yield* Layer.build(layer)
const service = Context.get(context, FileSystemSearch.Service)
expect(yield* service.find({ query: "", type: "file", limit: 1 })).toEqual([])
}),
),
)
}
expect(calls).toEqual({ fff: 0, ripgrep: 0 })
} finally {
FileFinder.create = create
await tmp?.[Symbol.asyncDispose]()
}
})
})
describe("FFF lifecycle", () => {
test("rechecks a changed symlink before creating a picker and retries with the canonical path", async () => {
await using tmp = await tmpdir()
const project = path.join(tmp.path, "project")
const link = path.join(tmp.path, "link")
const type = process.platform === "win32" ? "junction" : "dir"
await fs.mkdir(project)
await fs.symlink(project, link, type)
const create = FileFinder.create
const calls: InitOptions[] = []
FileFinder.create = (opts) => {
calls.push(opts)
return { ok: false, error: "native creation intercepted" }
}
try {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const { FileSystemSearch } = yield* Effect.promise(() => import("@opencode-ai/core/filesystem/search"))
const layer = FileSystemSearch.fffLayer.pipe(
Layer.provide(FSUtil.defaultLayer),
Layer.provide(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(link) }))),
),
)
const context = yield* Layer.build(layer)
const service = Context.get(context, FileSystemSearch.Service)
expect(calls).toEqual([])
yield* Effect.promise(async () => {
await fs.unlink(link)
await fs.symlink(path.parse(project).root, link, type)
})
for (const effect of [
service.find({ query: "", type: "file", limit: 1 }).pipe(Effect.asVoid),
service.glob({ pattern: "*", limit: 1 }).pipe(Effect.asVoid),
service.grep({ pattern: "needle", limit: 1 }).pipe(Effect.asVoid),
]) {
const result = yield* Effect.exit(effect)
expect(result._tag).toBe("Failure")
if (result._tag === "Failure") {
expect(Cause.pretty(result.cause)).toContain("FFF indexing is disabled for filesystem roots.")
}
}
expect(calls).toEqual([])
yield* Effect.promise(async () => {
await fs.unlink(link)
await fs.symlink(project, link, type)
})
yield* Effect.exit(service.find({ query: "", type: "file", limit: 1 }))
expect(calls).toHaveLength(1)
expect(calls[0].basePath).toBe(project)
}),
),
)
} finally {
FileFinder.create = create
}
})
test("retries a failed first search and reuses one picker", async () => {
if (!Fff.available()) return
const dir = await tmpdir()
expect(allowed(dir.path)).toBe(true)
const create = FileFinder.create
const calls = { create: 0, destroy: 0, opts: undefined as InitOptions | undefined }
try {
@@ -97,6 +216,8 @@ describe("FFF lifecycle", () => {
expect(calls.create).toBe(2)
expect(calls.opts?.disableMmapCache).toBe(true)
expect(calls.opts?.disableContentIndexing).toBe(true)
expect(calls.opts).not.toHaveProperty("enableFsRootScanning")
expect(calls.opts?.enableHomeDirScanning).toBe(false)
yield* service.find({ query: "", type: "file", limit: 1 })
expect(calls.create).toBe(2)
+12 -3
View File
@@ -1,5 +1,6 @@
import z from "zod"
import path from "path"
import { realpathSync } from "node:fs"
import { Effect, Schema } from "effect"
import { type IndexingTelemetryEvent, type VectorStoreSearchResult } from "@kilocode/kilo-indexing/engine"
import { toIndexingConfigInput, type IndexingConfig } from "@kilocode/kilo-indexing/config"
@@ -7,6 +8,7 @@ import { hasIndexingPlugin } from "@kilocode/kilo-indexing/detect"
import { IndexingStatus, disabledIndexingStatus } from "@kilocode/kilo-indexing/status"
import { Telemetry } from "@kilocode/kilo-telemetry"
import { fetchKiloEmbeddingModelCatalog } from "@kilocode/kilo-gateway"
import { allowed } from "@opencode-ai/core/kilocode/fff"
import { Instance } from "@/kilocode/instance"
import { Bus } from "@/bus"
import { Config } from "@/config/config"
@@ -32,6 +34,7 @@ const consent = new Map<string, boolean>()
const missing = () => disabledIndexingStatus("Indexing plugin is not enabled for this workspace.")
const noWorkspace = () =>
disabledIndexingStatus("Codebase indexing is disabled because no workspace folder is open in VS Code.")
const unsafeRoot = () => disabledIndexingStatus("Codebase indexing is disabled for filesystem roots.")
const noConsent = () =>
disabledIndexingStatus("Codebase indexing is disabled until you enable it for this project in Kilo Settings.")
@@ -264,6 +267,15 @@ export namespace KiloIndexing {
const boot = async (hit: Cache): Promise<Entry> => {
const dir = Instance.directory
if (process.env["KILO_DISABLE_CODEBASE_INDEXING"] === "vscode-no-workspace") {
return track(hit, await inert(() => noWorkspace()))
}
try {
if (!allowed(realpathSync.native(dir))) return track(hit, await inert(() => unsafeRoot()))
} catch (err) {
log.warn("indexing directory resolution failed", { err, workspacePath: dir })
return track(hit, await inert(() => failed(err)))
}
const startup = await AppRuntime.runPromise(
Effect.gen(function* () {
const baseline = yield* baselineDirectory(dir)
@@ -275,9 +287,6 @@ export namespace KiloIndexing {
const cfg = startup.cfg
const project = (await AppRuntime.runPromise(primaryWorktree(dir))) ?? dir
projects.set(dir, project)
if (process.env["KILO_DISABLE_CODEBASE_INDEXING"] === "vscode-no-workspace") {
return track(hit, await inert(() => noWorkspace()))
}
if (process.env["KILO_PLATFORM"] === "vscode" && !consent.get(project)) {
return track(hit, await inert(() => noConsent()))
}
@@ -533,6 +533,74 @@ describe("indexing startup degradation", () => {
})
})
test("does not allocate an indexing worker for a filesystem root", async () => {
const created: string[] = []
IndexingWorker.override((directory, root, hooks) => {
created.push(directory)
return inline(directory, root, hooks)
})
const root = path.parse(process.cwd()).root
await using tmp = await tmpdir()
const link = process.platform === "win32" ? undefined : path.join(tmp.path, "root")
if (link) await fs.symlink(root, link)
for (const directory of [root, link].filter((item): item is string => item !== undefined)) {
await provideTestInstance({
directory,
fn: async () => {
const status = await KiloIndexing.current()
expect(status).toMatchObject({
state: "Disabled",
message: "Codebase indexing is disabled for filesystem roots.",
})
expect(await KiloIndexing.available()).toBe(false)
expect(KiloIndexing.ready()).toBe(false)
expect(await KiloIndexing.search("filesystem root")).toEqual([])
expect(created).toEqual([])
},
})
}
})
test.each([false, true])("handles removed directories with no-workspace flag %s", async (disabled) => {
await using tmp = await tmpdir({ config: cfg })
process.env["KILO_CONFIG_DIR"] = tmp.path
delete process.env["KILO_DISABLE_CODEBASE_INDEXING"]
if (disabled) process.env["KILO_DISABLE_CODEBASE_INDEXING"] = "vscode-no-workspace"
const directory = path.join(tmp.path, "project")
await fs.mkdir(directory)
const created: string[] = []
IndexingWorker.override((directory) => {
created.push(directory)
throw new Error("removed workspaces must not start an indexing worker")
})
await provideTestInstance({
directory,
fn: async () => {
await fs.rmdir(directory)
await KiloIndexing.init()
const status = await KiloIndexing.current()
expect(await KiloIndexing.available()).toBe(false)
expect(KiloIndexing.ready()).toBe(false)
expect(await KiloIndexing.search("removed workspace")).toEqual([])
expect(created).toEqual([])
if (disabled) {
expect(status).toMatchObject({
state: "Disabled",
message: "Codebase indexing is disabled because no workspace folder is open in VS Code.",
})
return
}
expect(status.state).toBe("Error")
expect(status.message).toContain("Failed to initialize:")
expect(status.message).toContain("ENOENT")
},
})
})
test("does not validate the indexing model when indexing is disabled", async () => {
global.fetch = (() =>
Promise.resolve(