Procházet zdrojové kódy

Add a command denylist (#5614)

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
Matt Rubens před 5 měsíci
rodič
revize
cdacdfd54b
48 změnil soubory, kde provedl 1663 přidání a 95 odebrání
  1. 1 0
      packages/types/src/global-settings.ts
  2. 31 3
      src/core/webview/ClineProvider.ts
  3. 16 0
      src/core/webview/webviewMessageHandler.ts
  4. 8 0
      src/package.json
  5. 1 0
      src/package.nls.ca.json
  6. 1 0
      src/package.nls.de.json
  7. 1 0
      src/package.nls.es.json
  8. 1 0
      src/package.nls.fr.json
  9. 1 0
      src/package.nls.hi.json
  10. 1 0
      src/package.nls.id.json
  11. 1 0
      src/package.nls.it.json
  12. 1 0
      src/package.nls.ja.json
  13. 1 0
      src/package.nls.json
  14. 1 0
      src/package.nls.ko.json
  15. 1 0
      src/package.nls.nl.json
  16. 1 0
      src/package.nls.pl.json
  17. 1 0
      src/package.nls.pt-BR.json
  18. 1 0
      src/package.nls.ru.json
  19. 1 0
      src/package.nls.tr.json
  20. 1 0
      src/package.nls.vi.json
  21. 1 0
      src/package.nls.zh-CN.json
  22. 1 0
      src/package.nls.zh-TW.json
  23. 1 0
      src/shared/ExtensionMessage.ts
  24. 1 0
      src/shared/WebviewMessage.ts
  25. 79 7
      webview-ui/src/components/chat/ChatView.tsx
  26. 66 0
      webview-ui/src/components/settings/AutoApproveSettings.tsx
  27. 3 0
      webview-ui/src/components/settings/SettingsView.tsx
  28. 3 0
      webview-ui/src/context/ExtensionStateContext.tsx
  29. 5 1
      webview-ui/src/i18n/locales/ca/settings.json
  30. 5 1
      webview-ui/src/i18n/locales/de/settings.json
  31. 5 1
      webview-ui/src/i18n/locales/en/settings.json
  32. 5 1
      webview-ui/src/i18n/locales/es/settings.json
  33. 5 1
      webview-ui/src/i18n/locales/fr/settings.json
  34. 5 1
      webview-ui/src/i18n/locales/hi/settings.json
  35. 5 1
      webview-ui/src/i18n/locales/id/settings.json
  36. 5 1
      webview-ui/src/i18n/locales/it/settings.json
  37. 5 1
      webview-ui/src/i18n/locales/ja/settings.json
  38. 5 1
      webview-ui/src/i18n/locales/ko/settings.json
  39. 5 1
      webview-ui/src/i18n/locales/nl/settings.json
  40. 5 1
      webview-ui/src/i18n/locales/pl/settings.json
  41. 5 1
      webview-ui/src/i18n/locales/pt-BR/settings.json
  42. 5 1
      webview-ui/src/i18n/locales/ru/settings.json
  43. 5 1
      webview-ui/src/i18n/locales/tr/settings.json
  44. 5 1
      webview-ui/src/i18n/locales/vi/settings.json
  45. 5 1
      webview-ui/src/i18n/locales/zh-CN/settings.json
  46. 5 1
      webview-ui/src/i18n/locales/zh-TW/settings.json
  47. 844 53
      webview-ui/src/utils/__tests__/command-validation.spec.ts
  48. 502 14
      webview-ui/src/utils/command-validation.ts

+ 1 - 0
packages/types/src/global-settings.ts

@@ -49,6 +49,7 @@ export const globalSettingsSchema = z.object({
 	followupAutoApproveTimeoutMs: z.number().optional(),
 	alwaysAllowUpdateTodoList: z.boolean().optional(),
 	allowedCommands: z.array(z.string()).optional(),
+	deniedCommands: z.array(z.string()).optional(),
 	allowedMaxRequests: z.number().nullish(),
 	autoCondenseContext: z.boolean().optional(),
 	autoCondenseContextPercent: z.number().optional(),

+ 31 - 3
src/core/webview/ClineProvider.ts

@@ -1304,6 +1304,31 @@ export class ClineProvider
 	 * with proper validation and deduplication
 	 */
 	private mergeAllowedCommands(globalStateCommands?: string[]): string[] {
+		return this.mergeCommandLists("allowedCommands", "allowed", globalStateCommands)
+	}
+
+	/**
+	 * Merges denied commands from global state and workspace configuration
+	 * with proper validation and deduplication
+	 */
+	private mergeDeniedCommands(globalStateCommands?: string[]): string[] {
+		return this.mergeCommandLists("deniedCommands", "denied", globalStateCommands)
+	}
+
+	/**
+	 * Common utility for merging command lists from global state and workspace configuration.
+	 * Implements the Command Denylist feature's merging strategy with proper validation.
+	 *
+	 * @param configKey - VSCode workspace configuration key
+	 * @param commandType - Type of commands for error logging
+	 * @param globalStateCommands - Commands from global state
+	 * @returns Merged and deduplicated command list
+	 */
+	private mergeCommandLists(
+		configKey: "allowedCommands" | "deniedCommands",
+		commandType: "allowed" | "denied",
+		globalStateCommands?: string[],
+	): string[] {
 		try {
 			// Validate and sanitize global state commands
 			const validGlobalCommands = Array.isArray(globalStateCommands)
@@ -1311,8 +1336,7 @@ export class ClineProvider
 				: []
 
 			// Get workspace configuration commands
-			const workspaceCommands =
-				vscode.workspace.getConfiguration(Package.name).get<string[]>("allowedCommands") || []
+			const workspaceCommands = vscode.workspace.getConfiguration(Package.name).get<string[]>(configKey) || []
 
 			// Validate and sanitize workspace commands
 			const validWorkspaceCommands = Array.isArray(workspaceCommands)
@@ -1325,7 +1349,7 @@ export class ClineProvider
 
 			return mergedCommands
 		} catch (error) {
-			console.error("Error merging allowed commands:", error)
+			console.error(`Error merging ${commandType} commands:`, error)
 			// Return empty array as fallback to prevent crashes
 			return []
 		}
@@ -1343,6 +1367,7 @@ export class ClineProvider
 			alwaysAllowWriteProtected,
 			alwaysAllowExecute,
 			allowedCommands,
+			deniedCommands,
 			alwaysAllowBrowser,
 			alwaysAllowMcp,
 			alwaysAllowModeSwitch,
@@ -1414,6 +1439,7 @@ export class ClineProvider
 		const telemetryKey = process.env.POSTHOG_API_KEY
 		const machineId = vscode.env.machineId
 		const mergedAllowedCommands = this.mergeAllowedCommands(allowedCommands)
+		const mergedDeniedCommands = this.mergeDeniedCommands(deniedCommands)
 		const cwd = this.cwd
 
 		// Check if there's a system prompt override for the current mode
@@ -1454,6 +1480,7 @@ export class ClineProvider
 			shouldShowAnnouncement:
 				telemetrySetting !== "unset" && lastShownAnnouncementId !== this.latestAnnouncementId,
 			allowedCommands: mergedAllowedCommands,
+			deniedCommands: mergedDeniedCommands,
 			soundVolume: soundVolume ?? 0.5,
 			browserViewportSize: browserViewportSize ?? "900x600",
 			screenshotQuality: screenshotQuality ?? 75,
@@ -1610,6 +1637,7 @@ export class ClineProvider
 			autoCondenseContextPercent: stateValues.autoCondenseContextPercent ?? 100,
 			taskHistory: stateValues.taskHistory,
 			allowedCommands: stateValues.allowedCommands,
+			deniedCommands: stateValues.deniedCommands,
 			soundEnabled: stateValues.soundEnabled ?? false,
 			ttsEnabled: stateValues.ttsEnabled ?? false,
 			ttsSpeed: stateValues.ttsSpeed ?? 1.0,

+ 16 - 0
src/core/webview/webviewMessageHandler.ts

@@ -776,6 +776,22 @@ export const webviewMessageHandler = async (
 
 			break
 		}
+		case "deniedCommands": {
+			// Validate and sanitize the commands array
+			const commands = message.commands ?? []
+			const validCommands = Array.isArray(commands)
+				? commands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0)
+				: []
+
+			await updateGlobalState("deniedCommands", validCommands)
+
+			// Also update workspace settings.
+			await vscode.workspace
+				.getConfiguration(Package.name)
+				.update("deniedCommands", validCommands, vscode.ConfigurationTarget.Global)
+
+			break
+		}
 		case "openCustomModesSettings": {
 			const customModesFilePath = await provider.customModesManager.getCustomModesFilePath()
 

+ 8 - 0
src/package.json

@@ -330,6 +330,14 @@
 					],
 					"description": "%commands.allowedCommands.description%"
 				},
+				"roo-cline.deniedCommands": {
+					"type": "array",
+					"items": {
+						"type": "string"
+					},
+					"default": [],
+					"description": "%commands.deniedCommands.description%"
+				},
 				"roo-cline.vsCodeLmModelSelector": {
 					"type": "object",
 					"properties": {

+ 1 - 0
src/package.nls.ca.json

@@ -27,6 +27,7 @@
 	"command.documentation.title": "Documentació",
 	"configuration.title": "Roo Code",
 	"commands.allowedCommands.description": "Ordres que es poden executar automàticament quan 'Aprova sempre les operacions d'execució' està activat",
+	"commands.deniedCommands.description": "Prefixos d'ordres que seran automàticament denegats sense demanar aprovació. En cas de conflictes amb ordres permeses, la coincidència de prefix més llarga té prioritat. Afegeix * per denegar totes les ordres.",
 	"settings.vsCodeLmModelSelector.description": "Configuració per a l'API del model de llenguatge VSCode",
 	"settings.vsCodeLmModelSelector.vendor.description": "El proveïdor del model de llenguatge (p. ex. copilot)",
 	"settings.vsCodeLmModelSelector.family.description": "La família del model de llenguatge (p. ex. gpt-4)",

+ 1 - 0
src/package.nls.de.json

@@ -27,6 +27,7 @@
 	"command.documentation.title": "Dokumentation",
 	"configuration.title": "Roo Code",
 	"commands.allowedCommands.description": "Befehle, die automatisch ausgeführt werden können, wenn 'Ausführungsoperationen immer genehmigen' aktiviert ist",
+	"commands.deniedCommands.description": "Befehlspräfixe, die automatisch abgelehnt werden, ohne nach Genehmigung zu fragen. Bei Konflikten mit erlaubten Befehlen hat die längste Präfix-Übereinstimmung Vorrang. Füge * hinzu, um alle Befehle abzulehnen.",
 	"settings.vsCodeLmModelSelector.description": "Einstellungen für die VSCode-Sprachmodell-API",
 	"settings.vsCodeLmModelSelector.vendor.description": "Der Anbieter des Sprachmodells (z.B. copilot)",
 	"settings.vsCodeLmModelSelector.family.description": "Die Familie des Sprachmodells (z.B. gpt-4)",

+ 1 - 0
src/package.nls.es.json

@@ -27,6 +27,7 @@
 	"command.documentation.title": "Documentación",
 	"configuration.title": "Roo Code",
 	"commands.allowedCommands.description": "Comandos que pueden ejecutarse automáticamente cuando 'Aprobar siempre operaciones de ejecución' está activado",
+	"commands.deniedCommands.description": "Prefijos de comandos que serán automáticamente denegados sin solicitar aprobación. En caso de conflictos con comandos permitidos, la coincidencia de prefijo más larga tiene prioridad. Añade * para denegar todos los comandos.",
 	"settings.vsCodeLmModelSelector.description": "Configuración para la API del modelo de lenguaje VSCode",
 	"settings.vsCodeLmModelSelector.vendor.description": "El proveedor del modelo de lenguaje (ej. copilot)",
 	"settings.vsCodeLmModelSelector.family.description": "La familia del modelo de lenguaje (ej. gpt-4)",

+ 1 - 0
src/package.nls.fr.json

@@ -27,6 +27,7 @@
 	"command.documentation.title": "Documentation",
 	"configuration.title": "Roo Code",
 	"commands.allowedCommands.description": "Commandes pouvant être exécutées automatiquement lorsque 'Toujours approuver les opérations d'exécution' est activé",
+	"commands.deniedCommands.description": "Préfixes de commandes qui seront automatiquement refusés sans demander d'approbation. En cas de conflit avec les commandes autorisées, la correspondance de préfixe la plus longue a la priorité. Ajouter * pour refuser toutes les commandes.",
 	"settings.vsCodeLmModelSelector.description": "Paramètres pour l'API du modèle de langage VSCode",
 	"settings.vsCodeLmModelSelector.vendor.description": "Le fournisseur du modèle de langage (ex: copilot)",
 	"settings.vsCodeLmModelSelector.family.description": "La famille du modèle de langage (ex: gpt-4)",

+ 1 - 0
src/package.nls.hi.json

@@ -27,6 +27,7 @@
 	"command.documentation.title": "दस्तावेज़ीकरण",
 	"configuration.title": "Roo Code",
 	"commands.allowedCommands.description": "वे कमांड जो स्वचालित रूप से निष्पादित की जा सकती हैं जब 'हमेशा निष्पादन संचालन को स्वीकृत करें' सक्रिय हो",
+	"commands.deniedCommands.description": "कमांड प्रीफिक्स जो स्वचालित रूप से अस्वीकार कर दिए जाएंगे बिना अनुमोदन मांगे। अनुमतित कमांड के साथ संघर्ष की स्थिति में, सबसे लंबा प्रीफिक्स मैच प्राथमिकता लेता है। सभी कमांड को अस्वीकार करने के लिए * जोड़ें।",
 	"settings.vsCodeLmModelSelector.description": "VSCode भाषा मॉडल API के लिए सेटिंग्स",
 	"settings.vsCodeLmModelSelector.vendor.description": "भाषा मॉडल का विक्रेता (उदा. copilot)",
 	"settings.vsCodeLmModelSelector.family.description": "भाषा मॉडल का परिवार (उदा. gpt-4)",

+ 1 - 0
src/package.nls.id.json

@@ -27,6 +27,7 @@
 	"command.acceptInput.title": "Terima Input/Saran",
 	"configuration.title": "Roo Code",
 	"commands.allowedCommands.description": "Perintah yang dapat dijalankan secara otomatis ketika 'Selalu setujui operasi eksekusi' diaktifkan",
+	"commands.deniedCommands.description": "Awalan perintah yang akan otomatis ditolak tanpa meminta persetujuan. Jika terjadi konflik dengan perintah yang diizinkan, pencocokan awalan terpanjang akan diprioritaskan. Tambahkan * untuk menolak semua perintah.",
 	"settings.vsCodeLmModelSelector.description": "Pengaturan untuk API Model Bahasa VSCode",
 	"settings.vsCodeLmModelSelector.vendor.description": "Vendor dari model bahasa (misalnya copilot)",
 	"settings.vsCodeLmModelSelector.family.description": "Keluarga dari model bahasa (misalnya gpt-4)",

+ 1 - 0
src/package.nls.it.json

@@ -27,6 +27,7 @@
 	"command.documentation.title": "Documentazione",
 	"configuration.title": "Roo Code",
 	"commands.allowedCommands.description": "Comandi che possono essere eseguiti automaticamente quando 'Approva sempre le operazioni di esecuzione' è attivato",
+	"commands.deniedCommands.description": "Prefissi di comandi che verranno automaticamente rifiutati senza richiedere approvazione. In caso di conflitti con comandi consentiti, la corrispondenza del prefisso più lungo ha la precedenza. Aggiungi * per rifiutare tutti i comandi.",
 	"settings.vsCodeLmModelSelector.description": "Impostazioni per l'API del modello linguistico VSCode",
 	"settings.vsCodeLmModelSelector.vendor.description": "Il fornitore del modello linguistico (es. copilot)",
 	"settings.vsCodeLmModelSelector.family.description": "La famiglia del modello linguistico (es. gpt-4)",

+ 1 - 0
src/package.nls.ja.json

@@ -27,6 +27,7 @@
 	"command.acceptInput.title": "入力/提案を承認",
 	"configuration.title": "Roo Code",
 	"commands.allowedCommands.description": "'常に実行操作を承認する'が有効な場合に自動実行できるコマンド",
+	"commands.deniedCommands.description": "承認を求めずに自動的に拒否されるコマンドプレフィックス。許可されたコマンドとの競合がある場合、最長プレフィックスマッチが優先されます。すべてのコマンドを拒否するには * を追加してください。",
 	"settings.vsCodeLmModelSelector.description": "VSCode 言語モデル API の設定",
 	"settings.vsCodeLmModelSelector.vendor.description": "言語モデルのベンダー(例:copilot)",
 	"settings.vsCodeLmModelSelector.family.description": "言語モデルのファミリー(例:gpt-4)",

+ 1 - 0
src/package.nls.json

@@ -27,6 +27,7 @@
 	"command.acceptInput.title": "Accept Input/Suggestion",
 	"configuration.title": "Roo Code",
 	"commands.allowedCommands.description": "Commands that can be auto-executed when 'Always approve execute operations' is enabled",
+	"commands.deniedCommands.description": "Command prefixes that will be automatically denied without asking for approval. In case of conflicts with allowed commands, the longest prefix match takes precedence. Add * to deny all commands.",
 	"settings.vsCodeLmModelSelector.description": "Settings for VSCode Language Model API",
 	"settings.vsCodeLmModelSelector.vendor.description": "The vendor of the language model (e.g. copilot)",
 	"settings.vsCodeLmModelSelector.family.description": "The family of the language model (e.g. gpt-4)",

+ 1 - 0
src/package.nls.ko.json

@@ -27,6 +27,7 @@
 	"command.documentation.title": "문서",
 	"configuration.title": "Roo Code",
 	"commands.allowedCommands.description": "'항상 실행 작업 승인' 이 활성화되어 있을 때 자동으로 실행할 수 있는 명령어",
+	"commands.deniedCommands.description": "승인을 요청하지 않고 자동으로 거부될 명령어 접두사. 허용된 명령어와 충돌하는 경우 가장 긴 접두사 일치가 우선됩니다. 모든 명령어를 거부하려면 *를 추가하세요.",
 	"settings.vsCodeLmModelSelector.description": "VSCode 언어 모델 API 설정",
 	"settings.vsCodeLmModelSelector.vendor.description": "언어 모델 공급자 (예: copilot)",
 	"settings.vsCodeLmModelSelector.family.description": "언어 모델 계열 (예: gpt-4)",

+ 1 - 0
src/package.nls.nl.json

@@ -27,6 +27,7 @@
 	"command.acceptInput.title": "Invoer/Suggestie Accepteren",
 	"configuration.title": "Roo Code",
 	"commands.allowedCommands.description": "Commando's die automatisch kunnen worden uitgevoerd wanneer 'Altijd goedkeuren uitvoerbewerkingen' is ingeschakeld",
+	"commands.deniedCommands.description": "Commando-prefixen die automatisch worden geweigerd zonder om goedkeuring te vragen. Bij conflicten met toegestane commando's heeft de langste prefix-match voorrang. Voeg * toe om alle commando's te weigeren.",
 	"settings.vsCodeLmModelSelector.description": "Instellingen voor VSCode Language Model API",
 	"settings.vsCodeLmModelSelector.vendor.description": "De leverancier van het taalmodel (bijv. copilot)",
 	"settings.vsCodeLmModelSelector.family.description": "De familie van het taalmodel (bijv. gpt-4)",

+ 1 - 0
src/package.nls.pl.json

@@ -27,6 +27,7 @@
 	"command.documentation.title": "Dokumentacja",
 	"configuration.title": "Roo Code",
 	"commands.allowedCommands.description": "Polecenia, które mogą być wykonywane automatycznie, gdy włączona jest opcja 'Zawsze zatwierdzaj operacje wykonania'",
+	"commands.deniedCommands.description": "Prefiksy poleceń, które będą automatycznie odrzucane bez pytania o zatwierdzenie. W przypadku konfliktów z dozwolonymi poleceniami, najdłuższe dopasowanie prefiksu ma pierwszeństwo. Dodaj * aby odrzucić wszystkie polecenia.",
 	"settings.vsCodeLmModelSelector.description": "Ustawienia dla API modelu językowego VSCode",
 	"settings.vsCodeLmModelSelector.vendor.description": "Dostawca modelu językowego (np. copilot)",
 	"settings.vsCodeLmModelSelector.family.description": "Rodzina modelu językowego (np. gpt-4)",

+ 1 - 0
src/package.nls.pt-BR.json

@@ -27,6 +27,7 @@
 	"command.documentation.title": "Documentação",
 	"configuration.title": "Roo Code",
 	"commands.allowedCommands.description": "Comandos que podem ser executados automaticamente quando 'Sempre aprovar operações de execução' está ativado",
+	"commands.deniedCommands.description": "Prefixos de comandos que serão automaticamente negados sem solicitar aprovação. Em caso de conflitos com comandos permitidos, a correspondência de prefixo mais longa tem precedência. Adicione * para negar todos os comandos.",
 	"settings.vsCodeLmModelSelector.description": "Configurações para a API do modelo de linguagem do VSCode",
 	"settings.vsCodeLmModelSelector.vendor.description": "O fornecedor do modelo de linguagem (ex: copilot)",
 	"settings.vsCodeLmModelSelector.family.description": "A família do modelo de linguagem (ex: gpt-4)",

+ 1 - 0
src/package.nls.ru.json

@@ -27,6 +27,7 @@
 	"command.acceptInput.title": "Принять ввод/предложение",
 	"configuration.title": "Roo Code",
 	"commands.allowedCommands.description": "Команды, которые могут быть автоматически выполнены, когда включена опция 'Всегда подтверждать операции выполнения'",
+	"commands.deniedCommands.description": "Префиксы команд, которые будут автоматически отклонены без запроса подтверждения. В случае конфликтов с разрешенными командами приоритет имеет самое длинное совпадение префикса. Добавьте * чтобы отклонить все команды.",
 	"settings.vsCodeLmModelSelector.description": "Настройки для VSCode Language Model API",
 	"settings.vsCodeLmModelSelector.vendor.description": "Поставщик языковой модели (например, copilot)",
 	"settings.vsCodeLmModelSelector.family.description": "Семейство языковой модели (например, gpt-4)",

+ 1 - 0
src/package.nls.tr.json

@@ -27,6 +27,7 @@
 	"command.documentation.title": "Dokümantasyon",
 	"configuration.title": "Roo Code",
 	"commands.allowedCommands.description": "'Her zaman yürütme işlemlerini onayla' etkinleştirildiğinde otomatik olarak yürütülebilen komutlar",
+	"commands.deniedCommands.description": "Onay istenmeden otomatik olarak reddedilecek komut önekleri. İzin verilen komutlarla çakışma durumunda en uzun önek eşleşmesi öncelik alır. Tüm komutları reddetmek için * ekleyin.",
 	"settings.vsCodeLmModelSelector.description": "VSCode dil modeli API'si için ayarlar",
 	"settings.vsCodeLmModelSelector.vendor.description": "Dil modelinin sağlayıcısı (örn: copilot)",
 	"settings.vsCodeLmModelSelector.family.description": "Dil modelinin ailesi (örn: gpt-4)",

+ 1 - 0
src/package.nls.vi.json

@@ -27,6 +27,7 @@
 	"command.documentation.title": "Tài Liệu",
 	"configuration.title": "Roo Code",
 	"commands.allowedCommands.description": "Các lệnh có thể được thực thi tự động khi 'Luôn phê duyệt các thao tác thực thi' được bật",
+	"commands.deniedCommands.description": "Các tiền tố lệnh sẽ được tự động từ chối mà không yêu cầu phê duyệt. Trong trường hợp xung đột với các lệnh được phép, việc khớp tiền tố dài nhất sẽ được ưu tiên. Thêm * để từ chối tất cả các lệnh.",
 	"settings.vsCodeLmModelSelector.description": "Cài đặt cho API mô hình ngôn ngữ VSCode",
 	"settings.vsCodeLmModelSelector.vendor.description": "Nhà cung cấp mô hình ngôn ngữ (ví dụ: copilot)",
 	"settings.vsCodeLmModelSelector.family.description": "Họ mô hình ngôn ngữ (ví dụ: gpt-4)",

+ 1 - 0
src/package.nls.zh-CN.json

@@ -27,6 +27,7 @@
 	"command.documentation.title": "文档",
 	"configuration.title": "Roo Code",
 	"commands.allowedCommands.description": "当启用'始终批准执行操作'时可以自动执行的命令",
+	"commands.deniedCommands.description": "将自动拒绝而无需请求批准的命令前缀。与允许命令冲突时,最长前缀匹配优先。添加 * 拒绝所有命令。",
 	"settings.vsCodeLmModelSelector.description": "VSCode 语言模型 API 的设置",
 	"settings.vsCodeLmModelSelector.vendor.description": "语言模型的供应商(例如:copilot)",
 	"settings.vsCodeLmModelSelector.family.description": "语言模型的系列(例如:gpt-4)",

+ 1 - 0
src/package.nls.zh-TW.json

@@ -27,6 +27,7 @@
 	"command.documentation.title": "文件",
 	"configuration.title": "Roo Code",
 	"commands.allowedCommands.description": "當啟用'始終批准執行操作'時可以自動執行的命令",
+	"commands.deniedCommands.description": "將自動拒絕而無需請求批准的命令前綴。與允許命令衝突時,最長前綴匹配優先。新增 * 拒絕所有命令。",
 	"settings.vsCodeLmModelSelector.description": "VSCode 語言模型 API 的設定",
 	"settings.vsCodeLmModelSelector.vendor.description": "語言模型供應商(例如:copilot)",
 	"settings.vsCodeLmModelSelector.family.description": "語言模型系列(例如:gpt-4)",

+ 1 - 0
src/shared/ExtensionMessage.ts

@@ -183,6 +183,7 @@ export type ExtensionState = Pick<
 	| "alwaysAllowExecute"
 	| "alwaysAllowUpdateTodoList"
 	| "allowedCommands"
+	| "deniedCommands"
 	| "allowedMaxRequests"
 	| "browserToolEnabled"
 	| "browserViewportSize"

+ 1 - 0
src/shared/WebviewMessage.ts

@@ -36,6 +36,7 @@ export interface WebviewMessage {
 		| "getListApiConfiguration"
 		| "customInstructions"
 		| "allowedCommands"
+		| "deniedCommands"
 		| "alwaysAllowReadOnly"
 		| "alwaysAllowReadOnlyOutsideWorkspace"
 		| "alwaysAllowWrite"

+ 79 - 7
webview-ui/src/components/chat/ChatView.tsx

@@ -24,7 +24,13 @@ import { getAllModes } from "@roo/modes"
 import { ProfileValidator } from "@roo/ProfileValidator"
 
 import { vscode } from "@src/utils/vscode"
-import { validateCommand } from "@src/utils/command-validation"
+import {
+	getCommandDecision,
+	CommandDecision,
+	findLongestPrefixMatch,
+	parseCommand,
+} from "@src/utils/command-validation"
+import { useTranslation } from "react-i18next"
 import { buildDocLink } from "@src/utils/docLinks"
 import { useAppTranslation } from "@src/i18n/TranslationContext"
 import { useExtensionState } from "@src/context/ExtensionStateContext"
@@ -72,6 +78,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 		return w.AUDIO_BASE_URI || ""
 	})
 	const { t } = useAppTranslation()
+	const { t: tSettings } = useTranslation("settings")
 	const modeShortcutText = `${isMac ? "⌘" : "Ctrl"} + . ${t("chat:forNextMode")}`
 	const {
 		clineMessages: messages,
@@ -89,6 +96,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 		alwaysAllowExecute,
 		alwaysAllowMcp,
 		allowedCommands,
+		deniedCommands,
 		writeDelayMs,
 		followupAutoApproveTimeoutMs,
 		mode,
@@ -908,13 +916,47 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 		[mcpServers],
 	)
 
-	// Check if a command message is allowed.
+	// Get the command decision using unified validation logic
+	const getCommandDecisionForMessage = useCallback(
+		(message: ClineMessage | undefined): CommandDecision => {
+			if (message?.type !== "ask") return "ask_user"
+			return getCommandDecision(message.text || "", allowedCommands || [], deniedCommands || [])
+		},
+		[allowedCommands, deniedCommands],
+	)
+
+	// Check if a command message should be auto-approved.
 	const isAllowedCommand = useCallback(
 		(message: ClineMessage | undefined): boolean => {
-			if (message?.type !== "ask") return false
-			return validateCommand(message.text || "", allowedCommands || [])
+			return getCommandDecisionForMessage(message) === "auto_approve"
+		},
+		[getCommandDecisionForMessage],
+	)
+
+	// Check if a command message should be auto-denied.
+	const isDeniedCommand = useCallback(
+		(message: ClineMessage | undefined): boolean => {
+			return getCommandDecisionForMessage(message) === "auto_deny"
+		},
+		[getCommandDecisionForMessage],
+	)
+
+	// Helper function to get the denied prefix for a command
+	const getDeniedPrefix = useCallback(
+		(command: string): string | null => {
+			if (!command || !deniedCommands?.length) return null
+
+			// Parse the command into sub-commands and check each one
+			const subCommands = parseCommand(command)
+			for (const cmd of subCommands) {
+				const deniedMatch = findLongestPrefixMatch(cmd, deniedCommands)
+				if (deniedMatch) {
+					return deniedMatch
+				}
+			}
+			return null
 		},
-		[allowedCommands],
+		[deniedCommands],
 	)
 
 	const isAutoApproved = useCallback(
@@ -1393,7 +1435,32 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 			return
 		}
 
-		const autoApprove = async () => {
+		const autoApproveOrReject = async () => {
+			// Check for auto-reject first (commands that should be denied)
+			if (lastMessage?.ask === "command" && isDeniedCommand(lastMessage)) {
+				// Get the denied prefix for the localized message
+				const deniedPrefix = getDeniedPrefix(lastMessage.text || "")
+				if (deniedPrefix) {
+					// Create the localized auto-deny message and send it with the rejection
+					const autoDenyMessage = tSettings("autoApprove.execute.autoDenied", { prefix: deniedPrefix })
+
+					vscode.postMessage({
+						type: "askResponse",
+						askResponse: "noButtonClicked",
+						text: autoDenyMessage,
+					})
+				} else {
+					// Auto-reject denied commands immediately if no prefix found
+					vscode.postMessage({ type: "askResponse", askResponse: "noButtonClicked" })
+				}
+
+				setSendingDisabled(true)
+				setClineAsk(undefined)
+				setEnableButtons(false)
+				return
+			}
+
+			// Then check for auto-approve
 			if (lastMessage?.ask && isAutoApproved(lastMessage)) {
 				// Special handling for follow-up questions
 				if (lastMessage.ask === "followup") {
@@ -1443,7 +1510,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 				setEnableButtons(false)
 			}
 		}
-		autoApprove()
+		autoApproveOrReject()
 
 		return () => {
 			if (autoApproveTimeoutRef.current) {
@@ -1465,6 +1532,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 		alwaysAllowMcp,
 		messages,
 		allowedCommands,
+		deniedCommands,
 		mcpServers,
 		isAutoApproved,
 		lastMessage,
@@ -1472,6 +1540,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 		isWriteToolAction,
 		alwaysAllowFollowupQuestions,
 		handleSuggestionClickInRow,
+		isAllowedCommand,
+		isDeniedCommand,
+		getDeniedPrefix,
+		tSettings,
 	])
 
 	// Function to handle mode switching

+ 66 - 0
webview-ui/src/components/settings/AutoApproveSettings.tsx

@@ -29,6 +29,7 @@ type AutoApproveSettingsProps = HTMLAttributes<HTMLDivElement> & {
 	alwaysAllowUpdateTodoList?: boolean
 	followupAutoApproveTimeoutMs?: number
 	allowedCommands?: string[]
+	deniedCommands?: string[]
 	setCachedStateField: SetCachedStateField<
 		| "alwaysAllowReadOnly"
 		| "alwaysAllowReadOnlyOutsideWorkspace"
@@ -46,6 +47,7 @@ type AutoApproveSettingsProps = HTMLAttributes<HTMLDivElement> & {
 		| "alwaysAllowFollowupQuestions"
 		| "followupAutoApproveTimeoutMs"
 		| "allowedCommands"
+		| "deniedCommands"
 		| "alwaysAllowUpdateTodoList"
 	>
 }
@@ -68,11 +70,13 @@ export const AutoApproveSettings = ({
 	followupAutoApproveTimeoutMs = 60000,
 	alwaysAllowUpdateTodoList,
 	allowedCommands,
+	deniedCommands,
 	setCachedStateField,
 	...props
 }: AutoApproveSettingsProps) => {
 	const { t } = useAppTranslation()
 	const [commandInput, setCommandInput] = useState("")
+	const [deniedCommandInput, setDeniedCommandInput] = useState("")
 
 	const handleAddCommand = () => {
 		const currentCommands = allowedCommands ?? []
@@ -85,6 +89,17 @@ export const AutoApproveSettings = ({
 		}
 	}
 
+	const handleAddDeniedCommand = () => {
+		const currentCommands = deniedCommands ?? []
+
+		if (deniedCommandInput && !currentCommands.includes(deniedCommandInput)) {
+			const newCommands = [...currentCommands, deniedCommandInput]
+			setCachedStateField("deniedCommands", newCommands)
+			setDeniedCommandInput("")
+			vscode.postMessage({ type: "deniedCommands", commands: newCommands })
+		}
+	}
+
 	return (
 		<div {...props}>
 			<SectionHeader description={t("settings:autoApprove.description")}>
@@ -293,6 +308,57 @@ export const AutoApproveSettings = ({
 								</Button>
 							))}
 						</div>
+
+						{/* Denied Commands Section */}
+						<div className="mt-6">
+							<label className="block font-medium mb-1" data-testid="denied-commands-heading">
+								{t("settings:autoApprove.execute.deniedCommands")}
+							</label>
+							<div className="text-vscode-descriptionForeground text-sm mt-1">
+								{t("settings:autoApprove.execute.deniedCommandsDescription")}
+							</div>
+						</div>
+
+						<div className="flex gap-2">
+							<Input
+								value={deniedCommandInput}
+								onChange={(e: any) => setDeniedCommandInput(e.target.value)}
+								onKeyDown={(e: any) => {
+									if (e.key === "Enter") {
+										e.preventDefault()
+										handleAddDeniedCommand()
+									}
+								}}
+								placeholder={t("settings:autoApprove.execute.deniedCommandPlaceholder")}
+								className="grow"
+								data-testid="denied-command-input"
+							/>
+							<Button
+								className="h-8"
+								onClick={handleAddDeniedCommand}
+								data-testid="add-denied-command-button">
+								{t("settings:autoApprove.execute.addButton")}
+							</Button>
+						</div>
+
+						<div className="flex flex-wrap gap-2">
+							{(deniedCommands ?? []).map((cmd, index) => (
+								<Button
+									key={index}
+									variant="secondary"
+									data-testid={`remove-denied-command-${index}`}
+									onClick={() => {
+										const newCommands = (deniedCommands ?? []).filter((_, i) => i !== index)
+										setCachedStateField("deniedCommands", newCommands)
+										vscode.postMessage({ type: "deniedCommands", commands: newCommands })
+									}}>
+									<div className="flex flex-row items-center gap-1">
+										<div>{cmd}</div>
+										<X className="text-foreground scale-75" />
+									</div>
+								</Button>
+							))}
+						</div>
 					</div>
 				)}
 			</Section>

+ 3 - 0
webview-ui/src/components/settings/SettingsView.tsx

@@ -123,6 +123,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
 		alwaysAllowReadOnly,
 		alwaysAllowReadOnlyOutsideWorkspace,
 		allowedCommands,
+		deniedCommands,
 		allowedMaxRequests,
 		language,
 		alwaysAllowBrowser,
@@ -274,6 +275,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
 			vscode.postMessage({ type: "alwaysAllowBrowser", bool: alwaysAllowBrowser })
 			vscode.postMessage({ type: "alwaysAllowMcp", bool: alwaysAllowMcp })
 			vscode.postMessage({ type: "allowedCommands", commands: allowedCommands ?? [] })
+			vscode.postMessage({ type: "deniedCommands", commands: deniedCommands ?? [] })
 			vscode.postMessage({ type: "allowedMaxRequests", value: allowedMaxRequests ?? undefined })
 			vscode.postMessage({ type: "autoCondenseContext", bool: autoCondenseContext })
 			vscode.postMessage({ type: "autoCondenseContextPercent", value: autoCondenseContextPercent })
@@ -606,6 +608,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
 							alwaysAllowUpdateTodoList={alwaysAllowUpdateTodoList}
 							followupAutoApproveTimeoutMs={followupAutoApproveTimeoutMs}
 							allowedCommands={allowedCommands}
+							deniedCommands={deniedCommands}
 							setCachedStateField={setCachedStateField}
 						/>
 					)}

+ 3 - 0
webview-ui/src/context/ExtensionStateContext.tsx

@@ -67,6 +67,7 @@ export interface ExtensionStateContextType extends ExtensionState {
 	setShowRooIgnoredFiles: (value: boolean) => void
 	setShowAnnouncement: (value: boolean) => void
 	setAllowedCommands: (value: string[]) => void
+	setDeniedCommands: (value: string[]) => void
 	setAllowedMaxRequests: (value: number | undefined) => void
 	setSoundEnabled: (value: boolean) => void
 	setSoundVolume: (value: number) => void
@@ -162,6 +163,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 		taskHistory: [],
 		shouldShowAnnouncement: false,
 		allowedCommands: [],
+		deniedCommands: [],
 		soundEnabled: false,
 		soundVolume: 0.5,
 		ttsEnabled: false,
@@ -393,6 +395,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 			setState((prevState) => ({ ...prevState, followupAutoApproveTimeoutMs: value })),
 		setShowAnnouncement: (value) => setState((prevState) => ({ ...prevState, shouldShowAnnouncement: value })),
 		setAllowedCommands: (value) => setState((prevState) => ({ ...prevState, allowedCommands: value })),
+		setDeniedCommands: (value) => setState((prevState) => ({ ...prevState, deniedCommands: value })),
 		setAllowedMaxRequests: (value) => setState((prevState) => ({ ...prevState, allowedMaxRequests: value })),
 		setSoundEnabled: (value) => setState((prevState) => ({ ...prevState, soundEnabled: value })),
 		setSoundVolume: (value) => setState((prevState) => ({ ...prevState, soundVolume: value })),

+ 5 - 1
webview-ui/src/i18n/locales/ca/settings.json

@@ -175,8 +175,12 @@
 			"description": "Executar automàticament comandes de terminal permeses sense requerir aprovació",
 			"allowedCommands": "Comandes d'auto-execució permeses",
 			"allowedCommandsDescription": "Prefixos de comandes que poden ser executats automàticament quan \"Aprovar sempre operacions d'execució\" està habilitat. Afegeix * per permetre totes les comandes (usar amb precaució).",
+			"deniedCommands": "Comandes denegades",
+			"deniedCommandsDescription": "Prefixos de comandes que es rebutjaran automàticament sense requerir aprovació. En cas de conflicte amb comandes permeses, la coincidència de prefix més llarga té prioritat. Afegeix * per denegar totes les comandes.",
 			"commandPlaceholder": "Introduïu prefix de comanda (ex. 'git ')",
-			"addButton": "Afegir"
+			"deniedCommandPlaceholder": "Introduïu prefix de comanda a denegar (ex. 'rm -rf')",
+			"addButton": "Afegir",
+			"autoDenied": "El prefix de comanda `{{prefix}}` ha estat automàticament denegat. Intentar eludir aquesta restricció pot comprometre la seguretat del sistema."
 		},
 		"updateTodoList": {
 			"label": "Todo",

+ 5 - 1
webview-ui/src/i18n/locales/de/settings.json

@@ -175,8 +175,12 @@
 			"description": "Erlaubte Terminal-Befehle automatisch ohne Genehmigung ausführen",
 			"allowedCommands": "Erlaubte Auto-Ausführungsbefehle",
 			"allowedCommandsDescription": "Befehlspräfixe, die automatisch ausgeführt werden können, wenn 'Ausführungsoperationen immer genehmigen' aktiviert ist. Fügen Sie * hinzu, um alle Befehle zu erlauben (mit Vorsicht verwenden).",
+			"deniedCommands": "Verweigerte Befehle",
+			"deniedCommandsDescription": "Befehlspräfixe, die automatisch verweigert werden, ohne nach Genehmigung zu fragen. Bei Konflikten mit erlaubten Befehlen hat die längste Präfix-Übereinstimmung Vorrang. Fügen Sie * hinzu, um alle Befehle zu verweigern.",
 			"commandPlaceholder": "Befehlspräfix eingeben (z.B. 'git ')",
-			"addButton": "Hinzufügen"
+			"deniedCommandPlaceholder": "Befehlspräfix zum Verweigern eingeben (z.B. 'rm -rf')",
+			"addButton": "Hinzufügen",
+			"autoDenied": "Das Befehlspräfix `{{prefix}}` wurde automatisch verweigert. Bitte versuchen Sie nicht, diese Beschränkung zu umgehen."
 		},
 		"updateTodoList": {
 			"label": "Todo",

+ 5 - 1
webview-ui/src/i18n/locales/en/settings.json

@@ -175,8 +175,12 @@
 			"description": "Automatically execute allowed terminal commands without requiring approval",
 			"allowedCommands": "Allowed Auto-Execute Commands",
 			"allowedCommandsDescription": "Command prefixes that can be auto-executed when \"Always approve execute operations\" is enabled. Add * to allow all commands (use with caution).",
+			"deniedCommands": "Denied Commands",
+			"deniedCommandsDescription": "Command prefixes that will be automatically denied without asking for approval. In case of conflicts with allowed commands, the longest prefix match takes precedence. Add * to deny all commands.",
 			"commandPlaceholder": "Enter command prefix (e.g., 'git ')",
-			"addButton": "Add"
+			"deniedCommandPlaceholder": "Enter command prefix to deny (e.g., 'rm -rf')",
+			"addButton": "Add",
+			"autoDenied": "The command prefix `{{prefix}}` has been automatically denied. Please do not attempt to bypass this restriction."
 		},
 		"updateTodoList": {
 			"label": "Todo",

+ 5 - 1
webview-ui/src/i18n/locales/es/settings.json

@@ -175,8 +175,12 @@
 			"description": "Ejecutar automáticamente comandos de terminal permitidos sin requerir aprobación",
 			"allowedCommands": "Comandos de auto-ejecución permitidos",
 			"allowedCommandsDescription": "Prefijos de comandos que pueden ser ejecutados automáticamente cuando \"Aprobar siempre operaciones de ejecución\" está habilitado. Añade * para permitir todos los comandos (usar con precaución).",
+			"deniedCommands": "Comandos denegados",
+			"deniedCommandsDescription": "Prefijos de comandos que serán automáticamente denegados sin pedir aprobación. En caso de conflictos con comandos permitidos, la coincidencia de prefijo más larga tiene prioridad. Añade * para denegar todos los comandos.",
 			"commandPlaceholder": "Ingrese prefijo de comando (ej. 'git ')",
-			"addButton": "Añadir"
+			"deniedCommandPlaceholder": "Ingrese prefijo de comando a denegar (ej. 'rm -rf')",
+			"addButton": "Añadir",
+			"autoDenied": "El prefijo de comando `{{prefix}}` ha sido automáticamente denegado. Por favor, no intente eludir esta restricción."
 		},
 		"updateTodoList": {
 			"label": "Todo",

+ 5 - 1
webview-ui/src/i18n/locales/fr/settings.json

@@ -175,8 +175,12 @@
 			"description": "Exécuter automatiquement les commandes de terminal autorisées sans nécessiter d'approbation",
 			"allowedCommands": "Commandes auto-exécutables autorisées",
 			"allowedCommandsDescription": "Préfixes de commandes qui peuvent être auto-exécutés lorsque \"Toujours approuver les opérations d'exécution\" est activé. Ajoutez * pour autoriser toutes les commandes (à utiliser avec précaution).",
+			"deniedCommands": "Commandes refusées",
+			"deniedCommandsDescription": "Préfixes de commandes qui seront automatiquement refusés sans demander d'approbation. En cas de conflit avec les commandes autorisées, la correspondance de préfixe la plus longue prend la priorité. Ajoutez * pour refuser toutes les commandes.",
 			"commandPlaceholder": "Entrez le préfixe de commande (ex. 'git ')",
-			"addButton": "Ajouter"
+			"deniedCommandPlaceholder": "Entrez le préfixe de commande à refuser (ex. 'rm -rf')",
+			"addButton": "Ajouter",
+			"autoDenied": "Le préfixe de commande `{{prefix}}` a été automatiquement refusé. Veuillez ne pas tenter de contourner cette restriction."
 		},
 		"updateTodoList": {
 			"label": "Todo",

+ 5 - 1
webview-ui/src/i18n/locales/hi/settings.json

@@ -175,8 +175,12 @@
 			"description": "अनुमोदन की आवश्यकता के बिना स्वचालित रूप से अनुमत टर्मिनल कमांड निष्पादित करें",
 			"allowedCommands": "अनुमत स्वतः-निष्पादन कमांड",
 			"allowedCommandsDescription": "कमांड प्रीफिक्स जो स्वचालित रूप से निष्पादित किए जा सकते हैं जब \"निष्पादन ऑपरेशन हमेशा अनुमोदित करें\" सक्षम है। सभी कमांड की अनुमति देने के लिए * जोड़ें (सावधानी से उपयोग करें)।",
+			"deniedCommands": "अस्वीकृत कमांड",
+			"deniedCommandsDescription": "कमांड प्रीफिक्स जो स्वचालित रूप से अस्वीकार हो जाएंगे बिना अनुमोदन की आवश्यकता के। अनुमत कमांड के साथ संघर्ष के मामले में, सबसे लंबा प्रीफिक्स मैच प्राथमिकता लेता है। सभी कमांड अस्वीकार करने के लिए * जोड़ें।",
 			"commandPlaceholder": "कमांड प्रीफिक्स दर्ज करें (उदा. 'git ')",
-			"addButton": "जोड़ें"
+			"deniedCommandPlaceholder": "अस्वीकार करने के लिए कमांड प्रीफिक्स दर्ज करें (उदा. 'rm -rf')",
+			"addButton": "जोड़ें",
+			"autoDenied": "कमांड प्रीफिक्स `{{prefix}}` को स्वचालित रूप से अस्वीकार कर दिया गया है। इस प्रतिबंध को दरकिनार करने की कोशिश सिस्टम सुरक्षा को खतरे में डाल सकती है।"
 		},
 		"updateTodoList": {
 			"label": "टूडू",

+ 5 - 1
webview-ui/src/i18n/locales/id/settings.json

@@ -175,8 +175,12 @@
 			"description": "Secara otomatis mengeksekusi perintah terminal yang diizinkan tanpa memerlukan persetujuan",
 			"allowedCommands": "Perintah Auto-Execute yang Diizinkan",
 			"allowedCommandsDescription": "Prefix perintah yang dapat di-auto-execute ketika \"Selalu setujui operasi eksekusi\" diaktifkan. Tambahkan * untuk mengizinkan semua perintah (gunakan dengan hati-hati).",
+			"deniedCommands": "Perintah yang ditolak",
+			"deniedCommandsDescription": "Prefix perintah yang akan ditolak secara otomatis tanpa memerlukan persetujuan. Dalam kasus konflik dengan perintah yang diizinkan, pencocokan prefix terpanjang diprioritaskan. Tambahkan * untuk menolak semua perintah.",
 			"commandPlaceholder": "Masukkan prefix perintah (misalnya, 'git ')",
-			"addButton": "Tambah"
+			"deniedCommandPlaceholder": "Masukkan prefix perintah untuk ditolak (misalnya, 'rm -rf')",
+			"addButton": "Tambah",
+			"autoDenied": "Awalan perintah `{{prefix}}` telah ditolak secara otomatis. Mencoba menghindari pembatasan ini dapat membahayakan keamanan sistem."
 		},
 		"showMenu": {
 			"label": "Tampilkan menu auto-approve di tampilan chat",

+ 5 - 1
webview-ui/src/i18n/locales/it/settings.json

@@ -175,8 +175,12 @@
 			"description": "Esegui automaticamente i comandi del terminale consentiti senza richiedere approvazione",
 			"allowedCommands": "Comandi di auto-esecuzione consentiti",
 			"allowedCommandsDescription": "Prefissi di comando che possono essere auto-eseguiti quando \"Approva sempre operazioni di esecuzione\" è abilitato. Aggiungi * per consentire tutti i comandi (usare con cautela).",
+			"deniedCommands": "Comandi negati",
+			"deniedCommandsDescription": "Prefissi di comandi che verranno automaticamente negati senza richiedere approvazione. In caso di conflitti con comandi consentiti, la corrispondenza del prefisso più lungo ha la precedenza. Aggiungi * per negare tutti i comandi.",
 			"commandPlaceholder": "Inserisci prefisso comando (es. 'git ')",
-			"addButton": "Aggiungi"
+			"deniedCommandPlaceholder": "Inserisci prefisso comando da negare (es. 'rm -rf')",
+			"addButton": "Aggiungi",
+			"autoDenied": "Il prefisso del comando `{{prefix}}` è stato automaticamente negato. Si prega di non tentare di aggirare questa restrizione."
 		},
 		"updateTodoList": {
 			"label": "Todo",

+ 5 - 1
webview-ui/src/i18n/locales/ja/settings.json

@@ -175,8 +175,12 @@
 			"description": "承認なしで自動的に許可されたターミナルコマンドを実行",
 			"allowedCommands": "許可された自動実行コマンド",
 			"allowedCommandsDescription": "「実行操作を常に承認」が有効な場合に自動実行できるコマンドプレフィックス。すべてのコマンドを許可するには * を追加します(注意して使用してください)。",
+			"deniedCommands": "拒否されたコマンド",
+			"deniedCommandsDescription": "承認を求めることなく自動的に拒否されるコマンドプレフィックス。許可されたコマンドとの競合がある場合、最長プレフィックスマッチが優先されます。すべてのコマンドを拒否するには * を追加します。",
 			"commandPlaceholder": "コマンドプレフィックスを入力(例:'git ')",
-			"addButton": "追加"
+			"deniedCommandPlaceholder": "拒否するコマンドプレフィックスを入力(例:'rm -rf')",
+			"addButton": "追加",
+			"autoDenied": "コマンドプレフィックス `{{prefix}}` が自動的に拒否されました。この制限を回避しようとしないでください。"
 		},
 		"updateTodoList": {
 			"label": "Todo",

+ 5 - 1
webview-ui/src/i18n/locales/ko/settings.json

@@ -175,8 +175,12 @@
 			"description": "승인 없이 자동으로 허용된 터미널 명령 실행",
 			"allowedCommands": "허용된 자동 실행 명령",
 			"allowedCommandsDescription": "\"실행 작업 항상 승인\"이 활성화되었을 때 자동 실행될 수 있는 명령 접두사. 모든 명령을 허용하려면 * 추가(주의해서 사용)",
+			"deniedCommands": "거부된 명령",
+			"deniedCommandsDescription": "승인을 요청하지 않고 자동으로 거부될 명령 접두사. 허용된 명령과 충돌하는 경우 가장 긴 접두사 일치가 우선됩니다. 모든 명령을 거부하려면 * 추가",
 			"commandPlaceholder": "명령 접두사 입력(예: 'git ')",
-			"addButton": "추가"
+			"deniedCommandPlaceholder": "거부할 명령 접두사 입력(예: 'rm -rf')",
+			"addButton": "추가",
+			"autoDenied": "명령어 접두사 `{{prefix}}`가 자동으로 거부되었습니다. 이 제한을 우회하려고 시도하면 시스템 보안이 위험해질 수 있습니다."
 		},
 		"updateTodoList": {
 			"label": "Todo",

+ 5 - 1
webview-ui/src/i18n/locales/nl/settings.json

@@ -175,8 +175,12 @@
 			"description": "Automatisch toegestane terminalcommando's uitvoeren zonder goedkeuring",
 			"allowedCommands": "Toegestane automatisch uit te voeren commando's",
 			"allowedCommandsDescription": "Commando-prefixen die automatisch kunnen worden uitgevoerd als 'Altijd goedkeuren voor uitvoeren' is ingeschakeld. Voeg * toe om alle commando's toe te staan (gebruik met voorzichtigheid).",
+			"deniedCommands": "Geweigerde commando's",
+			"deniedCommandsDescription": "Commando-prefixen die automatisch worden geweigerd zonder om goedkeuring te vragen. Bij conflicten met toegestane commando's heeft de langste prefixovereenkomst voorrang. Voeg * toe om alle commando's te weigeren.",
 			"commandPlaceholder": "Voer commando-prefix in (bijv. 'git ')",
-			"addButton": "Toevoegen"
+			"deniedCommandPlaceholder": "Voer te weigeren commando-prefix in (bijv. 'rm -rf')",
+			"addButton": "Toevoegen",
+			"autoDenied": "Het commando-prefix `{{prefix}}` is automatisch geweigerd. Pogingen om deze beperking te omzeilen kunnen de systeemveiligheid in gevaar brengen."
 		},
 		"updateTodoList": {
 			"label": "Todo",

+ 5 - 1
webview-ui/src/i18n/locales/pl/settings.json

@@ -175,8 +175,12 @@
 			"description": "Automatycznie wykonuj dozwolone polecenia terminala bez konieczności zatwierdzania",
 			"allowedCommands": "Dozwolone polecenia auto-wykonania",
 			"allowedCommandsDescription": "Prefiksy poleceń, które mogą być automatycznie wykonywane, gdy \"Zawsze zatwierdzaj operacje wykonania\" jest włączone. Dodaj * aby zezwolić na wszystkie polecenia (używaj z ostrożnością).",
+			"deniedCommands": "Odrzucone polecenia",
+			"deniedCommandsDescription": "Prefiksy poleceń, które będą automatycznie odrzucane bez pytania o zatwierdzenie. W przypadku konfliktów z dozwolonymi poleceniami, najdłuższe dopasowanie prefiksu ma pierwszeństwo. Dodaj * aby odrzucić wszystkie polecenia.",
 			"commandPlaceholder": "Wprowadź prefiks polecenia (np. 'git ')",
-			"addButton": "Dodaj"
+			"deniedCommandPlaceholder": "Wprowadź prefiks polecenia do odrzucenia (np. 'rm -rf')",
+			"addButton": "Dodaj",
+			"autoDenied": "Prefiks polecenia `{{prefix}}` został automatycznie odrzucony. Próba obejścia tego ograniczenia może narazić system na ryzyko bezpieczeństwa."
 		},
 		"updateTodoList": {
 			"label": "Todo",

+ 5 - 1
webview-ui/src/i18n/locales/pt-BR/settings.json

@@ -175,8 +175,12 @@
 			"description": "Executar automaticamente comandos de terminal permitidos sem exigir aprovação",
 			"allowedCommands": "Comandos de auto-execução permitidos",
 			"allowedCommandsDescription": "Prefixos de comando que podem ser auto-executados quando \"Aprovar sempre operações de execução\" está ativado. Adicione * para permitir todos os comandos (use com cautela).",
+			"deniedCommands": "Comandos negados",
+			"deniedCommandsDescription": "Prefixos de comandos que serão automaticamente negados sem pedir aprovação. Em caso de conflitos com comandos permitidos, a correspondência de prefixo mais longa tem precedência. Adicione * para negar todos os comandos.",
 			"commandPlaceholder": "Digite o prefixo do comando (ex. 'git ')",
-			"addButton": "Adicionar"
+			"deniedCommandPlaceholder": "Digite o prefixo do comando para negar (ex. 'rm -rf')",
+			"addButton": "Adicionar",
+			"autoDenied": "O prefixo do comando `{{prefix}}` foi automaticamente negado. Tentar contornar essa restrição pode expor o sistema a riscos de segurança."
 		},
 		"updateTodoList": {
 			"label": "Todo",

+ 5 - 1
webview-ui/src/i18n/locales/ru/settings.json

@@ -175,8 +175,12 @@
 			"description": "Автоматически выполнять разрешённые команды терминала без необходимости одобрения",
 			"allowedCommands": "Разрешённые авто-выполняемые команды",
 			"allowedCommandsDescription": "Префиксы команд, которые могут быть автоматически выполнены при включённом параметре \"Всегда одобрять выполнение операций\". Добавьте * для разрешения всех команд (используйте с осторожностью).",
+			"deniedCommands": "Запрещенные команды",
+			"deniedCommandsDescription": "Префиксы команд, которые будут автоматически отклонены без запроса одобрения. В случае конфликтов с разрешенными командами, приоритет имеет самое длинное совпадение префикса. Добавьте * чтобы запретить все команды.",
 			"commandPlaceholder": "Введите префикс команды (например, 'git ')",
-			"addButton": "Добавить"
+			"deniedCommandPlaceholder": "Введите префикс команды для запрета (например, 'rm -rf')",
+			"addButton": "Добавить",
+			"autoDenied": "Префикс команды `{{prefix}}` был автоматически отклонен. Попытка обойти это ограничение может подвергнуть систему риску безопасности."
 		},
 		"updateTodoList": {
 			"label": "Todo",

+ 5 - 1
webview-ui/src/i18n/locales/tr/settings.json

@@ -175,8 +175,12 @@
 			"description": "Onay gerektirmeden otomatik olarak izin verilen terminal komutlarını yürüt",
 			"allowedCommands": "İzin Verilen Otomatik Yürütme Komutları",
 			"allowedCommandsDescription": "\"Yürütme işlemlerini her zaman onayla\" etkinleştirildiğinde otomatik olarak yürütülebilen komut önekleri. Tüm komutlara izin vermek için * ekleyin (dikkatli kullanın).",
+			"deniedCommands": "Reddedilen komutlar",
+			"deniedCommandsDescription": "Onay istenmeden otomatik olarak reddedilecek komut önekleri. İzin verilen komutlarla çakışma durumunda, en uzun önek eşleşmesi öncelik alır. Tüm komutları reddetmek için * ekleyin.",
 			"commandPlaceholder": "Komut öneki girin (örn. 'git ')",
-			"addButton": "Ekle"
+			"deniedCommandPlaceholder": "Reddetmek için komut öneki girin (örn. 'rm -rf')",
+			"addButton": "Ekle",
+			"autoDenied": "Komut öneki `{{prefix}}` otomatik olarak reddedildi. Bu kısıtlamayı aşmaya çalışmak sistemi güvenlik risklerine maruz bırakabilir."
 		},
 		"updateTodoList": {
 			"label": "Todo",

+ 5 - 1
webview-ui/src/i18n/locales/vi/settings.json

@@ -175,8 +175,12 @@
 			"description": "Tự động thực thi các lệnh terminal được phép mà không cần phê duyệt",
 			"allowedCommands": "Các lệnh tự động thực thi được phép",
 			"allowedCommandsDescription": "Tiền tố lệnh có thể được tự động thực thi khi \"Luôn phê duyệt các hoạt động thực thi\" được bật. Thêm * để cho phép tất cả các lệnh (sử dụng cẩn thận).",
+			"deniedCommands": "Lệnh bị từ chối",
+			"deniedCommandsDescription": "Tiền tố lệnh sẽ được tự động từ chối mà không yêu cầu phê duyệt. Trong trường hợp xung đột với lệnh được phép, khớp tiền tố dài nhất sẽ được ưu tiên. Thêm * để từ chối tất cả lệnh.",
 			"commandPlaceholder": "Nhập tiền tố lệnh (ví dụ: 'git ')",
-			"addButton": "Thêm"
+			"deniedCommandPlaceholder": "Nhập tiền tố lệnh để từ chối (ví dụ: 'rm -rf')",
+			"addButton": "Thêm",
+			"autoDenied": "Tiền tố lệnh `{{prefix}}` đã được tự động từ chối. Việc cố gắng vượt qua hạn chế này có thể khiến hệ thống gặp rủi ro bảo mật."
 		},
 		"updateTodoList": {
 			"label": "Todo",

+ 5 - 1
webview-ui/src/i18n/locales/zh-CN/settings.json

@@ -175,8 +175,12 @@
 			"description": "自动执行白名单中的命令而无需批准",
 			"allowedCommands": "命令白名单",
 			"allowedCommandsDescription": "当\"自动批准命令行操作\"启用时可以自动执行的命令前缀。添加 * 以允许所有命令(谨慎使用)。",
+			"deniedCommands": "拒绝的命令",
+			"deniedCommandsDescription": "将自动拒绝的命令前缀,无需用户批准。与允许命令冲突时,最长前缀匹配优先。添加 * 拒绝所有命令。",
 			"commandPlaceholder": "输入命令前缀(例如 'git ')",
-			"addButton": "添加"
+			"deniedCommandPlaceholder": "输入要拒绝的命令前缀(例如 'rm -rf')",
+			"addButton": "添加",
+			"autoDenied": "命令前缀 `{{prefix}}` 已被自动拒绝。尝试绕过此限制可能会使系统面临安全风险。"
 		},
 		"updateTodoList": {
 			"label": "待办",

+ 5 - 1
webview-ui/src/i18n/locales/zh-TW/settings.json

@@ -175,8 +175,12 @@
 			"description": "自動執行允許的終端機命令而無需核准",
 			"allowedCommands": "允許自動執行的命令",
 			"allowedCommandsDescription": "當「始終核准執行操作」啟用時可以自動執行的命令前綴。新增 * 以允許所有命令(請謹慎使用)。",
+			"deniedCommands": "拒絕的命令",
+			"deniedCommandsDescription": "將自動拒絕的命令前綴,無需使用者核准。與允許命令衝突時,最長前綴匹配優先。新增 * 拒絕所有命令。",
 			"commandPlaceholder": "輸入命令前綴(例如 'git ')",
-			"addButton": "新增"
+			"deniedCommandPlaceholder": "輸入要拒絕的命令前綴(例如 'rm -rf')",
+			"addButton": "新增",
+			"autoDenied": "命令前綴 `{{prefix}}` 已被自動拒絕。嘗試繞過此限制可能會使系統面臨安全風險。"
 		},
 		"updateTodoList": {
 			"label": "待辦",

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

@@ -2,7 +2,18 @@
 
 // npx vitest src/utils/__tests__/command-validation.spec.ts
 
-import { parseCommand, isAllowedSingleCommand, validateCommand } from "../command-validation"
+import {
+	parseCommand,
+	isAutoApprovedSingleCommand,
+	isAutoDeniedSingleCommand,
+	isAutoApprovedCommand,
+	isAutoDeniedCommand,
+	findLongestPrefixMatch,
+	getCommandDecision,
+	getSingleCommandDecision,
+	CommandValidator,
+	createCommandValidator,
+} from "../command-validation"
 
 describe("Command Validation", () => {
 	describe("parseCommand", () => {
@@ -41,85 +52,117 @@ describe("Command Validation", () => {
 		})
 	})
 
-	describe("isAllowedSingleCommand", () => {
+	describe("isAutoApprovedSingleCommand (legacy behavior)", () => {
 		const allowedCommands = ["npm test", "npm run", "echo"]
 
 		it("matches commands case-insensitively", () => {
-			expect(isAllowedSingleCommand("NPM TEST", allowedCommands)).toBe(true)
-			expect(isAllowedSingleCommand("npm TEST --coverage", allowedCommands)).toBe(true)
-			expect(isAllowedSingleCommand("ECHO hello", allowedCommands)).toBe(true)
+			expect(isAutoApprovedSingleCommand("NPM TEST", allowedCommands)).toBe(true)
+			expect(isAutoApprovedSingleCommand("npm TEST --coverage", allowedCommands)).toBe(true)
+			expect(isAutoApprovedSingleCommand("ECHO hello", allowedCommands)).toBe(true)
 		})
 
 		it("matches command prefixes", () => {
-			expect(isAllowedSingleCommand("npm test --coverage", allowedCommands)).toBe(true)
-			expect(isAllowedSingleCommand("npm run build", allowedCommands)).toBe(true)
-			expect(isAllowedSingleCommand('echo "hello world"', allowedCommands)).toBe(true)
+			expect(isAutoApprovedSingleCommand("npm test --coverage", allowedCommands)).toBe(true)
+			expect(isAutoApprovedSingleCommand("npm run build", allowedCommands)).toBe(true)
+			expect(isAutoApprovedSingleCommand('echo "hello world"', allowedCommands)).toBe(true)
 		})
 
 		it("rejects non-matching commands", () => {
-			expect(isAllowedSingleCommand("npmtest", allowedCommands)).toBe(false)
-			expect(isAllowedSingleCommand("dangerous", allowedCommands)).toBe(false)
-			expect(isAllowedSingleCommand("rm -rf /", allowedCommands)).toBe(false)
+			expect(isAutoApprovedSingleCommand("npmtest", allowedCommands)).toBe(false)
+			expect(isAutoApprovedSingleCommand("dangerous", allowedCommands)).toBe(false)
+			expect(isAutoApprovedSingleCommand("rm -rf /", allowedCommands)).toBe(false)
 		})
 
 		it("handles undefined/empty allowed commands", () => {
-			expect(isAllowedSingleCommand("npm test", undefined as any)).toBe(false)
-			expect(isAllowedSingleCommand("npm test", [])).toBe(false)
+			expect(isAutoApprovedSingleCommand("npm test", undefined as any)).toBe(false)
+			expect(isAutoApprovedSingleCommand("npm test", [])).toBe(false)
 		})
 	})
 
-	describe("validateCommand", () => {
+	describe("isAutoApprovedCommand (legacy behavior)", () => {
 		const allowedCommands = ["npm test", "npm run", "echo", "Select-String"]
 
 		it("validates simple commands", () => {
-			expect(validateCommand("npm test", allowedCommands)).toBe(true)
-			expect(validateCommand("npm run build", allowedCommands)).toBe(true)
-			expect(validateCommand("dangerous", allowedCommands)).toBe(false)
+			expect(isAutoApprovedCommand("npm test", allowedCommands)).toBe(true)
+			expect(isAutoApprovedCommand("npm run build", allowedCommands)).toBe(true)
+			expect(isAutoApprovedCommand("dangerous", allowedCommands)).toBe(false)
 		})
 
 		it("validates chained commands", () => {
-			expect(validateCommand("npm test && npm run build", allowedCommands)).toBe(true)
-			expect(validateCommand("npm test && dangerous", allowedCommands)).toBe(false)
-			expect(validateCommand('npm test | Select-String "Error"', allowedCommands)).toBe(true)
-			expect(validateCommand("npm test | rm -rf /", allowedCommands)).toBe(false)
+			expect(isAutoApprovedCommand("npm test && npm run build", allowedCommands)).toBe(true)
+			expect(isAutoApprovedCommand("npm test && dangerous", allowedCommands)).toBe(false)
+			expect(isAutoApprovedCommand('npm test | Select-String "Error"', allowedCommands)).toBe(true)
+			expect(isAutoApprovedCommand("npm test | rm -rf /", allowedCommands)).toBe(false)
 		})
 
 		it("handles quoted content correctly", () => {
-			expect(validateCommand('npm test "param with | inside"', allowedCommands)).toBe(true)
-			expect(validateCommand('echo "hello | world"', allowedCommands)).toBe(true)
-			expect(validateCommand('npm test "param with && inside"', allowedCommands)).toBe(true)
+			expect(isAutoApprovedCommand('npm test "param with | inside"', allowedCommands)).toBe(true)
+			expect(isAutoApprovedCommand('echo "hello | world"', allowedCommands)).toBe(true)
+			expect(isAutoApprovedCommand('npm test "param with && inside"', allowedCommands)).toBe(true)
 		})
 
 		it("handles subshell execution attempts", () => {
-			expect(validateCommand("npm test $(echo dangerous)", allowedCommands)).toBe(false)
-			expect(validateCommand("npm test `rm -rf /`", allowedCommands)).toBe(false)
+			// Without denylist, subshells should be allowed if all subcommands are allowed
+			expect(isAutoApprovedCommand("npm test $(echo hello)", allowedCommands)).toBe(true)
+			expect(isAutoApprovedCommand("npm test `echo world`", allowedCommands)).toBe(true)
+
+			// With denylist, subshells should be blocked regardless of subcommands
+			expect(isAutoApprovedCommand("npm test $(echo hello)", allowedCommands, ["rm"])).toBe(false)
+			expect(isAutoApprovedCommand("npm test `echo world`", allowedCommands, ["rm"])).toBe(false)
 		})
 
 		it("handles PowerShell patterns", () => {
-			expect(validateCommand('npm test 2>&1 | Select-String "Error"', allowedCommands)).toBe(true)
+			expect(isAutoApprovedCommand('npm test 2>&1 | Select-String "Error"', allowedCommands)).toBe(true)
 			expect(
-				validateCommand(
+				isAutoApprovedCommand(
 					'npm test | Select-String -NotMatch "node_modules" | Select-String "FAIL|Error"',
 					allowedCommands,
 				),
 			).toBe(true)
-			expect(validateCommand("npm test | Select-String | dangerous", allowedCommands)).toBe(false)
+			expect(isAutoApprovedCommand("npm test | Select-String | dangerous", allowedCommands)).toBe(false)
 		})
 
 		it("handles empty input", () => {
-			expect(validateCommand("", allowedCommands)).toBe(true)
-			expect(validateCommand("	", allowedCommands)).toBe(true)
+			expect(isAutoApprovedCommand("", allowedCommands)).toBe(true)
+			expect(isAutoApprovedCommand("	", allowedCommands)).toBe(true)
 		})
 
 		it("allows all commands when wildcard is present", () => {
 			const wildcardAllowedCommands = ["*"]
 			// Should allow any command, including dangerous ones
-			expect(validateCommand("rm -rf /", wildcardAllowedCommands)).toBe(true)
-			expect(validateCommand("dangerous-command", wildcardAllowedCommands)).toBe(true)
-			expect(validateCommand("npm test && rm -rf /", wildcardAllowedCommands)).toBe(true)
-			// Should even allow subshell commands that are normally blocked
-			expect(validateCommand("npm test $(echo dangerous)", wildcardAllowedCommands)).toBe(true)
-			expect(validateCommand("npm test `rm -rf /`", wildcardAllowedCommands)).toBe(true)
+			expect(isAutoApprovedCommand("rm -rf /", wildcardAllowedCommands)).toBe(true)
+			expect(isAutoApprovedCommand("dangerous-command", wildcardAllowedCommands)).toBe(true)
+			expect(isAutoApprovedCommand("npm test && rm -rf /", wildcardAllowedCommands)).toBe(true)
+			// Should allow subshell commands with wildcard when no denylist is present
+			expect(isAutoApprovedCommand("npm test $(echo dangerous)", wildcardAllowedCommands)).toBe(true)
+			expect(isAutoApprovedCommand("npm test `rm -rf /`", wildcardAllowedCommands)).toBe(true)
+
+			// But should block subshells when denylist is present
+			expect(isAutoApprovedCommand("npm test $(echo dangerous)", wildcardAllowedCommands, ["rm"])).toBe(false)
+			expect(isAutoApprovedCommand("npm test `rm -rf /`", wildcardAllowedCommands, ["rm"])).toBe(false)
+		})
+
+		it("respects denylist even with wildcard in allowlist", () => {
+			const wildcardAllowedCommands = ["*"]
+			const deniedCommands = ["rm -rf", "dangerous"]
+
+			// Wildcard should allow most commands
+			expect(isAutoApprovedCommand("npm test", wildcardAllowedCommands, deniedCommands)).toBe(true)
+			expect(isAutoApprovedCommand("echo hello", wildcardAllowedCommands, deniedCommands)).toBe(true)
+			expect(isAutoApprovedCommand("git status", wildcardAllowedCommands, deniedCommands)).toBe(true)
+
+			// But denylist should still block specific commands
+			expect(isAutoApprovedCommand("rm -rf /", wildcardAllowedCommands, deniedCommands)).toBe(false)
+			expect(isAutoApprovedCommand("dangerous-command", wildcardAllowedCommands, deniedCommands)).toBe(false)
+
+			// Chained commands with denied subcommands should be blocked
+			expect(isAutoApprovedCommand("npm test && rm -rf /", wildcardAllowedCommands, deniedCommands)).toBe(false)
+			expect(
+				isAutoApprovedCommand("echo hello && dangerous-command", wildcardAllowedCommands, deniedCommands),
+			).toBe(false)
+
+			// But chained commands with all allowed subcommands should work
+			expect(isAutoApprovedCommand("npm test && echo done", wildcardAllowedCommands, deniedCommands)).toBe(true)
 		})
 	})
 })
@@ -235,14 +278,14 @@ done`
 		})
 	})
 
-	describe("validateCommand", () => {
+	describe("isAutoApprovedCommand (legacy behavior)", () => {
 		it("should validate allowed commands", () => {
-			const result = validateCommand("echo hello", ["echo"])
+			const result = isAutoApprovedCommand("echo hello", ["echo"])
 			expect(result).toBe(true)
 		})
 
 		it("should reject disallowed commands", () => {
-			const result = validateCommand("rm -rf /", ["echo", "ls"])
+			const result = isAutoApprovedCommand("rm -rf /", ["echo", "ls"])
 			expect(result).toBe(false)
 		})
 
@@ -250,7 +293,7 @@ done`
 			const commandWithRandom = "echo $RANDOM"
 
 			expect(() => {
-				validateCommand(commandWithRandom, ["echo"])
+				isAutoApprovedCommand(commandWithRandom, ["echo"])
 			}).not.toThrow()
 		})
 
@@ -258,25 +301,773 @@ done`
 			const commandWithRandomIndex = "echo ${array[$RANDOM]}"
 
 			expect(() => {
-				validateCommand(commandWithRandomIndex, ["echo"])
+				isAutoApprovedCommand(commandWithRandomIndex, ["echo"])
 			}).not.toThrow()
 		})
 
-		it("should return false for the full log generator command due to subshell detection", () => {
+		it("should return false for the full log generator command due to subshell detection when denylist is present", () => {
 			// This is the exact command from the original error message
 			const logGeneratorCommand = `while true; do \\
-  levels=(INFO WARN ERROR DEBUG); \\
-  msgs=("User logged in" "Connection timeout" "Processing request" "Cache miss" "Database query"); \\
-  level=\${levels[$RANDOM % \${#levels[@]}]}; \\
-  msg=\${msgs[$RANDOM % \${#msgs[@]}]}; \\
-  echo "\$(date '+%Y-%m-%d %H:%M:%S') [$level] $msg"; \\
-  sleep 1; \\
+		levels=(INFO WARN ERROR DEBUG); \\
+		msgs=("User logged in" "Connection timeout" "Processing request" "Cache miss" "Database query"); \\
+		level=\${levels[$RANDOM % \${#levels[@]}]}; \\
+		msg=\${msgs[$RANDOM % \${#msgs[@]}]}; \\
+		echo "\$(date '+%Y-%m-%d %H:%M:%S') [$level] $msg"; \\
+		sleep 1; \\
 done`
 
-			// validateCommand should return false due to subshell detection
-			// without throwing an error
-			const result = validateCommand(logGeneratorCommand, ["while"])
-			expect(result).toBe(false)
+			// Without denylist, should allow subshells if all subcommands are allowed (use wildcard)
+			expect(isAutoApprovedCommand(logGeneratorCommand, ["*"])).toBe(true)
+
+			// With denylist, should return false due to subshell detection
+			expect(isAutoApprovedCommand(logGeneratorCommand, ["*"], ["rm"])).toBe(false)
+		})
+	})
+
+	describe("Denylist Command Validation", () => {
+		describe("findLongestPrefixMatch", () => {
+			it("finds the longest matching prefix", () => {
+				const prefixes = ["npm", "npm test", "npm run"]
+				expect(findLongestPrefixMatch("npm test --coverage", prefixes)).toBe("npm test")
+				expect(findLongestPrefixMatch("npm run build", prefixes)).toBe("npm run")
+				expect(findLongestPrefixMatch("npm install", prefixes)).toBe("npm")
+			})
+
+			it("returns null when no prefix matches", () => {
+				const prefixes = ["npm", "echo"]
+				expect(findLongestPrefixMatch("rm -rf /", prefixes)).toBe(null)
+				expect(findLongestPrefixMatch("dangerous", prefixes)).toBe(null)
+			})
+
+			it("handles case insensitive matching", () => {
+				const prefixes = ["npm test", "Echo"]
+				expect(findLongestPrefixMatch("NPM TEST --coverage", prefixes)).toBe("npm test")
+				expect(findLongestPrefixMatch("echo hello", prefixes)).toBe("echo")
+			})
+
+			it("handles empty inputs", () => {
+				expect(findLongestPrefixMatch("", ["npm"])).toBe(null)
+				expect(findLongestPrefixMatch("npm test", [])).toBe(null)
+				expect(findLongestPrefixMatch("npm test", undefined as any)).toBe(null)
+			})
+		})
+
+		describe("Legacy isAllowedSingleCommand behavior (now using isAutoApprovedSingleCommand)", () => {
+			const allowedCommands = ["npm", "echo", "git"]
+			const deniedCommands = ["npm test", "git push"]
+
+			it("allows commands that match allowlist but not denylist", () => {
+				expect(isAutoApprovedSingleCommand("npm install", allowedCommands, deniedCommands)).toBe(true)
+				expect(isAutoApprovedSingleCommand("echo hello", allowedCommands, deniedCommands)).toBe(true)
+				expect(isAutoApprovedSingleCommand("git status", allowedCommands, deniedCommands)).toBe(true)
+			})
+
+			it("denies commands that match denylist", () => {
+				expect(isAutoApprovedSingleCommand("npm test --coverage", allowedCommands, deniedCommands)).toBe(false)
+				expect(isAutoApprovedSingleCommand("git push origin main", allowedCommands, deniedCommands)).toBe(false)
+			})
+
+			it("uses longest prefix match rule", () => {
+				const allowedLong = ["npm", "npm test"]
+				const deniedShort = ["npm"]
+
+				// "npm test" is longer than "npm", so it should be allowed
+				expect(isAutoApprovedSingleCommand("npm test --coverage", allowedLong, deniedShort)).toBe(true)
+
+				const allowedShort = ["npm"]
+				const deniedLong = ["npm test"]
+
+				// "npm test" is longer than "npm", so it should be denied
+				expect(isAutoApprovedSingleCommand("npm test --coverage", allowedShort, deniedLong)).toBe(false)
+			})
+
+			it("handles wildcard patterns with longest prefix match", () => {
+				const allowedWithWildcard = ["*"]
+				const deniedWithWildcard = ["*"]
+
+				// Both wildcards have length 1, so it's a tie - longest prefix match rule applies
+				// Since both match with same length, denylist wins in tie-breaker
+				expect(isAutoApprovedSingleCommand("any command", allowedWithWildcard, deniedWithWildcard)).toBe(false)
+
+				// Test wildcard vs specific pattern
+				const allowedWithWildcard2 = ["*"]
+				const deniedSpecific = ["rm -rf"]
+
+				// "rm -rf" (length 6) is longer than "*" (length 1), so denylist wins
+				expect(isAutoApprovedSingleCommand("rm -rf /", allowedWithWildcard2, deniedSpecific)).toBe(false)
+				// Commands not matching "rm -rf" should be allowed by "*"
+				expect(isAutoApprovedSingleCommand("npm test", allowedWithWildcard2, deniedSpecific)).toBe(true)
+			})
+
+			it("handles specific pattern vs wildcard", () => {
+				const allowedSpecific = ["npm test"]
+				const deniedWildcard = ["*"]
+
+				// "npm test" (length 8) is longer than "*" (length 1), so allowlist wins
+				expect(isAutoApprovedSingleCommand("npm test --coverage", allowedSpecific, deniedWildcard)).toBe(true)
+				// Commands not matching "npm test" should be denied by "*"
+				expect(isAutoApprovedSingleCommand("git status", allowedSpecific, deniedWildcard)).toBe(false)
+			})
+
+			it("denies commands that match neither list", () => {
+				expect(isAutoApprovedSingleCommand("dangerous", allowedCommands, deniedCommands)).toBe(false)
+				expect(isAutoApprovedSingleCommand("rm -rf /", allowedCommands, deniedCommands)).toBe(false)
+			})
+
+			it("handles empty command", () => {
+				expect(isAutoApprovedSingleCommand("", allowedCommands, deniedCommands)).toBe(true)
+			})
+
+			it("handles empty lists", () => {
+				// When both lists are empty, nothing is auto-approved (ask user is default)
+				expect(isAutoApprovedSingleCommand("npm test", [], [])).toBe(false)
+				expect(isAutoApprovedSingleCommand("npm test", undefined as any, undefined as any)).toBe(false)
+			})
+
+			describe("Three-Tier Command Validation", () => {
+				const allowedCommands = ["npm", "echo", "git"]
+				const deniedCommands = ["npm test", "git push"]
+
+				describe("isAutoApprovedSingleCommand", () => {
+					it("auto-approves commands that match allowlist but not denylist", () => {
+						expect(isAutoApprovedSingleCommand("npm install", allowedCommands, deniedCommands)).toBe(true)
+						expect(isAutoApprovedSingleCommand("echo hello", allowedCommands, deniedCommands)).toBe(true)
+						expect(isAutoApprovedSingleCommand("git status", allowedCommands, deniedCommands)).toBe(true)
+					})
+
+					it("does not auto-approve commands that match denylist", () => {
+						expect(
+							isAutoApprovedSingleCommand("npm test --coverage", allowedCommands, deniedCommands),
+						).toBe(false)
+						expect(
+							isAutoApprovedSingleCommand("git push origin main", allowedCommands, deniedCommands),
+						).toBe(false)
+					})
+
+					it("does not auto-approve commands that match neither list", () => {
+						expect(isAutoApprovedSingleCommand("dangerous", allowedCommands, deniedCommands)).toBe(false)
+						expect(isAutoApprovedSingleCommand("rm -rf /", allowedCommands, deniedCommands)).toBe(false)
+					})
+
+					it("does not auto-approve when no allowlist configured", () => {
+						expect(isAutoApprovedSingleCommand("npm test", [], deniedCommands)).toBe(false)
+						expect(isAutoApprovedSingleCommand("npm test", undefined as any, deniedCommands)).toBe(false)
+					})
+
+					it("uses longest prefix match rule for auto-approval", () => {
+						const allowedLong = ["npm", "npm test"]
+						const deniedShort = ["npm"]
+
+						// "npm test" is longer than "npm", so it should be auto-approved
+						expect(isAutoApprovedSingleCommand("npm test --coverage", allowedLong, deniedShort)).toBe(true)
+					})
+				})
+
+				describe("isAutoDeniedSingleCommand", () => {
+					it("auto-denies commands that match denylist but not allowlist", () => {
+						expect(isAutoDeniedSingleCommand("npm test --coverage", allowedCommands, deniedCommands)).toBe(
+							true,
+						)
+						expect(isAutoDeniedSingleCommand("git push origin main", allowedCommands, deniedCommands)).toBe(
+							true,
+						)
+					})
+
+					it("does not auto-deny commands that match allowlist", () => {
+						expect(isAutoDeniedSingleCommand("npm install", allowedCommands, deniedCommands)).toBe(false)
+						expect(isAutoDeniedSingleCommand("echo hello", allowedCommands, deniedCommands)).toBe(false)
+						expect(isAutoDeniedSingleCommand("git status", allowedCommands, deniedCommands)).toBe(false)
+					})
+
+					it("does not auto-deny commands that match neither list", () => {
+						expect(isAutoDeniedSingleCommand("dangerous", allowedCommands, deniedCommands)).toBe(false)
+						expect(isAutoDeniedSingleCommand("rm -rf /", allowedCommands, deniedCommands)).toBe(false)
+					})
+
+					it("does not auto-deny when no denylist configured", () => {
+						expect(isAutoDeniedSingleCommand("npm test", allowedCommands, [])).toBe(false)
+						expect(isAutoDeniedSingleCommand("npm test", allowedCommands, undefined as any)).toBe(false)
+					})
+
+					it("uses longest prefix match rule for auto-denial", () => {
+						const allowedShort = ["npm"]
+						const deniedLong = ["npm test"]
+
+						// "npm test" is longer than "npm", so it should be auto-denied
+						expect(isAutoDeniedSingleCommand("npm test --coverage", allowedShort, deniedLong)).toBe(true)
+					})
+
+					it("auto-denies when denylist match is equal length to allowlist match", () => {
+						const allowedEqual = ["npm test"]
+						const deniedEqual = ["npm test"]
+
+						// Equal length matches should result in auto-denial
+						expect(isAutoDeniedSingleCommand("npm test --coverage", allowedEqual, deniedEqual)).toBe(true)
+					})
+				})
+
+				describe("Three-tier behavior verification", () => {
+					it("demonstrates the three-tier system", () => {
+						const allowed = ["npm"]
+						const denied = ["npm test"]
+
+						// Auto-approved: matches allowlist, doesn't match denylist
+						expect(isAutoApprovedSingleCommand("npm install", allowed, denied)).toBe(true)
+						expect(isAutoDeniedSingleCommand("npm install", allowed, denied)).toBe(false)
+
+						// Auto-denied: matches denylist with longer or equal match
+						expect(isAutoApprovedSingleCommand("npm test --coverage", allowed, denied)).toBe(false)
+						expect(isAutoDeniedSingleCommand("npm test --coverage", allowed, denied)).toBe(true)
+
+						// Ask user: matches neither list
+						expect(isAutoApprovedSingleCommand("dangerous", allowed, denied)).toBe(false)
+						expect(isAutoDeniedSingleCommand("dangerous", allowed, denied)).toBe(false)
+
+						// Ask user: no lists configured
+						expect(isAutoApprovedSingleCommand("npm test", [], [])).toBe(false)
+						expect(isAutoDeniedSingleCommand("npm test", [], [])).toBe(false)
+					})
+				})
+			})
+
+			describe("Command-level three-tier validation", () => {
+				const allowedCommands = ["npm", "echo"]
+				const deniedCommands = ["npm test"]
+
+				describe("isAutoApprovedCommand", () => {
+					it("auto-approves commands with all sub-commands auto-approved", () => {
+						expect(isAutoApprovedCommand("npm install", allowedCommands, deniedCommands)).toBe(true)
+						expect(isAutoApprovedCommand("npm install && echo done", allowedCommands, deniedCommands)).toBe(
+							true,
+						)
+					})
+
+					it("does not auto-approve commands with any sub-command not auto-approved", () => {
+						expect(isAutoApprovedCommand("npm test", allowedCommands, deniedCommands)).toBe(false)
+						expect(isAutoApprovedCommand("npm install && npm test", allowedCommands, deniedCommands)).toBe(
+							false,
+						)
+					})
+
+					it("blocks subshell commands only when denylist is present", () => {
+						// Without denylist, should allow subshells
+						expect(isAutoApprovedCommand("npm install $(echo test)", allowedCommands)).toBe(true)
+						expect(isAutoApprovedCommand("npm install `echo test`", allowedCommands)).toBe(true)
+
+						// With denylist, should block subshells
+						expect(isAutoApprovedCommand("npm install $(echo test)", allowedCommands, deniedCommands)).toBe(
+							false,
+						)
+						expect(isAutoApprovedCommand("npm install `echo test`", allowedCommands, deniedCommands)).toBe(
+							false,
+						)
+					})
+				})
+
+				describe("isAutoDeniedCommand", () => {
+					it("auto-denies commands with any sub-command auto-denied", () => {
+						expect(isAutoDeniedCommand("npm test", allowedCommands, deniedCommands)).toBe(true)
+						expect(isAutoDeniedCommand("npm install && npm test", allowedCommands, deniedCommands)).toBe(
+							true,
+						)
+					})
+
+					it("does not auto-deny commands with all sub-commands not auto-denied", () => {
+						expect(isAutoDeniedCommand("npm install", allowedCommands, deniedCommands)).toBe(false)
+						expect(isAutoDeniedCommand("npm install && echo done", allowedCommands, deniedCommands)).toBe(
+							false,
+						)
+					})
+
+					it("auto-denies subshell commands only when denylist is present", () => {
+						// Without denylist, should not auto-deny subshells
+						expect(isAutoDeniedCommand("npm install $(echo test)", allowedCommands)).toBe(false)
+						expect(isAutoDeniedCommand("npm install `echo test`", allowedCommands)).toBe(false)
+
+						// With denylist, should auto-deny subshells
+						expect(isAutoDeniedCommand("npm install $(echo test)", allowedCommands, deniedCommands)).toBe(
+							true,
+						)
+						expect(isAutoDeniedCommand("npm install `echo test`", allowedCommands, deniedCommands)).toBe(
+							true,
+						)
+					})
+				})
+			})
+		})
+	})
+})
+
+describe("Unified Command Decision Functions", () => {
+	describe("getSingleCommandDecision", () => {
+		const allowedCommands = ["npm", "echo", "git"]
+		const deniedCommands = ["npm test", "git push"]
+
+		it("returns auto_approve for commands that match allowlist but not denylist", () => {
+			expect(getSingleCommandDecision("npm install", allowedCommands, deniedCommands)).toBe("auto_approve")
+			expect(getSingleCommandDecision("echo hello", allowedCommands, deniedCommands)).toBe("auto_approve")
+			expect(getSingleCommandDecision("git status", allowedCommands, deniedCommands)).toBe("auto_approve")
+		})
+
+		it("returns auto_deny for commands that match denylist", () => {
+			expect(getSingleCommandDecision("npm test --coverage", allowedCommands, deniedCommands)).toBe("auto_deny")
+			expect(getSingleCommandDecision("git push origin main", allowedCommands, deniedCommands)).toBe("auto_deny")
+		})
+
+		it("returns ask_user for commands that match neither list", () => {
+			expect(getSingleCommandDecision("dangerous", allowedCommands, deniedCommands)).toBe("ask_user")
+			expect(getSingleCommandDecision("rm -rf /", allowedCommands, deniedCommands)).toBe("ask_user")
+		})
+
+		it("implements longest prefix match rule correctly", () => {
+			const allowedLong = ["npm", "npm test"]
+			const deniedShort = ["npm"]
+
+			// "npm test" (8 chars) is longer than "npm" (3 chars), so allowlist wins
+			expect(getSingleCommandDecision("npm test --coverage", allowedLong, deniedShort)).toBe("auto_approve")
+
+			const allowedShort = ["npm"]
+			const deniedLong = ["npm test"]
+
+			// "npm test" (8 chars) is longer than "npm" (3 chars), so denylist wins
+			expect(getSingleCommandDecision("npm test --coverage", allowedShort, deniedLong)).toBe("auto_deny")
+		})
+
+		it("handles equal length matches with denylist winning", () => {
+			const allowedEqual = ["npm test"]
+			const deniedEqual = ["npm test"]
+
+			// Equal length - denylist wins (secure by default)
+			expect(getSingleCommandDecision("npm test --coverage", allowedEqual, deniedEqual)).toBe("auto_deny")
+		})
+
+		it("handles wildcard patterns correctly", () => {
+			const allowedWildcard = ["*"]
+			const deniedSpecific = ["rm -rf"]
+
+			// "*" (1 char) vs "rm -rf" (6 chars) - denylist wins for matching commands
+			expect(getSingleCommandDecision("rm -rf /", allowedWildcard, deniedSpecific)).toBe("auto_deny")
+			// Non-matching commands should be auto-approved by wildcard
+			expect(getSingleCommandDecision("npm test", allowedWildcard, deniedSpecific)).toBe("auto_approve")
+		})
+
+		it("handles empty command", () => {
+			expect(getSingleCommandDecision("", allowedCommands, deniedCommands)).toBe("auto_approve")
+		})
+
+		it("handles empty lists", () => {
+			expect(getSingleCommandDecision("npm test", [], [])).toBe("ask_user")
+			expect(getSingleCommandDecision("npm test", undefined as any, undefined as any)).toBe("ask_user")
+		})
+	})
+
+	describe("getCommandDecision", () => {
+		const allowedCommands = ["npm", "echo"]
+		const deniedCommands = ["npm test"]
+
+		it("returns auto_approve for commands with all sub-commands auto-approved", () => {
+			expect(getCommandDecision("npm install", allowedCommands, deniedCommands)).toBe("auto_approve")
+			expect(getCommandDecision("npm install && echo done", allowedCommands, deniedCommands)).toBe("auto_approve")
+		})
+
+		it("returns auto_deny for commands with any sub-command auto-denied", () => {
+			expect(getCommandDecision("npm test", allowedCommands, deniedCommands)).toBe("auto_deny")
+			expect(getCommandDecision("npm install && npm test", allowedCommands, deniedCommands)).toBe("auto_deny")
+		})
+
+		it("returns ask_user for commands with mixed or unknown sub-commands", () => {
+			expect(getCommandDecision("dangerous", allowedCommands, deniedCommands)).toBe("ask_user")
+			expect(getCommandDecision("npm install && dangerous", allowedCommands, deniedCommands)).toBe("ask_user")
+		})
+
+		it("returns auto_deny for subshell commands when denylist is present", () => {
+			expect(getCommandDecision("npm install $(echo test)", allowedCommands, deniedCommands)).toBe("auto_deny")
+			expect(getCommandDecision("npm install `echo test`", allowedCommands, deniedCommands)).toBe("auto_deny")
+		})
+
+		it("allows subshell commands when no denylist is present", () => {
+			expect(getCommandDecision("npm install $(echo test)", allowedCommands)).toBe("auto_approve")
+			expect(getCommandDecision("npm install `echo test`", allowedCommands)).toBe("auto_approve")
+		})
+
+		it("handles empty command", () => {
+			expect(getCommandDecision("", allowedCommands, deniedCommands)).toBe("auto_approve")
+		})
+
+		it("handles complex chained commands", () => {
+			// All sub-commands auto-approved
+			expect(getCommandDecision("npm install && echo success && npm run build", ["npm", "echo"], [])).toBe(
+				"auto_approve",
+			)
+
+			// One sub-command auto-denied
+			expect(getCommandDecision("npm install && npm test && echo done", allowedCommands, deniedCommands)).toBe(
+				"auto_deny",
+			)
+
+			// Mixed decisions (some ask_user)
+			expect(getCommandDecision("npm install && dangerous && echo done", allowedCommands, deniedCommands)).toBe(
+				"ask_user",
+			)
+		})
+
+		it("demonstrates the three-tier system comprehensively", () => {
+			const allowed = ["npm"]
+			const denied = ["npm test"]
+
+			// Auto-approved: all sub-commands match allowlist, none match denylist
+			expect(getCommandDecision("npm install", allowed, denied)).toBe("auto_approve")
+			expect(getCommandDecision("npm install && npm run build", allowed, denied)).toBe("auto_approve")
+
+			// Auto-denied: any sub-command matches denylist
+			expect(getCommandDecision("npm test", allowed, denied)).toBe("auto_deny")
+			expect(getCommandDecision("npm install && npm test", allowed, denied)).toBe("auto_deny")
+
+			// Ask user: commands that match neither list
+			expect(getCommandDecision("dangerous", allowed, denied)).toBe("ask_user")
+			expect(getCommandDecision("npm install && dangerous", allowed, denied)).toBe("ask_user")
+		})
+	})
+
+	describe("Integration with existing functions", () => {
+		it("maintains backward compatibility with existing behavior", () => {
+			const allowedCommands = ["npm", "echo"]
+			const deniedCommands = ["npm test"]
+
+			// Test that new unified functions produce same results as old separate functions
+			const testCommands = [
+				"npm install", // should be auto-approved
+				"npm test", // should be auto-denied
+				"dangerous", // should ask user
+				"echo hello", // should be auto-approved
+			]
+
+			testCommands.forEach((cmd) => {
+				const decision = getCommandDecision(cmd, allowedCommands, deniedCommands)
+				const oldApproved = isAutoApprovedCommand(cmd, allowedCommands, deniedCommands)
+				const oldDenied = isAutoDeniedCommand(cmd, allowedCommands, deniedCommands)
+
+				// Verify consistency
+				if (decision === "auto_approve") {
+					expect(oldApproved).toBe(true)
+					expect(oldDenied).toBe(false)
+				} else if (decision === "auto_deny") {
+					expect(oldApproved).toBe(false)
+					expect(oldDenied).toBe(true)
+				} else if (decision === "ask_user") {
+					expect(oldApproved).toBe(false)
+					expect(oldDenied).toBe(false)
+				}
+			})
+		})
+	})
+
+	describe("CommandValidator Integration Tests", () => {
+		describe("CommandValidator Class", () => {
+			let validator: CommandValidator
+
+			beforeEach(() => {
+				validator = new CommandValidator(["npm", "echo", "git"], ["npm test", "git push"])
+			})
+
+			describe("Basic validation methods", () => {
+				it("validates commands correctly", () => {
+					expect(validator.validateCommand("npm install")).toBe("auto_approve")
+					expect(validator.validateCommand("npm test")).toBe("auto_deny")
+					expect(validator.validateCommand("dangerous")).toBe("ask_user")
+				})
+
+				it("provides convenience methods", () => {
+					expect(validator.isAutoApproved("npm install")).toBe(true)
+					expect(validator.isAutoApproved("npm test")).toBe(false)
+					expect(validator.isAutoApproved("dangerous")).toBe(false)
+
+					expect(validator.isAutoDenied("npm install")).toBe(false)
+					expect(validator.isAutoDenied("npm test")).toBe(true)
+					expect(validator.isAutoDenied("dangerous")).toBe(false)
+
+					expect(validator.requiresUserInput("npm install")).toBe(false)
+					expect(validator.requiresUserInput("npm test")).toBe(false)
+					expect(validator.requiresUserInput("dangerous")).toBe(true)
+				})
+			})
+
+			describe("Configuration management", () => {
+				it("updates command lists", () => {
+					validator.updateCommandLists(["echo"], ["echo hello"])
+
+					expect(validator.validateCommand("npm install")).toBe("ask_user")
+					expect(validator.validateCommand("echo world")).toBe("auto_approve")
+					expect(validator.validateCommand("echo hello")).toBe("auto_deny")
+				})
+
+				it("gets current command lists", () => {
+					const lists = validator.getCommandLists()
+					expect(lists.allowedCommands).toEqual(["npm", "echo", "git"])
+					expect(lists.deniedCommands).toEqual(["npm test", "git push"])
+				})
+
+				it("handles undefined denied commands", () => {
+					const validatorNoDeny = new CommandValidator(["npm"])
+					const lists = validatorNoDeny.getCommandLists()
+					expect(lists.allowedCommands).toEqual(["npm"])
+					expect(lists.deniedCommands).toBeUndefined()
+				})
+			})
+
+			describe("Detailed validation information", () => {
+				it("provides comprehensive validation details", () => {
+					const details = validator.getValidationDetails("npm install && echo done")
+
+					expect(details.decision).toBe("auto_approve")
+					expect(details.subCommands).toEqual(["npm install", "echo done"])
+					expect(details.hasSubshells).toBe(false)
+					expect(details.allowedMatches).toHaveLength(2)
+					expect(details.deniedMatches).toHaveLength(2)
+
+					// Check specific matches
+					expect(details.allowedMatches[0]).toEqual({ command: "npm install", match: "npm" })
+					expect(details.allowedMatches[1]).toEqual({ command: "echo done", match: "echo" })
+					expect(details.deniedMatches[0]).toEqual({ command: "npm install", match: null })
+					expect(details.deniedMatches[1]).toEqual({ command: "echo done", match: null })
+				})
+
+				it("detects subshells correctly", () => {
+					const details = validator.getValidationDetails("npm install $(echo test)")
+					expect(details.hasSubshells).toBe(true)
+					expect(details.decision).toBe("auto_deny") // blocked due to subshells with denylist
+				})
+
+				it("handles complex command chains", () => {
+					const details = validator.getValidationDetails("npm test && git push origin")
+
+					expect(details.decision).toBe("auto_deny")
+					expect(details.subCommands).toEqual(["npm test", "git push origin"])
+					expect(details.deniedMatches[0]).toEqual({ command: "npm test", match: "npm test" })
+					expect(details.deniedMatches[1]).toEqual({ command: "git push origin", match: "git push" })
+				})
+			})
+
+			describe("Batch validation", () => {
+				it("validates multiple commands at once", () => {
+					const commands = ["npm install", "npm test", "dangerous", "echo hello"]
+					const results = validator.validateCommands(commands)
+
+					expect(results.get("npm install")).toBe("auto_approve")
+					expect(results.get("npm test")).toBe("auto_deny")
+					expect(results.get("dangerous")).toBe("ask_user")
+					expect(results.get("echo hello")).toBe("auto_approve")
+					expect(results.size).toBe(4)
+				})
+
+				it("handles empty command list", () => {
+					const results = validator.validateCommands([])
+					expect(results.size).toBe(0)
+				})
+			})
+
+			describe("Configuration analysis", () => {
+				it("detects if rules are configured", () => {
+					expect(validator.hasRules()).toBe(true)
+
+					const emptyValidator = new CommandValidator([], [])
+					expect(emptyValidator.hasRules()).toBe(false)
+
+					const allowOnlyValidator = new CommandValidator(["npm"], [])
+					expect(allowOnlyValidator.hasRules()).toBe(true)
+
+					const denyOnlyValidator = new CommandValidator([], ["rm"])
+					expect(denyOnlyValidator.hasRules()).toBe(true)
+				})
+
+				it("provides configuration statistics", () => {
+					const stats = validator.getStats()
+					expect(stats.allowedCount).toBe(3)
+					expect(stats.deniedCount).toBe(2)
+					expect(stats.hasWildcard).toBe(false)
+					expect(stats.hasRules).toBe(true)
+				})
+
+				it("detects wildcard configuration", () => {
+					const wildcardValidator = new CommandValidator(["*", "npm"], ["rm"])
+					const stats = wildcardValidator.getStats()
+					expect(stats.hasWildcard).toBe(true)
+				})
+			})
+
+			describe("Edge cases and error handling", () => {
+				it("handles empty commands gracefully", () => {
+					expect(validator.validateCommand("")).toBe("auto_approve")
+					expect(validator.validateCommand("   ")).toBe("auto_approve")
+				})
+
+				it("handles commands with only whitespace", () => {
+					const details = validator.getValidationDetails("   ")
+					expect(details.decision).toBe("auto_approve")
+					expect(details.subCommands).toEqual([])
+				})
+
+				it("handles malformed commands", () => {
+					// Commands with unmatched quotes or brackets should not crash
+					expect(() => validator.validateCommand('npm test "unclosed quote')).not.toThrow()
+					expect(() => validator.validateCommand("npm test $(unclosed")).not.toThrow()
+				})
+			})
+		})
+
+		describe("Factory function", () => {
+			it("creates validator instances correctly", () => {
+				const validator = createCommandValidator(["npm"], ["rm"])
+				expect(validator).toBeInstanceOf(CommandValidator)
+				expect(validator.validateCommand("npm test")).toBe("auto_approve")
+				expect(validator.validateCommand("rm file")).toBe("auto_deny")
+			})
+
+			it("handles optional denied commands", () => {
+				const validator = createCommandValidator(["npm"])
+				expect(validator.validateCommand("npm test")).toBe("auto_approve")
+				expect(validator.validateCommand("dangerous")).toBe("ask_user")
+			})
+		})
+
+		describe("Real-world integration scenarios", () => {
+			describe("Development workflow validation", () => {
+				let devValidator: CommandValidator
+
+				beforeEach(() => {
+					devValidator = createCommandValidator(
+						["npm", "git", "echo", "ls", "cat"],
+						["git push", "rm", "sudo", "npm publish"],
+					)
+				})
+
+				it("allows common development commands", () => {
+					const commonCommands = [
+						"npm install",
+						"npm test",
+						"npm run build",
+						"git status",
+						"git add .",
+						"git commit -m 'fix'",
+						"echo 'done'",
+						"ls -la",
+						"cat package.json",
+					]
+
+					commonCommands.forEach((cmd) => {
+						expect(devValidator.isAutoApproved(cmd)).toBe(true)
+					})
+				})
+
+				it("blocks dangerous commands", () => {
+					const dangerousCommands = [
+						"git push origin main",
+						"rm -rf node_modules",
+						"sudo apt install",
+						"npm publish",
+					]
+
+					dangerousCommands.forEach((cmd) => {
+						expect(devValidator.isAutoDenied(cmd)).toBe(true)
+					})
+				})
+
+				it("requires user input for unknown commands", () => {
+					const unknownCommands = ["docker run", "python script.py", "curl https://api.example.com"]
+
+					unknownCommands.forEach((cmd) => {
+						expect(devValidator.requiresUserInput(cmd)).toBe(true)
+					})
+				})
+			})
+
+			describe("Production environment validation", () => {
+				let prodValidator: CommandValidator
+
+				beforeEach(() => {
+					prodValidator = createCommandValidator(
+						["ls", "cat", "grep", "tail"],
+						["*"], // Deny everything by default
+					)
+				})
+
+				it("allows only read-only commands", () => {
+					expect(prodValidator.isAutoApproved("ls -la")).toBe(true)
+					expect(prodValidator.isAutoApproved("cat /var/log/app.log")).toBe(true)
+					expect(prodValidator.isAutoApproved("grep ERROR /var/log/app.log")).toBe(true)
+					expect(prodValidator.isAutoApproved("tail -f /var/log/app.log")).toBe(true)
+				})
+
+				it("blocks all other commands due to wildcard deny", () => {
+					const blockedCommands = ["npm install", "git push", "rm file", "echo hello", "mkdir test"]
+
+					blockedCommands.forEach((cmd) => {
+						expect(prodValidator.isAutoDenied(cmd)).toBe(true)
+					})
+				})
+			})
+
+			describe("Longest prefix match in complex scenarios", () => {
+				let complexValidator: CommandValidator
+
+				beforeEach(() => {
+					complexValidator = createCommandValidator(
+						["git", "git push", "git push --dry-run", "npm", "npm test"],
+						["git push", "npm test --coverage"],
+					)
+				})
+
+				it("demonstrates longest prefix match resolution", () => {
+					// git push --dry-run (allowed, 18 chars) vs git push (denied, 8 chars) -> allow
+					expect(complexValidator.isAutoApproved("git push --dry-run origin main")).toBe(true)
+
+					// git push origin (denied, 8 chars) vs git (allowed, 3 chars) -> deny
+					expect(complexValidator.isAutoDenied("git push origin main")).toBe(true)
+
+					// npm test (allowed, 8 chars) vs npm test --coverage (denied, 19 chars) -> deny
+					expect(complexValidator.isAutoDenied("npm test --coverage --watch")).toBe(true)
+
+					// npm test basic (allowed, 8 chars) vs no deny match -> allow
+					expect(complexValidator.isAutoApproved("npm test basic")).toBe(true)
+				})
+
+				it("handles command chains with mixed decisions", () => {
+					// One command denied -> whole chain denied
+					expect(complexValidator.isAutoDenied("git status && git push origin")).toBe(true)
+
+					// All commands approved -> whole chain approved
+					expect(complexValidator.isAutoApproved("git status && git push --dry-run")).toBe(true)
+
+					// Mixed with unknown -> ask user
+					expect(complexValidator.requiresUserInput("git status && unknown-command")).toBe(true)
+				})
+			})
+
+			describe("Performance and scalability", () => {
+				it("handles large command lists efficiently", () => {
+					const largeAllowList = Array.from({ length: 1000 }, (_, i) => `command${i}`)
+					const largeDenyList = Array.from({ length: 500 }, (_, i) => `dangerous${i}`)
+
+					const largeValidator = createCommandValidator(largeAllowList, largeDenyList)
+
+					// Should still work efficiently
+					expect(largeValidator.isAutoApproved("command500 --flag")).toBe(true)
+					expect(largeValidator.isAutoDenied("dangerous250 --flag")).toBe(true)
+					expect(largeValidator.requiresUserInput("unknown")).toBe(true)
+				})
+
+				it("handles batch validation efficiently", () => {
+					const batchValidator = createCommandValidator(["npm"], ["rm"])
+					const commands = Array.from({ length: 100 }, (_, i) => `npm test${i}`)
+					const results = batchValidator.validateCommands(commands)
+
+					expect(results.size).toBe(100)
+					// All should be auto-approved since they match "npm" allowlist
+					Array.from(results.values()).forEach((decision) => {
+						expect(decision).toBe("auto_approve")
+					})
+				})
+			})
 		})
 	})
 })

+ 502 - 14
webview-ui/src/utils/command-validation.ts

@@ -2,6 +2,62 @@ import { parse } from "shell-quote"
 
 type ShellToken = string | { op: string } | { command: string }
 
+/**
+ * # Command Denylist Feature - Longest Prefix Match Strategy
+ *
+ * This module implements a sophisticated command validation system that uses the
+ * "longest prefix match" strategy to resolve conflicts between allowlist and denylist patterns.
+ *
+ * ## Core Concept: Longest Prefix Match
+ *
+ * When a command matches patterns in both the allowlist and denylist, the system uses
+ * the longest (most specific) match to determine the final decision. This approach
+ * provides fine-grained control over command execution permissions.
+ *
+ * ### Examples:
+ *
+ * **Example 1: Specific denial overrides general allowance**
+ * - Allowlist: ["git"]
+ * - Denylist: ["git push"]
+ * - Command: "git push origin main"
+ * - Result: DENIED (denylist match "git push" is longer than allowlist match "git")
+ *
+ * **Example 2: Specific allowance overrides general denial**
+ * - Allowlist: ["git push --dry-run"]
+ * - Denylist: ["git push"]
+ * - Command: "git push --dry-run origin main"
+ * - Result: APPROVED (allowlist match is longer and more specific)
+ *
+ * **Example 3: Wildcard handling**
+ * - Allowlist: ["*"]
+ * - Denylist: ["rm", "sudo"]
+ * - Command: "rm -rf /"
+ * - Result: DENIED (specific denylist match overrides wildcard allowlist)
+ *
+ * ## Command Processing Pipeline:
+ *
+ * 1. **Subshell Detection**: Commands containing $() or `` are blocked if denylist exists
+ * 2. **Command Parsing**: Split chained commands (&&, ||, ;, |) into individual commands
+ * 3. **Pattern Matching**: For each command, find longest matching prefixes in both lists
+ * 4. **Decision Logic**: Apply longest prefix match rule to determine approval/denial
+ * 5. **Aggregation**: Combine decisions (any denial blocks the entire command chain)
+ *
+ * ## Security Considerations:
+ *
+ * - **Subshell Protection**: Prevents command injection via $(command) or `command`
+ * - **Chain Analysis**: Each command in a chain (cmd1 && cmd2) is validated separately
+ * - **Case Insensitive**: All matching is case-insensitive for consistency
+ * - **Whitespace Handling**: Commands are trimmed and normalized before matching
+ *
+ * ## Configuration Merging:
+ *
+ * The system merges command lists from two sources with global state taking precedence:
+ * 1. Global state (user preferences)
+ * 2. Workspace configuration (project-specific settings)
+ *
+ * This allows users to have personal defaults while projects can define specific restrictions.
+ */
+
 /**
  * Split a command string into individual sub-commands by
  * chaining operators (&&, ||, ;, or |).
@@ -100,36 +156,468 @@ export function parseCommand(command: string): string[] {
 }
 
 /**
- * Check if a single command is allowed based on prefix matching.
+ * Find the longest matching prefix from a list of prefixes for a given command.
+ *
+ * This is the core function that implements the "longest prefix match" strategy.
+ * It searches through all provided prefixes and returns the longest one that
+ * matches the beginning of the command (case-insensitive).
+ *
+ * **Special Cases:**
+ * - Wildcard "*" matches any command but is treated as length 1 for comparison
+ * - Empty command or empty prefixes list returns null
+ * - Matching is case-insensitive and uses startsWith logic
+ *
+ * **Examples:**
+ * ```typescript
+ * findLongestPrefixMatch("git push origin", ["git", "git push"])
+ * // Returns "git push" (longer match)
+ *
+ * findLongestPrefixMatch("npm install", ["*", "npm"])
+ * // Returns "npm" (specific match preferred over wildcard)
+ *
+ * findLongestPrefixMatch("unknown command", ["git", "npm"])
+ * // Returns null (no match found)
+ * ```
+ *
+ * @param command - The command to match against
+ * @param prefixes - List of prefix patterns to search through
+ * @returns The longest matching prefix, or null if no match found
  */
-export function isAllowedSingleCommand(command: string, allowedCommands: string[]): boolean {
-	if (!command || !allowedCommands?.length) return false
+export function findLongestPrefixMatch(command: string, prefixes: string[]): string | null {
+	if (!command || !prefixes?.length) return null
+
 	const trimmedCommand = command.trim().toLowerCase()
-	return allowedCommands.some((prefix) => trimmedCommand.startsWith(prefix.toLowerCase()))
+	let longestMatch: string | null = null
+
+	for (const prefix of prefixes) {
+		const lowerPrefix = prefix.toLowerCase()
+		// Handle wildcard "*" - it matches any command
+		if (lowerPrefix === "*" || trimmedCommand.startsWith(lowerPrefix)) {
+			if (!longestMatch || lowerPrefix.length > longestMatch.length) {
+				longestMatch = lowerPrefix
+			}
+		}
+	}
+
+	return longestMatch
 }
 
 /**
- * Check if a command string is allowed based on the allowed command prefixes.
- * This version also blocks subshell attempts by checking for `$(` or `` ` ``.
+ * Check if a single command should be auto-approved.
+ * Returns true only for commands that explicitly match the allowlist
+ * and either don't match the denylist or have a longer allowlist match.
+ *
+ * Special handling for wildcards: "*" in allowlist allows any command,
+ * but denylist can still block specific commands.
  */
-export function validateCommand(command: string, allowedCommands: string[]): boolean {
-	if (!command?.trim()) return true
+export function isAutoApprovedSingleCommand(
+	command: string,
+	allowedCommands: string[],
+	deniedCommands?: string[],
+): boolean {
+	if (!command) return true
+
+	// If no allowlist configured, nothing can be auto-approved
+	if (!allowedCommands?.length) return false
+
+	// Check if wildcard is present in allowlist
+	const hasWildcard = allowedCommands.some((cmd) => cmd.toLowerCase() === "*")
+
+	// If no denylist provided (undefined), use simple allowlist logic
+	if (deniedCommands === undefined) {
+		const trimmedCommand = command.trim().toLowerCase()
+		return allowedCommands.some((prefix) => {
+			const lowerPrefix = prefix.toLowerCase()
+			// Handle wildcard "*" - it matches any command
+			return lowerPrefix === "*" || trimmedCommand.startsWith(lowerPrefix)
+		})
+	}
+
+	// Find longest matching prefix in both lists
+	const longestDeniedMatch = findLongestPrefixMatch(command, deniedCommands)
+	const longestAllowedMatch = findLongestPrefixMatch(command, allowedCommands)
+
+	// Special case: if wildcard is present and no denylist match, auto-approve
+	if (hasWildcard && !longestDeniedMatch) return true
+
+	// Must have an allowlist match to be auto-approved
+	if (!longestAllowedMatch) return false
 
-	// If '*' is in allowed commands, everything is allowed
-	if (allowedCommands?.includes("*")) return true
+	// If no denylist match, auto-approve
+	if (!longestDeniedMatch) return true
 
-	// Block subshell execution attempts
-	if (command.includes("$(") || command.includes("`")) {
+	// Both have matches - allowlist must be longer to auto-approve
+	return longestAllowedMatch.length > longestDeniedMatch.length
+}
+
+/**
+ * Check if a single command should be auto-denied.
+ * Returns true only for commands that explicitly match the denylist
+ * and either don't match the allowlist or have a longer denylist match.
+ */
+export function isAutoDeniedSingleCommand(
+	command: string,
+	allowedCommands: string[],
+	deniedCommands?: string[],
+): boolean {
+	if (!command) return false
+
+	// If no denylist configured, nothing can be auto-denied
+	if (!deniedCommands?.length) return false
+
+	// Find longest matching prefix in both lists
+	const longestDeniedMatch = findLongestPrefixMatch(command, deniedCommands)
+	const longestAllowedMatch = findLongestPrefixMatch(command, allowedCommands || [])
+
+	// Must have a denylist match to be auto-denied
+	if (!longestDeniedMatch) return false
+
+	// If no allowlist match, auto-deny
+	if (!longestAllowedMatch) return true
+
+	// Both have matches - denylist must be longer or equal to auto-deny
+	return longestDeniedMatch.length >= longestAllowedMatch.length
+}
+
+/**
+ * Check if a command string should be auto-approved.
+ * Only blocks subshell attempts if there's a denylist configured.
+ * Requires all sub-commands to be auto-approved.
+ */
+export function isAutoApprovedCommand(command: string, allowedCommands: string[], deniedCommands?: string[]): boolean {
+	if (!command?.trim()) return true
+
+	// Only block subshell execution attempts if there's a denylist configured
+	if ((command.includes("$(") || command.includes("`")) && deniedCommands?.length) {
 		return false
 	}
 
 	// Parse into sub-commands (split by &&, ||, ;, |)
 	const subCommands = parseCommand(command)
 
-	// Then ensure every sub-command starts with an allowed prefix
+	// Ensure every sub-command is auto-approved
 	return subCommands.every((cmd) => {
 		// Remove simple PowerShell-like redirections (e.g. 2>&1) before checking
 		const cmdWithoutRedirection = cmd.replace(/\d*>&\d*/, "").trim()
-		return isAllowedSingleCommand(cmdWithoutRedirection, allowedCommands)
+
+		return isAutoApprovedSingleCommand(cmdWithoutRedirection, allowedCommands, deniedCommands)
 	})
 }
+
+/**
+ * Check if a command string should be auto-denied.
+ * Only blocks subshell attempts if there's a denylist configured.
+ * Auto-denies if any sub-command is auto-denied.
+ */
+export function isAutoDeniedCommand(command: string, allowedCommands: string[], deniedCommands?: string[]): boolean {
+	if (!command?.trim()) return false
+
+	// Only block subshell execution attempts if there's a denylist configured
+	if ((command.includes("$(") || command.includes("`")) && deniedCommands?.length) {
+		return true
+	}
+
+	// Parse into sub-commands (split by &&, ||, ;, |)
+	const subCommands = parseCommand(command)
+
+	// Auto-deny if any sub-command is auto-denied
+	return subCommands.some((cmd) => {
+		// Remove simple PowerShell-like redirections (e.g. 2>&1) before checking
+		const cmdWithoutRedirection = cmd.replace(/\d*>&\d*/, "").trim()
+
+		return isAutoDeniedSingleCommand(cmdWithoutRedirection, allowedCommands, deniedCommands)
+	})
+}
+
+/**
+ * Command approval decision types
+ */
+export type CommandDecision = "auto_approve" | "auto_deny" | "ask_user"
+
+/**
+ * Unified command validation that implements the longest prefix match rule.
+ * Returns a definitive decision for a command based on allowlist and denylist.
+ *
+ * This is the main entry point for command validation in the Command Denylist feature.
+ * It handles complex command chains and applies the longest prefix match strategy
+ * to resolve conflicts between allowlist and denylist patterns.
+ *
+ * **Decision Logic:**
+ * 1. **Subshell Protection**: If subshells ($() or ``) are present and denylist exists → auto-deny
+ * 2. **Command Parsing**: Split command chains (&&, ||, ;, |) into individual commands
+ * 3. **Individual Validation**: For each sub-command, apply longest prefix match rule
+ * 4. **Aggregation**: Combine decisions using "any denial blocks all" principle
+ *
+ * **Return Values:**
+ * - `"auto_approve"`: All sub-commands are explicitly allowed
+ * - `"auto_deny"`: At least one sub-command is explicitly denied
+ * - `"ask_user"`: Mixed or no matches found, requires user decision
+ *
+ * **Examples:**
+ * ```typescript
+ * // Simple approval
+ * getCommandDecision("git status", ["git"], [])
+ * // Returns "auto_approve"
+ *
+ * // Longest prefix match - denial wins
+ * getCommandDecision("git push origin", ["git"], ["git push"])
+ * // Returns "auto_deny"
+ *
+ * // Command chain - any denial blocks all
+ * getCommandDecision("git status && rm file", ["git"], ["rm"])
+ * // Returns "auto_deny"
+ *
+ * // No matches - ask user
+ * getCommandDecision("unknown command", ["git"], ["rm"])
+ * // Returns "ask_user"
+ * ```
+ *
+ * @param command - The full command string to validate
+ * @param allowedCommands - List of allowed command prefixes
+ * @param deniedCommands - Optional list of denied command prefixes
+ * @returns Decision indicating whether to approve, deny, or ask user
+ */
+export function getCommandDecision(
+	command: string,
+	allowedCommands: string[],
+	deniedCommands?: string[],
+): CommandDecision {
+	if (!command?.trim()) return "auto_approve"
+
+	// Only block subshell execution attempts if there's a denylist configured
+	if ((command.includes("$(") || command.includes("`")) && deniedCommands?.length) {
+		return "auto_deny"
+	}
+
+	// Parse into sub-commands (split by &&, ||, ;, |)
+	const subCommands = parseCommand(command)
+
+	// Check each sub-command and collect decisions
+	const decisions: CommandDecision[] = subCommands.map((cmd) => {
+		// Remove simple PowerShell-like redirections (e.g. 2>&1) before checking
+		const cmdWithoutRedirection = cmd.replace(/\d*>&\d*/, "").trim()
+
+		return getSingleCommandDecision(cmdWithoutRedirection, allowedCommands, deniedCommands)
+	})
+
+	// If any sub-command is denied, deny the whole command
+	if (decisions.includes("auto_deny")) {
+		return "auto_deny"
+	}
+
+	// If all sub-commands are approved, approve the whole command
+	if (decisions.every((decision) => decision === "auto_approve")) {
+		return "auto_approve"
+	}
+
+	// Otherwise, ask user
+	return "ask_user"
+}
+
+/**
+ * Get the decision for a single command using longest prefix match rule.
+ *
+ * This is the core logic that implements the conflict resolution between
+ * allowlist and denylist using the "longest prefix match" strategy.
+ *
+ * **Longest Prefix Match Algorithm:**
+ * 1. Find the longest matching prefix in the allowlist
+ * 2. Find the longest matching prefix in the denylist
+ * 3. Compare lengths to determine which rule takes precedence
+ * 4. Longer (more specific) match wins the conflict
+ *
+ * **Decision Matrix:**
+ * | Allowlist Match | Denylist Match | Result | Reason |
+ * |----------------|----------------|---------|---------|
+ * | Yes | No | auto_approve | Only allowlist matches |
+ * | No | Yes | auto_deny | Only denylist matches |
+ * | Yes | Yes (shorter) | auto_approve | Allowlist is more specific |
+ * | Yes | Yes (longer/equal) | auto_deny | Denylist is more specific |
+ * | No | No | ask_user | No rules apply |
+ *
+ * **Examples:**
+ * ```typescript
+ * // Only allowlist matches
+ * getSingleCommandDecision("git status", ["git"], ["npm"])
+ * // Returns "auto_approve"
+ *
+ * // Denylist is more specific
+ * getSingleCommandDecision("git push origin", ["git"], ["git push"])
+ * // Returns "auto_deny" (denylist "git push" > allowlist "git")
+ *
+ * // Allowlist is more specific
+ * getSingleCommandDecision("git push --dry-run", ["git push --dry-run"], ["git push"])
+ * // Returns "auto_approve" (allowlist is longer)
+ *
+ * // No matches
+ * getSingleCommandDecision("unknown", ["git"], ["npm"])
+ * // Returns "ask_user"
+ * ```
+ *
+ * @param command - Single command to validate (no chaining)
+ * @param allowedCommands - List of allowed command prefixes
+ * @param deniedCommands - Optional list of denied command prefixes
+ * @returns Decision for this specific command
+ */
+export function getSingleCommandDecision(
+	command: string,
+	allowedCommands: string[],
+	deniedCommands?: string[],
+): CommandDecision {
+	if (!command) return "auto_approve"
+
+	// Find longest matching prefixes in both lists
+	const longestAllowedMatch = findLongestPrefixMatch(command, allowedCommands || [])
+	const longestDeniedMatch = findLongestPrefixMatch(command, deniedCommands || [])
+
+	// If only allowlist has a match, auto-approve
+	if (longestAllowedMatch && !longestDeniedMatch) {
+		return "auto_approve"
+	}
+
+	// If only denylist has a match, auto-deny
+	if (!longestAllowedMatch && longestDeniedMatch) {
+		return "auto_deny"
+	}
+
+	// Both lists have matches - apply longest prefix match rule
+	if (longestAllowedMatch && longestDeniedMatch) {
+		return longestAllowedMatch.length > longestDeniedMatch.length ? "auto_approve" : "auto_deny"
+	}
+
+	// If neither list has a match, ask user
+	return "ask_user"
+}
+
+/**
+ * Centralized Command Validation Service
+ *
+ * This class provides a unified interface for all command validation operations
+ * in the Command Denylist feature. It encapsulates the validation logic and
+ * provides convenient methods for different validation scenarios.
+ */
+export class CommandValidator {
+	constructor(
+		private allowedCommands: string[],
+		private deniedCommands?: string[],
+	) {}
+
+	/**
+	 * Update the command lists used for validation
+	 */
+	updateCommandLists(allowedCommands: string[], deniedCommands?: string[]) {
+		this.allowedCommands = allowedCommands
+		this.deniedCommands = deniedCommands
+	}
+
+	/**
+	 * Get the current command lists
+	 */
+	getCommandLists() {
+		return {
+			allowedCommands: [...this.allowedCommands],
+			deniedCommands: this.deniedCommands ? [...this.deniedCommands] : undefined,
+		}
+	}
+
+	/**
+	 * Validate a command and return a decision
+	 * This is the main validation method that should be used for all command validation
+	 */
+	validateCommand(command: string): CommandDecision {
+		return getCommandDecision(command, this.allowedCommands, this.deniedCommands)
+	}
+
+	/**
+	 * Check if a command would be auto-approved
+	 */
+	isAutoApproved(command: string): boolean {
+		return this.validateCommand(command) === "auto_approve"
+	}
+
+	/**
+	 * Check if a command would be auto-denied
+	 */
+	isAutoDenied(command: string): boolean {
+		return this.validateCommand(command) === "auto_deny"
+	}
+
+	/**
+	 * Check if a command requires user input
+	 */
+	requiresUserInput(command: string): boolean {
+		return this.validateCommand(command) === "ask_user"
+	}
+
+	/**
+	 * Get detailed validation information for a command
+	 * Useful for debugging and providing user feedback
+	 */
+	getValidationDetails(command: string): {
+		decision: CommandDecision
+		subCommands: string[]
+		allowedMatches: Array<{ command: string; match: string | null }>
+		deniedMatches: Array<{ command: string; match: string | null }>
+		hasSubshells: boolean
+	} {
+		const subCommands = parseCommand(command)
+		const hasSubshells = command.includes("$(") || command.includes("`")
+
+		const allowedMatches = subCommands.map((cmd) => ({
+			command: cmd,
+			match: findLongestPrefixMatch(cmd.replace(/\d*>&\d*/, "").trim(), this.allowedCommands),
+		}))
+
+		const deniedMatches = subCommands.map((cmd) => ({
+			command: cmd,
+			match: findLongestPrefixMatch(cmd.replace(/\d*>&\d*/, "").trim(), this.deniedCommands || []),
+		}))
+
+		return {
+			decision: this.validateCommand(command),
+			subCommands,
+			allowedMatches,
+			deniedMatches,
+			hasSubshells,
+		}
+	}
+
+	/**
+	 * Validate multiple commands at once
+	 * Returns a map of command to decision
+	 */
+	validateCommands(commands: string[]): Map<string, CommandDecision> {
+		const results = new Map<string, CommandDecision>()
+		for (const command of commands) {
+			results.set(command, this.validateCommand(command))
+		}
+		return results
+	}
+
+	/**
+	 * Check if the validator has any rules configured
+	 */
+	hasRules(): boolean {
+		return this.allowedCommands.length > 0 || (this.deniedCommands?.length ?? 0) > 0
+	}
+
+	/**
+	 * Get statistics about the current configuration
+	 */
+	getStats() {
+		return {
+			allowedCount: this.allowedCommands.length,
+			deniedCount: this.deniedCommands?.length ?? 0,
+			hasWildcard: this.allowedCommands.some((cmd) => cmd.toLowerCase() === "*"),
+			hasRules: this.hasRules(),
+		}
+	}
+}
+
+/**
+ * Factory function to create a CommandValidator instance
+ * This is the recommended way to create validators in the application
+ */
+export function createCommandValidator(allowedCommands: string[], deniedCommands?: string[]): CommandValidator {
+	return new CommandValidator(allowedCommands, deniedCommands)
+}