Attention Is Not What You Need (#1680)

* initialize class

* wip

* wip

* cleaning up

* update comment

* fire n forget error handling

* files added

* changeset

* duplicate path function

* cleanup

* Use special error type for when cline tries to access clineignore'd file

* Rename LLMFileAccessController to ClineIgnoreController

* Use better changeset

* Remove unnecessary file parsing

* Allow access to files outside cwd

* Use more efficient clineIgnoreExists

* Modify clineignore prompt

* Use LOCK_TEXT_SYMBOL

* Block commands attempting to access clineignored files

* Fix prompt responses

* fix tests; make sure .clineignore is ignored

* Fix clineignore prompt

* Fixes

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
This commit is contained in:
Evan 2025-02-08 11:28:10 +08:00 committed by GitHub
parent 4449b51e2c
commit 19c56c6ec4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 426 additions and 234 deletions

View file

@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Add .clineignore file to block Cline from accessing specified file patterns

View file

@ -9,6 +9,9 @@ import * as path from "path"
import { serializeError } from "serialize-error"
import * as vscode from "vscode"
import { ApiHandler, buildApiHandler } from "../api"
import { OpenAiHandler } from "../api/providers/openai"
import { OpenRouterHandler } from "../api/providers/openrouter"
import { ApiStream } from "../api/transform/stream"
import CheckpointTracker from "../integrations/checkpoints/CheckpointTracker"
import { DIFF_VIEW_URI_SCHEME, DiffViewProvider } from "../integrations/editor/DiffViewProvider"
import { findToolName, formatContentBlockToMarkdown } from "../integrations/misc/export-markdown"
@ -46,20 +49,16 @@ import { HistoryItem } from "../shared/HistoryItem"
import { ClineAskResponse, ClineCheckpointRestore } from "../shared/WebviewMessage"
import { calculateApiCost } from "../utils/cost"
import { fileExistsAtPath } from "../utils/fs"
import { LLMFileAccessController } from "../services/llm-access-control/LLMFileAccessController"
import { arePathsEqual, getReadablePath } from "../utils/path"
import { fixModelHtmlEscaping, removeInvalidChars } from "../utils/string"
import { AssistantMessageContent, parseAssistantMessage, ToolParamName, ToolUseName } from "./assistant-message"
import { constructNewFileContent } from "./assistant-message/diff"
import { ClineIgnoreController, LOCK_TEXT_SYMBOL } from "./ignore/ClineIgnoreController"
import { parseMentions } from "./mentions"
import { formatResponse } from "./prompts/responses"
import { ClineProvider, GlobalFileNames } from "./webview/ClineProvider"
import { OpenRouterHandler } from "../api/providers/openrouter"
import { addUserInstructions, SYSTEM_PROMPT } from "./prompts/system"
import { getNextTruncationRange, getTruncatedMessages } from "./sliding-window"
import { SYSTEM_PROMPT } from "./prompts/system"
import { addUserInstructions } from "./prompts/system"
import { OpenAiHandler } from "../api/providers/openai"
import { ApiStream } from "../api/transform/stream"
import { ClineProvider, GlobalFileNames } from "./webview/ClineProvider"
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution
@ -81,7 +80,7 @@ export class Cline {
private chatSettings: ChatSettings
apiConversationHistory: Anthropic.MessageParam[] = []
clineMessages: ClineMessage[] = []
private llmAccessController: LLMFileAccessController
private clineIgnoreController: ClineIgnoreController
private askResponse?: ClineAskResponse
private askResponseText?: string
private askResponseImages?: string[]
@ -125,9 +124,9 @@ export class Cline {
images?: string[],
historyItem?: HistoryItem,
) {
this.llmAccessController = new LLMFileAccessController(cwd)
this.llmAccessController.initialize().catch((error) => {
console.error("Failed to initialize LLMFileAccessController:", error)
this.clineIgnoreController = new ClineIgnoreController(cwd)
this.clineIgnoreController.initialize().catch((error) => {
console.error("Failed to initialize ClineIgnoreController:", error)
})
this.providerRef = new WeakRef(provider)
this.api = buildApiHandler(apiConfiguration)
@ -1057,7 +1056,7 @@ export class Cline {
this.terminalManager.disposeAll()
this.urlContentFetcher.closeBrowser()
this.browserSession.closeBrowser()
this.llmAccessController.dispose()
this.clineIgnoreController.dispose()
await this.diffViewProvider.revertChanges() // need to await for when we want to make sure directories/files are reverted before re-starting the task from a checkpoint
}
@ -1242,9 +1241,15 @@ export class Cline {
}
}
const clineIgnoreContent = this.clineIgnoreController.clineIgnoreContent
let clineIgnoreInstructions: string | undefined
if (clineIgnoreContent) {
clineIgnoreInstructions = `# .clineignore\n\n(The following is provided by a root-level .clineignore 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${clineIgnoreContent}\n.clineignore`
}
if (settingsCustomInstructions || clineRulesFileInstructions) {
// altering the system prompt mid-task will break the prompt cache, but in the grand scheme this will not change often so it's better to not pollute user messages with it the way we have to with <potentially relevant details>
systemPrompt += addUserInstructions(settingsCustomInstructions, clineRulesFileInstructions)
systemPrompt += addUserInstructions(settingsCustomInstructions, clineRulesFileInstructions, clineIgnoreInstructions)
}
// If the previous API request's total token usage is close to the context window, truncate the conversation history to free up space for the new request
@ -1591,6 +1596,15 @@ export class Cline {
// wait so we can determine if it's a new file or editing an existing file
break
}
const accessAllowed = this.clineIgnoreController.validateAccess(relPath)
if (!accessAllowed) {
await this.say("clineignore_error", relPath)
pushToolResult(formatResponse.toolError(formatResponse.clineIgnoreError(relPath)))
await this.saveCheckpoint()
break
}
// Check if file exists using cached map or fs.access
let fileExists: boolean
if (this.diffViewProvider.editType !== undefined) {
@ -1708,6 +1722,7 @@ export class Cline {
await this.saveCheckpoint()
break
}
this.consecutiveMistakeCount = 0
// if isEditingFile false, that means we have the full contents of the file already.
@ -1859,6 +1874,15 @@ export class Cline {
await this.saveCheckpoint()
break
}
const accessAllowed = this.clineIgnoreController.validateAccess(relPath)
if (!accessAllowed) {
await this.say("clineignore_error", relPath)
pushToolResult(formatResponse.toolError(formatResponse.clineIgnoreError(relPath)))
await this.saveCheckpoint()
break
}
this.consecutiveMistakeCount = 0
const absolutePath = path.resolve(cwd, relPath)
const completeMessage = JSON.stringify({
@ -1922,9 +1946,17 @@ export class Cline {
break
}
this.consecutiveMistakeCount = 0
const absolutePath = path.resolve(cwd, relDirPath)
const [files, didHitLimit] = await listFiles(absolutePath, recursive, 200)
const result = formatResponse.formatFilesList(absolutePath, files, didHitLimit)
const result = formatResponse.formatFilesList(
absolutePath,
files,
didHitLimit,
this.clineIgnoreController,
)
const completeMessage = JSON.stringify({
...sharedMessageProps,
content: result,
@ -1981,9 +2013,15 @@ export class Cline {
await this.saveCheckpoint()
break
}
this.consecutiveMistakeCount = 0
const absolutePath = path.resolve(cwd, relDirPath)
const result = await parseSourceCodeForDefinitionsTopLevel(absolutePath)
const result = await parseSourceCodeForDefinitionsTopLevel(
absolutePath,
this.clineIgnoreController,
)
const completeMessage = JSON.stringify({
...sharedMessageProps,
content: result,
@ -2051,8 +2089,16 @@ export class Cline {
break
}
this.consecutiveMistakeCount = 0
const absolutePath = path.resolve(cwd, relDirPath)
const results = await regexSearchFiles(cwd, absolutePath, regex, filePattern)
const results = await regexSearchFiles(
cwd,
absolutePath,
regex,
filePattern,
this.clineIgnoreController,
)
const completeMessage = JSON.stringify({
...sharedMessageProps,
content: results,
@ -2289,6 +2335,16 @@ export class Cline {
}
this.consecutiveMistakeCount = 0
const ignoredFileAttemptedToAccess = this.clineIgnoreController.validateCommand(command)
if (ignoredFileAttemptedToAccess) {
await this.say("clineignore_error", ignoredFileAttemptedToAccess)
pushToolResult(
formatResponse.toolError(formatResponse.clineIgnoreError(ignoredFileAttemptedToAccess)),
)
await this.saveCheckpoint()
break
}
let didAutoApprove = false
if (!requiresApproval && this.shouldAutoApproveTool(block.name)) {
@ -3192,26 +3248,38 @@ export class Cline {
// It could be useful for cline to know if the user went from one or no file to another between messages, so we always include this context
details += "\n\n# VSCode Visible Files"
const visibleFiles = vscode.window.visibleTextEditors
const visibleFilePaths = vscode.window.visibleTextEditors
?.map((editor) => editor.document?.uri?.fsPath)
.filter(Boolean)
.map((absolutePath) => path.relative(cwd, absolutePath).toPosix())
.map((absolutePath) => path.relative(cwd, absolutePath))
// Filter paths through clineIgnoreController
const allowedVisibleFiles = this.clineIgnoreController
.filterPaths(visibleFilePaths)
.map((p) => p.toPosix())
.join("\n")
if (visibleFiles) {
details += `\n${visibleFiles}`
if (allowedVisibleFiles) {
details += `\n${allowedVisibleFiles}`
} else {
details += "\n(No visible files)"
}
details += "\n\n# VSCode Open Tabs"
const openTabs = vscode.window.tabGroups.all
const openTabPaths = vscode.window.tabGroups.all
.flatMap((group) => group.tabs)
.map((tab) => (tab.input as vscode.TabInputText)?.uri?.fsPath)
.filter(Boolean)
.map((absolutePath) => path.relative(cwd, absolutePath).toPosix())
.map((absolutePath) => path.relative(cwd, absolutePath))
// Filter paths through clineIgnoreController
const allowedOpenTabs = this.clineIgnoreController
.filterPaths(openTabPaths)
.map((p) => p.toPosix())
.join("\n")
if (openTabs) {
details += `\n${openTabs}`
if (allowedOpenTabs) {
details += `\n${allowedOpenTabs}`
} else {
details += "\n(No open tabs)"
}
@ -3325,7 +3393,7 @@ export class Cline {
details += "(Desktop files not shown automatically. Use list_files to explore if needed.)"
} else {
const [files, didHitLimit] = await listFiles(cwd, true, 200)
const result = formatResponse.formatFilesList(cwd, files, didHitLimit)
const result = formatResponse.formatFilesList(cwd, files, didHitLimit, this.clineIgnoreController)
details += result
}
}

View file

@ -1,13 +1,13 @@
import { LLMFileAccessController } from "./LLMFileAccessController"
import { ClineIgnoreController } from "./ClineIgnoreController"
import fs from "fs/promises"
import path from "path"
import os from "os"
import { after, beforeEach, describe, it } from "mocha"
import "should"
describe("LLMFileAccessController", () => {
describe("ClineIgnoreController", () => {
let tempDir: string
let controller: LLMFileAccessController
let controller: ClineIgnoreController
beforeEach(async () => {
// Create a temp directory for testing
@ -22,7 +22,7 @@ describe("LLMFileAccessController", () => {
),
)
controller = new LLMFileAccessController(tempDir)
controller = new ClineIgnoreController(tempDir)
await controller.initialize()
})
@ -49,6 +49,11 @@ describe("LLMFileAccessController", () => {
]
results.forEach((result) => result.should.be.true())
})
it("should block access to .clineignore file", async () => {
const result = controller.validateAccess(".clineignore")
result.should.be.false()
})
})
describe("Custom Patterns", () => {
@ -80,7 +85,7 @@ describe("LLMFileAccessController", () => {
["*.secret", "private/", "*.tmp", "data-*.json", "temp/*"].join("\n"),
)
controller = new LLMFileAccessController(tempDir)
controller = new ClineIgnoreController(tempDir)
await controller.initialize()
const results = [
@ -111,7 +116,7 @@ describe("LLMFileAccessController", () => {
// ].join("\n"),
// )
// controller = new LLMFileAccessController(tempDir)
// controller = new ClineIgnoreController(tempDir)
// const results = [
// // Basic negation
@ -150,7 +155,7 @@ describe("LLMFileAccessController", () => {
["# Comment line", "*.secret", "private/", "temp.*"].join("\n"),
)
controller = new LLMFileAccessController(tempDir)
controller = new ClineIgnoreController(tempDir)
await controller.initialize()
const result = controller.validateAccess("test.secret")
@ -194,25 +199,6 @@ describe("LLMFileAccessController", () => {
const result = controller.validateAccess("src\\file.ts")
result.should.be.true()
})
it("should handle paths outside cwd", async () => {
// Create a path that points to parent directory of cwd
const outsidePath = path.join(path.dirname(tempDir), "outside.txt")
const result = controller.validateAccess(outsidePath)
// Should return false for security since path is outside cwd
result.should.be.false()
// Test with a deeply nested path outside cwd
const deepOutsidePath = path.join(path.dirname(tempDir), "deep", "nested", "outside.secret")
const deepResult = controller.validateAccess(deepOutsidePath)
deepResult.should.be.false()
// Test with a path that tries to escape using ../
const escapeAttemptPath = path.join(tempDir, "..", "escape-attempt.txt")
const escapeResult = controller.validateAccess(escapeAttemptPath)
escapeResult.should.be.false()
})
})
describe("Batch Filtering", () => {
@ -237,7 +223,7 @@ describe("LLMFileAccessController", () => {
await fs.mkdir(emptyDir)
try {
const controller = new LLMFileAccessController(emptyDir)
const controller = new ClineIgnoreController(emptyDir)
await controller.initialize()
const result = controller.validateAccess("file.txt")
result.should.be.true()
@ -249,7 +235,7 @@ describe("LLMFileAccessController", () => {
it("should handle empty .clineignore", async () => {
await fs.writeFile(path.join(tempDir, ".clineignore"), "")
controller = new LLMFileAccessController(tempDir)
controller = new ClineIgnoreController(tempDir)
await controller.initialize()
const result = controller.validateAccess("regular-file.txt")

View file

@ -0,0 +1,189 @@
import path from "path"
import { fileExistsAtPath } from "../../utils/fs"
import fs from "fs/promises"
import ignore, { Ignore } from "ignore"
import * as vscode from "vscode"
export const LOCK_TEXT_SYMBOL = "\u{1F512}"
/**
* Controls LLM access to files by enforcing ignore patterns.
* Designed to be instantiated once in Cline.ts and passed to file manipulation services.
* Uses the 'ignore' library to support standard .gitignore syntax in .clineignore files.
*/
export class ClineIgnoreController {
private cwd: string
private ignoreInstance: Ignore
private disposables: vscode.Disposable[] = []
clineIgnoreContent: string | undefined
constructor(cwd: string) {
this.cwd = cwd
this.ignoreInstance = ignore()
this.clineIgnoreContent = undefined
// Set up file watcher for .clineignore
this.setupFileWatcher()
}
/**
* Initialize the controller by loading custom patterns
* Must be called after construction and before using the controller
*/
async initialize(): Promise<void> {
await this.loadClineIgnore()
}
/**
* Set up the file watcher for .clineignore changes
*/
private setupFileWatcher(): void {
const clineignorePattern = new vscode.RelativePattern(this.cwd, ".clineignore")
const fileWatcher = vscode.workspace.createFileSystemWatcher(clineignorePattern)
// Watch for changes and updates
this.disposables.push(
fileWatcher.onDidChange(() => {
this.loadClineIgnore()
}),
fileWatcher.onDidCreate(() => {
this.loadClineIgnore()
}),
fileWatcher.onDidDelete(() => {
this.loadClineIgnore()
}),
)
// Add fileWatcher itself to disposables
this.disposables.push(fileWatcher)
}
/**
* Load custom patterns from .clineignore if it exists
*/
private async loadClineIgnore(): Promise<void> {
try {
// Reset ignore instance to prevent duplicate patterns
this.ignoreInstance = ignore()
const ignorePath = path.join(this.cwd, ".clineignore")
if (await fileExistsAtPath(ignorePath)) {
const content = await fs.readFile(ignorePath, "utf8")
this.clineIgnoreContent = content
this.ignoreInstance.add(content)
this.ignoreInstance.add(".clineignore")
} else {
this.clineIgnoreContent = undefined
}
} catch (error) {
// Should never happen: reading file failed even though it exists
console.error("Unexpected error loading .clineignore:", error)
}
}
/**
* Check if a file should be accessible to the LLM
* @param filePath - Path to check (relative to cwd)
* @returns true if file is accessible, false if ignored
*/
validateAccess(filePath: string): boolean {
// Always allow access if .clineignore does not exist
if (!this.clineIgnoreContent) {
return true
}
try {
// Normalize path to be relative to cwd and use forward slashes
const absolutePath = path.resolve(this.cwd, filePath)
const relativePath = path.relative(this.cwd, absolutePath).toPosix()
// Ignore expects paths to be path.relative()'d
return !this.ignoreInstance.ignores(relativePath)
} catch (error) {
// console.error(`Error validating access for ${filePath}:`, error)
// Ignore is designed to work with relative file paths, so will throw error for paths outside cwd. We are allowing access to all files outside cwd.
return true
}
}
/**
* Check if a terminal command should be allowed to execute based on file access patterns
* @param command - Terminal command to validate
* @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 .clineignore exists
if (!this.clineIgnoreContent) {
return undefined
}
// Split command into parts and get the base command
const parts = command.trim().split(/\s+/)
const baseCommand = parts[0].toLowerCase()
// Commands that read file contents
const fileReadingCommands = [
// Unix commands
"cat",
"less",
"more",
"head",
"tail",
"grep",
"awk",
"sed",
// PowerShell commands and aliases
"get-content",
"gc",
"type",
"select-string",
"sls",
]
if (fileReadingCommands.includes(baseCommand)) {
// Check each argument that could be a file path
for (let i = 1; i < parts.length; i++) {
const arg = parts[i]
// Skip command flags/options (both Unix and PowerShell style)
if (arg.startsWith("-") || arg.startsWith("/")) {
continue
}
// Ignore PowerShell parameter names
if (arg.includes(":")) {
continue
}
// Validate file access
if (!this.validateAccess(arg)) {
return arg
}
}
}
return undefined
}
/**
* Filter an array of paths, removing those that should be ignored
* @param paths - Array of paths to filter (relative to cwd)
* @returns Array of allowed paths
*/
filterPaths(paths: string[]): string[] {
try {
return paths
.map((p) => ({
path: p,
allowed: this.validateAccess(p),
}))
.filter((x) => x.allowed)
.map((x) => x.path)
} catch (error) {
console.error("Error filtering paths:", error)
return [] // Fail closed for security
}
}
/**
* Clean up resources when the controller is no longer needed
*/
dispose(): void {
this.disposables.forEach((d) => d.dispose())
this.disposables = []
}
}

View file

@ -1,6 +1,7 @@
import { Anthropic } from "@anthropic-ai/sdk"
import * as path from "path"
import * as diff from "diff"
import * as path from "path"
import { ClineIgnoreController, LOCK_TEXT_SYMBOL } from "../ignore/ClineIgnoreController"
export const formatResponse = {
toolDenied: () => `The user denied this operation.`,
@ -10,6 +11,9 @@ export const formatResponse = {
toolError: (error?: string) => `The tool execution failed with the following error:\n<error>\n${error}\n</error>`,
clineIgnoreError: (path: string) =>
`Access to ${path} is blocked by the .clineignore file settings. You must try to continue in the task without using this file, or ask the user to update the .clineignore file.`,
noToolsUsed: () =>
`[ERROR] You did not use a tool in your previous response! Please retry with a tool use.
@ -46,7 +50,12 @@ Otherwise, if you have not completed the task and do not need additional informa
return formatImagesIntoBlocks(images)
},
formatFilesList: (absolutePath: string, files: string[], didHitLimit: boolean): string => {
formatFilesList: (
absolutePath: string,
files: string[],
didHitLimit: boolean,
clineIgnoreController?: ClineIgnoreController,
): string => {
const sorted = files
.map((file) => {
// convert absolute path to relative path
@ -77,14 +86,30 @@ Otherwise, if you have not completed the task and do not need additional informa
// the shorter one comes first
return aParts.length - bParts.length
})
const clineIgnoreParsed = clineIgnoreController
? sorted.map((filePath) => {
// path is relative to absolute path, not cwd
// validateAccess expects either path relative to cwd or absolute path
// otherwise, for validating against ignore patterns like "assets/icons", we would end up with just "icons", which would result in the path not being ignored.
const absoluteFilePath = path.resolve(absolutePath, filePath)
const isIgnored = !clineIgnoreController.validateAccess(absoluteFilePath)
if (isIgnored) {
return LOCK_TEXT_SYMBOL + " " + filePath
}
return filePath
})
: sorted
if (didHitLimit) {
return `${sorted.join(
return `${clineIgnoreParsed.join(
"\n",
)}\n\n(File list truncated. Use list_files on specific subdirectories if you need to explore further.)`
} else if (sorted.length === 0 || (sorted.length === 1 && sorted[0] === "")) {
} else if (clineIgnoreParsed.length === 0 || (clineIgnoreParsed.length === 1 && clineIgnoreParsed[0] === "")) {
return "No files found."
} else {
return sorted.join("\n")
return clineIgnoreParsed.join("\n")
}
},

View file

@ -957,13 +957,20 @@ You accomplish a given task iteratively, breaking it down into clear steps and w
4. Once you've completed the user's task, you must use the attempt_completion tool to present the result of the task to the user. You may also provide a CLI command to showcase the result of your task; this can be particularly useful for web development tasks, where you can run e.g. \`open index.html\` to show the website you've built.
5. The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance.`
export function addUserInstructions(settingsCustomInstructions?: string, clineRulesFileInstructions?: string) {
export function addUserInstructions(
settingsCustomInstructions?: string,
clineRulesFileInstructions?: string,
clineIgnoreInstructions?: string,
) {
let customInstructions = ""
if (settingsCustomInstructions) {
customInstructions += settingsCustomInstructions + "\n\n"
}
if (clineRulesFileInstructions) {
customInstructions += clineRulesFileInstructions
customInstructions += clineRulesFileInstructions + "\n\n"
}
if (clineIgnoreInstructions) {
customInstructions += clineIgnoreInstructions
}
return `

View file

@ -45,9 +45,11 @@ export async function listFiles(dirPath: string, recursive: boolean, limit: numb
ignore: recursive ? dirsToIgnore : undefined, // just in case there is no gitignore, we ignore sensible defaults
onlyFiles: false, // true by default, false means it will list directories on their own too
}
// * globs all files in one dir, ** globs files in nested directories
const files = recursive ? await globbyLevelByLevel(limit, options) : (await globby("*", options)).slice(0, limit)
return [files, files.length >= limit]
const filePaths = recursive ? await globbyLevelByLevel(limit, options) : (await globby("*", options)).slice(0, limit)
return [filePaths, filePaths.length >= limit]
}
/*

View file

@ -1,151 +0,0 @@
import path from "path"
import { fileExistsAtPath } from "../../utils/fs"
import fs from "fs/promises"
import ignore, { Ignore } from "ignore"
import * as vscode from "vscode"
/**
* Controls LLM access to files by enforcing ignore patterns.
* Designed to be instantiated once in Cline.ts and passed to file manipulation services.
* Uses the 'ignore' library to support standard .gitignore syntax in .clineignore files.
*/
export class LLMFileAccessController {
private cwd: string
private ignoreInstance: Ignore
private fileWatcher: vscode.FileSystemWatcher | null
private disposables: vscode.Disposable[] = []
/**
* Default patterns that are always ignored for security
*/
private static readonly DEFAULT_PATTERNS = [] // empty for now
constructor(cwd: string) {
this.cwd = cwd
this.ignoreInstance = ignore()
this.ignoreInstance.add(LLMFileAccessController.DEFAULT_PATTERNS)
this.fileWatcher = null
// Set up file watcher for .clineignore
this.setupFileWatcher()
}
/**
* Initialize the controller by loading custom patterns
* Must be called after construction and before using the controller
*/
async initialize(): Promise<void> {
await this.loadCustomPatterns()
}
/**
* Set up the file watcher for .clineignore changes
*/
private setupFileWatcher(): void {
const clineignorePattern = new vscode.RelativePattern(this.cwd, ".clineignore")
this.fileWatcher = vscode.workspace.createFileSystemWatcher(clineignorePattern)
// Watch for changes and updates
this.disposables.push(
this.fileWatcher.onDidChange(() => {
this.loadCustomPatterns().catch((error) => {
console.error("Failed to load updated .clineignore patterns:", error)
})
}),
this.fileWatcher.onDidCreate(() => {
this.loadCustomPatterns().catch((error) => {
console.error("Failed to load new .clineignore patterns:", error)
})
}),
this.fileWatcher.onDidDelete(() => {
this.resetToDefaultPatterns()
}),
)
// Add fileWatcher itself to disposables
this.disposables.push(this.fileWatcher)
}
/**
* Load custom patterns from .clineignore if it exists
*/
private async loadCustomPatterns(): Promise<void> {
try {
const ignorePath = path.join(this.cwd, ".clineignore")
if (await fileExistsAtPath(ignorePath)) {
// Reset ignore instance to prevent duplicate patterns
this.resetToDefaultPatterns()
const content = await fs.readFile(ignorePath, "utf8")
const customPatterns = content
.split("\n")
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#"))
this.ignoreInstance.add(customPatterns)
}
} catch (error) {
// Continue with default patterns
}
}
/**
* Reset ignore patterns to defaults
*/
private resetToDefaultPatterns(): void {
this.ignoreInstance = ignore()
this.ignoreInstance.add(LLMFileAccessController.DEFAULT_PATTERNS)
}
/**
* Check if a file should be accessible to the LLM
* @param filePath - Path to check (relative to cwd)
* @returns true if file is accessible, false if ignored
*/
validateAccess(filePath: string): boolean {
try {
// Normalize path to be relative to cwd and use forward slashes
const absolutePath = path.resolve(this.cwd, filePath)
const relativePath = path.relative(this.cwd, absolutePath).replace(/\\/g, "/")
// Block access to paths outside cwd (those starting with '..')
if (relativePath.startsWith("..")) {
return false
}
// Use ignore library to check if path should be ignored
return !this.ignoreInstance.ignores(relativePath)
} catch (error) {
console.error(`Error validating access for ${filePath}:`, error)
return false // Fail closed for security
}
}
/**
* Filter an array of paths, removing those that should be ignored
* @param paths - Array of paths to filter (relative to cwd)
* @returns Array of allowed paths
*/
filterPaths(paths: string[]): string[] {
try {
return paths
.map((p) => ({
path: p,
allowed: this.validateAccess(p),
}))
.filter((x) => x.allowed)
.map((x) => x.path)
} catch (error) {
console.error("Error filtering paths:", error)
return [] // Fail closed for security
}
}
/**
* Clean up resources when the controller is no longer needed
*/
dispose(): void {
this.disposables.forEach((d) => d.dispose())
this.disposables = []
this.fileWatcher = null
}
}

View file

@ -1,8 +1,9 @@
import * as vscode from "vscode"
import * as childProcess from "child_process"
import * as path from "path"
import * as fs from "fs"
import * as readline from "readline"
import { fileExistsAtPath } from "../../utils/fs"
import { ClineIgnoreController } from "../../core/ignore/ClineIgnoreController"
/*
This file provides functionality to perform regex searches on files using ripgrep.
@ -50,7 +51,7 @@ const isWindows = /^win/.test(process.platform)
const binName = isWindows ? "rg.exe" : "rg"
interface SearchResult {
file: string
filePath: string
line: number
column: number
match: string
@ -63,7 +64,7 @@ const MAX_RESULTS = 300
async function getBinPath(vscodeAppRoot: string): Promise<string | undefined> {
const checkPath = async (pkgFolder: string) => {
const fullPath = path.join(vscodeAppRoot, pkgFolder, binName)
return (await pathExists(fullPath)) ? fullPath : undefined
return (await fileExistsAtPath(fullPath)) ? fullPath : undefined
}
return (
@ -74,14 +75,6 @@ async function getBinPath(vscodeAppRoot: string): Promise<string | undefined> {
)
}
async function pathExists(path: string): Promise<boolean> {
return new Promise((resolve) => {
fs.access(path, (err) => {
resolve(err === null)
})
})
}
async function execRipgrep(bin: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
const rgProcess = childProcess.spawn(bin, args)
@ -122,7 +115,13 @@ async function execRipgrep(bin: string, args: string[]): Promise<string> {
})
}
export async function regexSearchFiles(cwd: string, directoryPath: string, regex: string, filePattern?: string): Promise<string> {
export async function regexSearchFiles(
cwd: string,
directoryPath: string,
regex: string,
filePattern?: string,
clineIgnoreController?: ClineIgnoreController,
): Promise<string> {
const vscodeAppRoot = vscode.env.appRoot
const rgPath = await getBinPath(vscodeAppRoot)
@ -150,7 +149,7 @@ export async function regexSearchFiles(cwd: string, directoryPath: string, regex
results.push(currentResult as SearchResult)
}
currentResult = {
file: parsed.data.path.text,
filePath: parsed.data.path.text,
line: parsed.data.line_number,
column: parsed.data.submatches[0].start,
match: parsed.data.lines.text,
@ -174,7 +173,12 @@ export async function regexSearchFiles(cwd: string, directoryPath: string, regex
results.push(currentResult as SearchResult)
}
return formatResults(results, cwd)
// Filter results using ClineIgnoreController if provided
const filteredResults = clineIgnoreController
? results.filter((result) => clineIgnoreController.validateAccess(result.filePath))
: results
return formatResults(filteredResults, cwd)
}
function formatResults(results: SearchResult[], cwd: string): string {
@ -189,7 +193,7 @@ function formatResults(results: SearchResult[], cwd: string): string {
// Group results by file name
results.slice(0, MAX_RESULTS).forEach((result) => {
const relativeFilePath = path.relative(cwd, result.file)
const relativeFilePath = path.relative(cwd, result.filePath)
if (!groupedResults[relativeFilePath]) {
groupedResults[relativeFilePath] = []
}

View file

@ -3,9 +3,13 @@ import * as path from "path"
import { listFiles } from "../glob/list-files"
import { LanguageParser, loadRequiredLanguageParsers } from "./languageParser"
import { fileExistsAtPath } from "../../utils/fs"
import { ClineIgnoreController } from "../../core/ignore/ClineIgnoreController"
// TODO: implement caching behavior to avoid having to keep analyzing project for new tasks.
export async function parseSourceCodeForDefinitionsTopLevel(dirPath: string): Promise<string> {
export async function parseSourceCodeForDefinitionsTopLevel(
dirPath: string,
clineIgnoreController?: ClineIgnoreController,
): Promise<string> {
// check if the path exists
const dirExists = await fileExistsAtPath(path.resolve(dirPath))
if (!dirExists) {
@ -24,10 +28,14 @@ export async function parseSourceCodeForDefinitionsTopLevel(dirPath: string): Pr
// Parse specific files we have language parsers for
// const filesWithoutDefinitions: string[] = []
for (const file of filesToParse) {
const definitions = await parseFile(file, languageParsers)
// Filter filepaths for access if controller is provided
const allowedFilesToParse = clineIgnoreController ? clineIgnoreController.filterPaths(filesToParse) : filesToParse
for (const filePath of allowedFilesToParse) {
const definitions = await parseFile(filePath, languageParsers, clineIgnoreController)
if (definitions) {
result += `${path.relative(dirPath, file).toPosix()}\n${definitions}\n`
result += `${path.relative(dirPath, filePath).toPosix()}\n${definitions}\n`
}
// else {
// filesWithoutDefinitions.push(file)
@ -98,7 +106,14 @@ This approach allows us to focus on the most relevant parts of the code (defined
- https://github.com/tree-sitter/tree-sitter/blob/master/lib/binding_web/test/helper.js
- https://tree-sitter.github.io/tree-sitter/code-navigation-systems
*/
async function parseFile(filePath: string, languageParsers: LanguageParser): Promise<string | undefined> {
async function parseFile(
filePath: string,
languageParsers: LanguageParser,
clineIgnoreController?: ClineIgnoreController,
): Promise<string | null> {
if (clineIgnoreController && !clineIgnoreController.validateAccess(filePath)) {
return null
}
const fileContent = await fs.readFile(filePath, "utf8")
const ext = path.extname(filePath).toLowerCase().slice(1)
@ -159,5 +174,5 @@ async function parseFile(filePath: string, languageParsers: LanguageParser): Pro
if (formattedOutput.length > 0) {
return `|----\n${formattedOutput}|----\n`
}
return undefined
return null
}

View file

@ -121,6 +121,7 @@ export type ClineSay =
| "use_mcp_server"
| "diff_error"
| "deleted_api_reqs"
| "clineignore_error"
export interface ClineSayTool {
tool:

View file

@ -989,6 +989,47 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
</div>
</>
)
case "clineignore_error":
return (
<>
<div
style={{
display: "flex",
flexDirection: "column",
backgroundColor: "rgba(255, 191, 0, 0.1)",
padding: 8,
borderRadius: 3,
fontSize: 12,
}}>
<div
style={{
display: "flex",
alignItems: "center",
marginBottom: 4,
}}>
<i
className="codicon codicon-error"
style={{
marginRight: 8,
fontSize: 18,
color: "#FFA500",
}}></i>
<span
style={{
fontWeight: 500,
color: "#FFA500",
}}>
Access Denied
</span>
</div>
<div>
Cline tried to access <code>{message.text}</code> which is blocked by the{" "}
<code>.clineignore</code>
file.
</div>
</div>
</>
)
case "completion_result":
const hasChanges = message.text?.endsWith(COMPLETION_RESULT_CHANGES_FLAG) ?? false
const text = hasChanges ? message.text?.slice(0, -COMPLETION_RESULT_CHANGES_FLAG.length) : message.text