mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Move auto-approval from ChatView to Task (#9157)
This commit is contained in:
parent
23856a5874
commit
6e6341346e
21 changed files with 951 additions and 4492 deletions
14
packages/types/src/__tests__/message.test.ts
Normal file
14
packages/types/src/__tests__/message.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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
29
pnpm-lock.yaml
generated
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { GlobalState, ClineMessage, ClineAsk } from "@roo-code/types"
|
||||
|
||||
import { getApiMetrics } from "../../shared/getApiMetrics"
|
||||
import { ClineAskResponse } from "../../shared/WebviewMessage"
|
||||
|
||||
|
|
@ -2,7 +2,6 @@ import { GlobalState, ClineMessage } from "@roo-code/types"
|
|||
|
||||
import { AutoApprovalHandler } from "../AutoApprovalHandler"
|
||||
|
||||
// Mock getApiMetrics
|
||||
vi.mock("../../../shared/getApiMetrics", () => ({
|
||||
getApiMetrics: vi.fn(),
|
||||
}))
|
||||
368
src/core/auto-approval/commands.ts
Normal file
368
src/core/auto-approval/commands.ts
Normal 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"
|
||||
}
|
||||
189
src/core/auto-approval/index.ts
Normal file
189
src/core/auto-approval/index.ts
Normal 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"
|
||||
13
src/core/auto-approval/mcp.ts
Normal file
13
src/core/auto-approval/mcp.ts
Normal 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
|
||||
}
|
||||
18
src/core/auto-approval/tools.ts
Normal file
18
src/core/auto-approval/tools.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ vi.mock("../../../api", () => ({
|
|||
getModel: () => ({ info: {}, id: "test-model" }),
|
||||
})),
|
||||
}))
|
||||
vi.mock("./AutoApprovalHandler")
|
||||
|
||||
// Mock TelemetryService
|
||||
vi.mock("@roo-code/telemetry", () => ({
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
223
src/shared/parse-command.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue