Skip to content
Draft
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
12 changes: 8 additions & 4 deletions src/core/context-tracking/FileContextTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import fs from "fs/promises"
import { ContextProxy } from "../config/ContextProxy"
import type { FileMetadataEntry, RecordSource, TaskMetadata } from "./FileContextTrackerTypes"
import { ClineProvider } from "../webview/ClineProvider"
import { getWorkspaceReadablePath, resolvePathInWorkspace } from "../../utils/pathUtils"

// This class is responsible for tracking file operations that may result in stale context.
// If a user modifies a file outside of Roo, the context may become stale and need to be updated.
Expand Down Expand Up @@ -45,7 +46,7 @@ export class FileContextTracker {
}

// File watchers are set up for each file that is tracked in the task metadata.
async setupFileWatcher(filePath: string) {
async setupFileWatcher(filePath: string, absolutePath?: string) {
// Only setup watcher if it doesn't already exist for this file
if (this.fileWatchers.has(filePath)) {
return
Expand All @@ -57,7 +58,7 @@ export class FileContextTracker {
}

// Create a file system watcher for this specific file
const fileUri = vscode.Uri.file(path.resolve(cwd, filePath))
const fileUri = vscode.Uri.file(absolutePath ?? (await resolvePathInWorkspace(cwd, filePath)))
const watcher = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(path.dirname(fileUri.fsPath), path.basename(fileUri.fsPath)),
)
Expand Down Expand Up @@ -85,10 +86,13 @@ export class FileContextTracker {
return
}

await this.addFileToFileContextTracker(this.taskId, filePath, operation)
const absolutePath = await resolvePathInWorkspace(cwd, filePath)
const trackedPath = getWorkspaceReadablePath(cwd, absolutePath, filePath)

await this.addFileToFileContextTracker(this.taskId, trackedPath, operation)

// Set up file watcher for this file
await this.setupFileWatcher(filePath)
await this.setupFileWatcher(trackedPath, absolutePath)
} catch (error) {
console.error("Failed to track file operation:", error)
}
Expand Down
124 changes: 100 additions & 24 deletions src/core/ignore/RooIgnoreController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import fsSync from "fs"
import ignore, { Ignore } from "ignore"
import * as vscode from "vscode"

import { getWorkspaceRelativePath, getWorkspaceRootForPath } from "../../utils/pathUtils"

export const LOCK_TEXT_SYMBOL = "\u{1F512}"

/**
Expand All @@ -15,6 +17,8 @@ export const LOCK_TEXT_SYMBOL = "\u{1F512}"
export class RooIgnoreController {
private cwd: string
private ignoreInstance: Ignore
private ignoreInstances = new Map<string, Ignore>()
private rooIgnoreContents = new Map<string, string | undefined>()
private disposables: vscode.Disposable[] = []
rooIgnoreContent: string | undefined

Expand All @@ -38,19 +42,23 @@ export class RooIgnoreController {
* Set up the file watcher for .rooignore changes
*/
private setupFileWatcher(): void {
const rooignorePattern = new vscode.RelativePattern(this.cwd, ".rooignore")
this.setupFileWatcherForRoot(this.cwd)
}

private setupFileWatcherForRoot(rootPath: string): void {
const rooignorePattern = new vscode.RelativePattern(rootPath, ".rooignore")
const fileWatcher = vscode.workspace.createFileSystemWatcher(rooignorePattern)

// Watch for changes and updates
this.disposables.push(
fileWatcher.onDidChange(() => {
this.loadRooIgnore()
this.loadRooIgnoreForRoot(rootPath)
}),
fileWatcher.onDidCreate(() => {
this.loadRooIgnore()
this.loadRooIgnoreForRoot(rootPath)
}),
fileWatcher.onDidDelete(() => {
this.loadRooIgnore()
this.loadRooIgnoreForRoot(rootPath)
}),
)

Expand All @@ -62,38 +70,99 @@ export class RooIgnoreController {
* Load custom patterns from .rooignore if it exists
*/
private async loadRooIgnore(): Promise<void> {
await this.loadRooIgnoreForRoot(this.cwd)
}

private async loadRooIgnoreForRoot(rootPath: string): Promise<void> {
try {
// Reset ignore instance to prevent duplicate patterns
this.ignoreInstance = ignore()
const ignorePath = path.join(this.cwd, ".rooignore")
const ignoreInstance = ignore()
const ignorePath = path.join(rootPath, ".rooignore")
if (await fileExistsAtPath(ignorePath)) {
const content = await fs.readFile(ignorePath, "utf8")
this.rooIgnoreContent = content
this.ignoreInstance.add(content)
this.ignoreInstance.add(".rooignore")
ignoreInstance.add(content)
ignoreInstance.add(".rooignore")
this.ignoreInstances.set(rootPath, ignoreInstance)
this.rooIgnoreContents.set(rootPath, content)
} else {
this.rooIgnoreContent = undefined
this.ignoreInstances.set(rootPath, ignoreInstance)
this.rooIgnoreContents.set(rootPath, undefined)
}

if (rootPath === this.cwd) {
Comment thread
roomote[bot] marked this conversation as resolved.
this.ignoreInstance = ignoreInstance
this.rooIgnoreContent = this.rooIgnoreContents.get(rootPath)
}
} catch (error) {
// Should never happen: reading file failed even though it exists
console.error("Unexpected error loading .rooignore:", error)
}
}

private getIgnoreStateForRoot(rootPath: string): { ignoreInstance: Ignore; content: string | undefined } {
const cached = this.ignoreInstances.get(rootPath)
if (cached) {
return { ignoreInstance: cached, content: this.rooIgnoreContents.get(rootPath) }
}

const ignoreInstance = ignore()
try {
const ignorePath = path.join(rootPath, ".rooignore")
if (fsSync.existsSync(ignorePath)) {
const content = fsSync.readFileSync(ignorePath, "utf8")
ignoreInstance.add(content)
ignoreInstance.add(".rooignore")
this.ignoreInstances.set(rootPath, ignoreInstance)
this.rooIgnoreContents.set(rootPath, content)
this.setupFileWatcherForRoot(rootPath)
return { ignoreInstance, content }
}
} catch (error) {
console.error("Unexpected error loading .rooignore:", error)
}

this.ignoreInstances.set(rootPath, ignoreInstance)
this.rooIgnoreContents.set(rootPath, undefined)
this.setupFileWatcherForRoot(rootPath)
return { ignoreInstance, content: undefined }
}

private getKnownWorkspaceRoots(): string[] {
const roots = new Set<string>([this.cwd])
for (const folder of vscode.workspace.workspaceFolders ?? []) {
roots.add(folder.uri.fsPath)
}
return [...roots]
}

private getAvailableIgnoreContents(): Array<{ rootPath: string; content: string }> {
return this.getKnownWorkspaceRoots()
.map((rootPath) => ({ rootPath, content: this.getIgnoreStateForRoot(rootPath).content }))
.filter((entry): entry is { rootPath: string; content: string } => typeof entry.content === "string")
}

/**
* Check if a file should be accessible to the LLM
* Automatically resolves symlinks
* @param filePath - Path to check (relative to cwd)
* @returns true if file is accessible, false if ignored
*/
validateAccess(filePath: string): boolean {
const absolutePath = path.isAbsolute(filePath) ? path.resolve(filePath) : path.resolve(this.cwd, filePath)
const rootPath = getWorkspaceRootForPath(absolutePath, this.cwd)

// Preserve backward compatibility for files outside the task workspace roots.
if (!rootPath) {
return true
}

const { ignoreInstance, content } = this.getIgnoreStateForRoot(rootPath)
// Always allow access if .rooignore does not exist
if (!this.rooIgnoreContent) {
if (!content) {
return true
}
try {
const absolutePath = path.resolve(this.cwd, filePath)

try {
// Follow symlinks to get the real path
let realPath: string
try {
Expand All @@ -105,10 +174,10 @@ export class RooIgnoreController {
}

// Convert real path to relative for .rooignore checking
const relativePath = path.relative(this.cwd, realPath).toPosix()
const relativePath = getWorkspaceRelativePath(rootPath, realPath)

// Check if the real path is ignored
return !this.ignoreInstance.ignores(relativePath)
return !ignoreInstance.ignores(relativePath)
} catch (error) {
// Allow access to files outside cwd or on errors (backward compatibility)
return true
Expand All @@ -121,11 +190,6 @@ export class RooIgnoreController {
* @returns path of file that is being accessed if it is being accessed, undefined if command is allowed
*/
validateCommand(command: string): string | undefined {
// Always allow if no .rooignore exists
if (!this.rooIgnoreContent) {
return undefined
}

// Split command into parts and get the base command
const parts = command.trim().split(/\s+/)
const baseCommand = parts[0].toLowerCase()
Expand Down Expand Up @@ -153,12 +217,13 @@ export class RooIgnoreController {
// Check each argument that could be a file path
for (let i = 1; i < parts.length; i++) {
const arg = parts[i]
const isWindowsAbsolutePath = path.win32.isAbsolute(arg)
// Skip command flags/options (both Unix and PowerShell style)
if (arg.startsWith("-") || arg.startsWith("/")) {
if (arg.startsWith("-") || (arg.startsWith("/") && !path.isAbsolute(arg))) {
Comment thread
roomote[bot] marked this conversation as resolved.
continue
}
// Ignore PowerShell parameter names
if (arg.includes(":")) {
if (arg.includes(":") && !isWindowsAbsolutePath) {
continue
}
// Validate file access
Expand Down Expand Up @@ -204,10 +269,21 @@ export class RooIgnoreController {
* @returns Formatted instructions or undefined if .rooignore doesn't exist
*/
getInstructions(): string | undefined {
if (!this.rooIgnoreContent) {
const ignoreEntries = this.getAvailableIgnoreContents()
if (ignoreEntries.length === 0) {
return undefined
}

return `# .rooignore\n\n(The following is provided by a root-level .rooignore file where the user has specified files and directories that should not be accessed. When using list_files, you'll notice a ${LOCK_TEXT_SYMBOL} next to files that are blocked. Attempting to access the file's contents e.g. through read_file will result in an error.)\n\n${this.rooIgnoreContent}\n.rooignore`
const sections = ignoreEntries
.map(({ rootPath, content }) => {
const workspaceName =
vscode.workspace.workspaceFolders?.find((folder) => folder.uri.fsPath === rootPath)?.name ??
path.basename(rootPath) ??
rootPath
return `## ${workspaceName}\n\n${content}\n.rooignore`
})
.join("\n\n")

return `# .rooignore\n\n(The following is provided by workspace-root .rooignore files where the user has specified files and directories that should not be accessed. When using list_files, you'll notice a ${LOCK_TEXT_SYMBOL} next to files that are blocked. Attempting to access the file's contents e.g. through read_file will result in an error.)\n\n${sections}`
}
}
93 changes: 93 additions & 0 deletions src/core/ignore/__tests__/RooIgnoreController.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ vi.mock("vscode", () => {

return {
workspace: {
workspaceFolders: undefined,
createFileSystemWatcher: vi.fn(() => ({
onDidCreate: vi.fn(() => mockDisposable),
onDidChange: vi.fn(() => mockDisposable),
Expand Down Expand Up @@ -63,6 +64,7 @@ describe("RooIgnoreController", () => {

// @ts-expect-error - Mocking
vscode.workspace.createFileSystemWatcher.mockReturnValue(mockWatcher)
;(vscode.workspace as any).workspaceFolders = undefined

// Setup fs mocks
mockFileExists = fileExistsAtPath as Mock<typeof fileExistsAtPath>
Expand Down Expand Up @@ -198,6 +200,27 @@ describe("RooIgnoreController", () => {
expect(controller.validateAccess(allowedAbsolutePath)).toBe(true)
})

it("should apply the .rooignore from a secondary workspace root for absolute paths", () => {
const secondaryRoot = "/test/secondary"
;(vscode.workspace as any).workspaceFolders = [
{ uri: { fsPath: TEST_CWD }, name: "primary", index: 0 },
{ uri: { fsPath: secondaryRoot }, name: "secondary", index: 1 },
]

vi.mocked(fsSync.existsSync).mockImplementation(
(filePath) => filePath === path.join(secondaryRoot, ".rooignore"),
)
vi.mocked(fsSync.readFileSync).mockImplementation((filePath) => {
if (filePath === path.join(secondaryRoot, ".rooignore")) {
return "private/**"
}
return ""
})

expect(controller.validateAccess(path.join(secondaryRoot, "private/secret.txt"))).toBe(false)
expect(controller.validateAccess(path.join(secondaryRoot, "src/app.ts"))).toBe(true)
})

/**
* Tests handling of paths outside cwd
*/
Expand Down Expand Up @@ -315,6 +338,42 @@ describe("RooIgnoreController", () => {
expect(emptyController.validateCommand("cat node_modules/package.json")).toBeUndefined()
expect(emptyController.validateCommand("grep pattern .git/config")).toBeUndefined()
})

it("should enforce a secondary workspace .rooignore for execute_command paths", async () => {
const secondaryRoot = "/test/secondary"
;(vscode.workspace as any).workspaceFolders = [
{ uri: { fsPath: TEST_CWD }, name: "primary", index: 0 },
{ uri: { fsPath: secondaryRoot }, name: "secondary", index: 1 },
]

mockFileExists.mockImplementation(async (filePath) => filePath === path.join(TEST_CWD, ".rooignore"))
mockReadFile.mockResolvedValue("node_modules\n.git\nsecrets/**\n*.log")
await controller.initialize()

vi.mocked(fsSync.existsSync).mockImplementation(
(filePath) => filePath === path.join(secondaryRoot, ".rooignore"),
)
vi.mocked(fsSync.readFileSync).mockImplementation((filePath) => {
if (filePath === path.join(secondaryRoot, ".rooignore")) {
return "private/**"
}
return ""
})

expect(controller.validateCommand(`cat ${path.join(secondaryRoot, "private", "secret.txt")}`)).toBe(
path.join(secondaryRoot, "private", "secret.txt"),
)
})

it("should validate Windows absolute paths instead of treating them as PowerShell parameters", () => {
const windowsPath = String.raw`C:\secondary\private\secret.txt`
const validateAccessSpy = vi.spyOn(controller, "validateAccess").mockImplementation((candidate) => {
return candidate !== windowsPath
})

expect(controller.validateCommand(`type ${windowsPath}`)).toBe(windowsPath)
expect(validateAccessSpy).toHaveBeenCalledWith(windowsPath)
})
})

describe("filterPaths", () => {
Expand Down Expand Up @@ -411,6 +470,40 @@ describe("RooIgnoreController", () => {
const instructions = controller.getInstructions()
expect(instructions).toBeUndefined()
})

it("should include secondary workspace .rooignore content in instructions", async () => {
const secondaryRoot = "/test/secondary"
;(vscode.workspace as any).workspaceFolders = [
{ uri: { fsPath: TEST_CWD }, name: "primary", index: 0 },
{ uri: { fsPath: secondaryRoot }, name: "secondary", index: 1 },
]

mockFileExists.mockImplementation(async (filePath) => filePath === path.join(TEST_CWD, ".rooignore"))
mockReadFile.mockResolvedValue("node_modules")
await controller.initialize()

vi.mocked(fsSync.existsSync).mockImplementation(
(filePath) =>
filePath === path.join(secondaryRoot, ".rooignore") ||
filePath === path.join(TEST_CWD, ".rooignore"),
)
vi.mocked(fsSync.readFileSync).mockImplementation((filePath) => {
if (filePath === path.join(secondaryRoot, ".rooignore")) {
return "private/**"
}
if (filePath === path.join(TEST_CWD, ".rooignore")) {
return "node_modules"
}
return ""
})

const instructions = controller.getInstructions()

expect(instructions).toContain("## primary")
expect(instructions).toContain("## secondary")
expect(instructions).toContain("node_modules")
expect(instructions).toContain("private/**")
})
})

describe("dispose", () => {
Expand Down
Loading
Loading