From 88a07de5fb3ab563f9677117d54c627c669e4c7e Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Thu, 6 Feb 2025 23:56:02 -0800 Subject: [PATCH 01/10] Use special error type for when cline tries to access clineignore'd file --- src/core/Cline.ts | 9 +++-- src/core/prompts/responses.ts | 3 ++ src/shared/ExtensionMessage.ts | 1 + webview-ui/src/components/chat/ChatRow.tsx | 41 ++++++++++++++++++++++ 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/src/core/Cline.ts b/src/core/Cline.ts index a7e999f6af..2d0c785c2e 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -1594,7 +1594,8 @@ export class Cline { const accessAllowed = this.llmFileAccessController.validateAccess(relPath) if (!accessAllowed) { - await handleError("writing file", new Error(`Access denied: ${relPath} (blocked by .clineignore)`)) + await this.say("clineignore_error", relPath) + pushToolResult(formatResponse.clineIgnoreError(relPath)) break } @@ -1870,10 +1871,8 @@ export class Cline { const accessAllowed = this.llmFileAccessController.validateAccess(relPath) if (!accessAllowed) { - await handleError( - "reading file", - new Error(`Access denied: ${relPath} (blocked by .clineignore)`), - ) + await this.say("clineignore_error", relPath) + pushToolResult(formatResponse.clineIgnoreError(relPath)) break } diff --git a/src/core/prompts/responses.ts b/src/core/prompts/responses.ts index 34341dba1d..7452106d5f 100644 --- a/src/core/prompts/responses.ts +++ b/src/core/prompts/responses.ts @@ -11,6 +11,9 @@ export const formatResponse = { toolError: (error?: string) => `The tool execution failed with the following error:\n\n${error}\n`, + 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. diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 5a93caf07b..7f03c8e230 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -121,6 +121,7 @@ export type ClineSay = | "use_mcp_server" | "diff_error" | "deleted_api_reqs" + | "clineignore_error" export interface ClineSayTool { tool: diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 59d40936f1..a4904eb89e 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -989,6 +989,47 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi ) + case "clineignore_error": + return ( + <> +
+
+ + + Access Denied + +
+
+ Cline tried to access {message.text} which is blocked by the{" "} + .clineignore + file settings. +
+
+ + ) 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 From 7f292bc9955828584a68e042d53d0a196bb041b6 Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Fri, 7 Feb 2025 00:25:29 -0800 Subject: [PATCH 02/10] Rename LLMFileAccessController to ClineIgnoreController --- src/core/Cline.ts | 32 +++++++++---------- .../ignore/ClineIgnoreController.test.ts} | 16 +++++----- .../ignore/ClineIgnoreController.ts} | 6 ++-- src/core/prompts/responses.ts | 10 +++--- src/services/ripgrep/index.ts | 10 +++--- src/services/tree-sitter/index.ts | 12 +++---- 6 files changed, 43 insertions(+), 43 deletions(-) rename src/{services/llm-access-control/LLMFileAccessController.test.ts => core/ignore/ClineIgnoreController.test.ts} (95%) rename src/{services/llm-access-control/LLMFileAccessController.ts => core/ignore/ClineIgnoreController.ts} (96%) diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 2d0c785c2e..5a520f1177 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -46,7 +46,7 @@ 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 { ClineIgnoreController } from "./ignore/ClineIgnoreController" import { arePathsEqual, getReadablePath } from "../utils/path" import { fixModelHtmlEscaping, removeInvalidChars } from "../utils/string" import { AssistantMessageContent, parseAssistantMessage, ToolParamName, ToolUseName } from "./assistant-message" @@ -81,7 +81,7 @@ export class Cline { private chatSettings: ChatSettings apiConversationHistory: Anthropic.MessageParam[] = [] clineMessages: ClineMessage[] = [] - private llmFileAccessController: LLMFileAccessController + private clineIgnoreController: ClineIgnoreController private askResponse?: ClineAskResponse private askResponseText?: string private askResponseImages?: string[] @@ -125,9 +125,9 @@ export class Cline { images?: string[], historyItem?: HistoryItem, ) { - this.llmFileAccessController = new LLMFileAccessController(cwd) - this.llmFileAccessController.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 +1057,7 @@ export class Cline { this.terminalManager.disposeAll() this.urlContentFetcher.closeBrowser() this.browserSession.closeBrowser() - this.llmFileAccessController.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 } @@ -1592,7 +1592,7 @@ export class Cline { break } - const accessAllowed = this.llmFileAccessController.validateAccess(relPath) + const accessAllowed = this.clineIgnoreController.validateAccess(relPath) if (!accessAllowed) { await this.say("clineignore_error", relPath) pushToolResult(formatResponse.clineIgnoreError(relPath)) @@ -1869,7 +1869,7 @@ export class Cline { break } - const accessAllowed = this.llmFileAccessController.validateAccess(relPath) + const accessAllowed = this.clineIgnoreController.validateAccess(relPath) if (!accessAllowed) { await this.say("clineignore_error", relPath) pushToolResult(formatResponse.clineIgnoreError(relPath)) @@ -1948,7 +1948,7 @@ export class Cline { absolutePath, files, didHitLimit, - this.llmFileAccessController, + this.clineIgnoreController, ) const completeMessage = JSON.stringify({ ...sharedMessageProps, @@ -2012,7 +2012,7 @@ export class Cline { const absolutePath = path.resolve(cwd, relDirPath) const result = await parseSourceCodeForDefinitionsTopLevel( absolutePath, - this.llmFileAccessController, + this.clineIgnoreController, ) const completeMessage = JSON.stringify({ @@ -2089,7 +2089,7 @@ export class Cline { absolutePath, regex, filePattern, - this.llmFileAccessController, + this.clineIgnoreController, ) const completeMessage = JSON.stringify({ @@ -3236,8 +3236,8 @@ export class Cline { .filter(Boolean) .map((absolutePath) => path.relative(cwd, absolutePath)) - // Filter paths through LLMFileAccessController - const allowedVisibleFiles = this.llmFileAccessController + // Filter paths through clineIgnoreController + const allowedVisibleFiles = this.clineIgnoreController .filterPaths(visibleFilePaths) .map((p) => p.toPosix()) .join("\n") @@ -3255,8 +3255,8 @@ export class Cline { .filter(Boolean) .map((absolutePath) => path.relative(cwd, absolutePath)) - // Filter paths through LLMFileAccessController - const allowedOpenTabs = this.llmFileAccessController + // Filter paths through clineIgnoreController + const allowedOpenTabs = this.clineIgnoreController .filterPaths(openTabPaths) .map((p) => p.toPosix()) .join("\n") @@ -3376,7 +3376,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, this.llmFileAccessController) + const result = formatResponse.formatFilesList(cwd, files, didHitLimit, this.clineIgnoreController) details += result } } diff --git a/src/services/llm-access-control/LLMFileAccessController.test.ts b/src/core/ignore/ClineIgnoreController.test.ts similarity index 95% rename from src/services/llm-access-control/LLMFileAccessController.test.ts rename to src/core/ignore/ClineIgnoreController.test.ts index 8bf6353a48..084ad311c1 100644 --- a/src/services/llm-access-control/LLMFileAccessController.test.ts +++ b/src/core/ignore/ClineIgnoreController.test.ts @@ -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() }) @@ -80,7 +80,7 @@ describe("LLMFileAccessController", () => { ["*.secret", "private/", "*.tmp", "data-*.json", "temp/*"].join("\n"), ) - controller = new LLMFileAccessController(tempDir) + controller = new ClineIgnoreController(tempDir) await controller.initialize() const results = [ @@ -150,7 +150,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") @@ -237,7 +237,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 +249,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") diff --git a/src/services/llm-access-control/LLMFileAccessController.ts b/src/core/ignore/ClineIgnoreController.ts similarity index 96% rename from src/services/llm-access-control/LLMFileAccessController.ts rename to src/core/ignore/ClineIgnoreController.ts index 2fa2f882f5..bcb02989e8 100644 --- a/src/services/llm-access-control/LLMFileAccessController.ts +++ b/src/core/ignore/ClineIgnoreController.ts @@ -9,7 +9,7 @@ import * as vscode from "vscode" * 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 { +export class ClineIgnoreController { private cwd: string private ignoreInstance: Ignore private fileWatcher: vscode.FileSystemWatcher | null @@ -23,7 +23,7 @@ export class LLMFileAccessController { constructor(cwd: string) { this.cwd = cwd this.ignoreInstance = ignore() - this.ignoreInstance.add(LLMFileAccessController.DEFAULT_PATTERNS) + this.ignoreInstance.add(ClineIgnoreController.DEFAULT_PATTERNS) this.fileWatcher = null // Set up file watcher for .clineignore @@ -93,7 +93,7 @@ export class LLMFileAccessController { */ private resetToDefaultPatterns(): void { this.ignoreInstance = ignore() - this.ignoreInstance.add(LLMFileAccessController.DEFAULT_PATTERNS) + this.ignoreInstance.add(ClineIgnoreController.DEFAULT_PATTERNS) } /** diff --git a/src/core/prompts/responses.ts b/src/core/prompts/responses.ts index 7452106d5f..5cce47e9e4 100644 --- a/src/core/prompts/responses.ts +++ b/src/core/prompts/responses.ts @@ -1,7 +1,7 @@ import { Anthropic } from "@anthropic-ai/sdk" -import * as path from "path" import * as diff from "diff" -import { LLMFileAccessController } from "../../services/llm-access-control/LLMFileAccessController" +import * as path from "path" +import { ClineIgnoreController } from "../ignore/ClineIgnoreController" export const formatResponse = { toolDenied: () => `The user denied this operation.`, @@ -54,7 +54,7 @@ Otherwise, if you have not completed the task and do not need additional informa absolutePath: string, files: string[], didHitLimit: boolean, - llmFileAccessController: LLMFileAccessController, + clineIgnoreController: ClineIgnoreController, ): string => { const sorted = files .map((file) => { @@ -87,13 +87,13 @@ Otherwise, if you have not completed the task and do not need additional informa return aParts.length - bParts.length }) - const accessControlledSortedFiles = llmFileAccessController + const accessControlledSortedFiles = 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 = !llmFileAccessController.validateAccess(absoluteFilePath) + const isIgnored = !clineIgnoreController.validateAccess(absoluteFilePath) if (isIgnored) { return "\u{1F512} " + filePath } diff --git a/src/services/ripgrep/index.ts b/src/services/ripgrep/index.ts index d6c0c72fe4..21d3d9d0df 100644 --- a/src/services/ripgrep/index.ts +++ b/src/services/ripgrep/index.ts @@ -2,8 +2,8 @@ import * as vscode from "vscode" import * as childProcess from "child_process" import * as path from "path" import * as readline from "readline" -import { LLMFileAccessController } from "../llm-access-control/LLMFileAccessController" import { fileExistsAtPath } from "../../utils/fs" +import { ClineIgnoreController } from "../../core/ignore/ClineIgnoreController" /* This file provides functionality to perform regex searches on files using ripgrep. @@ -120,7 +120,7 @@ export async function regexSearchFiles( directoryPath: string, regex: string, filePattern?: string, - llmFileAccessController?: LLMFileAccessController, + clineIgnoreController?: ClineIgnoreController, ): Promise { const vscodeAppRoot = vscode.env.appRoot const rgPath = await getBinPath(vscodeAppRoot) @@ -173,9 +173,9 @@ export async function regexSearchFiles( results.push(currentResult as SearchResult) } - // Filter results using LLMFileAccessController if provided - const filteredResults = llmFileAccessController - ? results.filter((result) => llmFileAccessController.validateAccess(result.filePath)) + // Filter results using ClineIgnoreController if provided + const filteredResults = clineIgnoreController + ? results.filter((result) => clineIgnoreController.validateAccess(result.filePath)) : results return formatResults(filteredResults, cwd) diff --git a/src/services/tree-sitter/index.ts b/src/services/tree-sitter/index.ts index 291127a6bf..262e3cd5cc 100644 --- a/src/services/tree-sitter/index.ts +++ b/src/services/tree-sitter/index.ts @@ -3,12 +3,12 @@ import * as path from "path" import { listFiles } from "../glob/list-files" import { LanguageParser, loadRequiredLanguageParsers } from "./languageParser" import { fileExistsAtPath } from "../../utils/fs" -import { LLMFileAccessController } from "../llm-access-control/LLMFileAccessController" +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, - llmFileAccessController?: LLMFileAccessController, + clineIgnoreController?: ClineIgnoreController, ): Promise { // check if the path exists const dirExists = await fileExistsAtPath(path.resolve(dirPath)) @@ -30,10 +30,10 @@ export async function parseSourceCodeForDefinitionsTopLevel( // const filesWithoutDefinitions: string[] = [] // Filter filepaths for access if controller is provided - const allowedFilesToParse = llmFileAccessController ? llmFileAccessController.filterPaths(filesToParse) : filesToParse + const allowedFilesToParse = clineIgnoreController ? clineIgnoreController.filterPaths(filesToParse) : filesToParse for (const filePath of allowedFilesToParse) { - const definitions = await parseFile(filePath, languageParsers, llmFileAccessController) + const definitions = await parseFile(filePath, languageParsers, clineIgnoreController) if (definitions) { result += `${path.relative(dirPath, filePath).toPosix()}\n${definitions}\n` } @@ -109,9 +109,9 @@ This approach allows us to focus on the most relevant parts of the code (defined async function parseFile( filePath: string, languageParsers: LanguageParser, - llmFileAccessController?: LLMFileAccessController, + clineIgnoreController?: ClineIgnoreController, ): Promise { - if (llmFileAccessController && !llmFileAccessController.validateAccess(filePath)) { + if (clineIgnoreController && !clineIgnoreController.validateAccess(filePath)) { return null } const fileContent = await fs.readFile(filePath, "utf8") From f9e310f3383b4231afbe2fd7c8831a3f07f5cbbc Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Fri, 7 Feb 2025 00:26:42 -0800 Subject: [PATCH 03/10] Use better changeset --- .changeset/loud-countries-draw.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/loud-countries-draw.md b/.changeset/loud-countries-draw.md index e290688320..3a6e55f337 100644 --- a/.changeset/loud-countries-draw.md +++ b/.changeset/loud-countries-draw.md @@ -2,4 +2,4 @@ "claude-dev": minor --- -Introducing .clineignore +Add .clineignore file to block Cline from accessing specified file patterns \ No newline at end of file From 4f2d5b15bf892fd2994838a16e23fb32c689a9c0 Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Fri, 7 Feb 2025 00:37:03 -0800 Subject: [PATCH 04/10] Remove unnecessary file parsing --- src/core/ignore/ClineIgnoreController.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/core/ignore/ClineIgnoreController.ts b/src/core/ignore/ClineIgnoreController.ts index bcb02989e8..68132145c8 100644 --- a/src/core/ignore/ClineIgnoreController.ts +++ b/src/core/ignore/ClineIgnoreController.ts @@ -76,12 +76,7 @@ export class ClineIgnoreController { // 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) + this.ignoreInstance.add(content) } } catch (error) { // Continue with default patterns From 88c75a06f2687d7648dbd9294c2745516fc0c791 Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Fri, 7 Feb 2025 00:58:20 -0800 Subject: [PATCH 05/10] Allow access to files outside cwd --- src/core/ignore/ClineIgnoreController.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/core/ignore/ClineIgnoreController.ts b/src/core/ignore/ClineIgnoreController.ts index 68132145c8..c1eb037d8d 100644 --- a/src/core/ignore/ClineIgnoreController.ts +++ b/src/core/ignore/ClineIgnoreController.ts @@ -100,18 +100,14 @@ export class ClineIgnoreController { 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, "/") + const relativePath = path.relative(this.cwd, absolutePath).toPosix() - // Block access to paths outside cwd (those starting with '..') - if (relativePath.startsWith("..")) { - return false - } - - // Use ignore library to check if path should be ignored + // Ignore expects paths to be path.relative()'d return !this.ignoreInstance.ignores(relativePath) } catch (error) { - console.error(`Error validating access for ${filePath}:`, error) - return false // Fail closed for security + // 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 } } From 8ae39e24be210bfbfe44fdecdca62c28476cfb43 Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Fri, 7 Feb 2025 10:33:47 -0800 Subject: [PATCH 06/10] Use more efficient clineIgnoreExists --- src/core/ignore/ClineIgnoreController.ts | 58 ++++++++++-------------- src/core/prompts/responses.ts | 13 ++---- 2 files changed, 28 insertions(+), 43 deletions(-) diff --git a/src/core/ignore/ClineIgnoreController.ts b/src/core/ignore/ClineIgnoreController.ts index c1eb037d8d..0fb882e121 100644 --- a/src/core/ignore/ClineIgnoreController.ts +++ b/src/core/ignore/ClineIgnoreController.ts @@ -12,20 +12,13 @@ import * as vscode from "vscode" export class ClineIgnoreController { private cwd: string private ignoreInstance: Ignore - private fileWatcher: vscode.FileSystemWatcher | null private disposables: vscode.Disposable[] = [] - - /** - * Default patterns that are always ignored - */ - private static readonly DEFAULT_PATTERNS = [".clineignore"] + clineIgnoreExists: boolean constructor(cwd: string) { this.cwd = cwd this.ignoreInstance = ignore() - this.ignoreInstance.add(ClineIgnoreController.DEFAULT_PATTERNS) - this.fileWatcher = null - + this.clineIgnoreExists = false // Set up file watcher for .clineignore this.setupFileWatcher() } @@ -35,7 +28,7 @@ export class ClineIgnoreController { * Must be called after construction and before using the controller */ async initialize(): Promise { - await this.loadCustomPatterns() + await this.loadClineIgnore() } /** @@ -43,60 +36,56 @@ export class ClineIgnoreController { */ private setupFileWatcher(): void { const clineignorePattern = new vscode.RelativePattern(this.cwd, ".clineignore") - this.fileWatcher = vscode.workspace.createFileSystemWatcher(clineignorePattern) + const 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) - }) + fileWatcher.onDidChange(() => { + this.loadClineIgnore() }), - this.fileWatcher.onDidCreate(() => { - this.loadCustomPatterns().catch((error) => { - console.error("Failed to load new .clineignore patterns:", error) - }) + fileWatcher.onDidCreate(() => { + this.loadClineIgnore() }), - this.fileWatcher.onDidDelete(() => { - this.resetToDefaultPatterns() + fileWatcher.onDidDelete(() => { + this.loadClineIgnore() }), ) // Add fileWatcher itself to disposables - this.disposables.push(this.fileWatcher) + this.disposables.push(fileWatcher) } /** * Load custom patterns from .clineignore if it exists */ - private async loadCustomPatterns(): Promise { + private async loadClineIgnore(): Promise { try { + // Reset ignore instance to prevent duplicate patterns + this.ignoreInstance = ignore() const ignorePath = path.join(this.cwd, ".clineignore") if (await fileExistsAtPath(ignorePath)) { - // Reset ignore instance to prevent duplicate patterns - this.resetToDefaultPatterns() + this.clineIgnoreExists = true const content = await fs.readFile(ignorePath, "utf8") this.ignoreInstance.add(content) + } else { + this.clineIgnoreExists = false } } catch (error) { - // Continue with default patterns + // Should never happen: reading file failed even though it exists + console.error("Unexpected error loading .clineignore:", error) } } - /** - * Reset ignore patterns to defaults - */ - private resetToDefaultPatterns(): void { - this.ignoreInstance = ignore() - this.ignoreInstance.add(ClineIgnoreController.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 { + // Always allow access if .clineignore does not exist + if (!this.clineIgnoreExists) { + return true + } try { // Normalize path to be relative to cwd and use forward slashes const absolutePath = path.resolve(this.cwd, filePath) @@ -137,6 +126,5 @@ export class ClineIgnoreController { dispose(): void { this.disposables.forEach((d) => d.dispose()) this.disposables = [] - this.fileWatcher = null } } diff --git a/src/core/prompts/responses.ts b/src/core/prompts/responses.ts index 5cce47e9e4..090667d3ff 100644 --- a/src/core/prompts/responses.ts +++ b/src/core/prompts/responses.ts @@ -54,7 +54,7 @@ Otherwise, if you have not completed the task and do not need additional informa absolutePath: string, files: string[], didHitLimit: boolean, - clineIgnoreController: ClineIgnoreController, + clineIgnoreController?: ClineIgnoreController, ): string => { const sorted = files .map((file) => { @@ -87,7 +87,7 @@ Otherwise, if you have not completed the task and do not need additional informa return aParts.length - bParts.length }) - const accessControlledSortedFiles = clineIgnoreController + const clineIgnoreParsed = clineIgnoreController ? sorted.map((filePath) => { // path is relative to absolute path, not cwd // validateAccess expects either path relative to cwd or absolute path @@ -103,16 +103,13 @@ Otherwise, if you have not completed the task and do not need additional informa : sorted if (didHitLimit) { - return `${accessControlledSortedFiles.join( + return `${clineIgnoreParsed.join( "\n", )}\n\n(File list truncated. Use list_files on specific subdirectories if you need to explore further.)` - } else if ( - accessControlledSortedFiles.length === 0 || - (accessControlledSortedFiles.length === 1 && accessControlledSortedFiles[0] === "") - ) { + } else if (clineIgnoreParsed.length === 0 || (clineIgnoreParsed.length === 1 && clineIgnoreParsed[0] === "")) { return "No files found." } else { - return accessControlledSortedFiles.join("\n") + return clineIgnoreParsed.join("\n") } }, From 53604c94131e9125ab68ae30d2f905f0a1378b9e Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Fri, 7 Feb 2025 10:51:22 -0800 Subject: [PATCH 07/10] Modify clineignore prompt --- src/core/Cline.ts | 8 +++++++- src/core/ignore/ClineIgnoreController.ts | 10 +++++----- src/core/prompts/system.ts | 12 +++++++++--- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 5a520f1177..81d97a087f 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -1242,9 +1242,15 @@ export class Cline { } } + const clineIgnoreContent = this.clineIgnoreController.clineIgnoreContent + let clineIgnoreInstructions: string | undefined + if (clineIgnoreContent) { + clineIgnoreInstructions = `# .clineignore\n\nThe 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 \u{1F512} 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}` + } + 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 - 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 diff --git a/src/core/ignore/ClineIgnoreController.ts b/src/core/ignore/ClineIgnoreController.ts index 0fb882e121..1ed8097382 100644 --- a/src/core/ignore/ClineIgnoreController.ts +++ b/src/core/ignore/ClineIgnoreController.ts @@ -13,12 +13,12 @@ export class ClineIgnoreController { private cwd: string private ignoreInstance: Ignore private disposables: vscode.Disposable[] = [] - clineIgnoreExists: boolean + clineIgnoreContent: string | undefined constructor(cwd: string) { this.cwd = cwd this.ignoreInstance = ignore() - this.clineIgnoreExists = false + this.clineIgnoreContent = undefined // Set up file watcher for .clineignore this.setupFileWatcher() } @@ -64,11 +64,11 @@ export class ClineIgnoreController { this.ignoreInstance = ignore() const ignorePath = path.join(this.cwd, ".clineignore") if (await fileExistsAtPath(ignorePath)) { - this.clineIgnoreExists = true const content = await fs.readFile(ignorePath, "utf8") + this.clineIgnoreContent = content this.ignoreInstance.add(content) } else { - this.clineIgnoreExists = false + this.clineIgnoreContent = undefined } } catch (error) { // Should never happen: reading file failed even though it exists @@ -83,7 +83,7 @@ export class ClineIgnoreController { */ validateAccess(filePath: string): boolean { // Always allow access if .clineignore does not exist - if (!this.clineIgnoreExists) { + if (!this.clineIgnoreContent) { return true } try { diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index d84391bed2..a9a13f1e90 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -957,20 +957,26 @@ 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 ` ==== USER'S CUSTOM INSTRUCTIONS -The user may have a ".clineignore" file that blocks you from accessing certain directories and files. The paths blocked by this file will be indicated to you with a \u{1F512}, but you need not mention the \u{1F512}. For example, do NOT say things like 'I notice that {some_file}.ts is marked with \u{1F512} in the file list.' Instead, if you ever encounter an error about being blocked by the .clineignore, don't make a big deal about it, just move on without remarking on it. If you absolutely have to say something, just make a casual remark that you are 'blocked by the .clineignore'. Note that the .clineignore is also blocked. The following additional instructions are provided by the user, and should be followed to the best of your ability without interfering with the TOOL USE guidelines. From 075e3171a8f3b96b63fb14a4c3ea5d9217f62a28 Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Fri, 7 Feb 2025 10:52:57 -0800 Subject: [PATCH 08/10] Use LOCK_TEXT_SYMBOL --- src/core/Cline.ts | 15 +++++++-------- src/core/ignore/ClineIgnoreController.ts | 2 ++ src/core/prompts/responses.ts | 4 ++-- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 81d97a087f..5b83218c14 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -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 { ClineIgnoreController } from "./ignore/ClineIgnoreController" 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 @@ -1245,7 +1244,7 @@ export class Cline { const clineIgnoreContent = this.clineIgnoreController.clineIgnoreContent let clineIgnoreInstructions: string | undefined if (clineIgnoreContent) { - clineIgnoreInstructions = `# .clineignore\n\nThe 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 \u{1F512} 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}` + clineIgnoreInstructions = `# .clineignore\n\nThe 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}` } if (settingsCustomInstructions || clineRulesFileInstructions) { diff --git a/src/core/ignore/ClineIgnoreController.ts b/src/core/ignore/ClineIgnoreController.ts index 1ed8097382..925dcd189a 100644 --- a/src/core/ignore/ClineIgnoreController.ts +++ b/src/core/ignore/ClineIgnoreController.ts @@ -4,6 +4,8 @@ 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. diff --git a/src/core/prompts/responses.ts b/src/core/prompts/responses.ts index 090667d3ff..623e3d8806 100644 --- a/src/core/prompts/responses.ts +++ b/src/core/prompts/responses.ts @@ -1,7 +1,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import * as diff from "diff" import * as path from "path" -import { ClineIgnoreController } from "../ignore/ClineIgnoreController" +import { ClineIgnoreController, LOCK_TEXT_SYMBOL } from "../ignore/ClineIgnoreController" export const formatResponse = { toolDenied: () => `The user denied this operation.`, @@ -95,7 +95,7 @@ Otherwise, if you have not completed the task and do not need additional informa const absoluteFilePath = path.resolve(absolutePath, filePath) const isIgnored = !clineIgnoreController.validateAccess(absoluteFilePath) if (isIgnored) { - return "\u{1F512} " + filePath + return LOCK_TEXT_SYMBOL + " " + filePath } return filePath From 5585c684e9f1f7555deaef2b423189425b33400a Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Fri, 7 Feb 2025 11:46:44 -0800 Subject: [PATCH 09/10] Block commands attempting to access clineignored files --- src/core/Cline.ts | 10 +++++ src/core/ignore/ClineIgnoreController.ts | 56 ++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 5b83218c14..f6ae751837 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -1601,6 +1601,7 @@ export class Cline { if (!accessAllowed) { await this.say("clineignore_error", relPath) pushToolResult(formatResponse.clineIgnoreError(relPath)) + await this.saveCheckpoint() break } @@ -1878,6 +1879,7 @@ export class Cline { if (!accessAllowed) { await this.say("clineignore_error", relPath) pushToolResult(formatResponse.clineIgnoreError(relPath)) + await this.saveCheckpoint() break } @@ -2333,6 +2335,14 @@ export class Cline { } this.consecutiveMistakeCount = 0 + const ignoredFileAttemptedToAccess = this.clineIgnoreController.validateCommand(command) + if (ignoredFileAttemptedToAccess) { + await this.say("clineignore_error", ignoredFileAttemptedToAccess) + pushToolResult(formatResponse.clineIgnoreError(ignoredFileAttemptedToAccess)) + await this.saveCheckpoint() + break + } + let didAutoApprove = false if (!requiresApproval && this.shouldAutoApproveTool(block.name)) { diff --git a/src/core/ignore/ClineIgnoreController.ts b/src/core/ignore/ClineIgnoreController.ts index 925dcd189a..d3637caa76 100644 --- a/src/core/ignore/ClineIgnoreController.ts +++ b/src/core/ignore/ClineIgnoreController.ts @@ -102,6 +102,62 @@ export class ClineIgnoreController { } } + /** + * 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) From b37f6887558399c1296c35ff6997f5097439aa45 Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Fri, 7 Feb 2025 12:11:14 -0800 Subject: [PATCH 10/10] Fix prompt responses --- src/core/Cline.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/core/Cline.ts b/src/core/Cline.ts index f6ae751837..fee138284d 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -1600,7 +1600,7 @@ export class Cline { const accessAllowed = this.clineIgnoreController.validateAccess(relPath) if (!accessAllowed) { await this.say("clineignore_error", relPath) - pushToolResult(formatResponse.clineIgnoreError(relPath)) + pushToolResult(formatResponse.toolError(formatResponse.clineIgnoreError(relPath))) await this.saveCheckpoint() break } @@ -1878,7 +1878,7 @@ export class Cline { const accessAllowed = this.clineIgnoreController.validateAccess(relPath) if (!accessAllowed) { await this.say("clineignore_error", relPath) - pushToolResult(formatResponse.clineIgnoreError(relPath)) + pushToolResult(formatResponse.toolError(formatResponse.clineIgnoreError(relPath))) await this.saveCheckpoint() break } @@ -2338,7 +2338,9 @@ export class Cline { const ignoredFileAttemptedToAccess = this.clineIgnoreController.validateCommand(command) if (ignoredFileAttemptedToAccess) { await this.say("clineignore_error", ignoredFileAttemptedToAccess) - pushToolResult(formatResponse.clineIgnoreError(ignoredFileAttemptedToAccess)) + pushToolResult( + formatResponse.toolError(formatResponse.clineIgnoreError(ignoredFileAttemptedToAccess)), + ) await this.saveCheckpoint() break }