Skip to content

Commit 34afbe6

Browse files
committed
PR feedback
1 parent 554e01d commit 34afbe6

File tree

3 files changed

+624
-37
lines changed

3 files changed

+624
-37
lines changed

src/core/webview/ClineProvider.ts

Lines changed: 20 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1304,47 +1304,39 @@ export class ClineProvider
13041304
* with proper validation and deduplication
13051305
*/
13061306
private mergeAllowedCommands(globalStateCommands?: string[]): string[] {
1307-
try {
1308-
// Validate and sanitize global state commands
1309-
const validGlobalCommands = Array.isArray(globalStateCommands)
1310-
? globalStateCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0)
1311-
: []
1312-
1313-
// Get workspace configuration commands
1314-
const workspaceCommands =
1315-
vscode.workspace.getConfiguration(Package.name).get<string[]>("allowedCommands") || []
1316-
1317-
// Validate and sanitize workspace commands
1318-
const validWorkspaceCommands = Array.isArray(workspaceCommands)
1319-
? workspaceCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0)
1320-
: []
1321-
1322-
// Combine and deduplicate commands
1323-
// Global state takes precedence over workspace configuration
1324-
const mergedCommands = [...new Set([...validGlobalCommands, ...validWorkspaceCommands])]
1325-
1326-
return mergedCommands
1327-
} catch (error) {
1328-
console.error("Error merging allowed commands:", error)
1329-
// Return empty array as fallback to prevent crashes
1330-
return []
1331-
}
1307+
return this.mergeCommandLists("allowedCommands", "allowed", globalStateCommands)
13321308
}
13331309

13341310
/**
13351311
* Merges denied commands from global state and workspace configuration
13361312
* with proper validation and deduplication
13371313
*/
13381314
private mergeDeniedCommands(globalStateCommands?: string[]): string[] {
1315+
return this.mergeCommandLists("deniedCommands", "denied", globalStateCommands)
1316+
}
1317+
1318+
/**
1319+
* Common utility for merging command lists from global state and workspace configuration.
1320+
* Implements the Command Denylist feature's merging strategy with proper validation.
1321+
*
1322+
* @param configKey - VSCode workspace configuration key
1323+
* @param commandType - Type of commands for error logging
1324+
* @param globalStateCommands - Commands from global state
1325+
* @returns Merged and deduplicated command list
1326+
*/
1327+
private mergeCommandLists(
1328+
configKey: "allowedCommands" | "deniedCommands",
1329+
commandType: "allowed" | "denied",
1330+
globalStateCommands?: string[],
1331+
): string[] {
13391332
try {
13401333
// Validate and sanitize global state commands
13411334
const validGlobalCommands = Array.isArray(globalStateCommands)
13421335
? globalStateCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0)
13431336
: []
13441337

13451338
// Get workspace configuration commands
1346-
const workspaceCommands =
1347-
vscode.workspace.getConfiguration(Package.name).get<string[]>("deniedCommands") || []
1339+
const workspaceCommands = vscode.workspace.getConfiguration(Package.name).get<string[]>(configKey) || []
13481340

13491341
// Validate and sanitize workspace commands
13501342
const validWorkspaceCommands = Array.isArray(workspaceCommands)
@@ -1357,7 +1349,7 @@ export class ClineProvider
13571349

13581350
return mergedCommands
13591351
} catch (error) {
1360-
console.error("Error merging denied commands:", error)
1352+
console.error(`Error merging ${commandType} commands:`, error)
13611353
// Return empty array as fallback to prevent crashes
13621354
return []
13631355
}

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

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
findLongestPrefixMatch,
1212
getCommandDecision,
1313
getSingleCommandDecision,
14+
CommandValidator,
15+
createCommandValidator,
1416
} from "../command-validation"
1517

1618
describe("Command Validation", () => {
@@ -759,4 +761,313 @@ describe("Unified Command Decision Functions", () => {
759761
})
760762
})
761763
})
764+
765+
describe("CommandValidator Integration Tests", () => {
766+
describe("CommandValidator Class", () => {
767+
let validator: CommandValidator
768+
769+
beforeEach(() => {
770+
validator = new CommandValidator(["npm", "echo", "git"], ["npm test", "git push"])
771+
})
772+
773+
describe("Basic validation methods", () => {
774+
it("validates commands correctly", () => {
775+
expect(validator.validateCommand("npm install")).toBe("auto_approve")
776+
expect(validator.validateCommand("npm test")).toBe("auto_deny")
777+
expect(validator.validateCommand("dangerous")).toBe("ask_user")
778+
})
779+
780+
it("provides convenience methods", () => {
781+
expect(validator.isAutoApproved("npm install")).toBe(true)
782+
expect(validator.isAutoApproved("npm test")).toBe(false)
783+
expect(validator.isAutoApproved("dangerous")).toBe(false)
784+
785+
expect(validator.isAutoDenied("npm install")).toBe(false)
786+
expect(validator.isAutoDenied("npm test")).toBe(true)
787+
expect(validator.isAutoDenied("dangerous")).toBe(false)
788+
789+
expect(validator.requiresUserInput("npm install")).toBe(false)
790+
expect(validator.requiresUserInput("npm test")).toBe(false)
791+
expect(validator.requiresUserInput("dangerous")).toBe(true)
792+
})
793+
})
794+
795+
describe("Configuration management", () => {
796+
it("updates command lists", () => {
797+
validator.updateCommandLists(["echo"], ["echo hello"])
798+
799+
expect(validator.validateCommand("npm install")).toBe("ask_user")
800+
expect(validator.validateCommand("echo world")).toBe("auto_approve")
801+
expect(validator.validateCommand("echo hello")).toBe("auto_deny")
802+
})
803+
804+
it("gets current command lists", () => {
805+
const lists = validator.getCommandLists()
806+
expect(lists.allowedCommands).toEqual(["npm", "echo", "git"])
807+
expect(lists.deniedCommands).toEqual(["npm test", "git push"])
808+
})
809+
810+
it("handles undefined denied commands", () => {
811+
const validatorNoDeny = new CommandValidator(["npm"])
812+
const lists = validatorNoDeny.getCommandLists()
813+
expect(lists.allowedCommands).toEqual(["npm"])
814+
expect(lists.deniedCommands).toBeUndefined()
815+
})
816+
})
817+
818+
describe("Detailed validation information", () => {
819+
it("provides comprehensive validation details", () => {
820+
const details = validator.getValidationDetails("npm install && echo done")
821+
822+
expect(details.decision).toBe("auto_approve")
823+
expect(details.subCommands).toEqual(["npm install", "echo done"])
824+
expect(details.hasSubshells).toBe(false)
825+
expect(details.allowedMatches).toHaveLength(2)
826+
expect(details.deniedMatches).toHaveLength(2)
827+
828+
// Check specific matches
829+
expect(details.allowedMatches[0]).toEqual({ command: "npm install", match: "npm" })
830+
expect(details.allowedMatches[1]).toEqual({ command: "echo done", match: "echo" })
831+
expect(details.deniedMatches[0]).toEqual({ command: "npm install", match: null })
832+
expect(details.deniedMatches[1]).toEqual({ command: "echo done", match: null })
833+
})
834+
835+
it("detects subshells correctly", () => {
836+
const details = validator.getValidationDetails("npm install $(echo test)")
837+
expect(details.hasSubshells).toBe(true)
838+
expect(details.decision).toBe("auto_deny") // blocked due to subshells with denylist
839+
})
840+
841+
it("handles complex command chains", () => {
842+
const details = validator.getValidationDetails("npm test && git push origin")
843+
844+
expect(details.decision).toBe("auto_deny")
845+
expect(details.subCommands).toEqual(["npm test", "git push origin"])
846+
expect(details.deniedMatches[0]).toEqual({ command: "npm test", match: "npm test" })
847+
expect(details.deniedMatches[1]).toEqual({ command: "git push origin", match: "git push" })
848+
})
849+
})
850+
851+
describe("Batch validation", () => {
852+
it("validates multiple commands at once", () => {
853+
const commands = ["npm install", "npm test", "dangerous", "echo hello"]
854+
const results = validator.validateCommands(commands)
855+
856+
expect(results.get("npm install")).toBe("auto_approve")
857+
expect(results.get("npm test")).toBe("auto_deny")
858+
expect(results.get("dangerous")).toBe("ask_user")
859+
expect(results.get("echo hello")).toBe("auto_approve")
860+
expect(results.size).toBe(4)
861+
})
862+
863+
it("handles empty command list", () => {
864+
const results = validator.validateCommands([])
865+
expect(results.size).toBe(0)
866+
})
867+
})
868+
869+
describe("Configuration analysis", () => {
870+
it("detects if rules are configured", () => {
871+
expect(validator.hasRules()).toBe(true)
872+
873+
const emptyValidator = new CommandValidator([], [])
874+
expect(emptyValidator.hasRules()).toBe(false)
875+
876+
const allowOnlyValidator = new CommandValidator(["npm"], [])
877+
expect(allowOnlyValidator.hasRules()).toBe(true)
878+
879+
const denyOnlyValidator = new CommandValidator([], ["rm"])
880+
expect(denyOnlyValidator.hasRules()).toBe(true)
881+
})
882+
883+
it("provides configuration statistics", () => {
884+
const stats = validator.getStats()
885+
expect(stats.allowedCount).toBe(3)
886+
expect(stats.deniedCount).toBe(2)
887+
expect(stats.hasWildcard).toBe(false)
888+
expect(stats.hasRules).toBe(true)
889+
})
890+
891+
it("detects wildcard configuration", () => {
892+
const wildcardValidator = new CommandValidator(["*", "npm"], ["rm"])
893+
const stats = wildcardValidator.getStats()
894+
expect(stats.hasWildcard).toBe(true)
895+
})
896+
})
897+
898+
describe("Edge cases and error handling", () => {
899+
it("handles empty commands gracefully", () => {
900+
expect(validator.validateCommand("")).toBe("auto_approve")
901+
expect(validator.validateCommand(" ")).toBe("auto_approve")
902+
})
903+
904+
it("handles commands with only whitespace", () => {
905+
const details = validator.getValidationDetails(" ")
906+
expect(details.decision).toBe("auto_approve")
907+
expect(details.subCommands).toEqual([])
908+
})
909+
910+
it("handles malformed commands", () => {
911+
// Commands with unmatched quotes or brackets should not crash
912+
expect(() => validator.validateCommand('npm test "unclosed quote')).not.toThrow()
913+
expect(() => validator.validateCommand("npm test $(unclosed")).not.toThrow()
914+
})
915+
})
916+
})
917+
918+
describe("Factory function", () => {
919+
it("creates validator instances correctly", () => {
920+
const validator = createCommandValidator(["npm"], ["rm"])
921+
expect(validator).toBeInstanceOf(CommandValidator)
922+
expect(validator.validateCommand("npm test")).toBe("auto_approve")
923+
expect(validator.validateCommand("rm file")).toBe("auto_deny")
924+
})
925+
926+
it("handles optional denied commands", () => {
927+
const validator = createCommandValidator(["npm"])
928+
expect(validator.validateCommand("npm test")).toBe("auto_approve")
929+
expect(validator.validateCommand("dangerous")).toBe("ask_user")
930+
})
931+
})
932+
933+
describe("Real-world integration scenarios", () => {
934+
describe("Development workflow validation", () => {
935+
let devValidator: CommandValidator
936+
937+
beforeEach(() => {
938+
devValidator = createCommandValidator(
939+
["npm", "git", "echo", "ls", "cat"],
940+
["git push", "rm", "sudo", "npm publish"],
941+
)
942+
})
943+
944+
it("allows common development commands", () => {
945+
const commonCommands = [
946+
"npm install",
947+
"npm test",
948+
"npm run build",
949+
"git status",
950+
"git add .",
951+
"git commit -m 'fix'",
952+
"echo 'done'",
953+
"ls -la",
954+
"cat package.json",
955+
]
956+
957+
commonCommands.forEach((cmd) => {
958+
expect(devValidator.isAutoApproved(cmd)).toBe(true)
959+
})
960+
})
961+
962+
it("blocks dangerous commands", () => {
963+
const dangerousCommands = [
964+
"git push origin main",
965+
"rm -rf node_modules",
966+
"sudo apt install",
967+
"npm publish",
968+
]
969+
970+
dangerousCommands.forEach((cmd) => {
971+
expect(devValidator.isAutoDenied(cmd)).toBe(true)
972+
})
973+
})
974+
975+
it("requires user input for unknown commands", () => {
976+
const unknownCommands = ["docker run", "python script.py", "curl https://api.example.com"]
977+
978+
unknownCommands.forEach((cmd) => {
979+
expect(devValidator.requiresUserInput(cmd)).toBe(true)
980+
})
981+
})
982+
})
983+
984+
describe("Production environment validation", () => {
985+
let prodValidator: CommandValidator
986+
987+
beforeEach(() => {
988+
prodValidator = createCommandValidator(
989+
["ls", "cat", "grep", "tail"],
990+
["*"], // Deny everything by default
991+
)
992+
})
993+
994+
it("allows only read-only commands", () => {
995+
expect(prodValidator.isAutoApproved("ls -la")).toBe(true)
996+
expect(prodValidator.isAutoApproved("cat /var/log/app.log")).toBe(true)
997+
expect(prodValidator.isAutoApproved("grep ERROR /var/log/app.log")).toBe(true)
998+
expect(prodValidator.isAutoApproved("tail -f /var/log/app.log")).toBe(true)
999+
})
1000+
1001+
it("blocks all other commands due to wildcard deny", () => {
1002+
const blockedCommands = ["npm install", "git push", "rm file", "echo hello", "mkdir test"]
1003+
1004+
blockedCommands.forEach((cmd) => {
1005+
expect(prodValidator.isAutoDenied(cmd)).toBe(true)
1006+
})
1007+
})
1008+
})
1009+
1010+
describe("Longest prefix match in complex scenarios", () => {
1011+
let complexValidator: CommandValidator
1012+
1013+
beforeEach(() => {
1014+
complexValidator = createCommandValidator(
1015+
["git", "git push", "git push --dry-run", "npm", "npm test"],
1016+
["git push", "npm test --coverage"],
1017+
)
1018+
})
1019+
1020+
it("demonstrates longest prefix match resolution", () => {
1021+
// git push --dry-run (allowed, 18 chars) vs git push (denied, 8 chars) -> allow
1022+
expect(complexValidator.isAutoApproved("git push --dry-run origin main")).toBe(true)
1023+
1024+
// git push origin (denied, 8 chars) vs git (allowed, 3 chars) -> deny
1025+
expect(complexValidator.isAutoDenied("git push origin main")).toBe(true)
1026+
1027+
// npm test (allowed, 8 chars) vs npm test --coverage (denied, 19 chars) -> deny
1028+
expect(complexValidator.isAutoDenied("npm test --coverage --watch")).toBe(true)
1029+
1030+
// npm test basic (allowed, 8 chars) vs no deny match -> allow
1031+
expect(complexValidator.isAutoApproved("npm test basic")).toBe(true)
1032+
})
1033+
1034+
it("handles command chains with mixed decisions", () => {
1035+
// One command denied -> whole chain denied
1036+
expect(complexValidator.isAutoDenied("git status && git push origin")).toBe(true)
1037+
1038+
// All commands approved -> whole chain approved
1039+
expect(complexValidator.isAutoApproved("git status && git push --dry-run")).toBe(true)
1040+
1041+
// Mixed with unknown -> ask user
1042+
expect(complexValidator.requiresUserInput("git status && unknown-command")).toBe(true)
1043+
})
1044+
})
1045+
1046+
describe("Performance and scalability", () => {
1047+
it("handles large command lists efficiently", () => {
1048+
const largeAllowList = Array.from({ length: 1000 }, (_, i) => `command${i}`)
1049+
const largeDenyList = Array.from({ length: 500 }, (_, i) => `dangerous${i}`)
1050+
1051+
const largeValidator = createCommandValidator(largeAllowList, largeDenyList)
1052+
1053+
// Should still work efficiently
1054+
expect(largeValidator.isAutoApproved("command500 --flag")).toBe(true)
1055+
expect(largeValidator.isAutoDenied("dangerous250 --flag")).toBe(true)
1056+
expect(largeValidator.requiresUserInput("unknown")).toBe(true)
1057+
})
1058+
1059+
it("handles batch validation efficiently", () => {
1060+
const batchValidator = createCommandValidator(["npm"], ["rm"])
1061+
const commands = Array.from({ length: 100 }, (_, i) => `npm test${i}`)
1062+
const results = batchValidator.validateCommands(commands)
1063+
1064+
expect(results.size).toBe(100)
1065+
// All should be auto-approved since they match "npm" allowlist
1066+
Array.from(results.values()).forEach((decision) => {
1067+
expect(decision).toBe("auto_approve")
1068+
})
1069+
})
1070+
})
1071+
})
1072+
})
7621073
})

0 commit comments

Comments
 (0)