mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
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:
parent
4449b51e2c
commit
19c56c6ec4
12 changed files with 426 additions and 234 deletions
5
.changeset/loud-countries-draw.md
Normal file
5
.changeset/loud-countries-draw.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Add .clineignore file to block Cline from accessing specified file patterns
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
189
src/core/ignore/ClineIgnoreController.ts
Normal file
189
src/core/ignore/ClineIgnoreController.ts
Normal 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 = []
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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 `
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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] = []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ export type ClineSay =
|
|||
| "use_mcp_server"
|
||||
| "diff_error"
|
||||
| "deleted_api_reqs"
|
||||
| "clineignore_error"
|
||||
|
||||
export interface ClineSayTool {
|
||||
tool:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue