Move auto-approval from ChatView to Task (#9157)

This commit is contained in:
Chris Estreich 2025-11-10 17:01:50 -08:00 committed by GitHub
parent 23856a5874
commit 6e6341346e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 951 additions and 4492 deletions

View file

@ -0,0 +1,14 @@
// pnpm --filter @roo-code/types test src/__tests__/message.test.ts
import { clineAsks, isIdleAsk, isInteractiveAsk, isResumableAsk, isNonBlockingAsk } from "../message.js"
describe("ask messages", () => {
test("all ask messages are classified", () => {
for (const ask of clineAsks) {
expect(
isIdleAsk(ask) || isInteractiveAsk(ask) || isResumableAsk(ask) || isNonBlockingAsk(ask),
`${ask} is not classified`,
).toBe(true)
}
})
})

View file

@ -43,25 +43,6 @@ export const clineAsks = [
export const clineAskSchema = z.enum(clineAsks)
export type ClineAsk = z.infer<typeof clineAskSchema>
// Needs classification:
// - `followup`
/**
* NonBlockingAsk
*
* Asks that should not block task execution. These are informational or optional
* asks where the task can proceed even without an immediate user response.
*/
export const nonBlockingAsks = ["command_output"] as const satisfies readonly ClineAsk[]
export type NonBlockingAsk = (typeof nonBlockingAsks)[number]
export function isNonBlockingAsk(ask: ClineAsk): ask is NonBlockingAsk {
return (nonBlockingAsks as readonly ClineAsk[]).includes(ask)
}
/**
* IdleAsk
*
@ -116,6 +97,21 @@ export function isInteractiveAsk(ask: ClineAsk): ask is InteractiveAsk {
return (interactiveAsks as readonly ClineAsk[]).includes(ask)
}
/**
* NonBlockingAsk
*
* Asks that are not associated with an actual approval, and are only used
* to update chat messages.
*/
export const nonBlockingAsks = ["command_output"] as const satisfies readonly ClineAsk[]
export type NonBlockingAsk = (typeof nonBlockingAsks)[number]
export function isNonBlockingAsk(ask: ClineAsk): ask is NonBlockingAsk {
return (nonBlockingAsks as readonly ClineAsk[]).includes(ask)
}
/**
* ClineSay
*/

29
pnpm-lock.yaml generated
View file

@ -782,6 +782,9 @@ importers:
serialize-error:
specifier: ^12.0.0
version: 12.0.0
shell-quote:
specifier: ^1.8.2
version: 1.8.3
simple-git:
specifier: ^3.27.0
version: 3.27.0
@ -879,6 +882,9 @@ importers:
'@types/ps-tree':
specifier: ^1.1.6
version: 1.1.6
'@types/shell-quote':
specifier: ^1.7.5
version: 1.7.5
'@types/stream-json':
specifier: ^1.7.8
version: 1.7.8
@ -4104,9 +4110,6 @@ packages:
'@types/node@20.17.57':
resolution: {integrity: sha512-f3T4y6VU4fVQDKVqJV4Uppy8c1p/sVvS3peyqxyWnzkqXFJLRU7Y1Bl7rMS1Qe9z0v4M6McY0Fp9yBsgHJUsWQ==}
'@types/node@20.19.23':
resolution: {integrity: sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==}
'@types/node@24.2.1':
resolution: {integrity: sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==}
@ -9626,9 +9629,6 @@ packages:
undici-types@6.19.8:
resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@7.10.0:
resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==}
@ -13795,11 +13795,6 @@ snapshots:
dependencies:
undici-types: 6.19.8
'@types/node@20.19.23':
dependencies:
undici-types: 6.21.0
optional: true
'@types/node@24.2.1':
dependencies:
undici-types: 7.10.0
@ -13867,7 +13862,7 @@ snapshots:
'@types/ws@8.18.1':
dependencies:
'@types/node': 20.19.23
'@types/node': 24.2.1
optional: true
'@types/yargs-parser@21.0.3': {}
@ -14046,7 +14041,7 @@ snapshots:
sirv: 3.0.1
tinyglobby: 0.2.14
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
'@vitest/utils@3.2.4':
dependencies:
@ -18176,7 +18171,7 @@ snapshots:
minimatch: 10.0.1
pidtree: 0.6.0
read-package-json-fast: 4.0.0
shell-quote: 1.8.2
shell-quote: 1.8.3
which: 5.0.0
npm-run-path@4.0.1:
@ -19462,8 +19457,7 @@ snapshots:
shell-quote@1.8.2: {}
shell-quote@1.8.3:
optional: true
shell-quote@1.8.3: {}
shiki@3.4.1:
dependencies:
@ -20229,9 +20223,6 @@ snapshots:
undici-types@6.19.8: {}
undici-types@6.21.0:
optional: true
undici-types@7.10.0: {}
undici@6.21.3: {}

View file

@ -1,4 +1,5 @@
import { GlobalState, ClineMessage, ClineAsk } from "@roo-code/types"
import { getApiMetrics } from "../../shared/getApiMetrics"
import { ClineAskResponse } from "../../shared/WebviewMessage"

View file

@ -2,7 +2,6 @@ import { GlobalState, ClineMessage } from "@roo-code/types"
import { AutoApprovalHandler } from "../AutoApprovalHandler"
// Mock getApiMetrics
vi.mock("../../../shared/getApiMetrics", () => ({
getApiMetrics: vi.fn(),
}))

View file

@ -0,0 +1,368 @@
import { parseCommand } from "../../shared/parse-command"
/**
* Detect dangerous parameter substitutions that could lead to command execution.
* These patterns are never auto-approved and always require explicit user approval.
*
* Detected patterns:
* - ${var@P} - Prompt string expansion (interprets escape sequences and executes embedded commands)
* - ${var@Q} - Quote removal
* - ${var@E} - Escape sequence expansion
* - ${var@A} - Assignment statement
* - ${var@a} - Attribute flags
* - ${var=value} with escape sequences - Can embed commands via \140 (backtick), \x60, or \u0060
* - ${!var} - Indirect variable references
* - <<<$(...) or <<<`...` - Here-strings with command substitution
* - =(...) - Zsh process substitution that executes commands
* - *(e:...:) or similar - Zsh glob qualifiers with code execution
*
* @param source - The command string to analyze
* @returns true if dangerous substitution patterns are detected, false otherwise
*/
export function containsDangerousSubstitution(source: string): boolean {
// Check for dangerous parameter expansion operators that can execute commands
// ${var@P} - Prompt string expansion (interprets escape sequences and executes embedded commands)
// ${var@Q} - Quote removal
// ${var@E} - Escape sequence expansion
// ${var@A} - Assignment statement
// ${var@a} - Attribute flags
const dangerousParameterExpansion = /\$\{[^}]*@[PQEAa][^}]*\}/.test(source)
// Check for parameter expansions with assignments that could contain escape sequences
// ${var=value} or ${var:=value} can embed commands via escape sequences like \140 (backtick)
// Also check for ${var+value}, ${var:-value}, ${var:+value}, ${var:?value}
const parameterAssignmentWithEscapes =
/\$\{[^}]*[=+\-?][^}]*\\[0-7]{3}[^}]*\}/.test(source) || // octal escapes
/\$\{[^}]*[=+\-?][^}]*\\x[0-9a-fA-F]{2}[^}]*\}/.test(source) || // hex escapes
/\$\{[^}]*[=+\-?][^}]*\\u[0-9a-fA-F]{4}[^}]*\}/.test(source) // unicode escapes
// Check for indirect variable references that could execute commands
// ${!var} performs indirect expansion which can be dangerous with crafted variable names
const indirectExpansion = /\$\{![^}]+\}/.test(source)
// Check for here-strings with command substitution
// <<<$(...) or <<<`...` can execute commands
const hereStringWithSubstitution = /<<<\s*(\$\(|`)/.test(source)
// Check for zsh process substitution =(...) which executes commands
// =(...) creates a temporary file containing the output of the command, but executes it
const zshProcessSubstitution = /=\([^)]+\)/.test(source)
// Check for zsh glob qualifiers with code execution (e:...:)
// Patterns like *(e:whoami:) or ?(e:rm -rf /:) execute commands during glob expansion
// This regex matches patterns like *(e:...:), ?(e:...:), +(e:...:), @(e:...:), !(e:...:)
const zshGlobQualifier = /[*?+@!]\(e:[^:]+:\)/.test(source)
// Return true if any dangerous pattern is detected
return (
dangerousParameterExpansion ||
parameterAssignmentWithEscapes ||
indirectExpansion ||
hereStringWithSubstitution ||
zshProcessSubstitution ||
zshGlobQualifier
)
}
/**
* Find the longest matching prefix from a list of prefixes for a given command.
*
* This is the core function that implements the "longest prefix match" strategy.
* It searches through all provided prefixes and returns the longest one that
* matches the beginning of the command (case-insensitive).
*
* **Special Cases:**
* - Wildcard "*" matches any command but is treated as length 1 for comparison
* - Empty command or empty prefixes list returns null
* - Matching is case-insensitive and uses startsWith logic
*
* **Examples:**
* ```typescript
* findLongestPrefixMatch("git push origin", ["git", "git push"])
* // Returns "git push" (longer match)
*
* findLongestPrefixMatch("npm install", ["*", "npm"])
* // Returns "npm" (specific match preferred over wildcard)
*
* findLongestPrefixMatch("unknown command", ["git", "npm"])
* // Returns null (no match found)
* ```
*
* @param command - The command to match against
* @param prefixes - List of prefix patterns to search through
* @returns The longest matching prefix, or null if no match found
*/
export function findLongestPrefixMatch(command: string, prefixes: string[]): string | null {
if (!command || !prefixes?.length) {
return null
}
const trimmedCommand = command.trim().toLowerCase()
let longestMatch: string | null = null
for (const prefix of prefixes) {
const lowerPrefix = prefix.toLowerCase()
// Handle wildcard "*" - it matches any command
if (lowerPrefix === "*" || trimmedCommand.startsWith(lowerPrefix)) {
if (!longestMatch || lowerPrefix.length > longestMatch.length) {
longestMatch = lowerPrefix
}
}
}
return longestMatch
}
/**
* Check if a single command should be auto-approved.
* Returns true only for commands that explicitly match the allowlist
* and either don't match the denylist or have a longer allowlist match.
*
* Special handling for wildcards: "*" in allowlist allows any command,
* but denylist can still block specific commands.
*/
export function isAutoApprovedSingleCommand(
command: string,
allowedCommands: string[],
deniedCommands?: string[],
): boolean {
if (!command) {
return true
}
// If no allowlist configured, nothing can be auto-approved
if (!allowedCommands?.length) {
return false
}
// Check if wildcard is present in allowlist
const hasWildcard = allowedCommands.some((cmd) => cmd.toLowerCase() === "*")
// If no denylist provided (undefined), use simple allowlist logic
if (deniedCommands === undefined) {
const trimmedCommand = command.trim().toLowerCase()
return allowedCommands.some((prefix) => {
const lowerPrefix = prefix.toLowerCase()
// Handle wildcard "*" - it matches any command
return lowerPrefix === "*" || trimmedCommand.startsWith(lowerPrefix)
})
}
// Find longest matching prefix in both lists
const longestDeniedMatch = findLongestPrefixMatch(command, deniedCommands)
const longestAllowedMatch = findLongestPrefixMatch(command, allowedCommands)
// Special case: if wildcard is present and no denylist match, auto-approve
if (hasWildcard && !longestDeniedMatch) {
return true
}
// Must have an allowlist match to be auto-approved
if (!longestAllowedMatch) {
return false
}
// If no denylist match, auto-approve
if (!longestDeniedMatch) {
return true
}
// Both have matches - allowlist must be longer to auto-approve
return longestAllowedMatch.length > longestDeniedMatch.length
}
/**
* Check if a single command should be auto-denied.
* Returns true only for commands that explicitly match the denylist
* and either don't match the allowlist or have a longer denylist match.
*/
export function isAutoDeniedSingleCommand(
command: string,
allowedCommands: string[],
deniedCommands?: string[],
): boolean {
if (!command) return false
// If no denylist configured, nothing can be auto-denied
if (!deniedCommands?.length) return false
// Find longest matching prefix in both lists
const longestDeniedMatch = findLongestPrefixMatch(command, deniedCommands)
const longestAllowedMatch = findLongestPrefixMatch(command, allowedCommands || [])
// Must have a denylist match to be auto-denied
if (!longestDeniedMatch) return false
// If no allowlist match, auto-deny
if (!longestAllowedMatch) return true
// Both have matches - denylist must be longer or equal to auto-deny
return longestDeniedMatch.length >= longestAllowedMatch.length
}
/**
* Command approval decision types
*/
export type CommandDecision = "auto_approve" | "auto_deny" | "ask_user"
/**
* Unified command validation that implements the longest prefix match rule.
* Returns a definitive decision for a command based on allowlist and denylist.
*
* This is the main entry point for command validation in the Command Denylist feature.
* It handles complex command chains and applies the longest prefix match strategy
* to resolve conflicts between allowlist and denylist patterns.
*
* **Decision Logic:**
* 1. **Dangerous Substitution Protection**: Commands with dangerous parameter expansions are never auto-approved
* 2. **Command Parsing**: Split command chains (&&, ||, ;, |, &) into individual commands
* 3. **Individual Validation**: For each sub-command, apply longest prefix match rule
* 4. **Aggregation**: Combine decisions using "any denial blocks all" principle
*
* **Return Values:**
* - `"auto_approve"`: All sub-commands are explicitly allowed and no dangerous patterns detected
* - `"auto_deny"`: At least one sub-command is explicitly denied
* - `"ask_user"`: Mixed or no matches found, requires user decision, or contains dangerous patterns
*
* **Examples:**
* ```typescript
* // Simple approval
* getCommandDecision("git status", ["git"], [])
* // Returns "auto_approve"
*
* // Dangerous pattern - never auto-approved
* getCommandDecision('echo "${var@P}"', ["echo"], [])
* // Returns "ask_user"
*
* // Longest prefix match - denial wins
* getCommandDecision("git push origin", ["git"], ["git push"])
* // Returns "auto_deny"
*
* // Command chain - any denial blocks all
* getCommandDecision("git status && rm file", ["git"], ["rm"])
* // Returns "auto_deny"
*
* // No matches - ask user
* getCommandDecision("unknown command", ["git"], ["rm"])
* // Returns "ask_user"
* ```
*
* @param command - The full command string to validate
* @param allowedCommands - List of allowed command prefixes
* @param deniedCommands - Optional list of denied command prefixes
* @returns Decision indicating whether to approve, deny, or ask user
*/
export function getCommandDecision(
command: string,
allowedCommands: string[],
deniedCommands?: string[],
): CommandDecision {
if (!command?.trim()) {
return "auto_approve"
}
// Parse into sub-commands (split by &&, ||, ;, |)
const subCommands = parseCommand(command)
// Check each sub-command and collect decisions
const decisions: CommandDecision[] = subCommands.map((cmd) => {
// Remove simple PowerShell-like redirections (e.g. 2>&1) before checking
const cmdWithoutRedirection = cmd.replace(/\d*>&\d*/, "").trim()
return getSingleCommandDecision(cmdWithoutRedirection, allowedCommands, deniedCommands)
})
// If any sub-command is denied, deny the whole command
if (decisions.includes("auto_deny")) {
return "auto_deny"
}
// Require explicit user approval for dangerous patterns
if (containsDangerousSubstitution(command)) {
return "ask_user"
}
// If all sub-commands are approved, approve the whole command
if (decisions.every((decision) => decision === "auto_approve")) {
return "auto_approve"
}
// Otherwise, ask user
return "ask_user"
}
/**
* Get the decision for a single command using longest prefix match rule.
*
* This is the core logic that implements the conflict resolution between
* allowlist and denylist using the "longest prefix match" strategy.
*
* **Longest Prefix Match Algorithm:**
* 1. Find the longest matching prefix in the allowlist
* 2. Find the longest matching prefix in the denylist
* 3. Compare lengths to determine which rule takes precedence
* 4. Longer (more specific) match wins the conflict
*
* **Decision Matrix:**
* | Allowlist Match | Denylist Match | Result | Reason |
* |----------------|----------------|---------|---------|
* | Yes | No | auto_approve | Only allowlist matches |
* | No | Yes | auto_deny | Only denylist matches |
* | Yes | Yes (shorter) | auto_approve | Allowlist is more specific |
* | Yes | Yes (longer/equal) | auto_deny | Denylist is more specific |
* | No | No | ask_user | No rules apply |
*
* **Examples:**
* ```typescript
* // Only allowlist matches
* getSingleCommandDecision("git status", ["git"], ["npm"])
* // Returns "auto_approve"
*
* // Denylist is more specific
* getSingleCommandDecision("git push origin", ["git"], ["git push"])
* // Returns "auto_deny" (denylist "git push" > allowlist "git")
*
* // Allowlist is more specific
* getSingleCommandDecision("git push --dry-run", ["git push --dry-run"], ["git push"])
* // Returns "auto_approve" (allowlist is longer)
*
* // No matches
* getSingleCommandDecision("unknown", ["git"], ["npm"])
* // Returns "ask_user"
* ```
*
* @param command - Single command to validate (no chaining)
* @param allowedCommands - List of allowed command prefixes
* @param deniedCommands - Optional list of denied command prefixes
* @returns Decision for this specific command
*/
export function getSingleCommandDecision(
command: string,
allowedCommands: string[],
deniedCommands?: string[],
): CommandDecision {
if (!command) return "auto_approve"
// Find longest matching prefixes in both lists
const longestAllowedMatch = findLongestPrefixMatch(command, allowedCommands || [])
const longestDeniedMatch = findLongestPrefixMatch(command, deniedCommands || [])
// If only allowlist has a match, auto-approve
if (longestAllowedMatch && !longestDeniedMatch) {
return "auto_approve"
}
// If only denylist has a match, auto-deny
if (!longestAllowedMatch && longestDeniedMatch) {
return "auto_deny"
}
// Both lists have matches - apply longest prefix match rule
if (longestAllowedMatch && longestDeniedMatch) {
return longestAllowedMatch.length > longestDeniedMatch.length ? "auto_approve" : "auto_deny"
}
// If neither list has a match, ask user
return "ask_user"
}

View file

@ -0,0 +1,189 @@
import { type ClineAsk, type McpServerUse, type FollowUpData, isNonBlockingAsk } from "@roo-code/types"
import type { ClineSayTool, ExtensionState } from "../../shared/ExtensionMessage"
import { ClineAskResponse } from "../../shared/WebviewMessage"
import { isWriteToolAction, isReadOnlyToolAction } from "./tools"
import { isMcpToolAlwaysAllowed } from "./mcp"
import { getCommandDecision } from "./commands"
// We have 10 different actions that can be auto-approved.
export type AutoApprovalState =
| "alwaysAllowReadOnly"
| "alwaysAllowWrite"
| "alwaysAllowBrowser"
| "alwaysApproveResubmit"
| "alwaysAllowMcp"
| "alwaysAllowModeSwitch"
| "alwaysAllowSubtasks"
| "alwaysAllowExecute"
| "alwaysAllowFollowupQuestions"
| "alwaysAllowUpdateTodoList"
// Some of these actions have additional settings associated with them.
export type AutoApprovalStateOptions =
| "autoApprovalEnabled"
| "alwaysAllowReadOnlyOutsideWorkspace" // For `alwaysAllowReadOnly`.
| "alwaysAllowWriteOutsideWorkspace" // For `alwaysAllowWrite`.
| "alwaysAllowWriteProtected"
| "followupAutoApproveTimeoutMs" // For `alwaysAllowFollowupQuestions`.
| "mcpServers" // For `alwaysAllowMcp`.
| "allowedCommands" // For `alwaysAllowExecute`.
| "deniedCommands"
export type CheckAutoApprovalResult =
| { decision: "approve" }
| { decision: "deny" }
| { decision: "ask" }
| {
decision: "timeout"
timeout: number
fn: () => { askResponse: ClineAskResponse; text?: string; images?: string[] }
}
export async function checkAutoApproval({
state,
ask,
text,
isProtected,
}: {
state?: Pick<ExtensionState, AutoApprovalState | AutoApprovalStateOptions>
ask: ClineAsk
text?: string
isProtected?: boolean
}): Promise<CheckAutoApprovalResult> {
if (isNonBlockingAsk(ask)) {
return { decision: "approve" }
}
if (!state || !state.autoApprovalEnabled) {
return { decision: "ask" }
}
if (ask === "followup") {
if (state.alwaysAllowFollowupQuestions === true) {
try {
const suggestion = (JSON.parse(text || "{}") as FollowUpData).suggest?.[0]
if (
suggestion &&
typeof state.followupAutoApproveTimeoutMs === "number" &&
state.followupAutoApproveTimeoutMs > 0
) {
return {
decision: "timeout",
timeout: state.followupAutoApproveTimeoutMs,
fn: () => ({ askResponse: "messageResponse", text: suggestion.answer }),
}
} else {
return { decision: "ask" }
}
} catch (error) {
return { decision: "ask" }
}
} else {
return { decision: "ask" }
}
}
if (ask === "browser_action_launch") {
return state.alwaysAllowBrowser === true ? { decision: "approve" } : { decision: "ask" }
}
if (ask === "use_mcp_server") {
if (!text) {
return { decision: "ask" }
}
try {
const mcpServerUse = JSON.parse(text) as McpServerUse
if (mcpServerUse.type === "use_mcp_tool") {
return state.alwaysAllowMcp === true && isMcpToolAlwaysAllowed(mcpServerUse, state.mcpServers)
? { decision: "approve" }
: { decision: "ask" }
} else if (mcpServerUse.type === "access_mcp_resource") {
return state.alwaysAllowMcp === true ? { decision: "approve" } : { decision: "ask" }
}
} catch (error) {
return { decision: "ask" }
}
return { decision: "ask" }
}
if (ask === "command") {
if (!text) {
return { decision: "ask" }
}
if (state.alwaysAllowExecute === true) {
const decision = getCommandDecision(text, state.allowedCommands || [], state.deniedCommands || [])
if (decision === "auto_approve") {
return { decision: "approve" }
} else if (decision === "auto_deny") {
return { decision: "deny" }
} else {
return { decision: "ask" }
}
}
}
if (ask === "tool") {
let tool: ClineSayTool | undefined
try {
tool = JSON.parse(text || "{}")
} catch (error) {
console.error("Failed to parse tool:", error)
}
if (!tool) {
return { decision: "ask" }
}
if (tool.tool === "updateTodoList") {
return state.alwaysAllowUpdateTodoList === true ? { decision: "approve" } : { decision: "ask" }
}
if (tool?.tool === "fetchInstructions") {
if (tool.content === "create_mode") {
return state.alwaysAllowModeSwitch === true ? { decision: "approve" } : { decision: "ask" }
}
if (tool.content === "create_mcp_server") {
return state.alwaysAllowMcp === true ? { decision: "approve" } : { decision: "ask" }
}
}
if (tool?.tool === "switchMode") {
return state.alwaysAllowModeSwitch === true ? { decision: "approve" } : { decision: "ask" }
}
if (["newTask", "finishTask"].includes(tool?.tool)) {
return state.alwaysAllowSubtasks === true ? { decision: "approve" } : { decision: "ask" }
}
const isOutsideWorkspace = !!tool.isOutsideWorkspace
if (isReadOnlyToolAction(tool)) {
return state.alwaysAllowReadOnly === true &&
(!isOutsideWorkspace || 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" }
}
}
return { decision: "ask" }
}
export { AutoApprovalHandler } from "./AutoApprovalHandler"

View file

@ -0,0 +1,13 @@
import type { McpServerUse } from "@roo-code/types"
import type { McpServer, McpTool } from "../../shared/mcp"
export function isMcpToolAlwaysAllowed(mcpServerUse: McpServerUse, mcpServers: McpServer[] | undefined): boolean {
if (mcpServerUse.type === "use_mcp_tool" && mcpServerUse.toolName) {
const server = mcpServers?.find((s: McpServer) => s.name === mcpServerUse.serverName)
const tool = server?.tools?.find((t: McpTool) => t.name === mcpServerUse.toolName)
return tool?.alwaysAllow || false
}
return false
}

View file

@ -0,0 +1,18 @@
import type { ClineSayTool } from "../../shared/ExtensionMessage"
export function isWriteToolAction(tool: ClineSayTool): boolean {
return ["editedExistingFile", "appliedDiff", "newFileCreated", "insertContent", "generateImage"].includes(tool.tool)
}
export function isReadOnlyToolAction(tool: ClineSayTool): boolean {
return [
"readFile",
"listFiles",
"listFilesTopLevel",
"listFilesRecursive",
"listCodeDefinitionNames",
"searchFiles",
"codebaseSearch",
"runSlashCommand",
].includes(tool.tool)
}

View file

@ -28,14 +28,13 @@ import {
TelemetryEventName,
TaskStatus,
TodoItem,
DEFAULT_CONSECUTIVE_MISTAKE_LIMIT,
getApiProtocol,
getModelId,
isIdleAsk,
isInteractiveAsk,
isResumableAsk,
isNonBlockingAsk,
QueuedMessage,
DEFAULT_CONSECUTIVE_MISTAKE_LIMIT,
DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
MAX_CHECKPOINT_TIMEOUT_SECONDS,
MIN_CHECKPOINT_TIMEOUT_SECONDS,
@ -70,7 +69,7 @@ import { RepoPerTaskCheckpointService } from "../../services/checkpoints"
// integrations
import { DiffViewProvider } from "../../integrations/editor/DiffViewProvider"
import { findToolName, formatContentBlockToMarkdown } from "../../integrations/misc/export-markdown"
import { findToolName } from "../../integrations/misc/export-markdown"
import { RooTerminalProcess } from "../../integrations/terminal/types"
import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
@ -117,8 +116,7 @@ import { processUserContentMentions } from "../mentions/processUserContentMentio
import { getMessagesSinceLastSummary, summarizeConversation } from "../condense"
import { Gpt5Metadata, ClineMessageWithMetadata } from "./types"
import { MessageQueueService } from "../message-queue/MessageQueueService"
import { AutoApprovalHandler } from "./AutoApprovalHandler"
import { AutoApprovalHandler, checkAutoApproval } from "../auto-approval"
const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes
const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds
@ -763,13 +761,16 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// saves, and only post parts of partial message instead of
// whole array in new listener.
this.updateClineMessage(lastMessage)
// console.log("Task#ask: current ask promise was ignored (#1)")
throw new Error("Current ask promise was ignored (#1)")
} else {
// This is a new partial message, so add it with partial
// state.
askTs = Date.now()
this.lastMessageTs = askTs
console.log(`Task#ask: new partial ask -> ${type} @ ${askTs}`)
await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, partial, isProtected })
// console.log("Task#ask: current ask promise was ignored (#2)")
throw new Error("Current ask promise was ignored (#2)")
}
} else {
@ -792,6 +793,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// So in this case we must make sure that the message ts is
// never altered after first setting it.
askTs = lastMessage.ts
console.log(`Task#ask: updating previous partial ask -> ${type} @ ${askTs}`)
this.lastMessageTs = askTs
lastMessage.text = text
lastMessage.partial = false
@ -805,6 +807,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.askResponseText = undefined
this.askResponseImages = undefined
askTs = Date.now()
console.log(`Task#ask: new complete ask -> ${type} @ ${askTs}`)
this.lastMessageTs = askTs
await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, isProtected })
}
@ -815,33 +818,60 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.askResponseText = undefined
this.askResponseImages = undefined
askTs = Date.now()
console.log(`Task#ask: new complete ask -> ${type} @ ${askTs}`)
this.lastMessageTs = askTs
await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, isProtected })
}
let timeouts: NodeJS.Timeout[] = []
// Automatically approve if the ask according to the user's settings.
const provider = this.providerRef.deref()
const state = provider ? await provider.getState() : undefined
const approval = await checkAutoApproval({ state, ask: type, text, isProtected })
if (approval.decision === "approve") {
this.approveAsk()
} else if (approval.decision === "deny") {
this.denyAsk()
} else if (approval.decision === "timeout") {
timeouts.push(
setTimeout(() => {
const { askResponse, text, images } = approval.fn()
this.handleWebviewAskResponse(askResponse, text, images)
}, approval.timeout),
)
}
// The state is mutable if the message is complete and the task will
// block (via the `pWaitFor`).
const isBlocking = !(this.askResponse !== undefined || this.lastMessageTs !== askTs)
const isMessageQueued = !this.messageQueueService.isEmpty()
// Non-blocking asks should not mutate task status since they don't actually block execution
const isStatusMutable = !partial && isBlocking && !isMessageQueued && !isNonBlockingAsk(type)
let statusMutationTimeouts: NodeJS.Timeout[] = []
const statusMutationTimeout = 5_000
const isStatusMutable = !partial && isBlocking && !isMessageQueued && approval.decision === "ask"
if (isBlocking) {
console.log(`Task#ask will block -> type: ${type}`)
}
if (isStatusMutable) {
console.log(`Task#ask: status is mutable -> type: ${type}`)
const statusMutationTimeout = 2_000
if (isInteractiveAsk(type)) {
statusMutationTimeouts.push(
timeouts.push(
setTimeout(() => {
const message = this.findMessageByTimestamp(askTs)
if (message) {
this.interactiveAsk = message
this.emit(RooCodeEventName.TaskInteractive, this.taskId)
provider?.postMessageToWebview({ type: "interactionRequired" })
}
}, statusMutationTimeout),
)
} else if (isResumableAsk(type)) {
statusMutationTimeouts.push(
timeouts.push(
setTimeout(() => {
const message = this.findMessageByTimestamp(askTs)
@ -852,7 +882,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}, statusMutationTimeout),
)
} else if (isIdleAsk(type)) {
statusMutationTimeouts.push(
timeouts.push(
setTimeout(() => {
const message = this.findMessageByTimestamp(askTs)
@ -864,7 +894,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
)
}
} else if (isMessageQueued) {
console.log("Task#ask will process message queue")
console.log(`Task#ask: will process message queue -> type: ${type}`)
const message = this.messageQueueService.dequeueMessage()
@ -882,25 +912,19 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
} else {
// For other ask types (like followup or command_output), fulfill the ask
// directly.
this.setMessageResponse(message.text, message.images)
this.handleWebviewAskResponse("messageResponse", message.text, message.images)
}
}
}
// Non-blocking asks return immediately without waiting
// The ask message is created in the UI, but the task doesn't wait for a response
// This prevents blocking in cloud/headless environments
if (isNonBlockingAsk(type)) {
return { response: "yesButtonClicked" as ClineAskResponse, text: undefined, images: undefined }
}
// Wait for askResponse to be set
// Wait for askResponse to be set.
await pWaitFor(() => this.askResponse !== undefined || this.lastMessageTs !== askTs, { interval: 100 })
if (this.lastMessageTs !== askTs) {
// Could happen if we send multiple asks in a row i.e. with
// command_output. It's important that when we know an ask could
// fail, it is handled gracefully.
console.log("Task#ask: current ask promise was ignored")
throw new Error("Current ask promise was ignored")
}
@ -910,7 +934,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.askResponseImages = undefined
// Cancel the timeouts if they are still running.
statusMutationTimeouts.forEach((timeout) => clearTimeout(timeout))
timeouts.forEach((timeout) => clearTimeout(timeout))
// Switch back to an active state.
if (this.idleAsk || this.resumableAsk || this.interactiveAsk) {
@ -924,10 +948,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
return result
}
public setMessageResponse(text: string, images?: string[]) {
this.handleWebviewAskResponse("messageResponse", text, images)
}
handleWebviewAskResponse(askResponse: ClineAskResponse, text?: string, images?: string[]) {
this.askResponse = askResponse
this.askResponseText = text

View file

@ -22,7 +22,6 @@ vi.mock("../../../api", () => ({
getModel: () => ({ info: {}, id: "test-model" }),
})),
}))
vi.mock("./AutoApprovalHandler")
// Mock TelemetryService
vi.mock("@roo-code/telemetry", () => ({

View file

@ -512,6 +512,7 @@
"sanitize-filename": "^1.6.3",
"say": "^0.16.0",
"serialize-error": "^12.0.0",
"shell-quote": "^1.8.2",
"simple-git": "^3.27.0",
"socket.io-client": "^4.8.1",
"sound-play": "^1.1.0",
@ -546,6 +547,7 @@
"@types/node-ipc": "^9.2.3",
"@types/proper-lockfile": "^4.1.4",
"@types/ps-tree": "^1.1.6",
"@types/shell-quote": "^1.7.5",
"@types/stream-json": "^1.7.8",
"@types/string-similarity": "^4.0.2",
"@types/tmp": "^0.2.6",

View file

@ -127,6 +127,7 @@ export interface ExtensionMessage {
| "insertTextIntoTextarea"
| "dismissedUpsells"
| "organizationSwitchResult"
| "interactionRequired"
text?: string
payload?: any // Add a generic payload for now, can refine later
// Checkpoint warning message
@ -383,6 +384,7 @@ export interface ClineSayTool {
| "generateImage"
| "imageGenerated"
| "runSlashCommand"
| "updateTodoList"
path?: string
diff?: string
content?: string

223
src/shared/parse-command.ts Normal file
View file

@ -0,0 +1,223 @@
import { parse } from "shell-quote"
export type ShellToken = string | { op: string } | { command: string }
/**
* Split a command string into individual sub-commands by
* chaining operators (&&, ||, ;, |, or &) and newlines.
*
* Uses shell-quote to properly handle:
* - Quoted strings (preserves quotes)
* - Subshell commands ($(cmd), `cmd`, <(cmd), >(cmd))
* - PowerShell redirections (2>&1)
* - Chain operators (&&, ||, ;, |, &)
* - Newlines as command separators
*/
export function parseCommand(command: string): string[] {
if (!command?.trim()) {
return []
}
// Split by newlines first (handle different line ending formats)
// This regex splits on \r\n (Windows), \n (Unix), or \r (old Mac)
const lines = command.split(/\r\n|\r|\n/)
const allCommands: string[] = []
for (const line of lines) {
// Skip empty lines
if (!line.trim()) {
continue
}
// Process each line through the existing parsing logic
const lineCommands = parseCommandLine(line)
allCommands.push(...lineCommands)
}
return allCommands
}
/**
* Parse a single line of commands.
*/
function parseCommandLine(command: string): string[] {
if (!command?.trim()) return []
// Storage for replaced content
const redirections: string[] = []
const subshells: string[] = []
const quotes: string[] = []
const arrayIndexing: string[] = []
const arithmeticExpressions: string[] = []
const variables: string[] = []
const parameterExpansions: string[] = []
// First handle PowerShell redirections by temporarily replacing them
let processedCommand = command.replace(/\d*>&\d*/g, (match) => {
redirections.push(match)
return `__REDIR_${redirections.length - 1}__`
})
// Handle arithmetic expressions: $((...)) pattern
// Match the entire arithmetic expression including nested parentheses
processedCommand = processedCommand.replace(/\$\(\([^)]*(?:\)[^)]*)*\)\)/g, (match) => {
arithmeticExpressions.push(match)
return `__ARITH_${arithmeticExpressions.length - 1}__`
})
// Handle $[...] arithmetic expressions (alternative syntax)
processedCommand = processedCommand.replace(/\$\[[^\]]*\]/g, (match) => {
arithmeticExpressions.push(match)
return `__ARITH_${arithmeticExpressions.length - 1}__`
})
// Handle parameter expansions: ${...} patterns (including array indexing)
// This covers ${var}, ${var:-default}, ${var:+alt}, ${#var}, ${var%pattern}, etc.
processedCommand = processedCommand.replace(/\$\{[^}]+\}/g, (match) => {
parameterExpansions.push(match)
return `__PARAM_${parameterExpansions.length - 1}__`
})
// Handle process substitutions: <(...) and >(...)
processedCommand = processedCommand.replace(/[<>]\(([^)]+)\)/g, (_, inner) => {
subshells.push(inner.trim())
return `__SUBSH_${subshells.length - 1}__`
})
// Handle simple variable references: $varname pattern
// This prevents shell-quote from splitting $count into separate tokens
processedCommand = processedCommand.replace(/\$[a-zA-Z_][a-zA-Z0-9_]*/g, (match) => {
variables.push(match)
return `__VAR_${variables.length - 1}__`
})
// Handle special bash variables: $?, $!, $#, $$, $@, $*, $-, $0-$9
processedCommand = processedCommand.replace(/\$[?!#$@*\-0-9]/g, (match) => {
variables.push(match)
return `__VAR_${variables.length - 1}__`
})
// Then handle subshell commands $() and back-ticks
processedCommand = processedCommand
.replace(/\$\((.*?)\)/g, (_, inner) => {
subshells.push(inner.trim())
return `__SUBSH_${subshells.length - 1}__`
})
.replace(/`(.*?)`/g, (_, inner) => {
subshells.push(inner.trim())
return `__SUBSH_${subshells.length - 1}__`
})
// Then handle quoted strings
processedCommand = processedCommand.replace(/"[^"]*"/g, (match) => {
quotes.push(match)
return `__QUOTE_${quotes.length - 1}__`
})
let tokens: ShellToken[]
try {
tokens = parse(processedCommand) as ShellToken[]
} catch (error: any) {
// If shell-quote fails to parse, fall back to simple splitting
console.warn("shell-quote parse error:", error.message, "for command:", processedCommand)
// Simple fallback: split by common operators
const fallbackCommands = processedCommand
.split(/(?:&&|\|\||;|\||&)/)
.map((cmd) => cmd.trim())
.filter((cmd) => cmd.length > 0)
// Restore all placeholders for each command
return fallbackCommands.map((cmd) =>
restorePlaceholders(
cmd,
quotes,
redirections,
arrayIndexing,
arithmeticExpressions,
parameterExpansions,
variables,
subshells,
),
)
}
const commands: string[] = []
let currentCommand: string[] = []
for (const token of tokens) {
if (typeof token === "object" && "op" in token) {
// Chain operator - split command
if (["&&", "||", ";", "|", "&"].includes(token.op)) {
if (currentCommand.length > 0) {
commands.push(currentCommand.join(" "))
currentCommand = []
}
} else {
// Other operators (>) are part of the command
currentCommand.push(token.op)
}
} else if (typeof token === "string") {
// Check if it's a subshell placeholder
const subshellMatch = token.match(/__SUBSH_(\d+)__/)
if (subshellMatch) {
if (currentCommand.length > 0) {
commands.push(currentCommand.join(" "))
currentCommand = []
}
commands.push(subshells[parseInt(subshellMatch[1])])
} else {
currentCommand.push(token)
}
}
}
// Add any remaining command
if (currentCommand.length > 0) {
commands.push(currentCommand.join(" "))
}
// Restore quotes and redirections
return commands.map((cmd) =>
restorePlaceholders(
cmd,
quotes,
redirections,
arrayIndexing,
arithmeticExpressions,
parameterExpansions,
variables,
subshells,
),
)
}
/**
* Helper function to restore placeholders in a command string.
*/
function restorePlaceholders(
command: string,
quotes: string[],
redirections: string[],
arrayIndexing: string[],
arithmeticExpressions: string[],
parameterExpansions: string[],
variables: string[],
subshells: string[],
): string {
let result = command
// Restore quotes
result = result.replace(/__QUOTE_(\d+)__/g, (_, i) => quotes[parseInt(i)])
// Restore redirections
result = result.replace(/__REDIR_(\d+)__/g, (_, i) => redirections[parseInt(i)])
// Restore array indexing expressions
result = result.replace(/__ARRAY_(\d+)__/g, (_, i) => arrayIndexing[parseInt(i)])
// Restore arithmetic expressions
result = result.replace(/__ARITH_(\d+)__/g, (_, i) => arithmeticExpressions[parseInt(i)])
// Restore parameter expansions
result = result.replace(/__PARAM_(\d+)__/g, (_, i) => parameterExpansions[parseInt(i)])
// Restore variable references
result = result.replace(/__VAR_(\d+)__/g, (_, i) => variables[parseInt(i)])
result = result.replace(/__SUBSH_(\d+)__/g, (_, i) => subshells[parseInt(i)])
return result
}

View file

@ -6,17 +6,16 @@ import removeMd from "remove-markdown"
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import useSound from "use-sound"
import { LRUCache } from "lru-cache"
import { Trans, useTranslation } from "react-i18next"
import { Trans } from "react-i18next"
import { useDebounceEffect } from "@src/utils/useDebounceEffect"
import { appendImages } from "@src/utils/imageUtils"
import type { ClineAsk, ClineMessage, McpServerUse } from "@roo-code/types"
import type { ClineAsk, ClineMessage } from "@roo-code/types"
import { ClineSayBrowserAction, ClineSayTool, ExtensionMessage } from "@roo/ExtensionMessage"
import { McpServer, McpTool } from "@roo/mcp"
import { findLast } from "@roo/array"
import { FollowUpData, SuggestionItem } from "@roo-code/types"
import { SuggestionItem } from "@roo-code/types"
import { combineApiRequests } from "@roo/combineApiRequests"
import { combineCommandSequences } from "@roo/combineCommandSequences"
import { getApiMetrics } from "@roo/getApiMetrics"
@ -26,20 +25,12 @@ import { ProfileValidator } from "@roo/ProfileValidator"
import { getLatestTodo } from "@roo/todo"
import { vscode } from "@src/utils/vscode"
import {
getCommandDecision,
CommandDecision,
findLongestPrefixMatch,
parseCommand,
} from "@src/utils/command-validation"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { useSelectedModel } from "@src/components/ui/hooks/useSelectedModel"
import RooHero from "@src/components/welcome/RooHero"
import RooTips from "@src/components/welcome/RooTips"
import { StandardTooltip } from "@src/components/ui"
import { useAutoApprovalState } from "@src/hooks/useAutoApprovalState"
import { useAutoApprovalToggles } from "@src/hooks/useAutoApprovalToggles"
import { CloudUpsellDialog } from "@src/components/cloud/CloudUpsellDialog"
import TelemetryBanner from "../common/TelemetryBanner"
@ -84,7 +75,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
})
const { t } = useAppTranslation()
const { t: tSettings } = useTranslation("settings")
const modeShortcutText = `${isMac ? "⌘" : "Ctrl"} + . ${t("chat:forNextMode")}, ${isMac ? "⌘" : "Ctrl"} + Shift + . ${t("chat:forPreviousMode")}`
const {
@ -94,25 +84,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
taskHistory,
apiConfiguration,
organizationAllowList,
mcpServers,
alwaysAllowBrowser,
alwaysAllowReadOnly,
alwaysAllowReadOnlyOutsideWorkspace,
alwaysAllowWrite,
alwaysAllowWriteOutsideWorkspace,
alwaysAllowWriteProtected,
alwaysAllowExecute,
alwaysAllowMcp,
allowedCommands,
deniedCommands,
writeDelayMs,
followupAutoApproveTimeoutMs,
mode,
setMode,
autoApprovalEnabled,
alwaysAllowModeSwitch,
alwaysAllowSubtasks,
alwaysAllowFollowupQuestions,
alwaysAllowUpdateTodoList,
customModes,
telemetrySetting,
@ -161,7 +135,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const [sendingDisabled, setSendingDisabled] = useState(false)
const [selectedImages, setSelectedImages] = useState<string[]>([])
// we need to hold on to the ask because useEffect > lastMessage will always let us know when an ask comes in and handle it, but by the time handleMessage is called, the last message might not be the ask anymore (it could be a say that followed)
// We need to hold on to the ask because useEffect > lastMessage will always
// let us know when an ask comes in and handle it, but by the time
// handleMessage is called, the last message might not be the ask anymore
// (it could be a say that followed).
const [clineAsk, setClineAsk] = useState<ClineAsk | undefined>(undefined)
const [enableButtons, setEnableButtons] = useState<boolean>(false)
const [primaryButtonText, setPrimaryButtonText] = useState<string | undefined>(undefined)
@ -222,43 +199,40 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
[apiConfiguration, organizationAllowList],
)
// UI layout depends on the last 2 messages
// (since it relies on the content of these messages, we are deep comparing. i.e. the button state after hitting button sets enableButtons to false, and this effect otherwise would have to true again even if messages didn't change
// UI layout depends on the last 2 messages (since it relies on the content
// of these messages, we are deep comparing) i.e. the button state after
// hitting button sets enableButtons to false, and this effect otherwise
// would have to true again even if messages didn't change.
const lastMessage = useMemo(() => messages.at(-1), [messages])
const secondLastMessage = useMemo(() => messages.at(-2), [messages])
// Setup sound hooks with use-sound
const volume = typeof soundVolume === "number" ? soundVolume : 0.5
const soundConfig = {
volume,
// useSound expects 'disabled' property, not 'soundEnabled'
soundEnabled,
}
const [playNotification] = useSound(`${audioBaseUri}/notification.wav`, { volume, soundEnabled })
const [playCelebration] = useSound(`${audioBaseUri}/celebration.wav`, { volume, soundEnabled })
const [playProgressLoop] = useSound(`${audioBaseUri}/progress_loop.wav`, { volume, soundEnabled })
const getAudioUrl = (path: string) => `${audioBaseUri}/${path}`
const playSound = useCallback(
(audioType: AudioType) => {
if (!soundEnabled) {
return
}
// Use the getAudioUrl helper function
const [playNotification] = useSound(getAudioUrl("notification.wav"), soundConfig)
const [playCelebration] = useSound(getAudioUrl("celebration.wav"), soundConfig)
const [playProgressLoop] = useSound(getAudioUrl("progress_loop.wav"), soundConfig)
function playSound(audioType: AudioType) {
// Play the appropriate sound based on type
// The disabled state is handled by the useSound hook configuration
switch (audioType) {
case "notification":
playNotification()
break
case "celebration":
playCelebration()
break
case "progress_loop":
playProgressLoop()
break
default:
console.warn(`Unknown audio type: ${audioType}`)
}
}
switch (audioType) {
case "notification":
playNotification()
break
case "celebration":
playCelebration()
break
case "progress_loop":
playProgressLoop()
break
default:
console.warn(`Unknown audio type: ${audioType}`)
}
},
[soundEnabled, playNotification, playCelebration, playProgressLoop],
)
function playTts(text: string) {
vscode.postMessage({ type: "playTts", text })
@ -292,9 +266,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setSecondaryButtonText(t("chat:startNewTask.title"))
break
case "followup":
if (!isPartial) {
playSound("notification")
}
setSendingDisabled(isPartial)
setClineAsk("followup")
// setting enable buttons to `false` would trigger a focus grab when
@ -306,9 +277,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setSecondaryButtonText(undefined)
break
case "tool":
if (!isAutoApproved(lastMessage) && !isPartial) {
playSound("notification")
}
setSendingDisabled(isPartial)
setClineAsk("tool")
setEnableButtons(!isPartial)
@ -342,9 +310,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
}
break
case "browser_action_launch":
if (!isAutoApproved(lastMessage) && !isPartial) {
playSound("notification")
}
setSendingDisabled(isPartial)
setClineAsk("browser_action_launch")
setEnableButtons(!isPartial)
@ -352,9 +317,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setSecondaryButtonText(t("chat:reject.title"))
break
case "command":
if (!isAutoApproved(lastMessage) && !isPartial) {
playSound("notification")
}
setSendingDisabled(isPartial)
setClineAsk("command")
setEnableButtons(!isPartial)
@ -369,9 +331,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setSecondaryButtonText(t("chat:killCommand.title"))
break
case "use_mcp_server":
if (!isAutoApproved(lastMessage) && !isPartial) {
playSound("notification")
}
setSendingDisabled(isPartial)
setClineAsk("use_mcp_server")
setEnableButtons(!isPartial)
@ -379,8 +338,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setSecondaryButtonText(t("chat:reject.title"))
break
case "completion_result":
// extension waiting for feedback. but we can just present a new task button
// Only play celebration sound if there are no queued messages
// Extension waiting for feedback, but we can just present a new task button.
// Only play celebration sound if there are no queued messages.
if (!isPartial && messageQueue.length === 0) {
playSound("celebration")
}
@ -823,6 +782,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
case "checkpointInitWarning":
setCheckpointWarning(message.checkpointWarning)
break
case "interactionRequired":
playSound("notification")
break
}
// textAreaRef.current is not explicitly required here since React
// guarantees that ref will be stable across re-renders, and we're
@ -840,6 +802,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
handlePrimaryButtonClick,
handleSecondaryButtonClick,
setCheckpointWarning,
playSound,
],
)
@ -969,239 +932,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
[isHidden, sendingDisabled, enableButtons],
)
const isReadOnlyToolAction = useCallback((message: ClineMessage | undefined) => {
if (message?.type === "ask") {
if (!message.text) {
return true
}
const tool = JSON.parse(message.text)
return [
"readFile",
"listFiles",
"listFilesTopLevel",
"listFilesRecursive",
"listCodeDefinitionNames",
"searchFiles",
"codebaseSearch",
"runSlashCommand",
].includes(tool.tool)
}
return false
}, [])
const isWriteToolAction = useCallback((message: ClineMessage | undefined) => {
if (message?.type === "ask") {
if (!message.text) {
return true
}
const tool = JSON.parse(message.text)
return ["editedExistingFile", "appliedDiff", "newFileCreated", "insertContent", "generateImage"].includes(
tool.tool,
)
}
return false
}, [])
const isMcpToolAlwaysAllowed = useCallback(
(message: ClineMessage | undefined) => {
if (message?.type === "ask" && message.ask === "use_mcp_server") {
if (!message.text) {
return true
}
const mcpServerUse = JSON.parse(message.text) as McpServerUse
if (mcpServerUse.type === "use_mcp_tool" && mcpServerUse.toolName) {
const server = mcpServers?.find((s: McpServer) => s.name === mcpServerUse.serverName)
const tool = server?.tools?.find((t: McpTool) => t.name === mcpServerUse.toolName)
return tool?.alwaysAllow || false
}
}
return false
},
[mcpServers],
)
// Get the command decision using unified validation logic
const getCommandDecisionForMessage = useCallback(
(message: ClineMessage | undefined): CommandDecision => {
if (message?.type !== "ask") return "ask_user"
return getCommandDecision(message.text || "", allowedCommands || [], deniedCommands || [])
},
[allowedCommands, deniedCommands],
)
// Check if a command message should be auto-approved.
const isAllowedCommand = useCallback(
(message: ClineMessage | undefined): boolean => {
return getCommandDecisionForMessage(message) === "auto_approve"
},
[getCommandDecisionForMessage],
)
// Check if a command message should be auto-denied.
const isDeniedCommand = useCallback(
(message: ClineMessage | undefined): boolean => {
return getCommandDecisionForMessage(message) === "auto_deny"
},
[getCommandDecisionForMessage],
)
// Helper function to get the denied prefix for a command
const getDeniedPrefix = useCallback(
(command: string): string | null => {
if (!command || !deniedCommands?.length) return null
// Parse the command into sub-commands and check each one
const subCommands = parseCommand(command)
for (const cmd of subCommands) {
const deniedMatch = findLongestPrefixMatch(cmd, deniedCommands)
if (deniedMatch) {
return deniedMatch
}
}
return null
},
[deniedCommands],
)
// Create toggles object for useAutoApprovalState hook
const autoApprovalToggles = useAutoApprovalToggles()
const { hasEnabledOptions } = useAutoApprovalState(autoApprovalToggles, autoApprovalEnabled)
const isAutoApproved = useCallback(
(message: ClineMessage | undefined) => {
// First check if auto-approval is enabled AND we have at least one permission
if (!autoApprovalEnabled || !message || message.type !== "ask") {
return false
}
// Use the hook's result instead of duplicating the logic
if (!hasEnabledOptions) {
return false
}
if (message.ask === "followup") {
return alwaysAllowFollowupQuestions
}
if (message.ask === "browser_action_launch") {
return alwaysAllowBrowser
}
if (message.ask === "use_mcp_server") {
// Check if it's a tool or resource access
if (!message.text) {
return false
}
try {
const mcpServerUse = JSON.parse(message.text) as McpServerUse
if (mcpServerUse.type === "use_mcp_tool") {
// For tools, check if the specific tool is always allowed
return alwaysAllowMcp && isMcpToolAlwaysAllowed(message)
} else if (mcpServerUse.type === "access_mcp_resource") {
// For resources, auto-approve if MCP is always allowed
// Resources don't have individual alwaysAllow settings like tools do
return alwaysAllowMcp
}
} catch (error) {
console.error("Failed to parse MCP server use message:", error)
return false
}
return false
}
if (message.ask === "command") {
return alwaysAllowExecute && isAllowedCommand(message)
}
// For read/write operations, check if it's outside workspace and if
// we have permission for that.
if (message.ask === "tool") {
let tool: any = {}
try {
tool = JSON.parse(message.text || "{}")
} catch (error) {
console.error("Failed to parse tool:", error)
}
if (!tool) {
return false
}
if (tool?.tool === "updateTodoList") {
return alwaysAllowUpdateTodoList
}
if (tool?.tool === "fetchInstructions") {
if (tool.content === "create_mode") {
return alwaysAllowModeSwitch
}
if (tool.content === "create_mcp_server") {
return alwaysAllowMcp
}
}
if (tool?.tool === "switchMode") {
return alwaysAllowModeSwitch
}
if (["newTask", "finishTask"].includes(tool?.tool)) {
return alwaysAllowSubtasks
}
const isOutsideWorkspace = !!tool.isOutsideWorkspace
const isProtected = message.isProtected
if (isReadOnlyToolAction(message)) {
return alwaysAllowReadOnly && (!isOutsideWorkspace || alwaysAllowReadOnlyOutsideWorkspace)
}
if (isWriteToolAction(message)) {
return (
alwaysAllowWrite &&
(!isOutsideWorkspace || alwaysAllowWriteOutsideWorkspace) &&
(!isProtected || alwaysAllowWriteProtected)
)
}
}
return false
},
[
autoApprovalEnabled,
hasEnabledOptions,
alwaysAllowBrowser,
alwaysAllowReadOnly,
alwaysAllowReadOnlyOutsideWorkspace,
isReadOnlyToolAction,
alwaysAllowWrite,
alwaysAllowWriteOutsideWorkspace,
alwaysAllowWriteProtected,
isWriteToolAction,
alwaysAllowExecute,
isAllowedCommand,
alwaysAllowMcp,
isMcpToolAlwaysAllowed,
alwaysAllowModeSwitch,
alwaysAllowFollowupQuestions,
alwaysAllowSubtasks,
alwaysAllowUpdateTodoList,
],
)
useEffect(() => {
// This ensures the first message is not read, future user messages are
// labeled as `user_feedback`.
@ -1233,7 +963,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
// Update previous value.
setWasStreaming(isStreaming)
}, [isStreaming, lastMessage, wasStreaming, isAutoApproved, messages.length])
}, [isStreaming, lastMessage, wasStreaming, messages.length])
const isBrowserSessionMessage = (message: ClineMessage): boolean => {
// Which of visible messages are browser session messages, see above.
@ -1557,132 +1287,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
],
)
useEffect(() => {
if (autoApproveTimeoutRef.current) {
clearTimeout(autoApproveTimeoutRef.current)
autoApproveTimeoutRef.current = null
}
if (!clineAsk || !enableButtons) {
return
}
// Exit early if user has already responded
if (userRespondedRef.current) {
return
}
const autoApproveOrReject = async () => {
// Check for auto-reject first (commands that should be denied)
if (lastMessage?.ask === "command" && isDeniedCommand(lastMessage)) {
// Get the denied prefix for the localized message
const deniedPrefix = getDeniedPrefix(lastMessage.text || "")
if (deniedPrefix) {
// Create the localized auto-deny message and send it with the rejection
const autoDenyMessage = tSettings("autoApprove.execute.autoDenied", { prefix: deniedPrefix })
vscode.postMessage({
type: "askResponse",
askResponse: "noButtonClicked",
text: autoDenyMessage,
})
} else {
// Auto-reject denied commands immediately if no prefix found
vscode.postMessage({ type: "askResponse", askResponse: "noButtonClicked" })
}
setSendingDisabled(true)
setClineAsk(undefined)
setEnableButtons(false)
return
}
// Then check for auto-approve
if (lastMessage?.ask && isAutoApproved(lastMessage)) {
// Special handling for follow-up questions
if (lastMessage.ask === "followup") {
// Handle invalid JSON
let followUpData: FollowUpData = {}
try {
followUpData = JSON.parse(lastMessage.text || "{}") as FollowUpData
} catch (error) {
console.error("Failed to parse follow-up data:", error)
return
}
if (followUpData && followUpData.suggest && followUpData.suggest.length > 0) {
// Wait for the configured timeout before auto-selecting the first suggestion
await new Promise<void>((resolve) => {
autoApproveTimeoutRef.current = setTimeout(() => {
autoApproveTimeoutRef.current = null
resolve()
}, followupAutoApproveTimeoutMs)
})
// Check if user responded manually
if (userRespondedRef.current) {
return
}
// Get the first suggestion
const firstSuggestion = followUpData.suggest[0]
// Handle the suggestion click
handleSuggestionClickInRow(firstSuggestion)
return
}
} else if (lastMessage.ask === "tool" && isWriteToolAction(lastMessage)) {
await new Promise<void>((resolve) => {
autoApproveTimeoutRef.current = setTimeout(() => {
autoApproveTimeoutRef.current = null
resolve()
}, writeDelayMs)
})
}
vscode.postMessage({ type: "askResponse", askResponse: "yesButtonClicked" })
setSendingDisabled(true)
setClineAsk(undefined)
setEnableButtons(false)
}
}
autoApproveOrReject()
return () => {
if (autoApproveTimeoutRef.current) {
clearTimeout(autoApproveTimeoutRef.current)
autoApproveTimeoutRef.current = null
}
}
}, [
clineAsk,
enableButtons,
handlePrimaryButtonClick,
alwaysAllowBrowser,
alwaysAllowReadOnly,
alwaysAllowReadOnlyOutsideWorkspace,
alwaysAllowWrite,
alwaysAllowWriteOutsideWorkspace,
alwaysAllowExecute,
followupAutoApproveTimeoutMs,
alwaysAllowMcp,
messages,
allowedCommands,
deniedCommands,
mcpServers,
isAutoApproved,
lastMessage,
writeDelayMs,
isWriteToolAction,
alwaysAllowFollowupQuestions,
handleSuggestionClickInRow,
isAllowedCommand,
isDeniedCommand,
getDeniedPrefix,
tSettings,
])
// Function to handle mode switching
const switchToNextMode = useCallback(() => {
const allModes = getAllModes(customModes)

View file

@ -1,5 +1,6 @@
import { useCallback, useState, memo, useMemo } from "react"
import { useEvent } from "react-use"
import { t } from "i18next"
import { ChevronDown, OctagonX } from "lucide-react"
import { CommandExecutionStatus, commandExecutionStatusSchema } from "@roo-code/types"
@ -8,16 +9,17 @@ import { ExtensionMessage } from "@roo/ExtensionMessage"
import { safeJsonParse } from "@roo/safeJsonParse"
import { COMMAND_OUTPUT_STRING } from "@roo/combineCommandSequences"
import { parseCommand } from "@roo/parse-command"
import { vscode } from "@src/utils/vscode"
import { extractPatternsFromCommand } from "@src/utils/command-parser"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { cn } from "@src/lib/utils"
import { Button, StandardTooltip } from "@src/components/ui"
import CodeBlock from "../common/CodeBlock"
import CodeBlock from "@src/components/common/CodeBlock"
import { CommandPatternSelector } from "./CommandPatternSelector"
import { parseCommand } from "../../utils/command-validation"
import { extractPatternsFromCommand } from "../../utils/command-parser"
import { t } from "i18next"
interface CommandPattern {
pattern: string

View file

@ -1,480 +0,0 @@
// npx vitest run src/components/chat/__tests__/ChatView.auto-approve-new.spec.tsx
import { render, waitFor } from "@/utils/test-utils"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext"
import { vscode } from "@src/utils/vscode"
import ChatView, { ChatViewProps } from "../ChatView"
// Mock vscode API
vi.mock("@src/utils/vscode", () => ({
vscode: {
postMessage: vi.fn(),
},
}))
// Mock all problematic dependencies
vi.mock("rehype-highlight", () => ({
default: () => () => {},
}))
vi.mock("hast-util-to-text", () => ({
default: () => "",
}))
// Mock components that use ESM dependencies
vi.mock("../BrowserSessionRow", () => ({
default: function MockBrowserSessionRow({ messages }: { messages: any[] }) {
return <div data-testid="browser-session">{JSON.stringify(messages)}</div>
},
}))
vi.mock("../ChatRow", () => ({
default: function MockChatRow({ message }: { message: any }) {
return <div data-testid="chat-row">{JSON.stringify(message)}</div>
},
}))
vi.mock("../TaskHeader", () => ({
default: function MockTaskHeader({ task }: { task: any }) {
return <div data-testid="task-header">{JSON.stringify(task)}</div>
},
}))
vi.mock("../AutoApproveMenu", () => ({
default: () => null,
}))
vi.mock("@src/components/common/CodeBlock", () => ({
default: () => null,
CODE_BLOCK_BG_COLOR: "rgb(30, 30, 30)",
}))
vi.mock("@src/components/common/CodeAccordion", () => ({
default: () => null,
}))
vi.mock("@src/components/chat/ContextMenu", () => ({
default: () => null,
}))
// Mock window.postMessage to trigger state hydration
const mockPostMessage = (state: any) => {
window.postMessage(
{
type: "state",
state: {
version: "1.0.0",
clineMessages: [],
taskHistory: [],
shouldShowAnnouncement: false,
allowedCommands: [],
alwaysAllowExecute: false,
autoApprovalEnabled: true,
...state,
},
},
"*",
)
}
const queryClient = new QueryClient()
const defaultProps: ChatViewProps = {
isHidden: false,
showAnnouncement: false,
hideAnnouncement: () => {},
}
const renderChatView = (props: Partial<ChatViewProps> = {}) => {
return render(
<ExtensionStateContextProvider>
<QueryClientProvider client={queryClient}>
<ChatView {...defaultProps} {...props} />
</QueryClientProvider>
</ExtensionStateContextProvider>,
)
}
describe("ChatView - New Auto Approval Logic Tests", () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe("Master auto-approval with no sub-options enabled", () => {
it("should NOT auto-approve when autoApprovalEnabled is true but no sub-options are enabled", async () => {
renderChatView()
// First hydrate state with initial task
mockPostMessage({
autoApprovalEnabled: true, // Master is enabled
alwaysAllowReadOnly: false, // But no sub-options are enabled
alwaysAllowWrite: false,
alwaysAllowExecute: false,
alwaysAllowBrowser: false,
alwaysAllowModeSwitch: false,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Then send a read tool ask message
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowReadOnly: false,
alwaysAllowWrite: false,
alwaysAllowExecute: false,
alwaysAllowBrowser: false,
alwaysAllowModeSwitch: false,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({ tool: "readFile", path: "test.txt" }),
partial: false,
},
],
})
// Wait and verify no auto-approval message was sent
await new Promise((resolve) => setTimeout(resolve, 100))
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
})
it("should NOT auto-approve write operations when only master is enabled", async () => {
renderChatView()
// First hydrate state with initial task
mockPostMessage({
autoApprovalEnabled: true, // Master is enabled
alwaysAllowReadOnly: false,
alwaysAllowWrite: false, // Write is not enabled
writeDelayMs: 0,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Then send a write tool ask message
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowReadOnly: false,
alwaysAllowWrite: false,
writeDelayMs: 0,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({ tool: "editedExistingFile", path: "test.txt" }),
partial: false,
},
],
})
// Wait and verify no auto-approval message was sent
await new Promise((resolve) => setTimeout(resolve, 100))
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
})
it("should NOT auto-approve browser actions when only master is enabled", async () => {
renderChatView()
// First hydrate state with initial task
mockPostMessage({
autoApprovalEnabled: true, // Master is enabled
alwaysAllowBrowser: false, // Browser is not enabled
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Then send a browser action ask message
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowBrowser: false,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "browser_action_launch",
ts: Date.now(),
text: JSON.stringify({ action: "launch", url: "http://example.com" }),
partial: false,
},
],
})
// Wait and verify no auto-approval message was sent
await new Promise((resolve) => setTimeout(resolve, 100))
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
})
})
describe("Correct auto-approval with sub-options enabled", () => {
it("should auto-approve when master and at least one sub-option are enabled", async () => {
renderChatView()
// First hydrate state with initial task
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowReadOnly: true, // At least one sub-option is enabled
alwaysAllowWrite: false,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Then send a read tool ask message
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowReadOnly: true,
alwaysAllowWrite: false,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({ tool: "readFile", path: "test.txt" }),
partial: false,
},
],
})
// Wait for the auto-approval message
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
})
})
it("should auto-approve when multiple sub-options are enabled", async () => {
renderChatView()
// First hydrate state with initial task
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowReadOnly: true, // Multiple sub-options enabled
alwaysAllowWrite: true,
alwaysAllowExecute: true,
writeDelayMs: 0,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Then send a write tool ask message
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowReadOnly: true,
alwaysAllowWrite: true,
alwaysAllowExecute: true,
writeDelayMs: 0,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({ tool: "editedExistingFile", path: "test.txt" }),
partial: false,
},
],
})
// Wait for the auto-approval message
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
})
})
})
describe("Edge cases", () => {
it("should handle state transitions correctly", async () => {
renderChatView()
// Start with auto-approval properly configured
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowReadOnly: true,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Then transition to a state where no sub-options are enabled
mockPostMessage({
autoApprovalEnabled: true, // Master still true
alwaysAllowReadOnly: false, // All sub-options now false
alwaysAllowWrite: false,
alwaysAllowExecute: false,
alwaysAllowBrowser: false,
alwaysAllowModeSwitch: false,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({ tool: "readFile", path: "test.txt" }),
partial: false,
},
],
})
// Wait and verify no auto-approval message was sent
await new Promise((resolve) => setTimeout(resolve, 100))
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
})
it("should respect the hasEnabledOptions check in isAutoApproved", async () => {
renderChatView()
// Configure state where master is true but effective approval should be false
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowReadOnly: false,
alwaysAllowReadOnlyOutsideWorkspace: false,
alwaysAllowWrite: false,
alwaysAllowWriteOutsideWorkspace: false,
alwaysAllowExecute: false,
alwaysAllowBrowser: false,
alwaysAllowModeSwitch: false,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Try various tool types - none should auto-approve
const toolRequests = [
{ tool: "readFile", path: "test.txt" },
{ tool: "editedExistingFile", path: "test.txt" },
{ tool: "executeCommand", command: "ls" },
{ tool: "switchMode", mode: "architect" },
]
for (const toolRequest of toolRequests) {
vi.clearAllMocks()
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowReadOnly: false,
alwaysAllowWrite: false,
alwaysAllowExecute: false,
alwaysAllowBrowser: false,
alwaysAllowModeSwitch: false,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify(toolRequest),
partial: false,
},
],
})
// Wait and verify no auto-approval for any tool type
await new Promise((resolve) => setTimeout(resolve, 100))
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
}
})
})
})

View file

@ -1,674 +0,0 @@
// npx vitest run src/components/chat/__tests__/ChatView.auto-approve.spec.tsx
import { render, waitFor } from "@/utils/test-utils"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext"
import { vscode } from "@src/utils/vscode"
import ChatView, { ChatViewProps } from "../ChatView"
// Mock vscode API
vi.mock("@src/utils/vscode", () => ({
vscode: {
postMessage: vi.fn(),
},
}))
// Mock all problematic dependencies
vi.mock("rehype-highlight", () => ({
default: () => () => {},
}))
vi.mock("hast-util-to-text", () => ({
default: () => "",
}))
// Mock components that use ESM dependencies
vi.mock("../BrowserSessionRow", () => ({
default: function MockBrowserSessionRow({ messages }: { messages: any[] }) {
return <div data-testid="browser-session">{JSON.stringify(messages)}</div>
},
}))
vi.mock("../ChatRow", () => ({
default: function MockChatRow({ message }: { message: any }) {
return <div data-testid="chat-row">{JSON.stringify(message)}</div>
},
}))
vi.mock("../TaskHeader", () => ({
default: function MockTaskHeader({ task }: { task: any }) {
return <div data-testid="task-header">{JSON.stringify(task)}</div>
},
}))
vi.mock("../AutoApproveMenu", () => ({
default: () => null,
}))
vi.mock("@src/components/common/CodeBlock", () => ({
default: () => null,
CODE_BLOCK_BG_COLOR: "rgb(30, 30, 30)",
}))
vi.mock("@src/components/common/CodeAccordian", () => ({
default: () => null,
}))
vi.mock("@src/components/chat/ContextMenu", () => ({
default: () => null,
}))
// Mock window.postMessage to trigger state hydration
const mockPostMessage = (state: any) => {
window.postMessage(
{
type: "state",
state: {
version: "1.0.0",
clineMessages: [],
taskHistory: [],
shouldShowAnnouncement: false,
allowedCommands: [],
alwaysAllowExecute: false,
autoApprovalEnabled: true,
...state,
},
},
"*",
)
}
const queryClient = new QueryClient()
const defaultProps: ChatViewProps = {
isHidden: false,
showAnnouncement: false,
hideAnnouncement: () => {},
}
const renderChatView = (props: Partial<ChatViewProps> = {}) => {
return render(
<ExtensionStateContextProvider>
<QueryClientProvider client={queryClient}>
<ChatView {...defaultProps} {...props} />
</QueryClientProvider>
</ExtensionStateContextProvider>,
)
}
describe("ChatView - Auto Approval Tests", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("auto-approves read operations when enabled", async () => {
renderChatView()
// First hydrate state with initial task
mockPostMessage({
alwaysAllowReadOnly: true,
autoApprovalEnabled: true,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Then send the read tool ask message
mockPostMessage({
alwaysAllowReadOnly: true,
autoApprovalEnabled: true,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({ tool: "readFile", path: "test.txt" }),
partial: false,
},
],
})
// Wait for the auto-approval message
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
})
})
it("auto-approves outside workspace read operations when enabled", async () => {
renderChatView()
// First hydrate state with initial task
mockPostMessage({
alwaysAllowReadOnly: true,
alwaysAllowReadOnlyOutsideWorkspace: true,
autoApprovalEnabled: true,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Then send the read tool ask message with an absolute path (outside workspace)
mockPostMessage({
alwaysAllowReadOnly: true,
alwaysAllowReadOnlyOutsideWorkspace: true,
autoApprovalEnabled: true,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({
tool: "readFile",
path: "/absolute/path/test.txt",
// Use an absolute path that's clearly outside workspace
}),
partial: false,
},
],
})
// Also mock the filePaths for workspace detection
mockPostMessage({
alwaysAllowReadOnly: true,
alwaysAllowReadOnlyOutsideWorkspace: true,
autoApprovalEnabled: true,
filePaths: ["/workspace/root", "/another/workspace"],
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({
tool: "readFile",
path: "/absolute/path/test.txt",
}),
partial: false,
},
],
})
// Wait for the auto-approval message
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
})
})
it("does not auto-approve outside workspace read operations without permission", async () => {
renderChatView()
// First hydrate state with initial task
mockPostMessage({
alwaysAllowReadOnly: true,
alwaysAllowReadOnlyOutsideWorkspace: false, // No permission for outside workspace
autoApprovalEnabled: true,
filePaths: ["/workspace/root", "/another/workspace"], // Same workspace paths as before
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Then send the read tool ask message with an absolute path (outside workspace)
mockPostMessage({
alwaysAllowReadOnly: true,
alwaysAllowReadOnlyOutsideWorkspace: false,
autoApprovalEnabled: true,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({
tool: "readFile",
path: "/absolute/path/test.txt",
isOutsideWorkspace: true, // Explicitly indicate this is outside workspace
}),
partial: false,
},
],
})
// Wait a short time and verify no auto-approval message was sent
await new Promise((resolve) => setTimeout(resolve, 100))
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
})
it("does not auto-approve when autoApprovalEnabled is false", async () => {
renderChatView()
// First hydrate state with initial task
mockPostMessage({
alwaysAllowReadOnly: true,
autoApprovalEnabled: false,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Then send the read tool ask message
mockPostMessage({
alwaysAllowReadOnly: true,
autoApprovalEnabled: false,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({ tool: "readFile", path: "test.txt" }),
partial: false,
},
],
})
// Verify no auto-approval message was sent
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
})
it("auto-approves write operations when enabled", async () => {
renderChatView()
// First hydrate state with initial task
mockPostMessage({
alwaysAllowWrite: true,
autoApprovalEnabled: true,
writeDelayMs: 0,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Then send the write tool ask message
mockPostMessage({
alwaysAllowWrite: true,
autoApprovalEnabled: true,
writeDelayMs: 0,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({ tool: "editedExistingFile", path: "test.txt" }),
partial: false,
},
],
})
// Wait for the auto-approval message
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
})
})
it("auto-approves outside workspace write operations when enabled", async () => {
renderChatView()
// First hydrate state with initial task
mockPostMessage({
alwaysAllowWrite: true,
alwaysAllowWriteOutsideWorkspace: true,
autoApprovalEnabled: true,
writeDelayMs: 0, // Set to 0 for testing
filePaths: ["/workspace/root", "/another/workspace"], // Define workspace paths for testing
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Then send the write tool ask message with an absolute path (outside workspace)
mockPostMessage({
alwaysAllowWrite: true,
alwaysAllowWriteOutsideWorkspace: true,
autoApprovalEnabled: true,
writeDelayMs: 0,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({
tool: "editedExistingFile",
path: "/absolute/path/test.txt",
content: "Test content",
}),
partial: false,
},
],
})
// Wait for the auto-approval message
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
})
})
it("does not auto-approve outside workspace write operations without permission", async () => {
renderChatView()
// First hydrate state with initial task
mockPostMessage({
alwaysAllowWrite: true,
alwaysAllowWriteOutsideWorkspace: false, // No permission for outside workspace
autoApprovalEnabled: true,
writeDelayMs: 0,
filePaths: ["/workspace/root", "/another/workspace"], // Define workspace paths for testing
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Then send the write tool ask message with an absolute path (outside workspace)
mockPostMessage({
alwaysAllowWrite: true,
alwaysAllowWriteOutsideWorkspace: false,
autoApprovalEnabled: true,
writeDelayMs: 0,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({
tool: "editedExistingFile",
path: "/absolute/path/test.txt",
content: "Test content",
isOutsideWorkspace: true, // Explicitly indicate this is outside workspace
}),
partial: false,
},
],
})
// Wait a short time and verify no auto-approval message was sent
await new Promise((resolve) => setTimeout(resolve, 100))
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
})
it("auto-approves browser actions when enabled", async () => {
renderChatView()
// First hydrate state with initial task
mockPostMessage({
alwaysAllowBrowser: true,
autoApprovalEnabled: true,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Then send the browser action ask message
mockPostMessage({
alwaysAllowBrowser: true,
autoApprovalEnabled: true,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "browser_action_launch",
ts: Date.now(),
text: JSON.stringify({ action: "launch", url: "http://example.com" }),
partial: false,
},
],
})
// Wait for the auto-approval message
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
})
})
it("auto-approves mode switch when enabled", async () => {
renderChatView()
// First hydrate state with initial task
mockPostMessage({
alwaysAllowModeSwitch: true,
autoApprovalEnabled: true,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Then send the mode switch ask message
mockPostMessage({
alwaysAllowModeSwitch: true,
autoApprovalEnabled: true,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({ tool: "switchMode" }),
partial: false,
},
],
})
// Wait for the auto-approval message
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
})
})
it("does not auto-approve mode switch when disabled", async () => {
renderChatView()
// First hydrate state with initial task
mockPostMessage({
alwaysAllowModeSwitch: false,
autoApprovalEnabled: true,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Then send the mode switch ask message
mockPostMessage({
alwaysAllowModeSwitch: false,
autoApprovalEnabled: true,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({ tool: "switchMode" }),
partial: false,
},
],
})
// Verify no auto-approval message was sent
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
})
it("does not auto-approve mode switch when auto-approval is disabled", async () => {
renderChatView()
// First hydrate state with initial task
mockPostMessage({
alwaysAllowModeSwitch: true,
autoApprovalEnabled: false,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Then send the mode switch ask message
mockPostMessage({
alwaysAllowModeSwitch: true,
autoApprovalEnabled: false,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({ tool: "switchMode" }),
partial: false,
},
],
})
// Verify no auto-approval message was sent
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
})
})

View file

@ -1,4 +1,4 @@
// npx vitest run src/components/chat/__tests__/ChatView.spec.tsx
// pnpm --filter @roo-code/vscode-webview test src/components/chat/__tests__/ChatView.spec.tsx
import React from "react"
import { render, waitFor, act, fireEvent } from "@/utils/test-utils"
@ -293,644 +293,9 @@ const renderChatView = (props: Partial<ChatViewProps> = {}) => {
)
}
describe("ChatView - Auto Approval Tests", () => {
beforeEach(() => vi.clearAllMocks())
it("does not auto-approve any actions when autoApprovalEnabled is false", () => {
renderChatView()
// First hydrate state with initial task
mockPostMessage({
autoApprovalEnabled: false,
alwaysAllowBrowser: true,
alwaysAllowReadOnly: true,
alwaysAllowWrite: true,
alwaysAllowExecute: true,
allowedCommands: ["npm test"],
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Test various types of actions that should not be auto-approved
const testCases = [
{
ask: "browser_action_launch",
text: JSON.stringify({ action: "launch", url: "http://example.com" }),
},
{
ask: "tool",
text: JSON.stringify({ tool: "readFile", path: "test.txt" }),
},
{
ask: "tool",
text: JSON.stringify({ tool: "editedExistingFile", path: "test.txt" }),
},
{
ask: "command",
text: "npm test",
},
]
testCases.forEach((testCase) => {
mockPostMessage({
autoApprovalEnabled: false,
alwaysAllowBrowser: true,
alwaysAllowReadOnly: true,
alwaysAllowWrite: true,
alwaysAllowExecute: true,
allowedCommands: ["npm test"],
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: testCase.ask as any,
ts: Date.now(),
text: testCase.text,
},
],
})
// Should not auto-approve when autoApprovalEnabled is false
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
})
})
it("auto-approves browser actions when alwaysAllowBrowser is enabled", async () => {
renderChatView()
// First hydrate state with initial task
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowBrowser: true,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Clear any initial calls
vi.mocked(vscode.postMessage).mockClear()
// Add browser action
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowBrowser: true,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "browser_action_launch",
ts: Date.now(),
text: JSON.stringify({ action: "launch", url: "http://example.com" }),
},
],
})
// Wait for auto-approval to happen
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
})
})
it("auto-approves read-only tools when alwaysAllowReadOnly is enabled", async () => {
renderChatView()
// First hydrate state with initial task
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowReadOnly: true,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Clear any initial calls
vi.mocked(vscode.postMessage).mockClear()
// Add read-only tool request
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowReadOnly: true,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({ tool: "readFile", path: "test.txt" }),
},
],
})
// Wait for auto-approval to happen
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
})
})
describe("Write Tool Auto-Approval Tests", () => {
it("auto-approves write tools when alwaysAllowWrite is enabled and message is a tool request", async () => {
renderChatView()
// First hydrate state with initial task
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowWrite: true,
writeDelayMs: 100, // Short delay for testing
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Clear any initial calls
vi.mocked(vscode.postMessage).mockClear()
// Add write tool request
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowWrite: true,
writeDelayMs: 100, // Short delay for testing
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({ tool: "editedExistingFile", path: "test.txt" }),
partial: false,
},
],
})
// Wait for auto-approval to happen (with delay for write tools)
await waitFor(
() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
},
{ timeout: 1000 },
)
})
it("does not auto-approve write operations when alwaysAllowWrite is enabled but message is not a tool request", () => {
renderChatView()
// First hydrate state with initial task
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowWrite: true,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Clear any initial calls
vi.mocked(vscode.postMessage).mockClear()
// Add non-tool write request
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowWrite: true,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "write_to_file",
ts: Date.now(),
text: "Writing to test.txt",
},
],
})
// Should not auto-approve non-tool write operations
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
})
})
it("auto-approves allowed commands when alwaysAllowExecute is enabled", async () => {
renderChatView()
// First hydrate state with initial task
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true,
allowedCommands: ["npm test", "npm run build"],
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Clear any initial calls
vi.mocked(vscode.postMessage).mockClear()
// Add allowed command
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true,
allowedCommands: ["npm test", "npm run build"],
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "command",
ts: Date.now(),
text: "npm test",
},
],
})
// Wait for auto-approval to happen
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
})
})
it("does not auto-approve disallowed commands even when alwaysAllowExecute is enabled", () => {
renderChatView()
// First hydrate state with initial task
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true,
allowedCommands: ["npm test"],
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Clear any initial calls
vi.mocked(vscode.postMessage).mockClear()
// Add disallowed command
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true,
allowedCommands: ["npm test"],
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "command",
ts: Date.now(),
text: "rm -rf /",
},
],
})
// Should not auto-approve disallowed command
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
})
describe("Command Chaining Tests", () => {
it("auto-approves chained commands when all parts are allowed", async () => {
renderChatView()
// First hydrate state with initial task
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true,
allowedCommands: ["npm test", "npm run build", "echo"],
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Clear any initial calls
vi.mocked(vscode.postMessage).mockClear()
// Test various chained commands
const chainedCommands = [
"npm test && npm run build",
"npm test || echo 'test failed'",
"npm test; npm run build",
]
for (const command of chainedCommands) {
vi.mocked(vscode.postMessage).mockClear()
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true,
allowedCommands: ["npm test", "npm run build", "echo"],
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "command",
ts: Date.now(),
text: command,
},
],
})
// Wait for auto-approval to happen
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
})
}
})
it("does not auto-approve chained commands when any part is disallowed", () => {
renderChatView()
// First hydrate state with initial task
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true,
allowedCommands: ["npm test", "echo"],
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Clear any initial calls
vi.mocked(vscode.postMessage).mockClear()
// Add chained command with disallowed part
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true,
allowedCommands: ["npm test", "echo"],
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "command",
ts: Date.now(),
text: "npm test && rm -rf /",
},
],
})
// Should not auto-approve chained command with disallowed part
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
})
it("handles complex PowerShell command chains correctly", async () => {
renderChatView()
// First hydrate state with initial task
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true,
allowedCommands: ["Get-Process", "Where-Object", "Select-Object"],
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Clear any initial calls
vi.mocked(vscode.postMessage).mockClear()
// Add PowerShell piped command
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true,
allowedCommands: ["Get-Process", "Where-Object", "Select-Object"],
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "command",
ts: Date.now(),
text: "Get-Process | Where-Object {$_.CPU -gt 10} | Select-Object Name, CPU",
},
],
})
// Wait for auto-approval to happen
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
})
})
})
})
describe("ChatView - Sound Playing Tests", () => {
beforeEach(() => vi.clearAllMocks())
it("does not play sound for auto-approved browser actions", () => {
renderChatView()
// First hydrate state with initial task
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowBrowser: true,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Clear any initial calls
mockPlayFunction.mockClear()
// Add browser action that will be auto-approved
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowBrowser: true,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "browser_action_launch",
ts: Date.now(),
text: JSON.stringify({ action: "launch", url: "http://example.com" }),
},
],
})
// Should not play sound for auto-approved action
expect(mockPlayFunction).not.toHaveBeenCalled()
})
it("plays notification sound for non-auto-approved browser actions", async () => {
renderChatView()
// First hydrate state with initial task
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowBrowser: false, // Browser actions not auto-approved
soundEnabled: true, // Enable sound
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Clear any initial calls
mockPlayFunction.mockClear()
// Add browser action that won't be auto-approved
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowBrowser: false,
soundEnabled: true, // Enable sound
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "browser_action_launch",
ts: Date.now(),
text: JSON.stringify({ action: "launch", url: "http://example.com" }),
partial: false, // Ensure it's not partial
},
],
})
// Wait for sound to be played
await waitFor(() => {
expect(mockPlayFunction).toHaveBeenCalled()
})
})
it("plays celebration sound for completion results", async () => {
renderChatView()

File diff suppressed because it is too large Load diff

View file

@ -1,760 +0,0 @@
import { parse } from "shell-quote"
type ShellToken = string | { op: string } | { command: string }
/**
* # Command Denylist Feature - Longest Prefix Match Strategy
*
* This module implements a sophisticated command validation system that uses the
* "longest prefix match" strategy to resolve conflicts between allowlist and denylist patterns.
*
* ## Core Concept: Longest Prefix Match
*
* When a command matches patterns in both the allowlist and denylist, the system uses
* the longest (most specific) match to determine the final decision. This approach
* provides fine-grained control over command execution permissions.
*
* ### Examples:
*
* **Example 1: Specific denial overrides general allowance**
* - Allowlist: ["git"]
* - Denylist: ["git push"]
* - Command: "git push origin main"
* - Result: DENIED (denylist match "git push" is longer than allowlist match "git")
*
* **Example 2: Specific allowance overrides general denial**
* - Allowlist: ["git push --dry-run"]
* - Denylist: ["git push"]
* - Command: "git push --dry-run origin main"
* - Result: APPROVED (allowlist match is longer and more specific)
*
* **Example 3: Wildcard handling**
* - Allowlist: ["*"]
* - Denylist: ["rm", "sudo"]
* - Command: "rm -rf /"
* - Result: DENIED (specific denylist match overrides wildcard allowlist)
*
* ## Command Processing Pipeline:
*
* 1. **Dangerous Substitution Detection**: Commands containing dangerous patterns like ${var@P} are never auto-approved
* 2. **Command Parsing**: Split chained commands (&&, ||, ;, |, &) into individual commands for separate validation
* 3. **Pattern Matching**: For each individual command, find the longest matching prefix in both allowlist and denylist
* 4. **Decision Logic**: Apply longest prefix match rule - more specific (longer) matches take precedence
* 5. **Aggregation**: Combine individual decisions - if any command is denied, the entire chain is denied
*
* ## Security Considerations:
*
* - **Dangerous Substitution Protection**: Detects dangerous parameter expansions and escape sequences that could execute commands
* - **Chain Analysis**: Each command in a chain (cmd1 && cmd2) is validated separately to prevent bypassing via chaining
* - **Case Insensitive**: All pattern matching is case-insensitive for consistent behavior across different input styles
* - **Whitespace Handling**: Commands are trimmed and normalized before matching to prevent whitespace-based bypasses
*
* ## Configuration Merging:
*
* The system merges command lists from two sources with global state taking precedence:
* 1. Global state (user preferences)
* 2. Workspace configuration (project-specific settings)
*
* This allows users to have personal defaults while projects can define specific restrictions.
*/
/**
* Detect dangerous parameter substitutions that could lead to command execution.
* These patterns are never auto-approved and always require explicit user approval.
*
* Detected patterns:
* - ${var@P} - Prompt string expansion (interprets escape sequences and executes embedded commands)
* - ${var@Q} - Quote removal
* - ${var@E} - Escape sequence expansion
* - ${var@A} - Assignment statement
* - ${var@a} - Attribute flags
* - ${var=value} with escape sequences - Can embed commands via \140 (backtick), \x60, or \u0060
* - ${!var} - Indirect variable references
* - <<<$(...) or <<<`...` - Here-strings with command substitution
* - =(...) - Zsh process substitution that executes commands
* - *(e:...:) or similar - Zsh glob qualifiers with code execution
*
* @param source - The command string to analyze
* @returns true if dangerous substitution patterns are detected, false otherwise
*/
export function containsDangerousSubstitution(source: string): boolean {
// Check for dangerous parameter expansion operators that can execute commands
// ${var@P} - Prompt string expansion (interprets escape sequences and executes embedded commands)
// ${var@Q} - Quote removal
// ${var@E} - Escape sequence expansion
// ${var@A} - Assignment statement
// ${var@a} - Attribute flags
const dangerousParameterExpansion = /\$\{[^}]*@[PQEAa][^}]*\}/.test(source)
// Check for parameter expansions with assignments that could contain escape sequences
// ${var=value} or ${var:=value} can embed commands via escape sequences like \140 (backtick)
// Also check for ${var+value}, ${var:-value}, ${var:+value}, ${var:?value}
const parameterAssignmentWithEscapes =
/\$\{[^}]*[=+\-?][^}]*\\[0-7]{3}[^}]*\}/.test(source) || // octal escapes
/\$\{[^}]*[=+\-?][^}]*\\x[0-9a-fA-F]{2}[^}]*\}/.test(source) || // hex escapes
/\$\{[^}]*[=+\-?][^}]*\\u[0-9a-fA-F]{4}[^}]*\}/.test(source) // unicode escapes
// Check for indirect variable references that could execute commands
// ${!var} performs indirect expansion which can be dangerous with crafted variable names
const indirectExpansion = /\$\{![^}]+\}/.test(source)
// Check for here-strings with command substitution
// <<<$(...) or <<<`...` can execute commands
const hereStringWithSubstitution = /<<<\s*(\$\(|`)/.test(source)
// Check for zsh process substitution =(...) which executes commands
// =(...) creates a temporary file containing the output of the command, but executes it
const zshProcessSubstitution = /=\([^)]+\)/.test(source)
// Check for zsh glob qualifiers with code execution (e:...:)
// Patterns like *(e:whoami:) or ?(e:rm -rf /:) execute commands during glob expansion
// This regex matches patterns like *(e:...:), ?(e:...:), +(e:...:), @(e:...:), !(e:...:)
const zshGlobQualifier = /[*?+@!]\(e:[^:]+:\)/.test(source)
// Return true if any dangerous pattern is detected
return (
dangerousParameterExpansion ||
parameterAssignmentWithEscapes ||
indirectExpansion ||
hereStringWithSubstitution ||
zshProcessSubstitution ||
zshGlobQualifier
)
}
/**
* Split a command string into individual sub-commands by
* chaining operators (&&, ||, ;, |, or &) and newlines.
*
* Uses shell-quote to properly handle:
* - Quoted strings (preserves quotes)
* - Subshell commands ($(cmd), `cmd`, <(cmd), >(cmd))
* - PowerShell redirections (2>&1)
* - Chain operators (&&, ||, ;, |, &)
* - Newlines as command separators
*/
export function parseCommand(command: string): string[] {
if (!command?.trim()) return []
// Split by newlines first (handle different line ending formats)
// This regex splits on \r\n (Windows), \n (Unix), or \r (old Mac)
const lines = command.split(/\r\n|\r|\n/)
const allCommands: string[] = []
for (const line of lines) {
// Skip empty lines
if (!line.trim()) continue
// Process each line through the existing parsing logic
const lineCommands = parseCommandLine(line)
allCommands.push(...lineCommands)
}
return allCommands
}
/**
* Helper function to restore placeholders in a command string
*/
function restorePlaceholders(
command: string,
quotes: string[],
redirections: string[],
arrayIndexing: string[],
arithmeticExpressions: string[],
parameterExpansions: string[],
variables: string[],
subshells: string[],
): string {
let result = command
// Restore quotes
result = result.replace(/__QUOTE_(\d+)__/g, (_, i) => quotes[parseInt(i)])
// Restore redirections
result = result.replace(/__REDIR_(\d+)__/g, (_, i) => redirections[parseInt(i)])
// Restore array indexing expressions
result = result.replace(/__ARRAY_(\d+)__/g, (_, i) => arrayIndexing[parseInt(i)])
// Restore arithmetic expressions
result = result.replace(/__ARITH_(\d+)__/g, (_, i) => arithmeticExpressions[parseInt(i)])
// Restore parameter expansions
result = result.replace(/__PARAM_(\d+)__/g, (_, i) => parameterExpansions[parseInt(i)])
// Restore variable references
result = result.replace(/__VAR_(\d+)__/g, (_, i) => variables[parseInt(i)])
result = result.replace(/__SUBSH_(\d+)__/g, (_, i) => subshells[parseInt(i)])
return result
}
/**
* Parse a single line of commands (internal helper function)
*/
function parseCommandLine(command: string): string[] {
if (!command?.trim()) return []
// Storage for replaced content
const redirections: string[] = []
const subshells: string[] = []
const quotes: string[] = []
const arrayIndexing: string[] = []
const arithmeticExpressions: string[] = []
const variables: string[] = []
const parameterExpansions: string[] = []
// First handle PowerShell redirections by temporarily replacing them
let processedCommand = command.replace(/\d*>&\d*/g, (match) => {
redirections.push(match)
return `__REDIR_${redirections.length - 1}__`
})
// Handle arithmetic expressions: $((...)) pattern
// Match the entire arithmetic expression including nested parentheses
processedCommand = processedCommand.replace(/\$\(\([^)]*(?:\)[^)]*)*\)\)/g, (match) => {
arithmeticExpressions.push(match)
return `__ARITH_${arithmeticExpressions.length - 1}__`
})
// Handle $[...] arithmetic expressions (alternative syntax)
processedCommand = processedCommand.replace(/\$\[[^\]]*\]/g, (match) => {
arithmeticExpressions.push(match)
return `__ARITH_${arithmeticExpressions.length - 1}__`
})
// Handle parameter expansions: ${...} patterns (including array indexing)
// This covers ${var}, ${var:-default}, ${var:+alt}, ${#var}, ${var%pattern}, etc.
processedCommand = processedCommand.replace(/\$\{[^}]+\}/g, (match) => {
parameterExpansions.push(match)
return `__PARAM_${parameterExpansions.length - 1}__`
})
// Handle process substitutions: <(...) and >(...)
processedCommand = processedCommand.replace(/[<>]\(([^)]+)\)/g, (_, inner) => {
subshells.push(inner.trim())
return `__SUBSH_${subshells.length - 1}__`
})
// Handle simple variable references: $varname pattern
// This prevents shell-quote from splitting $count into separate tokens
processedCommand = processedCommand.replace(/\$[a-zA-Z_][a-zA-Z0-9_]*/g, (match) => {
variables.push(match)
return `__VAR_${variables.length - 1}__`
})
// Handle special bash variables: $?, $!, $#, $$, $@, $*, $-, $0-$9
processedCommand = processedCommand.replace(/\$[?!#$@*\-0-9]/g, (match) => {
variables.push(match)
return `__VAR_${variables.length - 1}__`
})
// Then handle subshell commands $() and back-ticks
processedCommand = processedCommand
.replace(/\$\((.*?)\)/g, (_, inner) => {
subshells.push(inner.trim())
return `__SUBSH_${subshells.length - 1}__`
})
.replace(/`(.*?)`/g, (_, inner) => {
subshells.push(inner.trim())
return `__SUBSH_${subshells.length - 1}__`
})
// Then handle quoted strings
processedCommand = processedCommand.replace(/"[^"]*"/g, (match) => {
quotes.push(match)
return `__QUOTE_${quotes.length - 1}__`
})
let tokens: ShellToken[]
try {
tokens = parse(processedCommand) as ShellToken[]
} catch (error: any) {
// If shell-quote fails to parse, fall back to simple splitting
console.warn("shell-quote parse error:", error.message, "for command:", processedCommand)
// Simple fallback: split by common operators
const fallbackCommands = processedCommand
.split(/(?:&&|\|\||;|\||&)/)
.map((cmd) => cmd.trim())
.filter((cmd) => cmd.length > 0)
// Restore all placeholders for each command
return fallbackCommands.map((cmd) =>
restorePlaceholders(
cmd,
quotes,
redirections,
arrayIndexing,
arithmeticExpressions,
parameterExpansions,
variables,
subshells,
),
)
}
const commands: string[] = []
let currentCommand: string[] = []
for (const token of tokens) {
if (typeof token === "object" && "op" in token) {
// Chain operator - split command
if (["&&", "||", ";", "|", "&"].includes(token.op)) {
if (currentCommand.length > 0) {
commands.push(currentCommand.join(" "))
currentCommand = []
}
} else {
// Other operators (>) are part of the command
currentCommand.push(token.op)
}
} else if (typeof token === "string") {
// Check if it's a subshell placeholder
const subshellMatch = token.match(/__SUBSH_(\d+)__/)
if (subshellMatch) {
if (currentCommand.length > 0) {
commands.push(currentCommand.join(" "))
currentCommand = []
}
commands.push(subshells[parseInt(subshellMatch[1])])
} else {
currentCommand.push(token)
}
}
}
// Add any remaining command
if (currentCommand.length > 0) {
commands.push(currentCommand.join(" "))
}
// Restore quotes and redirections
return commands.map((cmd) =>
restorePlaceholders(
cmd,
quotes,
redirections,
arrayIndexing,
arithmeticExpressions,
parameterExpansions,
variables,
subshells,
),
)
}
/**
* Find the longest matching prefix from a list of prefixes for a given command.
*
* This is the core function that implements the "longest prefix match" strategy.
* It searches through all provided prefixes and returns the longest one that
* matches the beginning of the command (case-insensitive).
*
* **Special Cases:**
* - Wildcard "*" matches any command but is treated as length 1 for comparison
* - Empty command or empty prefixes list returns null
* - Matching is case-insensitive and uses startsWith logic
*
* **Examples:**
* ```typescript
* findLongestPrefixMatch("git push origin", ["git", "git push"])
* // Returns "git push" (longer match)
*
* findLongestPrefixMatch("npm install", ["*", "npm"])
* // Returns "npm" (specific match preferred over wildcard)
*
* findLongestPrefixMatch("unknown command", ["git", "npm"])
* // Returns null (no match found)
* ```
*
* @param command - The command to match against
* @param prefixes - List of prefix patterns to search through
* @returns The longest matching prefix, or null if no match found
*/
export function findLongestPrefixMatch(command: string, prefixes: string[]): string | null {
if (!command || !prefixes?.length) return null
const trimmedCommand = command.trim().toLowerCase()
let longestMatch: string | null = null
for (const prefix of prefixes) {
const lowerPrefix = prefix.toLowerCase()
// Handle wildcard "*" - it matches any command
if (lowerPrefix === "*" || trimmedCommand.startsWith(lowerPrefix)) {
if (!longestMatch || lowerPrefix.length > longestMatch.length) {
longestMatch = lowerPrefix
}
}
}
return longestMatch
}
/**
* Check if a single command should be auto-approved.
* Returns true only for commands that explicitly match the allowlist
* and either don't match the denylist or have a longer allowlist match.
*
* Special handling for wildcards: "*" in allowlist allows any command,
* but denylist can still block specific commands.
*/
export function isAutoApprovedSingleCommand(
command: string,
allowedCommands: string[],
deniedCommands?: string[],
): boolean {
if (!command) return true
// If no allowlist configured, nothing can be auto-approved
if (!allowedCommands?.length) return false
// Check if wildcard is present in allowlist
const hasWildcard = allowedCommands.some((cmd) => cmd.toLowerCase() === "*")
// If no denylist provided (undefined), use simple allowlist logic
if (deniedCommands === undefined) {
const trimmedCommand = command.trim().toLowerCase()
return allowedCommands.some((prefix) => {
const lowerPrefix = prefix.toLowerCase()
// Handle wildcard "*" - it matches any command
return lowerPrefix === "*" || trimmedCommand.startsWith(lowerPrefix)
})
}
// Find longest matching prefix in both lists
const longestDeniedMatch = findLongestPrefixMatch(command, deniedCommands)
const longestAllowedMatch = findLongestPrefixMatch(command, allowedCommands)
// Special case: if wildcard is present and no denylist match, auto-approve
if (hasWildcard && !longestDeniedMatch) return true
// Must have an allowlist match to be auto-approved
if (!longestAllowedMatch) return false
// If no denylist match, auto-approve
if (!longestDeniedMatch) return true
// Both have matches - allowlist must be longer to auto-approve
return longestAllowedMatch.length > longestDeniedMatch.length
}
/**
* Check if a single command should be auto-denied.
* Returns true only for commands that explicitly match the denylist
* and either don't match the allowlist or have a longer denylist match.
*/
export function isAutoDeniedSingleCommand(
command: string,
allowedCommands: string[],
deniedCommands?: string[],
): boolean {
if (!command) return false
// If no denylist configured, nothing can be auto-denied
if (!deniedCommands?.length) return false
// Find longest matching prefix in both lists
const longestDeniedMatch = findLongestPrefixMatch(command, deniedCommands)
const longestAllowedMatch = findLongestPrefixMatch(command, allowedCommands || [])
// Must have a denylist match to be auto-denied
if (!longestDeniedMatch) return false
// If no allowlist match, auto-deny
if (!longestAllowedMatch) return true
// Both have matches - denylist must be longer or equal to auto-deny
return longestDeniedMatch.length >= longestAllowedMatch.length
}
/**
* Command approval decision types
*/
export type CommandDecision = "auto_approve" | "auto_deny" | "ask_user"
/**
* Unified command validation that implements the longest prefix match rule.
* Returns a definitive decision for a command based on allowlist and denylist.
*
* This is the main entry point for command validation in the Command Denylist feature.
* It handles complex command chains and applies the longest prefix match strategy
* to resolve conflicts between allowlist and denylist patterns.
*
* **Decision Logic:**
* 1. **Dangerous Substitution Protection**: Commands with dangerous parameter expansions are never auto-approved
* 2. **Command Parsing**: Split command chains (&&, ||, ;, |, &) into individual commands
* 3. **Individual Validation**: For each sub-command, apply longest prefix match rule
* 4. **Aggregation**: Combine decisions using "any denial blocks all" principle
*
* **Return Values:**
* - `"auto_approve"`: All sub-commands are explicitly allowed and no dangerous patterns detected
* - `"auto_deny"`: At least one sub-command is explicitly denied
* - `"ask_user"`: Mixed or no matches found, requires user decision, or contains dangerous patterns
*
* **Examples:**
* ```typescript
* // Simple approval
* getCommandDecision("git status", ["git"], [])
* // Returns "auto_approve"
*
* // Dangerous pattern - never auto-approved
* getCommandDecision('echo "${var@P}"', ["echo"], [])
* // Returns "ask_user"
*
* // Longest prefix match - denial wins
* getCommandDecision("git push origin", ["git"], ["git push"])
* // Returns "auto_deny"
*
* // Command chain - any denial blocks all
* getCommandDecision("git status && rm file", ["git"], ["rm"])
* // Returns "auto_deny"
*
* // No matches - ask user
* getCommandDecision("unknown command", ["git"], ["rm"])
* // Returns "ask_user"
* ```
*
* @param command - The full command string to validate
* @param allowedCommands - List of allowed command prefixes
* @param deniedCommands - Optional list of denied command prefixes
* @returns Decision indicating whether to approve, deny, or ask user
*/
export function getCommandDecision(
command: string,
allowedCommands: string[],
deniedCommands?: string[],
): CommandDecision {
if (!command?.trim()) return "auto_approve"
// Parse into sub-commands (split by &&, ||, ;, |)
const subCommands = parseCommand(command)
// Check each sub-command and collect decisions
const decisions: CommandDecision[] = subCommands.map((cmd) => {
// Remove simple PowerShell-like redirections (e.g. 2>&1) before checking
const cmdWithoutRedirection = cmd.replace(/\d*>&\d*/, "").trim()
return getSingleCommandDecision(cmdWithoutRedirection, allowedCommands, deniedCommands)
})
// If any sub-command is denied, deny the whole command
if (decisions.includes("auto_deny")) {
return "auto_deny"
}
// Require explicit user approval for dangerous patterns
if (containsDangerousSubstitution(command)) {
return "ask_user"
}
// If all sub-commands are approved, approve the whole command
if (decisions.every((decision) => decision === "auto_approve")) {
return "auto_approve"
}
// Otherwise, ask user
return "ask_user"
}
/**
* Get the decision for a single command using longest prefix match rule.
*
* This is the core logic that implements the conflict resolution between
* allowlist and denylist using the "longest prefix match" strategy.
*
* **Longest Prefix Match Algorithm:**
* 1. Find the longest matching prefix in the allowlist
* 2. Find the longest matching prefix in the denylist
* 3. Compare lengths to determine which rule takes precedence
* 4. Longer (more specific) match wins the conflict
*
* **Decision Matrix:**
* | Allowlist Match | Denylist Match | Result | Reason |
* |----------------|----------------|---------|---------|
* | Yes | No | auto_approve | Only allowlist matches |
* | No | Yes | auto_deny | Only denylist matches |
* | Yes | Yes (shorter) | auto_approve | Allowlist is more specific |
* | Yes | Yes (longer/equal) | auto_deny | Denylist is more specific |
* | No | No | ask_user | No rules apply |
*
* **Examples:**
* ```typescript
* // Only allowlist matches
* getSingleCommandDecision("git status", ["git"], ["npm"])
* // Returns "auto_approve"
*
* // Denylist is more specific
* getSingleCommandDecision("git push origin", ["git"], ["git push"])
* // Returns "auto_deny" (denylist "git push" > allowlist "git")
*
* // Allowlist is more specific
* getSingleCommandDecision("git push --dry-run", ["git push --dry-run"], ["git push"])
* // Returns "auto_approve" (allowlist is longer)
*
* // No matches
* getSingleCommandDecision("unknown", ["git"], ["npm"])
* // Returns "ask_user"
* ```
*
* @param command - Single command to validate (no chaining)
* @param allowedCommands - List of allowed command prefixes
* @param deniedCommands - Optional list of denied command prefixes
* @returns Decision for this specific command
*/
export function getSingleCommandDecision(
command: string,
allowedCommands: string[],
deniedCommands?: string[],
): CommandDecision {
if (!command) return "auto_approve"
// Find longest matching prefixes in both lists
const longestAllowedMatch = findLongestPrefixMatch(command, allowedCommands || [])
const longestDeniedMatch = findLongestPrefixMatch(command, deniedCommands || [])
// If only allowlist has a match, auto-approve
if (longestAllowedMatch && !longestDeniedMatch) {
return "auto_approve"
}
// If only denylist has a match, auto-deny
if (!longestAllowedMatch && longestDeniedMatch) {
return "auto_deny"
}
// Both lists have matches - apply longest prefix match rule
if (longestAllowedMatch && longestDeniedMatch) {
return longestAllowedMatch.length > longestDeniedMatch.length ? "auto_approve" : "auto_deny"
}
// If neither list has a match, ask user
return "ask_user"
}
/**
* Centralized Command Validation Service
*
* This class provides a unified interface for all command validation operations
* in the Command Denylist feature. It encapsulates the validation logic and
* provides convenient methods for different validation scenarios.
*/
export class CommandValidator {
constructor(
private allowedCommands: string[],
private deniedCommands?: string[],
) {}
/**
* Update the command lists used for validation
*/
updateCommandLists(allowedCommands: string[], deniedCommands?: string[]) {
this.allowedCommands = allowedCommands
this.deniedCommands = deniedCommands
}
/**
* Get the current command lists
*/
getCommandLists() {
return {
allowedCommands: [...this.allowedCommands],
deniedCommands: this.deniedCommands ? [...this.deniedCommands] : undefined,
}
}
/**
* Validate a command and return a decision
* This is the main validation method that should be used for all command validation
*/
validateCommand(command: string): CommandDecision {
return getCommandDecision(command, this.allowedCommands, this.deniedCommands)
}
/**
* Check if a command would be auto-approved
*/
isAutoApproved(command: string): boolean {
return this.validateCommand(command) === "auto_approve"
}
/**
* Check if a command would be auto-denied
*/
isAutoDenied(command: string): boolean {
return this.validateCommand(command) === "auto_deny"
}
/**
* Check if a command requires user input
*/
requiresUserInput(command: string): boolean {
return this.validateCommand(command) === "ask_user"
}
/**
* Get detailed validation information for a command
* Useful for debugging and providing user feedback
*/
getValidationDetails(command: string): {
decision: CommandDecision
subCommands: string[]
allowedMatches: Array<{ command: string; match: string | null }>
deniedMatches: Array<{ command: string; match: string | null }>
hasDangerousSubstitution: boolean
} {
const subCommands = parseCommand(command)
const hasDangerousSubstitution = containsDangerousSubstitution(command)
const allowedMatches = subCommands.map((cmd) => ({
command: cmd,
match: findLongestPrefixMatch(cmd.replace(/\d*>&\d*/, "").trim(), this.allowedCommands),
}))
const deniedMatches = subCommands.map((cmd) => ({
command: cmd,
match: findLongestPrefixMatch(cmd.replace(/\d*>&\d*/, "").trim(), this.deniedCommands || []),
}))
return {
decision: this.validateCommand(command),
subCommands,
allowedMatches,
deniedMatches,
hasDangerousSubstitution,
}
}
/**
* Validate multiple commands at once
* Returns a map of command to decision
*/
validateCommands(commands: string[]): Map<string, CommandDecision> {
const results = new Map<string, CommandDecision>()
for (const command of commands) {
results.set(command, this.validateCommand(command))
}
return results
}
/**
* Check if the validator has any rules configured
*/
hasRules(): boolean {
return this.allowedCommands.length > 0 || (this.deniedCommands?.length ?? 0) > 0
}
/**
* Get statistics about the current configuration
*/
getStats() {
return {
allowedCount: this.allowedCommands.length,
deniedCount: this.deniedCommands?.length ?? 0,
hasWildcard: this.allowedCommands.some((cmd) => cmd.toLowerCase() === "*"),
hasRules: this.hasRules(),
}
}
}
/**
* Factory function to create a CommandValidator instance
* This is the recommended way to create validators in the application
*/
export function createCommandValidator(allowedCommands: string[], deniedCommands?: string[]): CommandValidator {
return new CommandValidator(allowedCommands, deniedCommands)
}