Skip to content

Commit cdb7d6b

Browse files
mrubensutarn
authored andcommitted
Improvements to subshell validation (RooCodeInc#6379)
1 parent edaf2f7 commit cdb7d6b

File tree

3 files changed

+133
-62
lines changed

3 files changed

+133
-62
lines changed

webview-ui/src/components/chat/CommandExecution.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { cn } from "@src/lib/utils"
1515
import { Button } from "@src/components/ui"
1616
import CodeBlock from "../common/CodeBlock"
1717
import { CommandPatternSelector } from "./CommandPatternSelector"
18+
import { parseCommand } from "../../utils/command-validation"
1819
import { extractPatternsFromCommand } from "../../utils/command-parser"
1920

2021
interface CommandPattern {
@@ -53,8 +54,26 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
5354

5455
// Extract command patterns from the actual command that was executed
5556
const commandPatterns = useMemo<CommandPattern[]>(() => {
56-
const extractedPatterns = extractPatternsFromCommand(command)
57-
return extractedPatterns.map((pattern) => ({
57+
// First get all individual commands (including subshell commands) using parseCommand
58+
const allCommands = parseCommand(command)
59+
60+
// Then extract patterns from each command using the existing pattern extraction logic
61+
const allPatterns = new Set<string>()
62+
63+
// Add all individual commands first
64+
allCommands.forEach((cmd) => {
65+
if (cmd.trim()) {
66+
allPatterns.add(cmd.trim())
67+
}
68+
})
69+
70+
// Then add extracted patterns for each command
71+
allCommands.forEach((cmd) => {
72+
const patterns = extractPatternsFromCommand(cmd)
73+
patterns.forEach((pattern) => allPatterns.add(pattern))
74+
})
75+
76+
return Array.from(allPatterns).map((pattern) => ({
5877
pattern,
5978
}))
6079
}, [command])

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

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
getSingleCommandDecision,
1212
CommandValidator,
1313
createCommandValidator,
14+
containsSubshell,
1415
} from "../command-validation"
1516

1617
describe("Command Validation", () => {
@@ -31,6 +32,20 @@ describe("Command Validation", () => {
3132
it("handles subshell patterns", () => {
3233
expect(parseCommand("npm test $(echo test)")).toEqual(["npm test", "echo test"])
3334
expect(parseCommand("npm test `echo test`")).toEqual(["npm test", "echo test"])
35+
expect(parseCommand("diff <(sort f1) <(sort f2)")).toEqual(["diff", "sort f1", "sort f2"])
36+
})
37+
38+
it("detects additional subshell patterns", () => {
39+
// Test $[] arithmetic expansion detection
40+
expect(parseCommand("echo $[1 + 2]")).toEqual(["echo $[1 + 2]"])
41+
42+
// Verify containsSubshell detects all subshell patterns
43+
expect(containsSubshell("echo $[1 + 2]")).toBe(true) // $[] arithmetic expansion
44+
expect(containsSubshell("echo $((1 + 2))")).toBe(true) // $(()) arithmetic expansion
45+
expect(containsSubshell("echo $(date)")).toBe(true) // $() command substitution
46+
expect(containsSubshell("echo `date`")).toBe(true) // backtick substitution
47+
expect(containsSubshell("diff <(sort f1) <(sort f2)")).toBe(true) // process substitution
48+
expect(containsSubshell("echo hello")).toBe(false) // no subshells
3449
})
3550

3651
it("handles empty and whitespace input", () => {
@@ -629,7 +644,6 @@ echo "Successfully converted $count .jsx files to .tsx"`
629644
})
630645
})
631646
})
632-
633647
describe("Unified Command Decision Functions", () => {
634648
describe("getSingleCommandDecision", () => {
635649
const allowedCommands = ["npm", "echo", "git"]
@@ -712,8 +726,8 @@ describe("Unified Command Decision Functions", () => {
712726
expect(getCommandDecision("npm install && dangerous", allowedCommands, deniedCommands)).toBe("ask_user")
713727
})
714728

715-
it("returns auto_deny for subshell commands only when they contain denied prefixes", () => {
716-
// Subshells without denied prefixes should not be auto-denied
729+
it("properly validates subshell commands by checking all parsed commands", () => {
730+
// Subshells without denied prefixes should be auto-approved if all commands are allowed
717731
expect(getCommandDecision("npm install $(echo test)", allowedCommands, deniedCommands)).toBe("auto_approve")
718732
expect(getCommandDecision("npm install `echo test`", allowedCommands, deniedCommands)).toBe("auto_approve")
719733

@@ -727,7 +741,7 @@ describe("Unified Command Decision Functions", () => {
727741
expect(getCommandDecision("npm test $(echo hello)", allowedCommands, deniedCommands)).toBe("auto_deny")
728742
})
729743

730-
it("allows subshell commands when no denylist is present", () => {
744+
it("properly validates subshell commands when no denylist is present", () => {
731745
expect(getCommandDecision("npm install $(echo test)", allowedCommands)).toBe("auto_approve")
732746
expect(getCommandDecision("npm install `echo test`", allowedCommands)).toBe("auto_approve")
733747
})
@@ -844,12 +858,12 @@ describe("Unified Command Decision Functions", () => {
844858
it("detects subshells correctly", () => {
845859
const details = validator.getValidationDetails("npm install $(echo test)")
846860
expect(details.hasSubshells).toBe(true)
847-
expect(details.decision).toBe("auto_approve") // not blocked since echo doesn't match denied prefixes
861+
expect(details.decision).toBe("auto_approve") // all commands are allowed
848862

849863
// Test with denied prefix in subshell
850864
const detailsWithDenied = validator.getValidationDetails("npm install $(npm test)")
851865
expect(detailsWithDenied.hasSubshells).toBe(true)
852-
expect(detailsWithDenied.decision).toBe("auto_deny") // blocked due to npm test in subshell
866+
expect(detailsWithDenied.decision).toBe("auto_deny") // npm test is denied
853867
})
854868

855869
it("handles complex command chains", () => {
@@ -955,9 +969,9 @@ describe("Unified Command Decision Functions", () => {
955969
// Multiple subshells, one with denied prefix
956970
expect(validator.validateCommand("echo $(date) $(rm file)")).toBe("auto_deny")
957971

958-
// Nested subshells - inner commands are extracted and not in allowlist
972+
// Nested subshells - validates individual parsed commands
959973
expect(validator.validateCommand("echo $(echo $(date))")).toBe("ask_user")
960-
expect(validator.validateCommand("echo $(echo $(rm file))")).toBe("auto_deny")
974+
expect(validator.validateCommand("echo $(echo $(rm file))")).toBe("ask_user") // complex nested parsing with mixed validation results
961975
})
962976

963977
it("handles complex commands with subshells", () => {

webview-ui/src/utils/command-validation.ts

Lines changed: 90 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ type ShellToken = string | { op: string } | { command: string }
4444
*
4545
* ## Security Considerations:
4646
*
47-
* - **Subshell Protection**: Prevents command injection via $(command) or `command`
47+
* - **Subshell Protection**: Prevents command injection via $(command), `command`, or process substitution
4848
* - **Chain Analysis**: Each command in a chain (cmd1 && cmd2) is validated separately
4949
* - **Case Insensitive**: All matching is case-insensitive for consistency
5050
* - **Whitespace Handling**: Commands are trimmed and normalized before matching
@@ -58,13 +58,36 @@ type ShellToken = string | { op: string } | { command: string }
5858
* This allows users to have personal defaults while projects can define specific restrictions.
5959
*/
6060

61+
/**
62+
* Detect subshell usage and command substitution patterns:
63+
* - $() - command substitution
64+
* - `` - backticks (legacy command substitution)
65+
* - <() - process substitution (input)
66+
* - >() - process substitution (output)
67+
* - $(()) - arithmetic expansion
68+
* - $[] - arithmetic expansion (alternative syntax)
69+
*
70+
* @example
71+
* ```typescript
72+
* containsSubshell("echo $(date)") // true - command substitution
73+
* containsSubshell("echo `date`") // true - backtick substitution
74+
* containsSubshell("diff <(sort f1)") // true - process substitution
75+
* containsSubshell("echo $((1+2))") // true - arithmetic expansion
76+
* containsSubshell("echo $[1+2]") // true - arithmetic expansion (alt)
77+
* containsSubshell("echo hello") // false - no subshells
78+
* ```
79+
*/
80+
export function containsSubshell(source: string): boolean {
81+
return /(\$\()|`|(<\(|>\()|(\$\(\()|(\$\[)/.test(source)
82+
}
83+
6184
/**
6285
* Split a command string into individual sub-commands by
6386
* chaining operators (&&, ||, ;, or |) and newlines.
6487
*
6588
* Uses shell-quote to properly handle:
6689
* - Quoted strings (preserves quotes)
67-
* - Subshell commands ($(cmd) or `cmd`)
90+
* - Subshell commands ($(cmd), `cmd`, <(cmd), >(cmd))
6891
* - PowerShell redirections (2>&1)
6992
* - Chain operators (&&, ||, ;, |)
7093
* - Newlines as command separators
@@ -89,6 +112,36 @@ export function parseCommand(command: string): string[] {
89112
return allCommands
90113
}
91114

115+
/**
116+
* Helper function to restore placeholders in a command string
117+
*/
118+
function restorePlaceholders(
119+
command: string,
120+
quotes: string[],
121+
redirections: string[],
122+
arrayIndexing: string[],
123+
arithmeticExpressions: string[],
124+
parameterExpansions: string[],
125+
variables: string[],
126+
subshells: string[],
127+
): string {
128+
let result = command
129+
// Restore quotes
130+
result = result.replace(/__QUOTE_(\d+)__/g, (_, i) => quotes[parseInt(i)])
131+
// Restore redirections
132+
result = result.replace(/__REDIR_(\d+)__/g, (_, i) => redirections[parseInt(i)])
133+
// Restore array indexing expressions
134+
result = result.replace(/__ARRAY_(\d+)__/g, (_, i) => arrayIndexing[parseInt(i)])
135+
// Restore arithmetic expressions
136+
result = result.replace(/__ARITH_(\d+)__/g, (_, i) => arithmeticExpressions[parseInt(i)])
137+
// Restore parameter expansions
138+
result = result.replace(/__PARAM_(\d+)__/g, (_, i) => parameterExpansions[parseInt(i)])
139+
// Restore variable references
140+
result = result.replace(/__VAR_(\d+)__/g, (_, i) => variables[parseInt(i)])
141+
result = result.replace(/__SUBSH_(\d+)__/g, (_, i) => subshells[parseInt(i)])
142+
return result
143+
}
144+
92145
/**
93146
* Parse a single line of commands (internal helper function)
94147
*/
@@ -103,7 +156,6 @@ function parseCommandLine(command: string): string[] {
103156
const arithmeticExpressions: string[] = []
104157
const variables: string[] = []
105158
const parameterExpansions: string[] = []
106-
const processSubstitutions: string[] = []
107159

108160
// First handle PowerShell redirections by temporarily replacing them
109161
let processedCommand = command.replace(/\d*>&\d*/g, (match) => {
@@ -118,6 +170,12 @@ function parseCommandLine(command: string): string[] {
118170
return `__ARITH_${arithmeticExpressions.length - 1}__`
119171
})
120172

173+
// Handle $[...] arithmetic expressions (alternative syntax)
174+
processedCommand = processedCommand.replace(/\$\[[^\]]*\]/g, (match) => {
175+
arithmeticExpressions.push(match)
176+
return `__ARITH_${arithmeticExpressions.length - 1}__`
177+
})
178+
121179
// Handle parameter expansions: ${...} patterns (including array indexing)
122180
// This covers ${var}, ${var:-default}, ${var:+alt}, ${#var}, ${var%pattern}, etc.
123181
processedCommand = processedCommand.replace(/\$\{[^}]+\}/g, (match) => {
@@ -126,9 +184,9 @@ function parseCommandLine(command: string): string[] {
126184
})
127185

128186
// Handle process substitutions: <(...) and >(...)
129-
processedCommand = processedCommand.replace(/[<>]\([^)]+\)/g, (match) => {
130-
processSubstitutions.push(match)
131-
return `__PROCSUB_${processSubstitutions.length - 1}__`
187+
processedCommand = processedCommand.replace(/[<>]\(([^)]+)\)/g, (_, inner) => {
188+
subshells.push(inner.trim())
189+
return `__SUBSH_${subshells.length - 1}__`
132190
})
133191

134192
// Handle simple variable references: $varname pattern
@@ -144,7 +202,7 @@ function parseCommandLine(command: string): string[] {
144202
return `__VAR_${variables.length - 1}__`
145203
})
146204

147-
// Then handle subshell commands
205+
// Then handle subshell commands $() and back-ticks
148206
processedCommand = processedCommand
149207
.replace(/\$\((.*?)\)/g, (_, inner) => {
150208
subshells.push(inner.trim())
@@ -175,24 +233,18 @@ function parseCommandLine(command: string): string[] {
175233
.filter((cmd) => cmd.length > 0)
176234

177235
// Restore all placeholders for each command
178-
return fallbackCommands.map((cmd) => {
179-
let result = cmd
180-
// Restore quotes
181-
result = result.replace(/__QUOTE_(\d+)__/g, (_, i) => quotes[parseInt(i)])
182-
// Restore redirections
183-
result = result.replace(/__REDIR_(\d+)__/g, (_, i) => redirections[parseInt(i)])
184-
// Restore array indexing expressions
185-
result = result.replace(/__ARRAY_(\d+)__/g, (_, i) => arrayIndexing[parseInt(i)])
186-
// Restore arithmetic expressions
187-
result = result.replace(/__ARITH_(\d+)__/g, (_, i) => arithmeticExpressions[parseInt(i)])
188-
// Restore parameter expansions
189-
result = result.replace(/__PARAM_(\d+)__/g, (_, i) => parameterExpansions[parseInt(i)])
190-
// Restore process substitutions
191-
result = result.replace(/__PROCSUB_(\d+)__/g, (_, i) => processSubstitutions[parseInt(i)])
192-
// Restore variable references
193-
result = result.replace(/__VAR_(\d+)__/g, (_, i) => variables[parseInt(i)])
194-
return result
195-
})
236+
return fallbackCommands.map((cmd) =>
237+
restorePlaceholders(
238+
cmd,
239+
quotes,
240+
redirections,
241+
arrayIndexing,
242+
arithmeticExpressions,
243+
parameterExpansions,
244+
variables,
245+
subshells,
246+
),
247+
)
196248
}
197249

198250
const commands: string[] = []
@@ -231,24 +283,18 @@ function parseCommandLine(command: string): string[] {
231283
}
232284

233285
// Restore quotes and redirections
234-
return commands.map((cmd) => {
235-
let result = cmd
236-
// Restore quotes
237-
result = result.replace(/__QUOTE_(\d+)__/g, (_, i) => quotes[parseInt(i)])
238-
// Restore redirections
239-
result = result.replace(/__REDIR_(\d+)__/g, (_, i) => redirections[parseInt(i)])
240-
// Restore array indexing expressions
241-
result = result.replace(/__ARRAY_(\d+)__/g, (_, i) => arrayIndexing[parseInt(i)])
242-
// Restore arithmetic expressions
243-
result = result.replace(/__ARITH_(\d+)__/g, (_, i) => arithmeticExpressions[parseInt(i)])
244-
// Restore parameter expansions
245-
result = result.replace(/__PARAM_(\d+)__/g, (_, i) => parameterExpansions[parseInt(i)])
246-
// Restore process substitutions
247-
result = result.replace(/__PROCSUB_(\d+)__/g, (_, i) => processSubstitutions[parseInt(i)])
248-
// Restore variable references
249-
result = result.replace(/__VAR_(\d+)__/g, (_, i) => variables[parseInt(i)])
250-
return result
251-
})
286+
return commands.map((cmd) =>
287+
restorePlaceholders(
288+
cmd,
289+
quotes,
290+
redirections,
291+
arrayIndexing,
292+
arithmeticExpressions,
293+
parameterExpansions,
294+
variables,
295+
subshells,
296+
),
297+
)
252298
}
253299

254300
/**
@@ -430,14 +476,6 @@ export function getCommandDecision(
430476
): CommandDecision {
431477
if (!command?.trim()) return "auto_approve"
432478

433-
// Check if subshells contain denied prefixes
434-
if ((command.includes("$(") || command.includes("`")) && deniedCommands?.length) {
435-
const mainCommandLower = command.toLowerCase()
436-
if (deniedCommands.some((denied) => mainCommandLower.includes(denied.toLowerCase()))) {
437-
return "auto_deny"
438-
}
439-
}
440-
441479
// Parse into sub-commands (split by &&, ||, ;, |)
442480
const subCommands = parseCommand(command)
443481

@@ -610,7 +648,7 @@ export class CommandValidator {
610648
hasSubshells: boolean
611649
} {
612650
const subCommands = parseCommand(command)
613-
const hasSubshells = command.includes("$(") || command.includes("`")
651+
const hasSubshells = containsSubshell(command)
614652

615653
const allowedMatches = subCommands.map((cmd) => ({
616654
command: cmd,

0 commit comments

Comments
 (0)