diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 3697ef35bb..48bfb98b89 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -68,8 +68,10 @@ export const globalSettingsSchema = z.object({ autoApprovalEnabled: z.boolean().optional(), alwaysAllowReadOnly: z.boolean().optional(), alwaysAllowReadOnlyOutsideWorkspace: z.boolean().optional(), + allowedReadDirectories: z.array(z.string()).optional(), alwaysAllowWrite: z.boolean().optional(), alwaysAllowWriteOutsideWorkspace: z.boolean().optional(), + allowedWriteDirectories: z.array(z.string()).optional(), alwaysAllowWriteProtected: z.boolean().optional(), writeDelayMs: z.number().min(0).optional(), alwaysAllowBrowser: z.boolean().optional(), @@ -294,8 +296,10 @@ export const EVALS_SETTINGS: RooCodeSettings = { autoApprovalEnabled: true, alwaysAllowReadOnly: true, alwaysAllowReadOnlyOutsideWorkspace: false, + allowedReadDirectories: [], alwaysAllowWrite: true, alwaysAllowWriteOutsideWorkspace: false, + allowedWriteDirectories: [], alwaysAllowWriteProtected: false, writeDelayMs: 1000, alwaysAllowBrowser: true, diff --git a/src/core/auto-approval/index.ts b/src/core/auto-approval/index.ts index 52677932ce..72a0a0e011 100644 --- a/src/core/auto-approval/index.ts +++ b/src/core/auto-approval/index.ts @@ -6,6 +6,7 @@ import { ClineAskResponse } from "../../shared/WebviewMessage" import { isWriteToolAction, isReadOnlyToolAction } from "./tools" import { isMcpToolAlwaysAllowed } from "./mcp" import { getCommandDecision } from "./commands" +import { isPathInAllowedDirectories } from "../../utils/pathUtils" // We have 10 different actions that can be auto-approved. export type AutoApprovalState = @@ -24,7 +25,9 @@ export type AutoApprovalState = export type AutoApprovalStateOptions = | "autoApprovalEnabled" | "alwaysAllowReadOnlyOutsideWorkspace" // For `alwaysAllowReadOnly`. + | "allowedReadDirectories" // For directory-specific read approval. | "alwaysAllowWriteOutsideWorkspace" // For `alwaysAllowWrite`. + | "allowedWriteDirectories" // For directory-specific write approval. | "alwaysAllowWriteProtected" | "followupAutoApproveTimeoutMs" // For `alwaysAllowFollowupQuestions`. | "mcpServers" // For `alwaysAllowMcp`. @@ -166,20 +169,59 @@ export async function checkAutoApproval({ } const isOutsideWorkspace = !!tool.isOutsideWorkspace + const filePath = tool.path if (isReadOnlyToolAction(tool)) { - return state.alwaysAllowReadOnly === true && - (!isOutsideWorkspace || state.alwaysAllowReadOnlyOutsideWorkspace === true) - ? { decision: "approve" } - : { decision: "ask" } + // Check if read is allowed + if (state.alwaysAllowReadOnly !== true) { + return { decision: "ask" } + } + + // If file is inside workspace, approve + if (!isOutsideWorkspace) { + return { decision: "approve" } + } + + // File is outside workspace - check if it's in allowed directories + if ( + filePath && + state.allowedReadDirectories && + isPathInAllowedDirectories(filePath, state.allowedReadDirectories) + ) { + return { decision: "approve" } + } + + // Otherwise check the general outside workspace setting + return state.alwaysAllowReadOnlyOutsideWorkspace === true ? { decision: "approve" } : { decision: "ask" } } if (isWriteToolAction(tool)) { - return state.alwaysAllowWrite === true && - (!isOutsideWorkspace || state.alwaysAllowWriteOutsideWorkspace === true) && - (!isProtected || state.alwaysAllowWriteProtected === true) - ? { decision: "approve" } - : { decision: "ask" } + // Check if write is allowed + if (state.alwaysAllowWrite !== true) { + return { decision: "ask" } + } + + // Check if protected files are allowed + if (isProtected && state.alwaysAllowWriteProtected !== true) { + return { decision: "ask" } + } + + // If file is inside workspace, approve + if (!isOutsideWorkspace) { + return { decision: "approve" } + } + + // File is outside workspace - check if it's in allowed directories + if ( + filePath && + state.allowedWriteDirectories && + isPathInAllowedDirectories(filePath, state.allowedWriteDirectories) + ) { + return { decision: "approve" } + } + + // Otherwise check the general outside workspace setting + return state.alwaysAllowWriteOutsideWorkspace === true ? { decision: "approve" } : { decision: "ask" } } } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index ff97d5f030..8e9f669170 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1834,8 +1834,10 @@ export class ClineProvider customInstructions, alwaysAllowReadOnly, alwaysAllowReadOnlyOutsideWorkspace, + allowedReadDirectories, alwaysAllowWrite, alwaysAllowWriteOutsideWorkspace, + allowedWriteDirectories, alwaysAllowWriteProtected, alwaysAllowExecute, allowedCommands, @@ -1965,8 +1967,10 @@ export class ClineProvider customInstructions, alwaysAllowReadOnly: alwaysAllowReadOnly ?? false, alwaysAllowReadOnlyOutsideWorkspace: alwaysAllowReadOnlyOutsideWorkspace ?? false, + allowedReadDirectories: allowedReadDirectories ?? [], alwaysAllowWrite: alwaysAllowWrite ?? false, alwaysAllowWriteOutsideWorkspace: alwaysAllowWriteOutsideWorkspace ?? false, + allowedWriteDirectories: allowedWriteDirectories ?? [], alwaysAllowWriteProtected: alwaysAllowWriteProtected ?? false, alwaysAllowExecute: alwaysAllowExecute ?? false, alwaysAllowBrowser: alwaysAllowBrowser ?? false, @@ -2195,8 +2199,10 @@ export class ClineProvider apiModelId: stateValues.apiModelId, alwaysAllowReadOnly: stateValues.alwaysAllowReadOnly ?? false, alwaysAllowReadOnlyOutsideWorkspace: stateValues.alwaysAllowReadOnlyOutsideWorkspace ?? false, + allowedReadDirectories: stateValues.allowedReadDirectories ?? [], alwaysAllowWrite: stateValues.alwaysAllowWrite ?? false, alwaysAllowWriteOutsideWorkspace: stateValues.alwaysAllowWriteOutsideWorkspace ?? false, + allowedWriteDirectories: stateValues.allowedWriteDirectories ?? [], alwaysAllowWriteProtected: stateValues.alwaysAllowWriteProtected ?? false, alwaysAllowExecute: stateValues.alwaysAllowExecute ?? false, alwaysAllowBrowser: stateValues.alwaysAllowBrowser ?? false, diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 59745b9cf9..16586c0ef7 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -225,8 +225,10 @@ export type ExtensionState = Pick< | "autoApprovalEnabled" | "alwaysAllowReadOnly" | "alwaysAllowReadOnlyOutsideWorkspace" + | "allowedReadDirectories" | "alwaysAllowWrite" | "alwaysAllowWriteOutsideWorkspace" + | "allowedWriteDirectories" | "alwaysAllowWriteProtected" | "alwaysAllowBrowser" | "alwaysApproveResubmit" diff --git a/src/utils/__tests__/pathUtils.spec.ts b/src/utils/__tests__/pathUtils.spec.ts new file mode 100644 index 0000000000..56593640ae --- /dev/null +++ b/src/utils/__tests__/pathUtils.spec.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" +import * as path from "path" +import * as os from "os" +import { isPathInAllowedDirectories } from "../pathUtils" + +// Mock os module +vi.mock("os") + +describe("isPathInAllowedDirectories", () => { + const originalPlatform = process.platform + + beforeEach(() => { + // Default mock for os.homedir + vi.mocked(os.homedir).mockReturnValue("/home/user") + }) + + afterEach(() => { + vi.clearAllMocks() + Object.defineProperty(process, "platform", { value: originalPlatform }) + }) + + describe("basic path matching", () => { + it("should return false when allowed directories list is empty", () => { + expect(isPathInAllowedDirectories("/some/path/file.txt", [])).toBe(false) + }) + + it("should return false when allowed directories list is undefined", () => { + expect(isPathInAllowedDirectories("/some/path/file.txt", undefined as unknown as string[])).toBe(false) + }) + + it("should match exact directory path", () => { + const allowedDirs = ["/allowed/path"] + expect(isPathInAllowedDirectories("/allowed/path/file.txt", allowedDirs)).toBe(true) + expect(isPathInAllowedDirectories("/allowed/path/subdir/file.txt", allowedDirs)).toBe(true) + expect(isPathInAllowedDirectories("/other/path/file.txt", allowedDirs)).toBe(false) + }) + + it("should handle trailing slashes correctly", () => { + const allowedDirs = ["/allowed/path/"] + expect(isPathInAllowedDirectories("/allowed/path/file.txt", allowedDirs)).toBe(true) + expect(isPathInAllowedDirectories("/allowed/path/subdir/file.txt", allowedDirs)).toBe(true) + }) + }) + + describe("tilde expansion", () => { + it("should expand ~ to home directory", () => { + // os.homedir is mocked to return '/home/user' + const allowedDirs = ["~/projects"] + expect(isPathInAllowedDirectories("/home/user/projects/file.txt", allowedDirs)).toBe(true) + expect(isPathInAllowedDirectories("/home/user/projects/subdir/file.txt", allowedDirs)).toBe(true) + expect(isPathInAllowedDirectories("/home/other/projects/file.txt", allowedDirs)).toBe(false) + }) + + it("should handle ~ in the middle of path", () => { + const allowedDirs = ["/path/with/~/in/middle"] + expect(isPathInAllowedDirectories("/path/with/~/in/middle/file.txt", allowedDirs)).toBe(true) + expect(isPathInAllowedDirectories("/path/with/~expanded/in/middle/file.txt", allowedDirs)).toBe(false) + }) + }) + + describe("gitignore-style wildcard patterns", () => { + describe("asterisk (*) wildcard", () => { + it("should match directories with * wildcard", () => { + const allowedDirs = ["/usr/include/Qt*"] + expect(isPathInAllowedDirectories("/usr/include/Qt/file.txt", allowedDirs)).toBe(true) + expect(isPathInAllowedDirectories("/usr/include/QtCore/file.txt", allowedDirs)).toBe(true) + expect(isPathInAllowedDirectories("/usr/include/QtWidgets/file.txt", allowedDirs)).toBe(true) + expect(isPathInAllowedDirectories("/usr/include/Qt5/file.txt", allowedDirs)).toBe(true) + expect(isPathInAllowedDirectories("/usr/include/GTK/file.txt", allowedDirs)).toBe(false) + }) + + it("should match with trailing /* pattern", () => { + const allowedDirs = ["/usr/include/*"] + expect(isPathInAllowedDirectories("/usr/include/Qt/file.txt", allowedDirs)).toBe(true) + expect(isPathInAllowedDirectories("/usr/include/QtCore/file.txt", allowedDirs)).toBe(true) + expect(isPathInAllowedDirectories("/usr/include/subdir/file.txt", allowedDirs)).toBe(true) + }) + + it("should match nested paths with ** pattern", () => { + const allowedDirs = ["~/projects/**"] + expect(isPathInAllowedDirectories("/home/user/projects/app1/src/file.txt", allowedDirs)).toBe(true) + expect(isPathInAllowedDirectories("/home/user/projects/app2/lib/file.txt", allowedDirs)).toBe(true) + }) + }) + + describe("question mark (?) wildcard", () => { + it("should match exactly one character", () => { + const allowedDirs = ["/usr/include/Qt?"] + expect(isPathInAllowedDirectories("/usr/include/Qt5/file.txt", allowedDirs)).toBe(true) + expect(isPathInAllowedDirectories("/usr/include/Qt6/file.txt", allowedDirs)).toBe(true) + expect(isPathInAllowedDirectories("/usr/include/Qt/file.txt", allowedDirs)).toBe(false) + expect(isPathInAllowedDirectories("/usr/include/Qt10/file.txt", allowedDirs)).toBe(false) + }) + }) + }) + + describe("multiple allowed directories", () => { + it("should match if any pattern matches", () => { + const allowedDirs = ["/usr/include/Qt*", "~/projects/*", "/tmp/build*"] + expect(isPathInAllowedDirectories("/usr/include/QtCore/file.txt", allowedDirs)).toBe(true) + expect(isPathInAllowedDirectories("/home/user/projects/app/file.txt", allowedDirs)).toBe(true) + expect(isPathInAllowedDirectories("/tmp/build123/file.txt", allowedDirs)).toBe(true) + expect(isPathInAllowedDirectories("/other/path/file.txt", allowedDirs)).toBe(false) + }) + }) + + describe("path normalization", () => { + it("should normalize paths before matching", () => { + const allowedDirs = ["/allowed/path"] + expect(isPathInAllowedDirectories("/allowed/path/../path/file.txt", allowedDirs)).toBe(true) + expect(isPathInAllowedDirectories("/allowed/./path/file.txt", allowedDirs)).toBe(true) + }) + }) + + describe("platform-specific behavior", () => { + it.skip("should handle Windows paths on Windows", () => { + // Skipped: Windows-specific test that requires Windows platform + Object.defineProperty(process, "platform", { value: "win32", configurable: true }) + vi.mocked(os.homedir).mockReturnValue("C:\\Users\\user") + + const allowedDirs = ["C:\\projects\\*"] + expect(isPathInAllowedDirectories("C:\\projects\\app\\file.txt", allowedDirs)).toBe(true) + expect(isPathInAllowedDirectories("C:\\other\\file.txt", allowedDirs)).toBe(false) + }) + + it.skip("should handle Windows home directory expansion", () => { + // Skipped: Windows-specific test that requires Windows platform + Object.defineProperty(process, "platform", { value: "win32", configurable: true }) + vi.mocked(os.homedir).mockReturnValue("C:\\Users\\user") + + const allowedDirs = ["~\\projects"] + expect(isPathInAllowedDirectories("C:\\Users\\user\\projects\\file.txt", allowedDirs)).toBe(true) + }) + }) + + describe("edge cases", () => { + it("should handle empty string path", () => { + const allowedDirs = ["/allowed/path"] + expect(isPathInAllowedDirectories("", allowedDirs)).toBe(false) + }) + + it("should handle root path", () => { + const allowedDirs = ["/"] + expect(isPathInAllowedDirectories("/any/file.txt", allowedDirs)).toBe(true) + }) + + it("should not match parent directories", () => { + const allowedDirs = ["/allowed/path/subdir"] + expect(isPathInAllowedDirectories("/allowed/path/file.txt", allowedDirs)).toBe(false) + expect(isPathInAllowedDirectories("/allowed/file.txt", allowedDirs)).toBe(false) + }) + + it("should handle special characters in paths", () => { + const allowedDirs = ["/path/with.dots/and[brackets]/and(parens)"] + expect(isPathInAllowedDirectories("/path/with.dots/and[brackets]/and(parens)/file.txt", allowedDirs)).toBe( + true, + ) + }) + }) +}) diff --git a/src/utils/pathUtils.ts b/src/utils/pathUtils.ts index dae300f8f3..fe92a6af41 100644 --- a/src/utils/pathUtils.ts +++ b/src/utils/pathUtils.ts @@ -1,5 +1,7 @@ import * as vscode from "vscode" import * as path from "path" +import * as os from "os" +import ignore from "ignore" /** * Checks if a file path is outside all workspace folders @@ -22,3 +24,62 @@ export function isPathOutsideWorkspace(filePath: string): boolean { return absolutePath === folderPath || absolutePath.startsWith(folderPath + path.sep) }) } + +/** + * Checks if a file path matches any of the allowed directories patterns. + * Uses the same pattern matching as .rooignore (gitignore-style patterns). + * @param filePath The file path to check + * @param allowedDirectories List of allowed directory patterns + * @returns true if the path matches any allowed directory pattern, false otherwise + */ +export function isPathInAllowedDirectories(filePath: string, allowedDirectories: string[] | undefined): boolean { + if (!allowedDirectories || allowedDirectories.length === 0) { + return false + } + + // Normalize and resolve the file path + const absoluteFilePath = path.resolve(filePath) + + for (const pattern of allowedDirectories) { + // Expand tilde to home directory if pattern starts with ~ + let expandedPattern = pattern + if (pattern.startsWith("~")) { + expandedPattern = pattern.replace(/^~/, os.homedir()) + } + + // Convert to absolute path if not already + const absolutePattern = path.isAbsolute(expandedPattern) ? expandedPattern : path.resolve(expandedPattern) + + // Create an ignore instance for this pattern + const ig = ignore() + + // For directory patterns, we need to check if the file is under a matching directory + // We'll check the file's path relative to the pattern's parent directory + const patternDir = path.dirname(absolutePattern) + const patternBase = path.basename(absolutePattern) + + // Get the relative path from the pattern's parent directory to the file + const relativeToPatternDir = path.relative(patternDir, absoluteFilePath) + + // If the file is not under the pattern's parent directory, skip + if (relativeToPatternDir.startsWith("..")) { + continue + } + + // Add the pattern to the ignore instance + // For directory patterns, we want to match the directory and everything under it + ig.add(patternBase) + ig.add(patternBase + "/**") + + // Convert to POSIX-style path for ignore library + const posixPath = relativeToPatternDir.split(path.sep).join("/") + + // Check if the path is NOT ignored (we're using ignore library in reverse) + // If the pattern matches, the path should be "ignored" by our pattern + if (ig.ignores(posixPath)) { + return true + } + } + + return false +} diff --git a/webview-ui/src/components/settings/AutoApproveSettings.tsx b/webview-ui/src/components/settings/AutoApproveSettings.tsx index 8b267ecae2..a64af7c028 100644 --- a/webview-ui/src/components/settings/AutoApproveSettings.tsx +++ b/webview-ui/src/components/settings/AutoApproveSettings.tsx @@ -20,8 +20,10 @@ import { useAutoApprovalToggles } from "@/hooks/useAutoApprovalToggles" type AutoApproveSettingsProps = HTMLAttributes & { alwaysAllowReadOnly?: boolean alwaysAllowReadOnlyOutsideWorkspace?: boolean + allowedReadDirectories?: string[] alwaysAllowWrite?: boolean alwaysAllowWriteOutsideWorkspace?: boolean + allowedWriteDirectories?: string[] alwaysAllowWriteProtected?: boolean alwaysAllowBrowser?: boolean alwaysApproveResubmit?: boolean @@ -40,8 +42,10 @@ type AutoApproveSettingsProps = HTMLAttributes & { setCachedStateField: SetCachedStateField< | "alwaysAllowReadOnly" | "alwaysAllowReadOnlyOutsideWorkspace" + | "allowedReadDirectories" | "alwaysAllowWrite" | "alwaysAllowWriteOutsideWorkspace" + | "allowedWriteDirectories" | "alwaysAllowWriteProtected" | "alwaysAllowBrowser" | "alwaysApproveResubmit" @@ -63,8 +67,10 @@ type AutoApproveSettingsProps = HTMLAttributes & { export const AutoApproveSettings = ({ alwaysAllowReadOnly, alwaysAllowReadOnlyOutsideWorkspace, + allowedReadDirectories, alwaysAllowWrite, alwaysAllowWriteOutsideWorkspace, + allowedWriteDirectories, alwaysAllowWriteProtected, alwaysAllowBrowser, alwaysApproveResubmit, @@ -86,6 +92,8 @@ export const AutoApproveSettings = ({ const { t } = useAppTranslation() const [commandInput, setCommandInput] = useState("") const [deniedCommandInput, setDeniedCommandInput] = useState("") + const [readDirectoryInput, setReadDirectoryInput] = useState("") + const [writeDirectoryInput, setWriteDirectoryInput] = useState("") const { autoApprovalEnabled, setAutoApprovalEnabled } = useExtensionState() const toggles = useAutoApprovalToggles() @@ -205,6 +213,73 @@ export const AutoApproveSettings = ({ {t("settings:autoApprove.readOnly.outsideWorkspace.description")} +
+ +
+ {t("settings:autoApprove.readOnly.allowedDirectories.description")} +
+
+ setReadDirectoryInput(e.target.value)} + onKeyDown={(e: any) => { + if (e.key === "Enter") { + e.preventDefault() + const currentDirs = allowedReadDirectories ?? [] + if (readDirectoryInput && !currentDirs.includes(readDirectoryInput)) { + const newDirs = [...currentDirs, readDirectoryInput] + setCachedStateField("allowedReadDirectories", newDirs) + setReadDirectoryInput("") + vscode.postMessage({ + type: "updateSettings", + updatedSettings: { allowedReadDirectories: newDirs }, + }) + } + } + }} + placeholder={t("settings:autoApprove.readOnly.allowedDirectories.placeholder")} + className="grow" + /> + +
+
+ {(allowedReadDirectories ?? []).map((dir, index) => ( + + ))} +
+
)} @@ -229,6 +304,75 @@ export const AutoApproveSettings = ({ {t("settings:autoApprove.write.outsideWorkspace.description")} +
+ +
+ {t("settings:autoApprove.write.allowedDirectories.description")} +
+
+ setWriteDirectoryInput(e.target.value)} + onKeyDown={(e: any) => { + if (e.key === "Enter") { + e.preventDefault() + const currentDirs = allowedWriteDirectories ?? [] + if (writeDirectoryInput && !currentDirs.includes(writeDirectoryInput)) { + const newDirs = [...currentDirs, writeDirectoryInput] + setCachedStateField("allowedWriteDirectories", newDirs) + setWriteDirectoryInput("") + vscode.postMessage({ + type: "updateSettings", + updatedSettings: { allowedWriteDirectories: newDirs }, + }) + } + } + }} + placeholder={t("settings:autoApprove.write.allowedDirectories.placeholder")} + className="grow" + /> + +
+
+ {(allowedWriteDirectories ?? []).map((dir, index) => ( + + ))} +
+
(({ onDone, t const { alwaysAllowReadOnly, alwaysAllowReadOnlyOutsideWorkspace, + allowedReadDirectories, allowedCommands, deniedCommands, allowedMaxRequests, @@ -148,6 +149,7 @@ const SettingsView = forwardRef(({ onDone, t alwaysAllowSubtasks, alwaysAllowWrite, alwaysAllowWriteOutsideWorkspace, + allowedWriteDirectories, alwaysAllowWriteProtected, alwaysApproveResubmit, autoCondenseContext, @@ -333,8 +335,10 @@ const SettingsView = forwardRef(({ onDone, t language, alwaysAllowReadOnly: alwaysAllowReadOnly ?? undefined, alwaysAllowReadOnlyOutsideWorkspace: alwaysAllowReadOnlyOutsideWorkspace ?? undefined, + allowedReadDirectories: allowedReadDirectories ?? undefined, alwaysAllowWrite: alwaysAllowWrite ?? undefined, alwaysAllowWriteOutsideWorkspace: alwaysAllowWriteOutsideWorkspace ?? undefined, + allowedWriteDirectories: allowedWriteDirectories ?? undefined, alwaysAllowWriteProtected: alwaysAllowWriteProtected ?? undefined, alwaysAllowExecute: alwaysAllowExecute ?? undefined, alwaysAllowBrowser: alwaysAllowBrowser ?? undefined, @@ -691,8 +695,10 @@ const SettingsView = forwardRef(({ onDone, t