Skip to content

Commit 19c56c6

Browse files
Attention Is Not What You Need (RooCodeInc#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 <[email protected]>
1 parent 4449b51 commit 19c56c6

File tree

12 files changed

+426
-234
lines changed

12 files changed

+426
-234
lines changed

.changeset/loud-countries-draw.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"claude-dev": minor
3+
---
4+
5+
Add .clineignore file to block Cline from accessing specified file patterns

src/core/Cline.ts

Lines changed: 93 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import * as path from "path"
99
import { serializeError } from "serialize-error"
1010
import * as vscode from "vscode"
1111
import { ApiHandler, buildApiHandler } from "../api"
12+
import { OpenAiHandler } from "../api/providers/openai"
13+
import { OpenRouterHandler } from "../api/providers/openrouter"
14+
import { ApiStream } from "../api/transform/stream"
1215
import CheckpointTracker from "../integrations/checkpoints/CheckpointTracker"
1316
import { DIFF_VIEW_URI_SCHEME, DiffViewProvider } from "../integrations/editor/DiffViewProvider"
1417
import { findToolName, formatContentBlockToMarkdown } from "../integrations/misc/export-markdown"
@@ -46,20 +49,16 @@ import { HistoryItem } from "../shared/HistoryItem"
4649
import { ClineAskResponse, ClineCheckpointRestore } from "../shared/WebviewMessage"
4750
import { calculateApiCost } from "../utils/cost"
4851
import { fileExistsAtPath } from "../utils/fs"
49-
import { LLMFileAccessController } from "../services/llm-access-control/LLMFileAccessController"
5052
import { arePathsEqual, getReadablePath } from "../utils/path"
5153
import { fixModelHtmlEscaping, removeInvalidChars } from "../utils/string"
5254
import { AssistantMessageContent, parseAssistantMessage, ToolParamName, ToolUseName } from "./assistant-message"
5355
import { constructNewFileContent } from "./assistant-message/diff"
56+
import { ClineIgnoreController, LOCK_TEXT_SYMBOL } from "./ignore/ClineIgnoreController"
5457
import { parseMentions } from "./mentions"
5558
import { formatResponse } from "./prompts/responses"
56-
import { ClineProvider, GlobalFileNames } from "./webview/ClineProvider"
57-
import { OpenRouterHandler } from "../api/providers/openrouter"
59+
import { addUserInstructions, SYSTEM_PROMPT } from "./prompts/system"
5860
import { getNextTruncationRange, getTruncatedMessages } from "./sliding-window"
59-
import { SYSTEM_PROMPT } from "./prompts/system"
60-
import { addUserInstructions } from "./prompts/system"
61-
import { OpenAiHandler } from "../api/providers/openai"
62-
import { ApiStream } from "../api/transform/stream"
61+
import { ClineProvider, GlobalFileNames } from "./webview/ClineProvider"
6362

6463
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
6564

@@ -81,7 +80,7 @@ export class Cline {
8180
private chatSettings: ChatSettings
8281
apiConversationHistory: Anthropic.MessageParam[] = []
8382
clineMessages: ClineMessage[] = []
84-
private llmAccessController: LLMFileAccessController
83+
private clineIgnoreController: ClineIgnoreController
8584
private askResponse?: ClineAskResponse
8685
private askResponseText?: string
8786
private askResponseImages?: string[]
@@ -125,9 +124,9 @@ export class Cline {
125124
images?: string[],
126125
historyItem?: HistoryItem,
127126
) {
128-
this.llmAccessController = new LLMFileAccessController(cwd)
129-
this.llmAccessController.initialize().catch((error) => {
130-
console.error("Failed to initialize LLMFileAccessController:", error)
127+
this.clineIgnoreController = new ClineIgnoreController(cwd)
128+
this.clineIgnoreController.initialize().catch((error) => {
129+
console.error("Failed to initialize ClineIgnoreController:", error)
131130
})
132131
this.providerRef = new WeakRef(provider)
133132
this.api = buildApiHandler(apiConfiguration)
@@ -1057,7 +1056,7 @@ export class Cline {
10571056
this.terminalManager.disposeAll()
10581057
this.urlContentFetcher.closeBrowser()
10591058
this.browserSession.closeBrowser()
1060-
this.llmAccessController.dispose()
1059+
this.clineIgnoreController.dispose()
10611060
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
10621061
}
10631062

@@ -1242,9 +1241,15 @@ export class Cline {
12421241
}
12431242
}
12441243

1244+
const clineIgnoreContent = this.clineIgnoreController.clineIgnoreContent
1245+
let clineIgnoreInstructions: string | undefined
1246+
if (clineIgnoreContent) {
1247+
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`
1248+
}
1249+
12451250
if (settingsCustomInstructions || clineRulesFileInstructions) {
12461251
// 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>
1247-
systemPrompt += addUserInstructions(settingsCustomInstructions, clineRulesFileInstructions)
1252+
systemPrompt += addUserInstructions(settingsCustomInstructions, clineRulesFileInstructions, clineIgnoreInstructions)
12481253
}
12491254

12501255
// 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 {
15911596
// wait so we can determine if it's a new file or editing an existing file
15921597
break
15931598
}
1599+
1600+
const accessAllowed = this.clineIgnoreController.validateAccess(relPath)
1601+
if (!accessAllowed) {
1602+
await this.say("clineignore_error", relPath)
1603+
pushToolResult(formatResponse.toolError(formatResponse.clineIgnoreError(relPath)))
1604+
await this.saveCheckpoint()
1605+
break
1606+
}
1607+
15941608
// Check if file exists using cached map or fs.access
15951609
let fileExists: boolean
15961610
if (this.diffViewProvider.editType !== undefined) {
@@ -1708,6 +1722,7 @@ export class Cline {
17081722
await this.saveCheckpoint()
17091723
break
17101724
}
1725+
17111726
this.consecutiveMistakeCount = 0
17121727

17131728
// if isEditingFile false, that means we have the full contents of the file already.
@@ -1859,6 +1874,15 @@ export class Cline {
18591874
await this.saveCheckpoint()
18601875
break
18611876
}
1877+
1878+
const accessAllowed = this.clineIgnoreController.validateAccess(relPath)
1879+
if (!accessAllowed) {
1880+
await this.say("clineignore_error", relPath)
1881+
pushToolResult(formatResponse.toolError(formatResponse.clineIgnoreError(relPath)))
1882+
await this.saveCheckpoint()
1883+
break
1884+
}
1885+
18621886
this.consecutiveMistakeCount = 0
18631887
const absolutePath = path.resolve(cwd, relPath)
18641888
const completeMessage = JSON.stringify({
@@ -1922,9 +1946,17 @@ export class Cline {
19221946
break
19231947
}
19241948
this.consecutiveMistakeCount = 0
1949+
19251950
const absolutePath = path.resolve(cwd, relDirPath)
1951+
19261952
const [files, didHitLimit] = await listFiles(absolutePath, recursive, 200)
1927-
const result = formatResponse.formatFilesList(absolutePath, files, didHitLimit)
1953+
1954+
const result = formatResponse.formatFilesList(
1955+
absolutePath,
1956+
files,
1957+
didHitLimit,
1958+
this.clineIgnoreController,
1959+
)
19281960
const completeMessage = JSON.stringify({
19291961
...sharedMessageProps,
19301962
content: result,
@@ -1981,9 +2013,15 @@ export class Cline {
19812013
await this.saveCheckpoint()
19822014
break
19832015
}
2016+
19842017
this.consecutiveMistakeCount = 0
2018+
19852019
const absolutePath = path.resolve(cwd, relDirPath)
1986-
const result = await parseSourceCodeForDefinitionsTopLevel(absolutePath)
2020+
const result = await parseSourceCodeForDefinitionsTopLevel(
2021+
absolutePath,
2022+
this.clineIgnoreController,
2023+
)
2024+
19872025
const completeMessage = JSON.stringify({
19882026
...sharedMessageProps,
19892027
content: result,
@@ -2051,8 +2089,16 @@ export class Cline {
20512089
break
20522090
}
20532091
this.consecutiveMistakeCount = 0
2092+
20542093
const absolutePath = path.resolve(cwd, relDirPath)
2055-
const results = await regexSearchFiles(cwd, absolutePath, regex, filePattern)
2094+
const results = await regexSearchFiles(
2095+
cwd,
2096+
absolutePath,
2097+
regex,
2098+
filePattern,
2099+
this.clineIgnoreController,
2100+
)
2101+
20562102
const completeMessage = JSON.stringify({
20572103
...sharedMessageProps,
20582104
content: results,
@@ -2289,6 +2335,16 @@ export class Cline {
22892335
}
22902336
this.consecutiveMistakeCount = 0
22912337

2338+
const ignoredFileAttemptedToAccess = this.clineIgnoreController.validateCommand(command)
2339+
if (ignoredFileAttemptedToAccess) {
2340+
await this.say("clineignore_error", ignoredFileAttemptedToAccess)
2341+
pushToolResult(
2342+
formatResponse.toolError(formatResponse.clineIgnoreError(ignoredFileAttemptedToAccess)),
2343+
)
2344+
await this.saveCheckpoint()
2345+
break
2346+
}
2347+
22922348
let didAutoApprove = false
22932349

22942350
if (!requiresApproval && this.shouldAutoApproveTool(block.name)) {
@@ -3192,26 +3248,38 @@ export class Cline {
31923248

31933249
// 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
31943250
details += "\n\n# VSCode Visible Files"
3195-
const visibleFiles = vscode.window.visibleTextEditors
3251+
const visibleFilePaths = vscode.window.visibleTextEditors
31963252
?.map((editor) => editor.document?.uri?.fsPath)
31973253
.filter(Boolean)
3198-
.map((absolutePath) => path.relative(cwd, absolutePath).toPosix())
3254+
.map((absolutePath) => path.relative(cwd, absolutePath))
3255+
3256+
// Filter paths through clineIgnoreController
3257+
const allowedVisibleFiles = this.clineIgnoreController
3258+
.filterPaths(visibleFilePaths)
3259+
.map((p) => p.toPosix())
31993260
.join("\n")
3200-
if (visibleFiles) {
3201-
details += `\n${visibleFiles}`
3261+
3262+
if (allowedVisibleFiles) {
3263+
details += `\n${allowedVisibleFiles}`
32023264
} else {
32033265
details += "\n(No visible files)"
32043266
}
32053267

32063268
details += "\n\n# VSCode Open Tabs"
3207-
const openTabs = vscode.window.tabGroups.all
3269+
const openTabPaths = vscode.window.tabGroups.all
32083270
.flatMap((group) => group.tabs)
32093271
.map((tab) => (tab.input as vscode.TabInputText)?.uri?.fsPath)
32103272
.filter(Boolean)
3211-
.map((absolutePath) => path.relative(cwd, absolutePath).toPosix())
3273+
.map((absolutePath) => path.relative(cwd, absolutePath))
3274+
3275+
// Filter paths through clineIgnoreController
3276+
const allowedOpenTabs = this.clineIgnoreController
3277+
.filterPaths(openTabPaths)
3278+
.map((p) => p.toPosix())
32123279
.join("\n")
3213-
if (openTabs) {
3214-
details += `\n${openTabs}`
3280+
3281+
if (allowedOpenTabs) {
3282+
details += `\n${allowedOpenTabs}`
32153283
} else {
32163284
details += "\n(No open tabs)"
32173285
}
@@ -3325,7 +3393,7 @@ export class Cline {
33253393
details += "(Desktop files not shown automatically. Use list_files to explore if needed.)"
33263394
} else {
33273395
const [files, didHitLimit] = await listFiles(cwd, true, 200)
3328-
const result = formatResponse.formatFilesList(cwd, files, didHitLimit)
3396+
const result = formatResponse.formatFilesList(cwd, files, didHitLimit, this.clineIgnoreController)
33293397
details += result
33303398
}
33313399
}

src/services/llm-access-control/LLMFileAccessController.test.ts renamed to src/core/ignore/ClineIgnoreController.test.ts

Lines changed: 14 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
import { LLMFileAccessController } from "./LLMFileAccessController"
1+
import { ClineIgnoreController } from "./ClineIgnoreController"
22
import fs from "fs/promises"
33
import path from "path"
44
import os from "os"
55
import { after, beforeEach, describe, it } from "mocha"
66
import "should"
77

8-
describe("LLMFileAccessController", () => {
8+
describe("ClineIgnoreController", () => {
99
let tempDir: string
10-
let controller: LLMFileAccessController
10+
let controller: ClineIgnoreController
1111

1212
beforeEach(async () => {
1313
// Create a temp directory for testing
@@ -22,7 +22,7 @@ describe("LLMFileAccessController", () => {
2222
),
2323
)
2424

25-
controller = new LLMFileAccessController(tempDir)
25+
controller = new ClineIgnoreController(tempDir)
2626
await controller.initialize()
2727
})
2828

@@ -49,6 +49,11 @@ describe("LLMFileAccessController", () => {
4949
]
5050
results.forEach((result) => result.should.be.true())
5151
})
52+
53+
it("should block access to .clineignore file", async () => {
54+
const result = controller.validateAccess(".clineignore")
55+
result.should.be.false()
56+
})
5257
})
5358

5459
describe("Custom Patterns", () => {
@@ -80,7 +85,7 @@ describe("LLMFileAccessController", () => {
8085
["*.secret", "private/", "*.tmp", "data-*.json", "temp/*"].join("\n"),
8186
)
8287

83-
controller = new LLMFileAccessController(tempDir)
88+
controller = new ClineIgnoreController(tempDir)
8489
await controller.initialize()
8590

8691
const results = [
@@ -111,7 +116,7 @@ describe("LLMFileAccessController", () => {
111116
// ].join("\n"),
112117
// )
113118

114-
// controller = new LLMFileAccessController(tempDir)
119+
// controller = new ClineIgnoreController(tempDir)
115120

116121
// const results = [
117122
// // Basic negation
@@ -150,7 +155,7 @@ describe("LLMFileAccessController", () => {
150155
["# Comment line", "*.secret", "private/", "temp.*"].join("\n"),
151156
)
152157

153-
controller = new LLMFileAccessController(tempDir)
158+
controller = new ClineIgnoreController(tempDir)
154159
await controller.initialize()
155160

156161
const result = controller.validateAccess("test.secret")
@@ -194,25 +199,6 @@ describe("LLMFileAccessController", () => {
194199
const result = controller.validateAccess("src\\file.ts")
195200
result.should.be.true()
196201
})
197-
198-
it("should handle paths outside cwd", async () => {
199-
// Create a path that points to parent directory of cwd
200-
const outsidePath = path.join(path.dirname(tempDir), "outside.txt")
201-
const result = controller.validateAccess(outsidePath)
202-
203-
// Should return false for security since path is outside cwd
204-
result.should.be.false()
205-
206-
// Test with a deeply nested path outside cwd
207-
const deepOutsidePath = path.join(path.dirname(tempDir), "deep", "nested", "outside.secret")
208-
const deepResult = controller.validateAccess(deepOutsidePath)
209-
deepResult.should.be.false()
210-
211-
// Test with a path that tries to escape using ../
212-
const escapeAttemptPath = path.join(tempDir, "..", "escape-attempt.txt")
213-
const escapeResult = controller.validateAccess(escapeAttemptPath)
214-
escapeResult.should.be.false()
215-
})
216202
})
217203

218204
describe("Batch Filtering", () => {
@@ -237,7 +223,7 @@ describe("LLMFileAccessController", () => {
237223
await fs.mkdir(emptyDir)
238224

239225
try {
240-
const controller = new LLMFileAccessController(emptyDir)
226+
const controller = new ClineIgnoreController(emptyDir)
241227
await controller.initialize()
242228
const result = controller.validateAccess("file.txt")
243229
result.should.be.true()
@@ -249,7 +235,7 @@ describe("LLMFileAccessController", () => {
249235
it("should handle empty .clineignore", async () => {
250236
await fs.writeFile(path.join(tempDir, ".clineignore"), "")
251237

252-
controller = new LLMFileAccessController(tempDir)
238+
controller = new ClineIgnoreController(tempDir)
253239
await controller.initialize()
254240

255241
const result = controller.validateAccess("regular-file.txt")

0 commit comments

Comments
 (0)