Skip to content

Commit 4042fb0

Browse files
authored
Smarter auto-deny (RooCodeInc#6123)
1 parent 9d434c2 commit 4042fb0

File tree

2 files changed

+61
-291
lines changed

2 files changed

+61
-291
lines changed

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

Lines changed: 56 additions & 239 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ import {
66
parseCommand,
77
isAutoApprovedSingleCommand,
88
isAutoDeniedSingleCommand,
9-
isAutoApprovedCommand,
10-
isAutoDeniedCommand,
119
findLongestPrefixMatch,
1210
getCommandDecision,
1311
getSingleCommandDecision,
@@ -167,7 +165,7 @@ ls -la || echo "Failed"`
167165
})
168166
})
169167

170-
describe("isAutoApprovedSingleCommand (legacy behavior)", () => {
168+
describe("isAutoApprovedSingleCommand", () => {
171169
const allowedCommands = ["npm test", "npm run", "echo"]
172170

173171
it("matches commands case-insensitively", () => {
@@ -193,93 +191,6 @@ ls -la || echo "Failed"`
193191
expect(isAutoApprovedSingleCommand("npm test", [])).toBe(false)
194192
})
195193
})
196-
197-
describe("isAutoApprovedCommand (legacy behavior)", () => {
198-
const allowedCommands = ["npm test", "npm run", "echo", "Select-String"]
199-
200-
it("validates simple commands", () => {
201-
expect(isAutoApprovedCommand("npm test", allowedCommands)).toBe(true)
202-
expect(isAutoApprovedCommand("npm run build", allowedCommands)).toBe(true)
203-
expect(isAutoApprovedCommand("dangerous", allowedCommands)).toBe(false)
204-
})
205-
206-
it("validates chained commands", () => {
207-
expect(isAutoApprovedCommand("npm test && npm run build", allowedCommands)).toBe(true)
208-
expect(isAutoApprovedCommand("npm test && dangerous", allowedCommands)).toBe(false)
209-
expect(isAutoApprovedCommand('npm test | Select-String "Error"', allowedCommands)).toBe(true)
210-
expect(isAutoApprovedCommand("npm test | rm -rf /", allowedCommands)).toBe(false)
211-
})
212-
213-
it("handles quoted content correctly", () => {
214-
expect(isAutoApprovedCommand('npm test "param with | inside"', allowedCommands)).toBe(true)
215-
expect(isAutoApprovedCommand('echo "hello | world"', allowedCommands)).toBe(true)
216-
expect(isAutoApprovedCommand('npm test "param with && inside"', allowedCommands)).toBe(true)
217-
})
218-
219-
it("handles subshell execution attempts", () => {
220-
// Without denylist, subshells should be allowed if all subcommands are allowed
221-
expect(isAutoApprovedCommand("npm test $(echo hello)", allowedCommands)).toBe(true)
222-
expect(isAutoApprovedCommand("npm test `echo world`", allowedCommands)).toBe(true)
223-
224-
// With denylist, subshells should be blocked regardless of subcommands
225-
expect(isAutoApprovedCommand("npm test $(echo hello)", allowedCommands, ["rm"])).toBe(false)
226-
expect(isAutoApprovedCommand("npm test `echo world`", allowedCommands, ["rm"])).toBe(false)
227-
})
228-
229-
it("handles PowerShell patterns", () => {
230-
expect(isAutoApprovedCommand('npm test 2>&1 | Select-String "Error"', allowedCommands)).toBe(true)
231-
expect(
232-
isAutoApprovedCommand(
233-
'npm test | Select-String -NotMatch "node_modules" | Select-String "FAIL|Error"',
234-
allowedCommands,
235-
),
236-
).toBe(true)
237-
expect(isAutoApprovedCommand("npm test | Select-String | dangerous", allowedCommands)).toBe(false)
238-
})
239-
240-
it("handles empty input", () => {
241-
expect(isAutoApprovedCommand("", allowedCommands)).toBe(true)
242-
expect(isAutoApprovedCommand(" ", allowedCommands)).toBe(true)
243-
})
244-
245-
it("allows all commands when wildcard is present", () => {
246-
const wildcardAllowedCommands = ["*"]
247-
// Should allow any command, including dangerous ones
248-
expect(isAutoApprovedCommand("rm -rf /", wildcardAllowedCommands)).toBe(true)
249-
expect(isAutoApprovedCommand("dangerous-command", wildcardAllowedCommands)).toBe(true)
250-
expect(isAutoApprovedCommand("npm test && rm -rf /", wildcardAllowedCommands)).toBe(true)
251-
// Should allow subshell commands with wildcard when no denylist is present
252-
expect(isAutoApprovedCommand("npm test $(echo dangerous)", wildcardAllowedCommands)).toBe(true)
253-
expect(isAutoApprovedCommand("npm test `rm -rf /`", wildcardAllowedCommands)).toBe(true)
254-
255-
// But should block subshells when denylist is present
256-
expect(isAutoApprovedCommand("npm test $(echo dangerous)", wildcardAllowedCommands, ["rm"])).toBe(false)
257-
expect(isAutoApprovedCommand("npm test `rm -rf /`", wildcardAllowedCommands, ["rm"])).toBe(false)
258-
})
259-
260-
it("respects denylist even with wildcard in allowlist", () => {
261-
const wildcardAllowedCommands = ["*"]
262-
const deniedCommands = ["rm -rf", "dangerous"]
263-
264-
// Wildcard should allow most commands
265-
expect(isAutoApprovedCommand("npm test", wildcardAllowedCommands, deniedCommands)).toBe(true)
266-
expect(isAutoApprovedCommand("echo hello", wildcardAllowedCommands, deniedCommands)).toBe(true)
267-
expect(isAutoApprovedCommand("git status", wildcardAllowedCommands, deniedCommands)).toBe(true)
268-
269-
// But denylist should still block specific commands
270-
expect(isAutoApprovedCommand("rm -rf /", wildcardAllowedCommands, deniedCommands)).toBe(false)
271-
expect(isAutoApprovedCommand("dangerous-command", wildcardAllowedCommands, deniedCommands)).toBe(false)
272-
273-
// Chained commands with denied subcommands should be blocked
274-
expect(isAutoApprovedCommand("npm test && rm -rf /", wildcardAllowedCommands, deniedCommands)).toBe(false)
275-
expect(
276-
isAutoApprovedCommand("echo hello && dangerous-command", wildcardAllowedCommands, deniedCommands),
277-
).toBe(false)
278-
279-
// But chained commands with all allowed subcommands should work
280-
expect(isAutoApprovedCommand("npm test && echo done", wildcardAllowedCommands, deniedCommands)).toBe(true)
281-
})
282-
})
283194
})
284195

285196
/**
@@ -510,52 +421,6 @@ echo "Successfully converted $count .jsx files to .tsx"`
510421
})
511422
})
512423

513-
describe("isAutoApprovedCommand (legacy behavior)", () => {
514-
it("should validate allowed commands", () => {
515-
const result = isAutoApprovedCommand("echo hello", ["echo"])
516-
expect(result).toBe(true)
517-
})
518-
519-
it("should reject disallowed commands", () => {
520-
const result = isAutoApprovedCommand("rm -rf /", ["echo", "ls"])
521-
expect(result).toBe(false)
522-
})
523-
524-
it("should not fail validation for commands with simple $RANDOM variable", () => {
525-
const commandWithRandom = "echo $RANDOM"
526-
527-
expect(() => {
528-
isAutoApprovedCommand(commandWithRandom, ["echo"])
529-
}).not.toThrow()
530-
})
531-
532-
it("should not fail validation for commands with simple array indexing using $RANDOM", () => {
533-
const commandWithRandomIndex = "echo ${array[$RANDOM]}"
534-
535-
expect(() => {
536-
isAutoApprovedCommand(commandWithRandomIndex, ["echo"])
537-
}).not.toThrow()
538-
})
539-
540-
it("should return false for the full log generator command due to subshell detection when denylist is present", () => {
541-
// This is the exact command from the original error message
542-
const logGeneratorCommand = `while true; do \\
543-
levels=(INFO WARN ERROR DEBUG); \\
544-
msgs=("User logged in" "Connection timeout" "Processing request" "Cache miss" "Database query"); \\
545-
level=\${levels[$RANDOM % \${#levels[@]}]}; \\
546-
msg=\${msgs[$RANDOM % \${#msgs[@]}]}; \\
547-
echo "\$(date '+%Y-%m-%d %H:%M:%S') [$level] $msg"; \\
548-
sleep 1; \\
549-
done`
550-
551-
// Without denylist, should allow subshells if all subcommands are allowed (use wildcard)
552-
expect(isAutoApprovedCommand(logGeneratorCommand, ["*"])).toBe(true)
553-
554-
// With denylist, should return false due to subshell detection
555-
expect(isAutoApprovedCommand(logGeneratorCommand, ["*"], ["rm"])).toBe(false)
556-
})
557-
})
558-
559424
describe("Denylist Command Validation", () => {
560425
describe("findLongestPrefixMatch", () => {
561426
it("finds the longest matching prefix", () => {
@@ -584,7 +449,7 @@ done`
584449
})
585450
})
586451

587-
describe("Legacy isAllowedSingleCommand behavior (now using isAutoApprovedSingleCommand)", () => {
452+
describe("isAutoApprovedSingleCommand", () => {
588453
const allowedCommands = ["npm", "echo", "git"]
589454
const deniedCommands = ["npm test", "git push"]
590455

@@ -761,71 +626,6 @@ done`
761626
})
762627
})
763628
})
764-
765-
describe("Command-level three-tier validation", () => {
766-
const allowedCommands = ["npm", "echo"]
767-
const deniedCommands = ["npm test"]
768-
769-
describe("isAutoApprovedCommand", () => {
770-
it("auto-approves commands with all sub-commands auto-approved", () => {
771-
expect(isAutoApprovedCommand("npm install", allowedCommands, deniedCommands)).toBe(true)
772-
expect(isAutoApprovedCommand("npm install && echo done", allowedCommands, deniedCommands)).toBe(
773-
true,
774-
)
775-
})
776-
777-
it("does not auto-approve commands with any sub-command not auto-approved", () => {
778-
expect(isAutoApprovedCommand("npm test", allowedCommands, deniedCommands)).toBe(false)
779-
expect(isAutoApprovedCommand("npm install && npm test", allowedCommands, deniedCommands)).toBe(
780-
false,
781-
)
782-
})
783-
784-
it("blocks subshell commands only when denylist is present", () => {
785-
// Without denylist, should allow subshells
786-
expect(isAutoApprovedCommand("npm install $(echo test)", allowedCommands)).toBe(true)
787-
expect(isAutoApprovedCommand("npm install `echo test`", allowedCommands)).toBe(true)
788-
789-
// With denylist, should block subshells
790-
expect(isAutoApprovedCommand("npm install $(echo test)", allowedCommands, deniedCommands)).toBe(
791-
false,
792-
)
793-
expect(isAutoApprovedCommand("npm install `echo test`", allowedCommands, deniedCommands)).toBe(
794-
false,
795-
)
796-
})
797-
})
798-
799-
describe("isAutoDeniedCommand", () => {
800-
it("auto-denies commands with any sub-command auto-denied", () => {
801-
expect(isAutoDeniedCommand("npm test", allowedCommands, deniedCommands)).toBe(true)
802-
expect(isAutoDeniedCommand("npm install && npm test", allowedCommands, deniedCommands)).toBe(
803-
true,
804-
)
805-
})
806-
807-
it("does not auto-deny commands with all sub-commands not auto-denied", () => {
808-
expect(isAutoDeniedCommand("npm install", allowedCommands, deniedCommands)).toBe(false)
809-
expect(isAutoDeniedCommand("npm install && echo done", allowedCommands, deniedCommands)).toBe(
810-
false,
811-
)
812-
})
813-
814-
it("auto-denies subshell commands only when denylist is present", () => {
815-
// Without denylist, should not auto-deny subshells
816-
expect(isAutoDeniedCommand("npm install $(echo test)", allowedCommands)).toBe(false)
817-
expect(isAutoDeniedCommand("npm install `echo test`", allowedCommands)).toBe(false)
818-
819-
// With denylist, should auto-deny subshells
820-
expect(isAutoDeniedCommand("npm install $(echo test)", allowedCommands, deniedCommands)).toBe(
821-
true,
822-
)
823-
expect(isAutoDeniedCommand("npm install `echo test`", allowedCommands, deniedCommands)).toBe(
824-
true,
825-
)
826-
})
827-
})
828-
})
829629
})
830630
})
831631
})
@@ -912,9 +712,19 @@ describe("Unified Command Decision Functions", () => {
912712
expect(getCommandDecision("npm install && dangerous", allowedCommands, deniedCommands)).toBe("ask_user")
913713
})
914714

915-
it("returns auto_deny for subshell commands when denylist is present", () => {
916-
expect(getCommandDecision("npm install $(echo test)", allowedCommands, deniedCommands)).toBe("auto_deny")
917-
expect(getCommandDecision("npm install `echo test`", allowedCommands, deniedCommands)).toBe("auto_deny")
715+
it("returns auto_deny for subshell commands only when they contain denied prefixes", () => {
716+
// Subshells without denied prefixes should not be auto-denied
717+
expect(getCommandDecision("npm install $(echo test)", allowedCommands, deniedCommands)).toBe("auto_approve")
718+
expect(getCommandDecision("npm install `echo test`", allowedCommands, deniedCommands)).toBe("auto_approve")
719+
720+
// Subshells with denied prefixes should be auto-denied
721+
expect(getCommandDecision("npm install $(npm test)", allowedCommands, deniedCommands)).toBe("auto_deny")
722+
expect(getCommandDecision("npm install `npm test --coverage`", allowedCommands, deniedCommands)).toBe(
723+
"auto_deny",
724+
)
725+
726+
// Main command with denied prefix should also be auto-denied
727+
expect(getCommandDecision("npm test $(echo hello)", allowedCommands, deniedCommands)).toBe("auto_deny")
918728
})
919729

920730
it("allows subshell commands when no denylist is present", () => {
@@ -961,39 +771,6 @@ describe("Unified Command Decision Functions", () => {
961771
})
962772
})
963773

964-
describe("Integration with existing functions", () => {
965-
it("maintains backward compatibility with existing behavior", () => {
966-
const allowedCommands = ["npm", "echo"]
967-
const deniedCommands = ["npm test"]
968-
969-
// Test that new unified functions produce same results as old separate functions
970-
const testCommands = [
971-
"npm install", // should be auto-approved
972-
"npm test", // should be auto-denied
973-
"dangerous", // should ask user
974-
"echo hello", // should be auto-approved
975-
]
976-
977-
testCommands.forEach((cmd) => {
978-
const decision = getCommandDecision(cmd, allowedCommands, deniedCommands)
979-
const oldApproved = isAutoApprovedCommand(cmd, allowedCommands, deniedCommands)
980-
const oldDenied = isAutoDeniedCommand(cmd, allowedCommands, deniedCommands)
981-
982-
// Verify consistency
983-
if (decision === "auto_approve") {
984-
expect(oldApproved).toBe(true)
985-
expect(oldDenied).toBe(false)
986-
} else if (decision === "auto_deny") {
987-
expect(oldApproved).toBe(false)
988-
expect(oldDenied).toBe(true)
989-
} else if (decision === "ask_user") {
990-
expect(oldApproved).toBe(false)
991-
expect(oldDenied).toBe(false)
992-
}
993-
})
994-
})
995-
})
996-
997774
describe("CommandValidator Integration Tests", () => {
998775
describe("CommandValidator Class", () => {
999776
let validator: CommandValidator
@@ -1067,7 +844,12 @@ describe("Unified Command Decision Functions", () => {
1067844
it("detects subshells correctly", () => {
1068845
const details = validator.getValidationDetails("npm install $(echo test)")
1069846
expect(details.hasSubshells).toBe(true)
1070-
expect(details.decision).toBe("auto_deny") // blocked due to subshells with denylist
847+
expect(details.decision).toBe("auto_approve") // not blocked since echo doesn't match denied prefixes
848+
849+
// Test with denied prefix in subshell
850+
const detailsWithDenied = validator.getValidationDetails("npm install $(npm test)")
851+
expect(detailsWithDenied.hasSubshells).toBe(true)
852+
expect(detailsWithDenied.decision).toBe("auto_deny") // blocked due to npm test in subshell
1071853
})
1072854

1073855
it("handles complex command chains", () => {
@@ -1162,6 +944,41 @@ describe("Unified Command Decision Functions", () => {
1162944
})
1163945
})
1164946

947+
describe("Subshell edge cases", () => {
948+
it("handles multiple subshells correctly", () => {
949+
const validator = createCommandValidator(["echo", "npm"], ["rm", "sudo"])
950+
951+
// Multiple subshells, none with denied prefixes but subshell commands not in allowlist
952+
// parseCommand extracts subshells as separate commands, so date and pwd are not allowed
953+
expect(validator.validateCommand("echo $(date) $(pwd)")).toBe("ask_user")
954+
955+
// Multiple subshells, one with denied prefix
956+
expect(validator.validateCommand("echo $(date) $(rm file)")).toBe("auto_deny")
957+
958+
// Nested subshells - inner commands are extracted and not in allowlist
959+
expect(validator.validateCommand("echo $(echo $(date))")).toBe("ask_user")
960+
expect(validator.validateCommand("echo $(echo $(rm file))")).toBe("auto_deny")
961+
})
962+
963+
it("handles complex commands with subshells", () => {
964+
const validator = createCommandValidator(["npm", "git", "echo"], ["git push", "npm publish"])
965+
966+
// Subshell with allowed command - git status is extracted as separate command
967+
// Since "git status" starts with "git" which is allowed, it's approved
968+
expect(validator.validateCommand("npm run $(git status)")).toBe("auto_approve")
969+
970+
// Subshell with denied command
971+
expect(validator.validateCommand("npm run $(git push origin)")).toBe("auto_deny")
972+
973+
// Main command denied, subshell allowed
974+
expect(validator.validateCommand("git push $(echo origin)")).toBe("auto_deny")
975+
976+
// Complex chain with subshells - need echo in allowlist
977+
expect(validator.validateCommand("npm install && echo $(git status) && npm test")).toBe("auto_approve")
978+
expect(validator.validateCommand("npm install && echo $(git push) && npm test")).toBe("auto_deny")
979+
})
980+
})
981+
1165982
describe("Real-world integration scenarios", () => {
1166983
describe("Development workflow validation", () => {
1167984
let devValidator: CommandValidator

0 commit comments

Comments
 (0)