diff --git a/webview-ui/src/utils/__tests__/command-validation.spec.ts b/webview-ui/src/utils/__tests__/command-validation.spec.ts index acea548e14..2f7b3ba4f7 100644 --- a/webview-ui/src/utils/__tests__/command-validation.spec.ts +++ b/webview-ui/src/utils/__tests__/command-validation.spec.ts @@ -41,6 +41,9 @@ describe("Command Validation", () => { 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"]) + // fish-style command substitution using parentheses + expect(parseCommand("echo (whoami)")).toEqual(["echo", "whoami"]) + expect(parseCommand("echo (echo test)")).toEqual(["echo", "echo test"]) }) it("handles nested backticks with escaped inner backticks", () => { @@ -1097,6 +1100,14 @@ describe("Unified Command Decision Functions", () => { expect(getCommandDecision("echo `echo \\`whoami\\``", ["echo"], [])).toBe("ask_user") // And should be auto-denied when inner command is on the denylist expect(getCommandDecision("echo `echo \\`whoami\\``", ["echo"], ["whoami"])).toBe("auto_deny") + + // Fish-style substitutions behave like subshells + expect(getCommandDecision("npm install (echo test)", allowedCommands, deniedCommands)).toBe("auto_approve") + expect(getCommandDecision("npm install (npm test)", allowedCommands, deniedCommands)).toBe("auto_deny") + + // Ensure hidden subshell commands are validated even if outer command is allowed + expect(getCommandDecision("echo (whoami)", ["echo", "ls"], [])).toBe("ask_user") + expect(getCommandDecision("echo (whoami)", ["echo", "ls"], ["whoami"])).toBe("auto_deny") }) it("properly validates subshell commands when no denylist is present", () => { diff --git a/webview-ui/src/utils/command-validation.ts b/webview-ui/src/utils/command-validation.ts index 9b4f93dad9..f309722991 100644 --- a/webview-ui/src/utils/command-validation.ts +++ b/webview-ui/src/utils/command-validation.ts @@ -273,6 +273,21 @@ function parseCommandLine(command: string): string[] { return `__SUBSH_${subshells.length - 1}__` }) + // Handle fish-style command substitutions and POSIX subshell grouping: ( ... ) + // At this point we've already replaced $(), <() and >() earlier, so any remaining (...) are either + // fish command substitutions or subshell groupings. We treat them as subshells to validate inner commands. + processedCommand = processedCommand.replace(/\(([^()]*)\)/g, (full, inner: string, offset: number, str: string) => { + // If this was actually a pattern preceded by $, < or > it would have been handled earlier. + // Guard anyway: if the preceding character indicates a different construct, keep original. + const prevChar = offset > 0 ? str[offset - 1] : "" + if (prevChar === "$" || prevChar === "<" || prevChar === ">") { + return full + } + const content = (inner || "").trim() + if (!content) return full + subshells.push(content) + return `__SUBSH_${subshells.length - 1}__` + }) // Then handle quoted strings processedCommand = processedCommand.replace(/"[^"]*"/g, (match) => { quotes.push(match)