Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down
60 changes: 51 additions & 9 deletions src/core/auto-approval/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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`.
Expand Down Expand Up @@ -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" }
}
}

Expand Down
6 changes: 6 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1834,8 +1834,10 @@ export class ClineProvider
customInstructions,
alwaysAllowReadOnly,
alwaysAllowReadOnlyOutsideWorkspace,
allowedReadDirectories,
alwaysAllowWrite,
alwaysAllowWriteOutsideWorkspace,
allowedWriteDirectories,
alwaysAllowWriteProtected,
alwaysAllowExecute,
allowedCommands,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/shared/ExtensionMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,10 @@ export type ExtensionState = Pick<
| "autoApprovalEnabled"
| "alwaysAllowReadOnly"
| "alwaysAllowReadOnlyOutsideWorkspace"
| "allowedReadDirectories"
| "alwaysAllowWrite"
| "alwaysAllowWriteOutsideWorkspace"
| "allowedWriteDirectories"
| "alwaysAllowWriteProtected"
| "alwaysAllowBrowser"
| "alwaysApproveResubmit"
Expand Down
160 changes: 160 additions & 0 deletions src/utils/__tests__/pathUtils.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
)
})
})
})
61 changes: 61 additions & 0 deletions src/utils/pathUtils.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
}
Loading
Loading