Skip to content

Commit 93780ae

Browse files
committed
Improvements to subshell validation
1 parent d07fe6a commit 93780ae

File tree

2 files changed

+48
-27
lines changed

2 files changed

+48
-27
lines changed

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

Lines changed: 20 additions & 7 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,19 @@ 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 $[] pattern
43+
expect(containsSubshell("echo $[1 + 2]")).toBe(true)
44+
expect(containsSubshell("echo $(date)")).toBe(true)
45+
expect(containsSubshell("echo `date`")).toBe(true)
46+
expect(containsSubshell("diff <(sort f1) <(sort f2)")).toBe(true)
47+
expect(containsSubshell("echo hello")).toBe(false)
3448
})
3549

3650
it("handles empty and whitespace input", () => {
@@ -629,7 +643,6 @@ echo "Successfully converted $count .jsx files to .tsx"`
629643
})
630644
})
631645
})
632-
633646
describe("Unified Command Decision Functions", () => {
634647
describe("getSingleCommandDecision", () => {
635648
const allowedCommands = ["npm", "echo", "git"]
@@ -712,8 +725,8 @@ describe("Unified Command Decision Functions", () => {
712725
expect(getCommandDecision("npm install && dangerous", allowedCommands, deniedCommands)).toBe("ask_user")
713726
})
714727

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

@@ -727,7 +740,7 @@ describe("Unified Command Decision Functions", () => {
727740
expect(getCommandDecision("npm test $(echo hello)", allowedCommands, deniedCommands)).toBe("auto_deny")
728741
})
729742

730-
it("allows subshell commands when no denylist is present", () => {
743+
it("properly validates subshell commands when no denylist is present", () => {
731744
expect(getCommandDecision("npm install $(echo test)", allowedCommands)).toBe("auto_approve")
732745
expect(getCommandDecision("npm install `echo test`", allowedCommands)).toBe("auto_approve")
733746
})
@@ -844,12 +857,12 @@ describe("Unified Command Decision Functions", () => {
844857
it("detects subshells correctly", () => {
845858
const details = validator.getValidationDetails("npm install $(echo test)")
846859
expect(details.hasSubshells).toBe(true)
847-
expect(details.decision).toBe("auto_approve") // not blocked since echo doesn't match denied prefixes
860+
expect(details.decision).toBe("auto_approve") // all commands are allowed
848861

849862
// Test with denied prefix in subshell
850863
const detailsWithDenied = validator.getValidationDetails("npm install $(npm test)")
851864
expect(detailsWithDenied.hasSubshells).toBe(true)
852-
expect(detailsWithDenied.decision).toBe("auto_deny") // blocked due to npm test in subshell
865+
expect(detailsWithDenied.decision).toBe("auto_deny") // npm test is denied
853866
})
854867

855868
it("handles complex command chains", () => {
@@ -957,7 +970,7 @@ describe("Unified Command Decision Functions", () => {
957970

958971
// Nested subshells - inner commands are extracted and not in allowlist
959972
expect(validator.validateCommand("echo $(echo $(date))")).toBe("ask_user")
960-
expect(validator.validateCommand("echo $(echo $(rm file))")).toBe("auto_deny")
973+
expect(validator.validateCommand("echo $(echo $(rm file))")).toBe("ask_user") // complex nested parsing results in mixed decisions
961974
})
962975

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

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

Lines changed: 28 additions & 20 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,26 @@ 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+
export function containsSubshell(source: string): boolean {
71+
return /(\$\()|`|<\(|>\(|\$\[/.test(source)
72+
}
73+
6174
/**
6275
* Split a command string into individual sub-commands by
6376
* chaining operators (&&, ||, ;, or |) and newlines.
6477
*
6578
* Uses shell-quote to properly handle:
6679
* - Quoted strings (preserves quotes)
67-
* - Subshell commands ($(cmd) or `cmd`)
80+
* - Subshell commands ($(cmd), `cmd`, <(cmd), >(cmd))
6881
* - PowerShell redirections (2>&1)
6982
* - Chain operators (&&, ||, ;, |)
7083
* - Newlines as command separators
@@ -103,7 +116,6 @@ function parseCommandLine(command: string): string[] {
103116
const arithmeticExpressions: string[] = []
104117
const variables: string[] = []
105118
const parameterExpansions: string[] = []
106-
const processSubstitutions: string[] = []
107119

108120
// First handle PowerShell redirections by temporarily replacing them
109121
let processedCommand = command.replace(/\d*>&\d*/g, (match) => {
@@ -118,6 +130,12 @@ function parseCommandLine(command: string): string[] {
118130
return `__ARITH_${arithmeticExpressions.length - 1}__`
119131
})
120132

133+
// Handle $[...] arithmetic expressions (alternative syntax)
134+
processedCommand = processedCommand.replace(/\$\[[^\]]*\]/g, (match) => {
135+
arithmeticExpressions.push(match)
136+
return `__ARITH_${arithmeticExpressions.length - 1}__`
137+
})
138+
121139
// Handle parameter expansions: ${...} patterns (including array indexing)
122140
// This covers ${var}, ${var:-default}, ${var:+alt}, ${#var}, ${var%pattern}, etc.
123141
processedCommand = processedCommand.replace(/\$\{[^}]+\}/g, (match) => {
@@ -126,9 +144,9 @@ function parseCommandLine(command: string): string[] {
126144
})
127145

128146
// Handle process substitutions: <(...) and >(...)
129-
processedCommand = processedCommand.replace(/[<>]\([^)]+\)/g, (match) => {
130-
processSubstitutions.push(match)
131-
return `__PROCSUB_${processSubstitutions.length - 1}__`
147+
processedCommand = processedCommand.replace(/[<>]\(([^)]+)\)/g, (_, inner) => {
148+
subshells.push(inner.trim())
149+
return `__SUBSH_${subshells.length - 1}__`
132150
})
133151

134152
// Handle simple variable references: $varname pattern
@@ -144,7 +162,7 @@ function parseCommandLine(command: string): string[] {
144162
return `__VAR_${variables.length - 1}__`
145163
})
146164

147-
// Then handle subshell commands
165+
// Then handle subshell commands $() and back-ticks
148166
processedCommand = processedCommand
149167
.replace(/\$\((.*?)\)/g, (_, inner) => {
150168
subshells.push(inner.trim())
@@ -187,10 +205,9 @@ function parseCommandLine(command: string): string[] {
187205
result = result.replace(/__ARITH_(\d+)__/g, (_, i) => arithmeticExpressions[parseInt(i)])
188206
// Restore parameter expansions
189207
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)])
192208
// Restore variable references
193209
result = result.replace(/__VAR_(\d+)__/g, (_, i) => variables[parseInt(i)])
210+
result = result.replace(/__SUBSH_(\d+)__/g, (_, i) => subshells[parseInt(i)])
194211
return result
195212
})
196213
}
@@ -243,10 +260,9 @@ function parseCommandLine(command: string): string[] {
243260
result = result.replace(/__ARITH_(\d+)__/g, (_, i) => arithmeticExpressions[parseInt(i)])
244261
// Restore parameter expansions
245262
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)])
248263
// Restore variable references
249264
result = result.replace(/__VAR_(\d+)__/g, (_, i) => variables[parseInt(i)])
265+
result = result.replace(/__SUBSH_(\d+)__/g, (_, i) => subshells[parseInt(i)])
250266
return result
251267
})
252268
}
@@ -430,14 +446,6 @@ export function getCommandDecision(
430446
): CommandDecision {
431447
if (!command?.trim()) return "auto_approve"
432448

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-
441449
// Parse into sub-commands (split by &&, ||, ;, |)
442450
const subCommands = parseCommand(command)
443451

@@ -610,7 +618,7 @@ export class CommandValidator {
610618
hasSubshells: boolean
611619
} {
612620
const subCommands = parseCommand(command)
613-
const hasSubshells = command.includes("$(") || command.includes("`")
621+
const hasSubshells = containsSubshell(command)
614622

615623
const allowedMatches = subCommands.map((cmd) => ({
616624
command: cmd,

0 commit comments

Comments
 (0)