diff --git a/src/core/Cline.ts b/src/core/Cline.ts index b5deecc463..1d9aa7189e 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -1,7 +1,8 @@ import { Anthropic } from "@anthropic-ai/sdk" +import { loadClineIgnoreFile } from "../utils/cline-ignore" import cloneDeep from "clone-deep" import { DiffStrategy, getDiffStrategy, UnifiedDiffStrategy } from "./diff/DiffStrategy" -import { validateToolUse, isToolAllowedForMode, ToolName } from "./mode-validator" +import { isToolAllowedForMode, ToolName } from "./mode-validator" import delay from "delay" import fs from "fs/promises" import os from "os" @@ -76,6 +77,7 @@ export class Cline { private urlContentFetcher: UrlContentFetcher private browserSession: BrowserSession private didEditFile: boolean = false + private ignoreContent: string = "" customInstructions?: string diffStrategy?: DiffStrategy diffEnabled: boolean = false @@ -132,6 +134,9 @@ export class Cline { this.providerRef = new WeakRef(provider) this.diffViewProvider = new DiffViewProvider(cwd) + // Load ignore content once at initialization + loadClineIgnoreFile(cwd).then((content) => (this.ignoreContent = content)) + if (historyItem) { this.taskId = historyItem.id } @@ -1149,15 +1154,17 @@ export class Cline { const { mode } = (await this.providerRef.deref()?.getState()) ?? {} const { customModes } = (await this.providerRef.deref()?.getState()) ?? {} try { - validateToolUse( + const isAllowed = isToolAllowedForMode( block.name as ToolName, mode ?? defaultModeSlug, customModes ?? [], - { - apply_diff: this.diffEnabled, - }, + { apply_diff: this.diffEnabled }, block.params, + this.ignoreContent, ) + if (!isAllowed) { + throw new Error(`Tool "${block.name}" is not allowed in ${mode ?? defaultModeSlug} mode.`) + } } catch (error) { this.consecutiveMistakeCount++ pushToolResult(formatResponse.toolError(error.message)) @@ -2718,9 +2725,7 @@ export class Cline { // Add warning if not in code mode if ( - !isToolAllowedForMode("write_to_file", currentMode, customModes ?? [], { - apply_diff: this.diffEnabled, - }) && + !isToolAllowedForMode("write_to_file", currentMode, customModes ?? [], { apply_diff: this.diffEnabled }) && !isToolAllowedForMode("apply_diff", currentMode, customModes ?? [], { apply_diff: this.diffEnabled }) ) { const currentModeName = getModeBySlug(currentMode, customModes)?.name ?? currentMode diff --git a/src/shared/__tests__/modes.test.ts b/src/shared/__tests__/modes.test.ts index ac9d1a5f7e..41747ad087 100644 --- a/src/shared/__tests__/modes.test.ts +++ b/src/shared/__tests__/modes.test.ts @@ -1,4 +1,4 @@ -import { isToolAllowedForMode, FileRestrictionError, ModeConfig } from "../modes" +import { isToolAllowedForMode, FileRestrictionError, IgnoredFileError, ModeConfig } from "../modes" describe("isToolAllowedForMode", () => { const customModes: ModeConfig[] = [ @@ -186,3 +186,47 @@ describe("FileRestrictionError", () => { expect(error.name).toBe("FileRestrictionError") }) }) + +describe("IgnoredFileError", () => { + it("formats error message correctly", () => { + const error = new IgnoredFileError("test.js") + expect(error.message).toBe("File test.js is ignored by .clineignore") + expect(error.name).toBe("IgnoredFileError") + }) +}) + +describe("clineignore handling", () => { + const ignoreContent = "*.js\n!important.js\nnode_modules/" + + it("throws IgnoredFileError for ignored files in read group", () => { + expect(() => + isToolAllowedForMode("read_file", "code", [], undefined, { path: "test.js" }, ignoreContent), + ).toThrow(IgnoredFileError) + + expect(() => + isToolAllowedForMode("read_file", "code", [], undefined, { path: "node_modules/test.ts" }, ignoreContent), + ).toThrow(IgnoredFileError) + }) + + it("throws IgnoredFileError for ignored files in edit group", () => { + expect(() => + isToolAllowedForMode("write_to_file", "code", [], undefined, { path: "test.js" }, ignoreContent), + ).toThrow(IgnoredFileError) + + expect(() => + isToolAllowedForMode("apply_diff", "code", [], undefined, { path: "node_modules/test.ts" }, ignoreContent), + ).toThrow(IgnoredFileError) + }) + + it("allows non-ignored files", () => { + expect(isToolAllowedForMode("read_file", "code", [], undefined, { path: "test.ts" }, ignoreContent)).toBe(true) + + expect( + isToolAllowedForMode("write_to_file", "code", [], undefined, { path: "important.js" }, ignoreContent), + ).toBe(true) + }) + + it("allows tools without paths", () => { + expect(isToolAllowedForMode("browser_action", "code", [], undefined, undefined, ignoreContent)).toBe(true) + }) +}) diff --git a/src/shared/modes.ts b/src/shared/modes.ts index c29800c5b5..0aea69983f 100644 --- a/src/shared/modes.ts +++ b/src/shared/modes.ts @@ -1,4 +1,5 @@ import { TOOL_GROUPS, ToolGroup, ALWAYS_AVAILABLE_TOOLS } from "./tool-groups" +const { shouldIgnorePath } = require("../utils/cline-ignore") // Mode types export type Mode = string @@ -154,12 +155,31 @@ export class FileRestrictionError extends Error { } } +// Custom error class for ignored files +export class IgnoredFileError extends Error { + constructor(filePath: string) { + super(`File ${filePath} is ignored by .clineignore`) + this.name = "IgnoredFileError" + } +} + +/** + * Checks if a tool is allowed for a given mode, including .clineignore restrictions + * @param tool The tool name to check + * @param modeSlug The mode slug to check against + * @param customModes Array of custom mode configurations + * @param toolRequirements Optional map of tool-specific requirements + * @param toolParams Optional parameters passed to the tool + * @param ignoreContent Optional .clineignore file content + * @returns true if tool is allowed, throws error otherwise + */ export function isToolAllowedForMode( tool: string, modeSlug: string, customModes: ModeConfig[], toolRequirements?: Record, - toolParams?: Record, // All tool parameters + toolParams?: Record, + ignoreContent?: string, ): boolean { // Always allow these tools if (ALWAYS_AVAILABLE_TOOLS.includes(tool as any)) { @@ -188,6 +208,16 @@ export function isToolAllowedForMode( continue } + // Check .clineignore for read/edit groups + if ( + toolParams?.path && + ignoreContent && + (groupName === "read" || groupName === "edit") && + shouldIgnorePath(toolParams.path, ignoreContent) + ) { + throw new IgnoredFileError(toolParams.path) + } + // If there are no options, allow the tool if (!options) { return true