diff --git a/package-lock.json b/package-lock.json index 9f3906f1d4..26881a7159 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } diff --git a/package.json b/package.json index eec6f71f30..2f1167ee04 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/services/llm-access-control/LLMFileAccessController.test.ts b/src/services/llm-access-control/LLMFileAccessController.test.ts new file mode 100644 index 0000000000..b8cee93e9a --- /dev/null +++ b/src/services/llm-access-control/LLMFileAccessController.test.ts @@ -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() + }) + }) +}) diff --git a/src/services/llm-access-control/LLMFileAccessController.ts b/src/services/llm-access-control/LLMFileAccessController.ts new file mode 100644 index 0000000000..b5139c43a8 --- /dev/null +++ b/src/services/llm-access-control/LLMFileAccessController.ts @@ -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 { + await this.loadCustomPatterns() + } + + /** + * Load custom patterns from .clineignore if it exists + */ + private async loadCustomPatterns(): Promise { + 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 + } + } +}