mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat: Add directory-specific auto-approval with wildcard support
- Add allowedReadDirectories and allowedWriteDirectories settings to GlobalSettings - Implement isPathInAllowedDirectories function with wildcard pattern matching - Update auto-approval logic to check against allowed directories list - Add UI components for managing allowed directories in AutoApproveSettings - Add translation keys for new UI elements - Include tests for path matching functionality Fixes #9428
This commit is contained in:
parent
4ae0fc5f02
commit
350d3bb78f
9 changed files with 533 additions and 9 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -225,8 +225,10 @@ export type ExtensionState = Pick<
|
|||
| "autoApprovalEnabled"
|
||||
| "alwaysAllowReadOnly"
|
||||
| "alwaysAllowReadOnlyOutsideWorkspace"
|
||||
| "allowedReadDirectories"
|
||||
| "alwaysAllowWrite"
|
||||
| "alwaysAllowWriteOutsideWorkspace"
|
||||
| "allowedWriteDirectories"
|
||||
| "alwaysAllowWriteProtected"
|
||||
| "alwaysAllowBrowser"
|
||||
| "alwaysApproveResubmit"
|
||||
|
|
|
|||
179
src/utils/__tests__/pathUtils.spec.ts
Normal file
179
src/utils/__tests__/pathUtils.spec.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
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("wildcard patterns", () => {
|
||||
describe("asterisk (*) wildcard", () => {
|
||||
it("should match zero or more characters", () => {
|
||||
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 multiple segments with * wildcard", () => {
|
||||
const allowedDirs = ["~/projects/*/src"]
|
||||
expect(isPathInAllowedDirectories("/home/user/projects/app1/src/file.txt", allowedDirs)).toBe(true)
|
||||
expect(isPathInAllowedDirectories("/home/user/projects/app2/src/file.txt", allowedDirs)).toBe(true)
|
||||
expect(isPathInAllowedDirectories("/home/user/projects/app1/lib/file.txt", allowedDirs)).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle multiple asterisks", () => {
|
||||
const allowedDirs = ["/path/*/sub*/file*"]
|
||||
expect(isPathInAllowedDirectories("/path/to/subdir/file.txt", allowedDirs)).toBe(true)
|
||||
expect(isPathInAllowedDirectories("/path/to/subfolder/filename.txt", allowedDirs)).toBe(true)
|
||||
expect(isPathInAllowedDirectories("/path/to/other/file.txt", allowedDirs)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
it("should handle multiple question marks", () => {
|
||||
const allowedDirs = ["/path/file???.txt"]
|
||||
expect(isPathInAllowedDirectories("/path/file123.txt", allowedDirs)).toBe(true)
|
||||
expect(isPathInAllowedDirectories("/path/fileABC.txt", allowedDirs)).toBe(true)
|
||||
expect(isPathInAllowedDirectories("/path/file12.txt", allowedDirs)).toBe(false)
|
||||
expect(isPathInAllowedDirectories("/path/file1234.txt", allowedDirs)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("combined wildcards", () => {
|
||||
it("should handle both * and ? in the same pattern", () => {
|
||||
const allowedDirs = ["/data/*/version?.?"]
|
||||
expect(isPathInAllowedDirectories("/data/project/version1.0/file.txt", allowedDirs)).toBe(true)
|
||||
expect(isPathInAllowedDirectories("/data/app/version2.5/file.txt", allowedDirs)).toBe(true)
|
||||
expect(isPathInAllowedDirectories("/data/app/version10.0/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("should handle Windows paths on Windows", () => {
|
||||
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("should handle Windows home directory expansion", () => {
|
||||
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 escape special regex characters in non-wildcard parts", () => {
|
||||
const allowedDirs = ["/path/with.dots/and[brackets]/and(parens)"]
|
||||
expect(isPathInAllowedDirectories("/path/with.dots/and[brackets]/and(parens)/file.txt", allowedDirs)).toBe(
|
||||
true,
|
||||
)
|
||||
expect(isPathInAllowedDirectories("/path/withXdots/and[brackets]/and(parens)/file.txt", allowedDirs)).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import * as vscode from "vscode"
|
||||
import * as path from "path"
|
||||
import * as os from "os"
|
||||
|
||||
/**
|
||||
* Checks if a file path is outside all workspace folders
|
||||
|
|
@ -22,3 +23,127 @@ export function isPathOutsideWorkspace(filePath: string): boolean {
|
|||
return absolutePath === folderPath || absolutePath.startsWith(folderPath + path.sep)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple wildcard pattern matching
|
||||
* Supports * (matches any characters) and ? (matches single character)
|
||||
* @param text The text to match
|
||||
* @param pattern The pattern with wildcards
|
||||
* @returns true if text matches pattern, false otherwise
|
||||
*/
|
||||
function matchWildcard(text: string, pattern: string): boolean {
|
||||
// Convert pattern to regex, escaping special regex chars except * and ?
|
||||
const regexPattern = pattern
|
||||
.split(/(\*|\?)/)
|
||||
.map((part, index) => {
|
||||
if (part === "*") return ".*"
|
||||
if (part === "?") return "."
|
||||
// Escape special regex characters in literal parts
|
||||
return part.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
})
|
||||
.join("")
|
||||
|
||||
const regex = new RegExp(`^${regexPattern}$`, process.platform === "win32" ? "i" : "")
|
||||
return regex.test(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a file path matches any of the allowed directories patterns.
|
||||
* Supports wildcards (*) for pattern matching.
|
||||
* @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)
|
||||
|
||||
// Check if pattern contains wildcards
|
||||
if (absolutePattern.includes("*") || absolutePattern.includes("?")) {
|
||||
// Check if this is a simple file pattern (e.g., /path/file???.txt)
|
||||
const basename = path.basename(absolutePattern)
|
||||
if (
|
||||
(basename.includes("*") || basename.includes("?")) &&
|
||||
!path.dirname(absolutePattern).includes("*") &&
|
||||
!path.dirname(absolutePattern).includes("?")
|
||||
) {
|
||||
// It's a file pattern - check if file is in correct directory with matching filename
|
||||
const dirPath = path.dirname(absolutePattern)
|
||||
if (path.dirname(absoluteFilePath) === dirPath) {
|
||||
if (matchWildcard(path.basename(absoluteFilePath), basename)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Directory pattern with wildcards (e.g., /usr/include/Qt*)
|
||||
// We need to check if the file is under a directory that matches the pattern
|
||||
|
||||
// For patterns like /usr/include/Qt*, we want to match:
|
||||
// - Files directly in /usr/include/Qt (if Qt matches Qt*)
|
||||
// - Files in /usr/include/QtCore (if QtCore matches Qt*)
|
||||
// - Files in subdirectories of matching directories
|
||||
|
||||
// Get the directory containing the file
|
||||
let checkPath = path.dirname(absoluteFilePath)
|
||||
|
||||
// Check each parent directory up to root
|
||||
while (checkPath) {
|
||||
// Check if this directory matches the pattern
|
||||
if (matchWildcard(checkPath, absolutePattern)) {
|
||||
// The file is in or under a directory that matches the pattern
|
||||
return true
|
||||
}
|
||||
|
||||
// Move up to parent directory
|
||||
const parent = path.dirname(checkPath)
|
||||
if (parent === checkPath) {
|
||||
// Reached root
|
||||
break
|
||||
}
|
||||
checkPath = parent
|
||||
}
|
||||
|
||||
// Also check if the file path itself matches (for completeness)
|
||||
if (matchWildcard(absoluteFilePath, absolutePattern)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For non-wildcard patterns, treat as directory prefix
|
||||
// Remove trailing slashes for consistent comparison
|
||||
let normalizedAbsPattern = absolutePattern
|
||||
if (normalizedAbsPattern.endsWith(path.sep) && normalizedAbsPattern !== path.sep) {
|
||||
normalizedAbsPattern = normalizedAbsPattern.slice(0, -1)
|
||||
}
|
||||
|
||||
// Special case for root path
|
||||
if (normalizedAbsPattern === path.sep || normalizedAbsPattern === "") {
|
||||
return true // All files are under root
|
||||
}
|
||||
|
||||
// Check if the file path is within this directory
|
||||
if (
|
||||
absoluteFilePath === normalizedAbsPattern ||
|
||||
absoluteFilePath.startsWith(normalizedAbsPattern + path.sep)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,10 @@ import { useAutoApprovalToggles } from "@/hooks/useAutoApprovalToggles"
|
|||
type AutoApproveSettingsProps = HTMLAttributes<HTMLDivElement> & {
|
||||
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<HTMLDivElement> & {
|
|||
setCachedStateField: SetCachedStateField<
|
||||
| "alwaysAllowReadOnly"
|
||||
| "alwaysAllowReadOnlyOutsideWorkspace"
|
||||
| "allowedReadDirectories"
|
||||
| "alwaysAllowWrite"
|
||||
| "alwaysAllowWriteOutsideWorkspace"
|
||||
| "allowedWriteDirectories"
|
||||
| "alwaysAllowWriteProtected"
|
||||
| "alwaysAllowBrowser"
|
||||
| "alwaysApproveResubmit"
|
||||
|
|
@ -63,8 +67,10 @@ type AutoApproveSettingsProps = HTMLAttributes<HTMLDivElement> & {
|
|||
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,77 @@ export const AutoApproveSettings = ({
|
|||
{t("settings:autoApprove.readOnly.outsideWorkspace.description")}
|
||||
</div>
|
||||
</div>
|
||||
{alwaysAllowReadOnlyOutsideWorkspace && (
|
||||
<div className="pt-3">
|
||||
<label className="block font-medium mb-1">
|
||||
{t("settings:autoApprove.readOnly.allowedDirectories.label")}
|
||||
</label>
|
||||
<div className="text-vscode-descriptionForeground text-sm mt-1 mb-2">
|
||||
{t("settings:autoApprove.readOnly.allowedDirectories.description")}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={readDirectoryInput}
|
||||
onChange={(e: any) => 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"
|
||||
/>
|
||||
<Button
|
||||
className="h-8"
|
||||
onClick={() => {
|
||||
const currentDirs = allowedReadDirectories ?? []
|
||||
if (readDirectoryInput && !currentDirs.includes(readDirectoryInput)) {
|
||||
const newDirs = [...currentDirs, readDirectoryInput]
|
||||
setCachedStateField("allowedReadDirectories", newDirs)
|
||||
setReadDirectoryInput("")
|
||||
vscode.postMessage({
|
||||
type: "updateSettings",
|
||||
updatedSettings: { allowedReadDirectories: newDirs },
|
||||
})
|
||||
}
|
||||
}}>
|
||||
{t("settings:autoApprove.execute.addButton")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{(allowedReadDirectories ?? []).map((dir, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const newDirs = (allowedReadDirectories ?? []).filter(
|
||||
(_, i) => i !== index,
|
||||
)
|
||||
setCachedStateField("allowedReadDirectories", newDirs)
|
||||
vscode.postMessage({
|
||||
type: "updateSettings",
|
||||
updatedSettings: { allowedReadDirectories: newDirs },
|
||||
})
|
||||
}}>
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<div>{dir}</div>
|
||||
<X className="text-foreground scale-75" />
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -229,6 +308,77 @@ export const AutoApproveSettings = ({
|
|||
{t("settings:autoApprove.write.outsideWorkspace.description")}
|
||||
</div>
|
||||
</div>
|
||||
{alwaysAllowWriteOutsideWorkspace && (
|
||||
<div className="pt-3">
|
||||
<label className="block font-medium mb-1">
|
||||
{t("settings:autoApprove.write.allowedDirectories.label")}
|
||||
</label>
|
||||
<div className="text-vscode-descriptionForeground text-sm mt-1 mb-2">
|
||||
{t("settings:autoApprove.write.allowedDirectories.description")}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={writeDirectoryInput}
|
||||
onChange={(e: any) => 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"
|
||||
/>
|
||||
<Button
|
||||
className="h-8"
|
||||
onClick={() => {
|
||||
const currentDirs = allowedWriteDirectories ?? []
|
||||
if (writeDirectoryInput && !currentDirs.includes(writeDirectoryInput)) {
|
||||
const newDirs = [...currentDirs, writeDirectoryInput]
|
||||
setCachedStateField("allowedWriteDirectories", newDirs)
|
||||
setWriteDirectoryInput("")
|
||||
vscode.postMessage({
|
||||
type: "updateSettings",
|
||||
updatedSettings: { allowedWriteDirectories: newDirs },
|
||||
})
|
||||
}
|
||||
}}>
|
||||
{t("settings:autoApprove.execute.addButton")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{(allowedWriteDirectories ?? []).map((dir, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const newDirs = (allowedWriteDirectories ?? []).filter(
|
||||
(_, i) => i !== index,
|
||||
)
|
||||
setCachedStateField("allowedWriteDirectories", newDirs)
|
||||
vscode.postMessage({
|
||||
type: "updateSettings",
|
||||
updatedSettings: { allowedWriteDirectories: newDirs },
|
||||
})
|
||||
}}>
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<div>{dir}</div>
|
||||
<X className="text-foreground scale-75" />
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<VSCodeCheckbox
|
||||
checked={alwaysAllowWriteProtected}
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
const {
|
||||
alwaysAllowReadOnly,
|
||||
alwaysAllowReadOnlyOutsideWorkspace,
|
||||
allowedReadDirectories,
|
||||
allowedCommands,
|
||||
deniedCommands,
|
||||
allowedMaxRequests,
|
||||
|
|
@ -148,6 +149,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
alwaysAllowSubtasks,
|
||||
alwaysAllowWrite,
|
||||
alwaysAllowWriteOutsideWorkspace,
|
||||
allowedWriteDirectories,
|
||||
alwaysAllowWriteProtected,
|
||||
alwaysApproveResubmit,
|
||||
autoCondenseContext,
|
||||
|
|
@ -333,8 +335,10 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ 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<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
<AutoApproveSettings
|
||||
alwaysAllowReadOnly={alwaysAllowReadOnly}
|
||||
alwaysAllowReadOnlyOutsideWorkspace={alwaysAllowReadOnlyOutsideWorkspace}
|
||||
allowedReadDirectories={allowedReadDirectories}
|
||||
alwaysAllowWrite={alwaysAllowWrite}
|
||||
alwaysAllowWriteOutsideWorkspace={alwaysAllowWriteOutsideWorkspace}
|
||||
allowedWriteDirectories={allowedWriteDirectories}
|
||||
alwaysAllowWriteProtected={alwaysAllowWriteProtected}
|
||||
alwaysAllowBrowser={alwaysAllowBrowser}
|
||||
alwaysApproveResubmit={alwaysApproveResubmit}
|
||||
|
|
|
|||
|
|
@ -154,6 +154,11 @@
|
|||
"outsideWorkspace": {
|
||||
"label": "Include files outside workspace",
|
||||
"description": "Allow Roo to read files outside the current workspace without requiring approval."
|
||||
},
|
||||
"allowedDirectories": {
|
||||
"label": "Allowed directories",
|
||||
"description": "Specify directories outside the workspace that can be read automatically. Supports wildcards (* and ?).",
|
||||
"placeholder": "Enter directory path (e.g., ~/projects/*, /usr/include/Qt*)"
|
||||
}
|
||||
},
|
||||
"write": {
|
||||
|
|
@ -167,6 +172,11 @@
|
|||
"protected": {
|
||||
"label": "Include protected files",
|
||||
"description": "Allow Roo to create and edit protected files (like .rooignore and .roo/ configuration files) without requiring approval."
|
||||
},
|
||||
"allowedDirectories": {
|
||||
"label": "Allowed directories",
|
||||
"description": "Specify directories outside the workspace that can be written to automatically. Supports wildcards (* and ?).",
|
||||
"placeholder": "Enter directory path (e.g., ~/output/*, /tmp/build*)"
|
||||
}
|
||||
},
|
||||
"browser": {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue