Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
179 changes: 179 additions & 0 deletions src/utils/__tests__/pathUtils.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
)
})
})
})
Loading
Loading