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:
Roo Code 2025-11-20 09:46:24 +00:00
parent 4ae0fc5f02
commit 350d3bb78f
9 changed files with 533 additions and 9 deletions

View file

@ -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,

View file

@ -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" }
}
}

View file

@ -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,

View file

@ -225,8 +225,10 @@ export type ExtensionState = Pick<
| "autoApprovalEnabled"
| "alwaysAllowReadOnly"
| "alwaysAllowReadOnlyOutsideWorkspace"
| "allowedReadDirectories"
| "alwaysAllowWrite"
| "alwaysAllowWriteOutsideWorkspace"
| "allowedWriteDirectories"
| "alwaysAllowWriteProtected"
| "alwaysAllowBrowser"
| "alwaysApproveResubmit"

View 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,
)
})
})
})

View file

@ -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
}

View file

@ -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}

View file

@ -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}

View file

@ -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": {