Class Implemented (#1577)

* wip

* LLMFileAccessController and tests

* added class and tests

* cleaning up

* formatting

* removing some defaults

* package json and remove defaults list
This commit is contained in:
Evan Fannin 2025-02-01 04:09:12 +08:00 committed by GitHub
parent 3df5e533b7
commit 2a078fee77
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 420 additions and 6 deletions

65
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "claude-dev",
"version": "3.2.6",
"version": "3.2.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.2.6",
"version": "3.2.9",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/bedrock-sdk": "^0.10.2",
@ -32,6 +32,7 @@
"firebase": "^11.2.0",
"get-folder-size": "^5.0.0",
"globby": "^14.0.2",
"ignore": "^7.0.3",
"isbinaryfile": "^5.0.2",
"mammoth": "^1.8.0",
"monaco-vscode-textmate-theme-converter": "^0.1.7",
@ -2610,6 +2611,15 @@
"concat-map": "0.0.1"
}
},
"node_modules/@eslint/eslintrc/node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"engines": {
"node": ">= 4"
}
},
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -3660,6 +3670,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@manypkg/get-packages/node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"engines": {
"node": ">= 4"
}
},
"node_modules/@manypkg/get-packages/node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@ -5634,6 +5653,15 @@
}
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"engines": {
"node": ">= 4"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "7.15.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.15.0.tgz",
@ -5772,6 +5800,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"engines": {
"node": ">= 4"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@ -7628,6 +7665,15 @@
"node": ">=10.13.0"
}
},
"node_modules/eslint/node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"engines": {
"node": ">= 4"
}
},
"node_modules/eslint/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -8604,6 +8650,14 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/globby/node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"engines": {
"node": ">= 4"
}
},
"node_modules/google-auth-library": {
"version": "9.14.0",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.14.0.tgz",
@ -8915,10 +8969,9 @@
"license": "BSD-3-Clause"
},
"node_modules/ignore": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz",
"integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==",
"license": "MIT",
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.3.tgz",
"integrity": "sha512-bAH5jbK/F3T3Jls4I0SO1hmPR0dKU0a7+SY6n1yzRtG54FLO8d6w/nxLFX2Nb7dBu6cCWXPaAME6cYqFUMmuCA==",
"engines": {
"node": ">= 4"
}

View file

@ -234,6 +234,7 @@
"firebase": "^11.2.0",
"get-folder-size": "^5.0.0",
"globby": "^14.0.2",
"ignore": "^7.0.3",
"isbinaryfile": "^5.0.2",
"mammoth": "^1.8.0",
"monaco-vscode-textmate-theme-converter": "^0.1.7",

View file

@ -0,0 +1,260 @@
import { LLMFileAccessController } from "./LLMFileAccessController"
import fs from "fs/promises"
import path from "path"
import os from "os"
import { after, beforeEach, describe, it } from "mocha"
import "should"
describe("LLMFileAccessController", () => {
let tempDir: string
let controller: LLMFileAccessController
beforeEach(async () => {
// Create a temp directory for testing
tempDir = path.join(os.tmpdir(), `llm-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
await fs.mkdir(tempDir)
// Create default .clineignore file
await fs.writeFile(
path.join(tempDir, ".clineignore"),
[".env", "*.secret", "private/", "# This is a comment", "", "temp.*", "file-with-space-at-end.* ", "**/.git/**"].join(
"\n",
),
)
controller = new LLMFileAccessController(tempDir)
await controller.initialize()
})
after(async () => {
// Clean up temp directory
await fs.rm(tempDir, { recursive: true, force: true })
})
describe("Default Patterns", () => {
// it("should block access to common ignored files", async () => {
// const results = await Promise.all([
// 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([
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([
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([
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())
})
it("should handle pattern edge cases", async () => {
await fs.writeFile(
path.join(tempDir, ".clineignore"),
["*.secret", "private/", "*.tmp", "data-*.json", "temp/*"].join("\n"),
)
controller = new LLMFileAccessController(tempDir)
await controller.initialize()
const results = await Promise.all([
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
results[2].should.be.false() // script.tmp
})
// ToDo: handle negation patterns successfully
// it("should handle negation patterns", async () => {
// await fs.writeFile(
// path.join(tempDir, ".clineignore"),
// [
// "temp/*", // Ignore everything in temp
// "!temp/allowed/*", // But allow files in temp/allowed
// "docs/**/*.md", // Ignore all markdown files in docs
// "!docs/README.md", // Except README.md
// "!docs/CONTRIBUTING.md", // And CONTRIBUTING.md
// "assets/", // Ignore all assets
// "!assets/public/", // Except public assets
// "!assets/public/*.png", // Specifically allow PNGs in public assets
// ].join("\n"),
// )
// controller = new LLMFileAccessController(tempDir)
// await controller.initialize()
// const results = await Promise.all([
// // Basic negation
// controller.validateAccess("temp/file.txt"), // Should be false (in temp/)
// controller.validateAccess("temp/allowed/file.txt"), // Should be true (negated)
// controller.validateAccess("temp/allowed/nested/file.txt"), // Should be true (negated with nested)
// // Multiple negations in same path
// controller.validateAccess("docs/guide.md"), // Should be false (matches docs/**/*.md)
// controller.validateAccess("docs/README.md"), // Should be true (negated)
// controller.validateAccess("docs/CONTRIBUTING.md"), // Should be true (negated)
// controller.validateAccess("docs/api/guide.md"), // Should be false (nested markdown)
// // Nested negations
// 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
// results[2].should.be.true() // temp/allowed/nested/file.txt
// results[3].should.be.false() // docs/guide.md
// results[4].should.be.true() // docs/README.md
// results[5].should.be.true() // docs/CONTRIBUTING.md
// results[6].should.be.false() // docs/api/guide.md
// results[7].should.be.false() // assets/logo.png
// results[8].should.be.true() // assets/public/logo.png
// results[9].should.be.true() // assets/public/data.json
// })
it("should handle comments in .clineignore", async () => {
// Create a new .clineignore with comments
await fs.writeFile(
path.join(tempDir, ".clineignore"),
["# Comment line", "*.secret", "private/", "temp.*"].join("\n"),
)
controller = new LLMFileAccessController(tempDir)
await controller.initialize()
const result = await controller.validateAccess("test.secret")
result.should.be.false()
})
})
describe("Path Handling", () => {
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)
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)
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)
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")
allowedResult.should.be.true()
// Test relative path that matches an ignore pattern (*.secret)
const ignoredResult = await controller.validateAccess("./config.secret")
ignoredResult.should.be.false()
// Test relative path in ignored directory (private/)
const ignoredDirResult = await controller.validateAccess("./private/data.txt")
ignoredDirResult.should.be.false()
})
it("should normalize paths with backslashes", async () => {
const result = await 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)
// 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)
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)
escapeResult.should.be.false()
})
})
describe("Batch Filtering", () => {
it("should filter an array of paths", async () => {
const paths = ["src/index.ts", ".env", "lib/utils.ts", ".git/config", "dist/bundle.js"]
const filtered = controller.filterPaths(paths)
filtered.should.deepEqual(["src/index.ts", "lib/utils.ts", "dist/bundle.js"])
})
})
describe("Error Handling", () => {
it("should handle invalid paths", async () => {
// Test with an invalid path containing null byte
const result = await controller.validateAccess("\0invalid")
result.should.be.true()
})
it("should handle missing .clineignore gracefully", async () => {
// Create a new controller in a directory without .clineignore
const emptyDir = path.join(os.tmpdir(), `llm-test-empty-${Date.now()}`)
await fs.mkdir(emptyDir)
try {
const controller = new LLMFileAccessController(emptyDir)
await controller.initialize()
const result = await controller.validateAccess("file.txt")
result.should.be.true()
} finally {
await fs.rm(emptyDir, { recursive: true, force: true })
}
})
it("should handle empty .clineignore", async () => {
await fs.writeFile(path.join(tempDir, ".clineignore"), "")
controller = new LLMFileAccessController(tempDir)
await controller.initialize()
const result = await controller.validateAccess("regular-file.txt")
result.should.be.true()
})
})
})

View file

@ -0,0 +1,100 @@
import path from "path"
import { fileExistsAtPath } from "../../utils/fs"
import fs from "fs/promises"
import ignore, { Ignore } from "ignore"
/**
* 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
/**
* 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()
// Add default patterns immediately
this.ignoreInstance.add(LLMFileAccessController.DEFAULT_PATTERNS)
}
/**
* Initialize the controller by loading custom patterns
* This must be called and awaited before using the controller
*/
async initialize(): Promise<void> {
await this.loadCustomPatterns()
}
/**
* 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)) {
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) {
console.error("Failed to load .clineignore:", error)
// Continue with 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
}
}
}