Skip to content

Commit

Permalink
Add clineignore class into cline file (cline#1623)
Browse files Browse the repository at this point in the history
  • Loading branch information
celestial-vault authored Feb 4, 2025
1 parent c97fb24 commit fa67db7
Show file tree
Hide file tree
Showing 3 changed files with 89 additions and 31 deletions.
8 changes: 8 additions & 0 deletions src/core/Cline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +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 { arePathsEqual, getReadablePath } from "../utils/path"
import { fixModelHtmlEscaping, removeInvalidChars } from "../utils/string"
import { AssistantMessageContent, parseAssistantMessage, ToolParamName, ToolUseName } from "./assistant-message"
Expand Down Expand Up @@ -80,6 +81,7 @@ export class Cline {
private chatSettings: ChatSettings
apiConversationHistory: Anthropic.MessageParam[] = []
clineMessages: ClineMessage[] = []
private llmAccessController: LLMFileAccessController
private askResponse?: ClineAskResponse
private askResponseText?: string
private askResponseImages?: string[]
Expand Down Expand Up @@ -123,6 +125,10 @@ 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.providerRef = new WeakRef(provider)
this.api = buildApiHandler(apiConfiguration)
this.terminalManager = new TerminalManager()
Expand Down Expand Up @@ -748,6 +754,7 @@ export class Cline {
// if the extension process were killed, then on restart the clineMessages might not be empty, so we need to set it to [] when we create a new Cline client (otherwise webview would show stale messages from previous session)
this.clineMessages = []
this.apiConversationHistory = []

await this.providerRef.deref()?.postStateToWebview()

await this.say("text", task, images)
Expand Down Expand Up @@ -1050,6 +1057,7 @@ export class Cline {
this.terminalManager.disposeAll()
this.urlContentFetcher.closeBrowser()
this.browserSession.closeBrowser()
this.llmAccessController.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
}

Expand Down
53 changes: 26 additions & 27 deletions src/services/llm-access-control/LLMFileAccessController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,44 +33,44 @@ describe("LLMFileAccessController", () => {

describe("Default Patterns", () => {
// it("should block access to common ignored files", async () => {
// const results = await Promise.all([
// const results = [
// controller.validateAccess(".env"),
// controller.validateAccess(".git/config"),
// controller.validateAccess("node_modules/package.json"),
// ])
// ]
// results.forEach((result) => result.should.be.false())
// })

it("should allow access to regular files", async () => {
const results = await Promise.all([
const results = [
controller.validateAccess("src/index.ts"),
controller.validateAccess("README.md"),
controller.validateAccess("package.json"),
])
]
results.forEach((result) => result.should.be.true())
})
})

describe("Custom Patterns", () => {
it("should block access to custom ignored patterns", async () => {
const results = await Promise.all([
const results = [
controller.validateAccess("config.secret"),
controller.validateAccess("private/data.txt"),
controller.validateAccess("temp.json"),
controller.validateAccess("nested/deep/file.secret"),
controller.validateAccess("private/nested/deep/file.txt"),
])
]
results.forEach((result) => result.should.be.false())
})

it("should allow access to non-ignored files", async () => {
const results = await Promise.all([
const results = [
controller.validateAccess("public/data.txt"),
controller.validateAccess("config.json"),
controller.validateAccess("src/temp/file.ts"),
controller.validateAccess("nested/deep/file.txt"),
controller.validateAccess("not-private/data.txt"),
])
]
results.forEach((result) => result.should.be.true())
})

Expand All @@ -83,11 +83,11 @@ describe("LLMFileAccessController", () => {
controller = new LLMFileAccessController(tempDir)
await controller.initialize()

const results = await Promise.all([
const results = [
controller.validateAccess("data-123.json"), // Should be false (wildcard)
controller.validateAccess("data.json"), // Should be true (doesn't match pattern)
controller.validateAccess("script.tmp"), // Should be false (extension match)
])
]

results[0].should.be.false() // data-123.json
results[1].should.be.true() // data.json
Expand All @@ -112,9 +112,8 @@ describe("LLMFileAccessController", () => {
// )

// controller = new LLMFileAccessController(tempDir)
// await controller.initialize()

// const results = await Promise.all([
// const results = [
// // Basic negation
// controller.validateAccess("temp/file.txt"), // Should be false (in temp/)
// controller.validateAccess("temp/allowed/file.txt"), // Should be true (negated)
Expand All @@ -130,7 +129,7 @@ describe("LLMFileAccessController", () => {
// controller.validateAccess("assets/logo.png"), // Should be false (in assets/)
// controller.validateAccess("assets/public/logo.png"), // Should be true (negated and matches *.png)
// controller.validateAccess("assets/public/data.json"), // Should be true (in negated public/)
// ])
// ]

// results[0].should.be.false() // temp/file.txt
// results[1].should.be.true() // temp/allowed/file.txt
Expand All @@ -154,7 +153,7 @@ describe("LLMFileAccessController", () => {
controller = new LLMFileAccessController(tempDir)
await controller.initialize()

const result = await controller.validateAccess("test.secret")
const result = controller.validateAccess("test.secret")
result.should.be.false()
})
})
Expand All @@ -163,55 +162,55 @@ describe("LLMFileAccessController", () => {
it("should handle absolute paths and match ignore patterns", async () => {
// Test absolute path that should be allowed
const allowedPath = path.join(tempDir, "src/file.ts")
const allowedResult = await controller.validateAccess(allowedPath)
const allowedResult = controller.validateAccess(allowedPath)
allowedResult.should.be.true()

// Test absolute path that matches an ignore pattern (*.secret)
const ignoredPath = path.join(tempDir, "config.secret")
const ignoredResult = await controller.validateAccess(ignoredPath)
const ignoredResult = controller.validateAccess(ignoredPath)
ignoredResult.should.be.false()

// Test absolute path in ignored directory (private/)
const ignoredDirPath = path.join(tempDir, "private/data.txt")
const ignoredDirResult = await controller.validateAccess(ignoredDirPath)
const ignoredDirResult = controller.validateAccess(ignoredDirPath)
ignoredDirResult.should.be.false()
})

it("should handle relative paths and match ignore patterns", async () => {
// Test relative path that should be allowed
const allowedResult = await controller.validateAccess("./src/file.ts")
const allowedResult = controller.validateAccess("./src/file.ts")
allowedResult.should.be.true()

// Test relative path that matches an ignore pattern (*.secret)
const ignoredResult = await controller.validateAccess("./config.secret")
const ignoredResult = controller.validateAccess("./config.secret")
ignoredResult.should.be.false()

// Test relative path in ignored directory (private/)
const ignoredDirResult = await controller.validateAccess("./private/data.txt")
const ignoredDirResult = controller.validateAccess("./private/data.txt")
ignoredDirResult.should.be.false()
})

it("should normalize paths with backslashes", async () => {
const result = await controller.validateAccess("src\\file.ts")
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 = await controller.validateAccess(outsidePath)
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 = await controller.validateAccess(deepOutsidePath)
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 = await controller.validateAccess(escapeAttemptPath)
const escapeResult = controller.validateAccess(escapeAttemptPath)
escapeResult.should.be.false()
})
})
Expand All @@ -228,7 +227,7 @@ describe("LLMFileAccessController", () => {
describe("Error Handling", () => {
it("should handle invalid paths", async () => {
// Test with an invalid path containing null byte
const result = await controller.validateAccess("\0invalid")
const result = controller.validateAccess("\0invalid")
result.should.be.true()
})

Expand All @@ -240,7 +239,7 @@ describe("LLMFileAccessController", () => {
try {
const controller = new LLMFileAccessController(emptyDir)
await controller.initialize()
const result = await controller.validateAccess("file.txt")
const result = controller.validateAccess("file.txt")
result.should.be.true()
} finally {
await fs.rm(emptyDir, { recursive: true, force: true })
Expand All @@ -253,7 +252,7 @@ describe("LLMFileAccessController", () => {
controller = new LLMFileAccessController(tempDir)
await controller.initialize()

const result = await controller.validateAccess("regular-file.txt")
const result = controller.validateAccess("regular-file.txt")
result.should.be.true()
})
})
Expand Down
59 changes: 55 additions & 4 deletions src/services/llm-access-control/LLMFileAccessController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ 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.
Expand All @@ -11,6 +12,8 @@ import ignore, { Ignore } from "ignore"
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
Expand All @@ -20,26 +23,58 @@ export class LLMFileAccessController {
constructor(cwd: string) {
this.cwd = cwd
this.ignoreInstance = ignore()

// Add default patterns immediately
this.ignoreInstance.add(LLMFileAccessController.DEFAULT_PATTERNS)
this.fileWatcher = null

// Set up file watcher for .clineignore
this.setupFileWatcher()
}

/**
* Initialize the controller by loading custom patterns
* This must be called and awaited before using the controller
* 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")
Expand All @@ -49,11 +84,18 @@ export class LLMFileAccessController {
this.ignoreInstance.add(customPatterns)
}
} catch (error) {
console.error("Failed to load .clineignore:", 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)
Expand Down Expand Up @@ -97,4 +139,13 @@ export class LLMFileAccessController {
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
}
}

0 comments on commit fa67db7

Please sign in to comment.