Skip to content

Commit ba42cbd

Browse files
committed
fix: enforce .rooignore rules for codebase indexing, file reads, and file listing
- Harden validateAccess() to fall back to original path when realpath resolves outside cwd (fixes submodule/symlink bypass) - Change error handling in validateAccess() to fail closed (deny access) instead of fail open - Add .rooignore post-filtering in CodebaseSearchTool to exclude ignored files from search results even if they were previously indexed - Pass RooIgnoreController from manager through service-factory to scanner so the scanner reuses the workspace-root controller instead of creating its own from the scan directory - Fix FileWatcher to initialize fallback RooIgnoreController in initialize() so .rooignore rules load even when manager controller is not passed - Add tests for realpath-outside-cwd fallback, fail-closed error handling, CodebaseSearchTool rooignore filtering, and scanner controller passthrough Fixes #11797
1 parent 1930531 commit ba42cbd

9 files changed

Lines changed: 335 additions & 26 deletions

File tree

src/core/ignore/RooIgnoreController.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,14 +104,26 @@ export class RooIgnoreController {
104104
realPath = absolutePath
105105
}
106106

107-
// Convert real path to relative for .rooignore checking
108-
const relativePath = path.relative(this.cwd, realPath).toPosix()
107+
// If realpath resolved outside cwd (e.g. symlinks, submodules),
108+
// fall back to the original absolute path for relative computation.
109+
// This prevents path.relative from producing "../" paths that the
110+
// ignore library cannot match against .rooignore patterns.
111+
const relativeFromReal = path.relative(this.cwd, realPath)
112+
const effectivePath = relativeFromReal.startsWith("..") ? absolutePath : realPath
113+
114+
// Convert to relative for .rooignore checking
115+
const relativePath = path.relative(this.cwd, effectivePath).toPosix()
116+
117+
// If the path is still outside cwd after fallback, deny access (fail closed)
118+
if (relativePath.startsWith("..")) {
119+
return false
120+
}
109121

110122
// Check if the real path is ignored
111123
return !this.ignoreInstance.ignores(relativePath)
112124
} catch (error) {
113-
// Allow access to files outside cwd or on errors (backward compatibility)
114-
return true
125+
// Fail closed: deny access on unexpected errors for security
126+
return false
115127
}
116128
}
117129

src/core/ignore/__tests__/RooIgnoreController.security.spec.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -182,21 +182,21 @@ describe("RooIgnoreController Security Tests", () => {
182182
const absolutePathToAllowed = path.join(TEST_CWD, "src/app.js")
183183
expect(controller.validateAccess(absolutePathToAllowed)).toBe(true)
184184

185-
// Absolute path outside cwd should be allowed
186-
expect(controller.validateAccess("/etc/hosts")).toBe(true)
187-
expect(controller.validateAccess("/var/log/system.log")).toBe(true)
185+
// Absolute path outside cwd should be denied (fail closed)
186+
expect(controller.validateAccess("/etc/hosts")).toBe(false)
187+
expect(controller.validateAccess("/var/log/system.log")).toBe(false)
188188
})
189189

190190
/**
191-
* Tests that paths outside cwd are allowed
191+
* Tests that paths outside cwd are denied (fail closed for security)
192192
*/
193-
it("should allow paths outside the current working directory", () => {
194-
// Paths outside cwd should be allowed
195-
expect(controller.validateAccess("../outside-project/file.txt")).toBe(true)
196-
expect(controller.validateAccess("../../other-project/secrets/keys.json")).toBe(true)
193+
it("should deny access to paths outside the current working directory", () => {
194+
// Paths outside cwd should be denied
195+
expect(controller.validateAccess("../outside-project/file.txt")).toBe(false)
196+
expect(controller.validateAccess("../../other-project/secrets/keys.json")).toBe(false)
197197

198198
// Edge case: path that would be ignored if inside cwd
199-
expect(controller.validateAccess("/other/path/secrets/keys.json")).toBe(true)
199+
expect(controller.validateAccess("/other/path/secrets/keys.json")).toBe(false)
200200
})
201201
})
202202

src/core/ignore/__tests__/RooIgnoreController.spec.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -199,14 +199,14 @@ describe("RooIgnoreController", () => {
199199
})
200200

201201
/**
202-
* Tests handling of paths outside cwd
202+
* Tests handling of paths outside cwd - should deny access (fail closed)
203203
*/
204-
it("should allow access to paths outside cwd", () => {
204+
it("should deny access to paths outside cwd", () => {
205205
// Path traversal outside cwd
206-
expect(controller.validateAccess("../outside-project/file.txt")).toBe(true)
206+
expect(controller.validateAccess("../outside-project/file.txt")).toBe(false)
207207

208208
// Completely different path
209-
expect(controller.validateAccess("/etc/hosts")).toBe(true)
209+
expect(controller.validateAccess("/etc/hosts")).toBe(false)
210210
})
211211

212212
/**
@@ -244,6 +244,43 @@ describe("RooIgnoreController", () => {
244244
// Symlink to ignored file should also be blocked
245245
expect(controller.validateAccess("config.json")).toBe(false)
246246
})
247+
248+
/**
249+
* Tests that when realpathSync resolves outside cwd (e.g. submodules),
250+
* the original path is used for ignore checking instead.
251+
*/
252+
it("should fall back to original path when realpath resolves outside cwd", () => {
253+
const mockRealpathSync = vi.mocked(fsSync.realpathSync)
254+
mockRealpathSync.mockImplementation((filePath) => {
255+
// Simulate a submodule file resolving to a path outside the workspace
256+
if (filePath.toString().includes("node_modules/submod/file.js")) {
257+
return "/some/other/location/file.js"
258+
}
259+
return filePath.toString()
260+
})
261+
262+
// Even though realpath resolves outside cwd, the original relative path
263+
// should be used for .rooignore checking, and node_modules is ignored
264+
expect(controller.validateAccess("node_modules/submod/file.js")).toBe(false)
265+
})
266+
267+
/**
268+
* Tests that unexpected errors in validateAccess fail closed (deny access)
269+
*/
270+
it("should deny access on unexpected errors (fail closed)", () => {
271+
// Force the ignoreInstance.ignores method to throw an error
272+
// by corrupting the internal ignore instance
273+
const originalIgnores = controller["ignoreInstance"].ignores
274+
controller["ignoreInstance"].ignores = () => {
275+
throw new Error("Unexpected ignore error")
276+
}
277+
278+
// Should deny access on error (fail closed)
279+
expect(controller.validateAccess("src/app.ts")).toBe(false)
280+
281+
// Restore
282+
controller["ignoreInstance"].ignores = originalIgnores
283+
})
247284
})
248285

249286
describe("validateCommand", () => {

src/core/tools/CodebaseSearchTool.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,20 @@ export class CodebaseSearchTool extends BaseTool<"codebase_search"> {
7272

7373
const searchResults: VectorStoreSearchResult[] = await manager.searchIndex(query, directoryPrefix)
7474

75-
if (!searchResults || searchResults.length === 0) {
75+
// Post-filter results through .rooignore to exclude files that were
76+
// indexed before being added to .rooignore, or that bypassed filtering
77+
// during indexing (e.g. due to symlink/submodule path resolution).
78+
const filteredResults = task.rooIgnoreController
79+
? searchResults.filter((result) => {
80+
// Guard clause: skip entries without a valid payload/filePath.
81+
// These are structural no-ops (not an ignore decision).
82+
if (!result.payload || !("filePath" in result.payload)) return false
83+
const relativePath = vscode.workspace.asRelativePath(result.payload.filePath, false)
84+
return task.rooIgnoreController!.validateAccess(relativePath)
85+
})
86+
: searchResults
87+
88+
if (!filteredResults || filteredResults.length === 0) {
7689
pushToolResult(`No relevant code snippets found for the query: "${query}"`)
7790
return
7891
}
@@ -91,7 +104,7 @@ export class CodebaseSearchTool extends BaseTool<"codebase_search"> {
91104
}>
92105
}
93106

94-
searchResults.forEach((result) => {
107+
filteredResults.forEach((result) => {
95108
if (!result.payload) return
96109
if (!("filePath" in result.payload)) return
97110

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// npx vitest core/tools/__tests__/codebaseSearchTool.spec.ts
2+
3+
import { codebaseSearchTool } from "../CodebaseSearchTool"
4+
import { CodeIndexManager } from "../../../services/code-index/manager"
5+
import { formatResponse } from "../../prompts/responses"
6+
7+
vi.mock("vscode", () => ({
8+
workspace: {
9+
asRelativePath: vi.fn((filePath: string) => {
10+
// Simple mock: return the path as-is for testing
11+
return filePath
12+
}),
13+
},
14+
}))
15+
16+
vi.mock("../../../services/code-index/manager", () => ({
17+
CodeIndexManager: {
18+
getInstance: vi.fn(),
19+
},
20+
}))
21+
22+
vi.mock("../../prompts/responses", () => ({
23+
formatResponse: {
24+
toolDenied: vi.fn(() => "Tool denied"),
25+
toolError: vi.fn((msg: string) => `Error: ${msg}`),
26+
},
27+
}))
28+
29+
vi.mock("../../../utils/path", () => ({
30+
getWorkspacePath: vi.fn(() => "/test/workspace"),
31+
}))
32+
33+
describe("CodebaseSearchTool", () => {
34+
let mockTask: any
35+
let mockCallbacks: any
36+
let mockManager: any
37+
let pushToolResultValue: string | undefined
38+
39+
beforeEach(() => {
40+
vi.clearAllMocks()
41+
pushToolResultValue = undefined
42+
43+
mockManager = {
44+
isFeatureEnabled: true,
45+
isFeatureConfigured: true,
46+
searchIndex: vi.fn(),
47+
}
48+
;(CodeIndexManager.getInstance as any).mockReturnValue(mockManager)
49+
50+
mockTask = {
51+
cwd: "/test/workspace",
52+
consecutiveMistakeCount: 0,
53+
didToolFailInCurrentTurn: false,
54+
sayAndCreateMissingParamError: vi.fn().mockResolvedValue("Missing param error"),
55+
say: vi.fn().mockResolvedValue(undefined),
56+
providerRef: {
57+
deref: vi.fn().mockReturnValue({
58+
context: { extensionPath: "/test" },
59+
}),
60+
},
61+
rooIgnoreController: {
62+
validateAccess: vi.fn().mockReturnValue(true),
63+
},
64+
}
65+
66+
mockCallbacks = {
67+
askApproval: vi.fn().mockResolvedValue(true),
68+
handleError: vi.fn(),
69+
pushToolResult: vi.fn((result: string) => {
70+
pushToolResultValue = result
71+
}),
72+
}
73+
})
74+
75+
it("should filter out rooignored files from search results", async () => {
76+
// Set up search results with some files that should be ignored
77+
mockManager.searchIndex.mockResolvedValue([
78+
{
79+
score: 0.95,
80+
payload: {
81+
filePath: "src/app.ts",
82+
startLine: 1,
83+
endLine: 10,
84+
codeChunk: "const app = express()",
85+
},
86+
},
87+
{
88+
score: 0.9,
89+
payload: {
90+
filePath: "vendor/some-lib/crypto.c",
91+
startLine: 1,
92+
endLine: 20,
93+
codeChunk: "void crypto_init() {}",
94+
},
95+
},
96+
{
97+
score: 0.85,
98+
payload: {
99+
filePath: "src/utils.ts",
100+
startLine: 5,
101+
endLine: 15,
102+
codeChunk: "export function helper() {}",
103+
},
104+
},
105+
])
106+
107+
// Mock rooIgnoreController to block vendor/ files
108+
mockTask.rooIgnoreController.validateAccess.mockImplementation((filePath: string) => {
109+
return !filePath.includes("vendor/")
110+
})
111+
112+
await codebaseSearchTool.execute({ query: "crypto" }, mockTask, mockCallbacks)
113+
114+
// Should have called pushToolResult with results that don't include vendor/ files
115+
expect(mockCallbacks.pushToolResult).toHaveBeenCalled()
116+
const result = pushToolResultValue!
117+
expect(result).toContain("src/app.ts")
118+
expect(result).toContain("src/utils.ts")
119+
expect(result).not.toContain("vendor/some-lib/crypto.c")
120+
})
121+
122+
it("should return no results message when all results are filtered by rooignore", async () => {
123+
mockManager.searchIndex.mockResolvedValue([
124+
{
125+
score: 0.9,
126+
payload: {
127+
filePath: "vendor/some-lib/crypto.c",
128+
startLine: 1,
129+
endLine: 20,
130+
codeChunk: "void crypto_init() {}",
131+
},
132+
},
133+
])
134+
135+
// Mock rooIgnoreController to block all results
136+
mockTask.rooIgnoreController.validateAccess.mockReturnValue(false)
137+
138+
await codebaseSearchTool.execute({ query: "crypto" }, mockTask, mockCallbacks)
139+
140+
expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith(
141+
'No relevant code snippets found for the query: "crypto"',
142+
)
143+
})
144+
145+
it("should pass all results through when no rooIgnoreController is set", async () => {
146+
mockTask.rooIgnoreController = undefined
147+
148+
mockManager.searchIndex.mockResolvedValue([
149+
{
150+
score: 0.95,
151+
payload: {
152+
filePath: "vendor/some-lib/crypto.c",
153+
startLine: 1,
154+
endLine: 20,
155+
codeChunk: "void crypto_init() {}",
156+
},
157+
},
158+
])
159+
160+
await codebaseSearchTool.execute({ query: "crypto" }, mockTask, mockCallbacks)
161+
162+
expect(mockCallbacks.pushToolResult).toHaveBeenCalled()
163+
const result = pushToolResultValue!
164+
expect(result).toContain("vendor/some-lib/crypto.c")
165+
})
166+
})

src/services/code-index/processors/__tests__/scanner.spec.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,5 +457,55 @@ describe("DirectoryScanner", () => {
457457
// Deleted file cleanup should not have run
458458
expect(mockVectorStore.deletePointsByFilePath).not.toHaveBeenCalled()
459459
})
460+
461+
it("should use the provided RooIgnoreController and not create a new one", async () => {
462+
const { RooIgnoreController } = await import("../../../../core/ignore/RooIgnoreController")
463+
const { listFiles } = await import("../../../glob/list-files")
464+
vi.mocked(listFiles).mockResolvedValue([["test/file1.js"], false])
465+
466+
// Create a mock controller with a spy on filterPaths
467+
const providedController = new RooIgnoreController("/test")
468+
const filterPathsSpy = vi.spyOn(providedController, "filterPaths").mockReturnValue(["test/file1.js"])
469+
const initializeSpy = vi.spyOn(providedController, "initialize")
470+
471+
// Create scanner WITH the provided controller
472+
const scannerWithController = new DirectoryScanner(
473+
mockEmbedder,
474+
mockVectorStore,
475+
mockCodeParser,
476+
mockCacheManager,
477+
mockIgnoreInstance,
478+
undefined,
479+
providedController,
480+
)
481+
482+
await scannerWithController.scanDirectory("/test")
483+
484+
// The provided controller's filterPaths should have been called
485+
expect(filterPathsSpy).toHaveBeenCalled()
486+
// The provided controller should NOT have been re-initialized
487+
// (it was already initialized by the manager)
488+
expect(initializeSpy).not.toHaveBeenCalled()
489+
})
490+
491+
it("should create and initialize a new RooIgnoreController when none is provided", async () => {
492+
const { RooIgnoreController } = await import("../../../../core/ignore/RooIgnoreController")
493+
const { listFiles } = await import("../../../glob/list-files")
494+
vi.mocked(listFiles).mockResolvedValue([["test/file1.js"], false])
495+
496+
// Spy on the prototype's initialize and filterPaths methods
497+
const initSpy = vi.spyOn(RooIgnoreController.prototype, "initialize")
498+
const filterSpy = vi.spyOn(RooIgnoreController.prototype, "filterPaths")
499+
500+
// Scanner without a provided controller (default from beforeEach)
501+
await scanner.scanDirectory("/test")
502+
503+
// A new RooIgnoreController should have been created and initialized internally
504+
expect(initSpy).toHaveBeenCalled()
505+
expect(filterSpy).toHaveBeenCalled()
506+
507+
initSpy.mockRestore()
508+
filterSpy.mockRestore()
509+
})
460510
})
461511
})

0 commit comments

Comments
 (0)