- {/* If we need real estate, we show a compressed version of the tips. Otherwise, we expand it. */}
- {cycle ? (
- <>
-
Did you know about...
-
- {" "}
-
-
- {t(currentTip.titleKey)}:{" "}
- {t(currentTip.descriptionKey)}
-
-
- >
- ) : (
- topTwoTips.map((tip) => (
+
+
+
+ the docs
+
+ ),
+ }}
+ />
+
+
+ {tips.map((tip) => (
@@ -88,8 +49,8 @@ const RooTips = ({ cycle = false }: RooTipsProps) => {
: {t(tip.descriptionKey)}
- ))
- )}
+ ))}
+
)
}
diff --git a/webview-ui/src/components/welcome/__tests__/RooTips.spec.tsx b/webview-ui/src/components/welcome/__tests__/RooTips.spec.tsx
index 8d907e2579..eb121aaadf 100644
--- a/webview-ui/src/components/welcome/__tests__/RooTips.spec.tsx
+++ b/webview-ui/src/components/welcome/__tests__/RooTips.spec.tsx
@@ -7,6 +7,16 @@ vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key, // Simple mock that returns the key
}),
+ Trans: ({
+ children,
+ components,
+ }: {
+ children?: React.ReactNode
+ components?: Record
+ }) => {
+ // Simple mock that renders children or the first component if no children
+ return children || (components && Object.values(components)[0]) || null
+ },
}))
vi.mock("@vscode/webview-ui-toolkit/react", () => ({
@@ -25,12 +35,12 @@ describe("RooTips Component", () => {
describe("when cycle is false (default)", () => {
beforeEach(() => {
- render()
+ render()
})
test("renders only the top two tips", () => {
- // Ensure only two tips are present (check by link role)
- expect(screen.getAllByRole("link")).toHaveLength(2)
+ // Ensure only two tips are present plus the docs link in the Trans component (3 total links)
+ expect(screen.getAllByRole("link")).toHaveLength(3)
})
})
})
diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json
index 10f7ad3dba..01d9ee1c1a 100644
--- a/webview-ui/src/i18n/locales/ca/chat.json
+++ b/webview-ui/src/i18n/locales/ca/chat.json
@@ -323,6 +323,11 @@
"versionIndicator": {
"ariaLabel": "Versió {{version}} - Feu clic per veure les notes de llançament"
},
+ "rooCloudCTA": {
+ "title": "Roo Code Cloud arribarà aviat!",
+ "description": "Executa agents remots al núvol, accedeix a les teves tasques des de qualsevol lloc, col·labora amb altres i molt més.",
+ "joinWaitlist": "Uneix-te a la llista d'espera per obtenir accés anticipat."
+ },
"editMessage": {
"placeholder": "Edita el teu missatge..."
}
diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json
index 95620eefdf..032145234d 100644
--- a/webview-ui/src/i18n/locales/de/chat.json
+++ b/webview-ui/src/i18n/locales/de/chat.json
@@ -323,6 +323,11 @@
"versionIndicator": {
"ariaLabel": "Version {{version}} - Klicken Sie, um die Versionshinweise anzuzeigen"
},
+ "rooCloudCTA": {
+ "title": "Roo Code Cloud kommt bald!",
+ "description": "Führe Remote-Agenten in der Cloud aus, greife von überall auf deine Aufgaben zu, arbeite mit anderen zusammen und vieles mehr.",
+ "joinWaitlist": "Tritt der Warteliste bei, um frühen Zugang zu erhalten."
+ },
"editMessage": {
"placeholder": "Bearbeite deine Nachricht..."
}
diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json
index aed3bcfdc5..3bbb3fbf72 100644
--- a/webview-ui/src/i18n/locales/en/chat.json
+++ b/webview-ui/src/i18n/locales/en/chat.json
@@ -324,5 +324,10 @@
},
"versionIndicator": {
"ariaLabel": "Version {{version}} - Click to view release notes"
+ },
+ "rooCloudCTA": {
+ "title": "Roo Code Cloud is coming soon!",
+ "description": "Run Roomote agents in the cloud, access your tasks from anywhere, collaborate with others, and more.",
+ "joinWaitlist": "Join the waitlist to get early access."
}
}
diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json
index a091aed1a6..adcfd1d40c 100644
--- a/webview-ui/src/i18n/locales/es/chat.json
+++ b/webview-ui/src/i18n/locales/es/chat.json
@@ -323,6 +323,11 @@
"versionIndicator": {
"ariaLabel": "Versión {{version}} - Haz clic para ver las notas de la versión"
},
+ "rooCloudCTA": {
+ "title": "¡Roo Code Cloud llegará pronto!",
+ "description": "Ejecuta agentes remotos en la nube, accede a tus tareas desde cualquier lugar, colabora con otros y mucho más.",
+ "joinWaitlist": "Únete a la lista de espera para obtener acceso anticipado."
+ },
"editMessage": {
"placeholder": "Edita tu mensaje..."
}
diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json
index 59b22149fb..3e49a64867 100644
--- a/webview-ui/src/i18n/locales/fr/chat.json
+++ b/webview-ui/src/i18n/locales/fr/chat.json
@@ -323,6 +323,11 @@
"versionIndicator": {
"ariaLabel": "Version {{version}} - Cliquez pour voir les notes de version"
},
+ "rooCloudCTA": {
+ "title": "Roo Code Cloud arrive bientôt !",
+ "description": "Exécutez des agents distants dans le cloud, accédez à vos tâches de n'importe où, collaborez avec d'autres et bien plus encore.",
+ "joinWaitlist": "Rejoignez la liste d'attente pour obtenir un accès anticipé."
+ },
"editMessage": {
"placeholder": "Modifiez votre message..."
}
diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json
index 08c7cc030a..3b5c7b8a67 100644
--- a/webview-ui/src/i18n/locales/hi/chat.json
+++ b/webview-ui/src/i18n/locales/hi/chat.json
@@ -323,6 +323,11 @@
"versionIndicator": {
"ariaLabel": "संस्करण {{version}} - रिलीज़ नोट्स देखने के लिए क्लिक करें"
},
+ "rooCloudCTA": {
+ "title": "Roo Code Cloud जल्द आ रहा है!",
+ "description": "क्लाउड में रिमोट एजेंट चलाएं, कहीं से भी अपने कार्यों तक पहुंचें, दूसरों के साथ सहयोग करें, और बहुत कुछ।",
+ "joinWaitlist": "जल्दी पहुंच पाने के लिए प्रतीक्षा सूची में शामिल हों।"
+ },
"editMessage": {
"placeholder": "अपना संदेश संपादित करें..."
}
diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json
index e242ebcf6c..2ef1cb75e7 100644
--- a/webview-ui/src/i18n/locales/id/chat.json
+++ b/webview-ui/src/i18n/locales/id/chat.json
@@ -329,6 +329,11 @@
"versionIndicator": {
"ariaLabel": "Versi {{version}} - Klik untuk melihat catatan rilis"
},
+ "rooCloudCTA": {
+ "title": "Roo Code Cloud segera hadir!",
+ "description": "Jalankan agen jarak jauh di cloud, akses tugas Anda dari mana saja, berkolaborasi dengan orang lain, dan banyak lagi.",
+ "joinWaitlist": "Bergabunglah dengan daftar tunggu untuk mendapatkan akses awal."
+ },
"editMessage": {
"placeholder": "Edit pesan Anda..."
}
diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json
index 9f2d1b523a..eb3984f1be 100644
--- a/webview-ui/src/i18n/locales/it/chat.json
+++ b/webview-ui/src/i18n/locales/it/chat.json
@@ -323,6 +323,11 @@
"versionIndicator": {
"ariaLabel": "Versione {{version}} - Clicca per visualizzare le note di rilascio"
},
+ "rooCloudCTA": {
+ "title": "Roo Code Cloud arriva presto!",
+ "description": "Esegui agenti remoti nel cloud, accedi alle tue attività da qualsiasi luogo, collabora con altri e molto altro.",
+ "joinWaitlist": "Unisciti alla lista d'attesa per ottenere l'accesso anticipato."
+ },
"editMessage": {
"placeholder": "Modifica il tuo messaggio..."
}
diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json
index 986b1d48bf..2f6e6bfab7 100644
--- a/webview-ui/src/i18n/locales/ja/chat.json
+++ b/webview-ui/src/i18n/locales/ja/chat.json
@@ -323,6 +323,11 @@
"versionIndicator": {
"ariaLabel": "バージョン {{version}} - クリックしてリリースノートを表示"
},
+ "rooCloudCTA": {
+ "title": "Roo Code Cloud が間もなく登場!",
+ "description": "クラウドでリモートエージェントを実行し、どこからでもタスクにアクセスし、他の人と協力し、その他多くの機能を利用できます。",
+ "joinWaitlist": "早期アクセスを取得するためにウェイトリストに参加してください。"
+ },
"editMessage": {
"placeholder": "メッセージを編集..."
}
diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json
index 295f11584c..f4a5c33602 100644
--- a/webview-ui/src/i18n/locales/ko/chat.json
+++ b/webview-ui/src/i18n/locales/ko/chat.json
@@ -323,6 +323,11 @@
"versionIndicator": {
"ariaLabel": "버전 {{version}} - 릴리스 노트를 보려면 클릭하세요"
},
+ "rooCloudCTA": {
+ "title": "Roo Code Cloud가 곧 출시됩니다!",
+ "description": "클라우드에서 원격 에이전트를 실행하고, 어디서나 작업에 액세스하고, 다른 사람들과 협업하는 등 다양한 기능을 이용하세요.",
+ "joinWaitlist": "얼리 액세스를 받으려면 대기 목록에 가입하세요."
+ },
"editMessage": {
"placeholder": "메시지 편집..."
}
diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json
index 9d8bd6b202..1d11db2668 100644
--- a/webview-ui/src/i18n/locales/nl/chat.json
+++ b/webview-ui/src/i18n/locales/nl/chat.json
@@ -323,6 +323,11 @@
"versionIndicator": {
"ariaLabel": "Versie {{version}} - Klik om release notes te bekijken"
},
+ "rooCloudCTA": {
+ "title": "Roo Code Cloud komt binnenkort!",
+ "description": "Voer externe agenten uit in de cloud, krijg overal toegang tot je taken, werk samen met anderen en nog veel meer.",
+ "joinWaitlist": "Sluit je aan bij de wachtlijst voor vroege toegang."
+ },
"editMessage": {
"placeholder": "Bewerk je bericht..."
}
diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json
index a5460909fb..88c58418ad 100644
--- a/webview-ui/src/i18n/locales/pl/chat.json
+++ b/webview-ui/src/i18n/locales/pl/chat.json
@@ -323,6 +323,11 @@
"versionIndicator": {
"ariaLabel": "Wersja {{version}} - Kliknij, aby wyświetlić informacje o wydaniu"
},
+ "rooCloudCTA": {
+ "title": "Roo Code Cloud już wkrótce!",
+ "description": "Uruchamiaj zdalne agenty w chmurze, uzyskuj dostęp do swoich zadań z dowolnego miejsca, współpracuj z innymi i wiele więcej.",
+ "joinWaitlist": "Dołącz do listy oczekujących, aby uzyskać wczesny dostęp."
+ },
"editMessage": {
"placeholder": "Edytuj swoją wiadomość..."
}
diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json
index 96cb0c7660..3784d6cc64 100644
--- a/webview-ui/src/i18n/locales/pt-BR/chat.json
+++ b/webview-ui/src/i18n/locales/pt-BR/chat.json
@@ -323,6 +323,11 @@
"versionIndicator": {
"ariaLabel": "Versão {{version}} - Clique para ver as notas de lançamento"
},
+ "rooCloudCTA": {
+ "title": "Roo Code Cloud chegará em breve!",
+ "description": "Execute agentes remotos na nuvem, acesse suas tarefas de qualquer lugar, colabore com outros e muito mais.",
+ "joinWaitlist": "Junte-se à lista de espera para obter acesso antecipado."
+ },
"editMessage": {
"placeholder": "Edite sua mensagem..."
}
diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json
index 90387a620c..0660d3e1d6 100644
--- a/webview-ui/src/i18n/locales/ru/chat.json
+++ b/webview-ui/src/i18n/locales/ru/chat.json
@@ -323,6 +323,11 @@
"versionIndicator": {
"ariaLabel": "Версия {{version}} - Нажмите, чтобы просмотреть примечания к выпуску"
},
+ "rooCloudCTA": {
+ "title": "Roo Code Cloud скоро появится!",
+ "description": "Запускайте удаленные агенты в облаке, получайте доступ к своим задачам из любого места, сотрудничайте с другими и многое другое.",
+ "joinWaitlist": "Присоединитесь к списку ожидания для получения раннего доступа."
+ },
"editMessage": {
"placeholder": "Редактировать сообщение..."
}
diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json
index 2188756f7f..75bc126dff 100644
--- a/webview-ui/src/i18n/locales/tr/chat.json
+++ b/webview-ui/src/i18n/locales/tr/chat.json
@@ -323,6 +323,11 @@
"versionIndicator": {
"ariaLabel": "Sürüm {{version}} - Sürüm notlarını görüntülemek için tıklayın"
},
+ "rooCloudCTA": {
+ "title": "Roo Code Cloud yakında geliyor!",
+ "description": "Bulutta uzak ajanlar çalıştırın, görevlerinize her yerden erişin, başkalarıyla işbirliği yapın ve daha fazlası.",
+ "joinWaitlist": "Erken erişim için bekleme listesine katılın."
+ },
"editMessage": {
"placeholder": "Mesajını düzenle..."
}
diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json
index cdab5bf1e5..944eabcb94 100644
--- a/webview-ui/src/i18n/locales/vi/chat.json
+++ b/webview-ui/src/i18n/locales/vi/chat.json
@@ -323,6 +323,11 @@
"versionIndicator": {
"ariaLabel": "Phiên bản {{version}} - Nhấp để xem ghi chú phát hành"
},
+ "rooCloudCTA": {
+ "title": "Roo Code Cloud sắp ra mắt!",
+ "description": "Chạy các agent từ xa trên cloud, truy cập các tác vụ của bạn từ mọi nơi, cộng tác với người khác và nhiều hơn nữa.",
+ "joinWaitlist": "Tham gia danh sách chờ để được truy cập sớm."
+ },
"editMessage": {
"placeholder": "Chỉnh sửa tin nhắn của bạn..."
}
diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json
index 58945ddf12..616cd14fec 100644
--- a/webview-ui/src/i18n/locales/zh-CN/chat.json
+++ b/webview-ui/src/i18n/locales/zh-CN/chat.json
@@ -323,6 +323,11 @@
"versionIndicator": {
"ariaLabel": "版本 {{version}} - 点击查看发布说明"
},
+ "rooCloudCTA": {
+ "title": "Roo Code Cloud 即将推出!",
+ "description": "在云端运行远程代理,随时随地访问任务,与他人协作等更多功能。",
+ "joinWaitlist": "加入等待列表获取早期访问权限。"
+ },
"editMessage": {
"placeholder": "编辑消息..."
}
diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json
index fab99e69ae..662421900e 100644
--- a/webview-ui/src/i18n/locales/zh-TW/chat.json
+++ b/webview-ui/src/i18n/locales/zh-TW/chat.json
@@ -323,6 +323,11 @@
"versionIndicator": {
"ariaLabel": "版本 {{version}} - 點擊查看發布說明"
},
+ "rooCloudCTA": {
+ "title": "Roo Code Cloud 即將推出!",
+ "description": "在雲端執行遠端代理,隨時隨地存取您的工作,與他人協作等更多功能。",
+ "joinWaitlist": "加入等候名單以獲得早期存取權限。"
+ },
"editMessage": {
"placeholder": "編輯訊息..."
}
diff --git a/webview-ui/src/utils/__tests__/command-validation.spec.ts b/webview-ui/src/utils/__tests__/command-validation.spec.ts
index 661d09c802..63a460ffaf 100644
--- a/webview-ui/src/utils/__tests__/command-validation.spec.ts
+++ b/webview-ui/src/utils/__tests__/command-validation.spec.ts
@@ -6,8 +6,6 @@ import {
parseCommand,
isAutoApprovedSingleCommand,
isAutoDeniedSingleCommand,
- isAutoApprovedCommand,
- isAutoDeniedCommand,
findLongestPrefixMatch,
getCommandDecision,
getSingleCommandDecision,
@@ -50,9 +48,124 @@ describe("Command Validation", () => {
parseCommand('npm test | Select-String -NotMatch "node_modules" | Select-String "FAIL|Error"'),
).toEqual(["npm test", 'Select-String -NotMatch "node_modules"', 'Select-String "FAIL|Error"'])
})
+
+ describe("newline handling", () => {
+ it("splits commands by Unix newlines (\\n)", () => {
+ expect(parseCommand("echo hello\ngit status\nnpm install")).toEqual([
+ "echo hello",
+ "git status",
+ "npm install",
+ ])
+ })
+
+ it("splits commands by Windows newlines (\\r\\n)", () => {
+ expect(parseCommand("echo hello\r\ngit status\r\nnpm install")).toEqual([
+ "echo hello",
+ "git status",
+ "npm install",
+ ])
+ })
+
+ it("splits commands by old Mac newlines (\\r)", () => {
+ expect(parseCommand("echo hello\rgit status\rnpm install")).toEqual([
+ "echo hello",
+ "git status",
+ "npm install",
+ ])
+ })
+
+ it("handles mixed line endings", () => {
+ expect(parseCommand("echo hello\ngit status\r\nnpm install\rls -la")).toEqual([
+ "echo hello",
+ "git status",
+ "npm install",
+ "ls -la",
+ ])
+ })
+
+ it("ignores empty lines", () => {
+ expect(parseCommand("echo hello\n\n\ngit status\r\n\r\nnpm install")).toEqual([
+ "echo hello",
+ "git status",
+ "npm install",
+ ])
+ })
+
+ it("handles newlines with chain operators", () => {
+ expect(parseCommand('npm install && npm test\ngit add .\ngit commit -m "test"')).toEqual([
+ "npm install",
+ "npm test",
+ "git add .",
+ 'git commit -m "test"',
+ ])
+ })
+
+ it("splits on actual newlines even within quotes", () => {
+ // Note: Since we split by newlines first, actual newlines in the input
+ // will split the command, even if they appear to be within quotes
+ // Using template literal to create actual newline
+ const commandWithNewlineInQuotes = `echo "Hello
+World"
+git status`
+ // The quotes get stripped because they're no longer properly paired after splitting
+ expect(parseCommand(commandWithNewlineInQuotes)).toEqual(["echo Hello", "World", "git status"])
+ })
+
+ it("handles quoted strings on single line", () => {
+ // When quotes are on the same line, they are preserved
+ expect(parseCommand('echo "Hello World"\ngit status')).toEqual(['echo "Hello World"', "git status"])
+ })
+
+ it("handles complex multi-line commands", () => {
+ const multiLineCommand = `npm install
+npm test && npm run build
+echo "Done" | tee output.log
+git status; git add .
+ls -la || echo "Failed"`
+
+ expect(parseCommand(multiLineCommand)).toEqual([
+ "npm install",
+ "npm test",
+ "npm run build",
+ 'echo "Done"',
+ "tee output.log",
+ "git status",
+ "git add .",
+ "ls -la",
+ 'echo "Failed"',
+ ])
+ })
+
+ it("handles newlines with subshells", () => {
+ expect(parseCommand("echo $(date)\nnpm test\ngit status")).toEqual([
+ "echo",
+ "date",
+ "npm test",
+ "git status",
+ ])
+ })
+
+ it("handles newlines with redirections", () => {
+ expect(parseCommand("npm test 2>&1\necho done\nls -la > files.txt")).toEqual([
+ "npm test 2>&1",
+ "echo done",
+ "ls -la > files.txt",
+ ])
+ })
+
+ it("handles empty input with newlines", () => {
+ expect(parseCommand("\n\n\n")).toEqual([])
+ expect(parseCommand("\r\n\r\n")).toEqual([])
+ expect(parseCommand("\r\r\r")).toEqual([])
+ })
+
+ it("handles whitespace-only lines", () => {
+ expect(parseCommand("echo hello\n \t \ngit status")).toEqual(["echo hello", "git status"])
+ })
+ })
})
- describe("isAutoApprovedSingleCommand (legacy behavior)", () => {
+ describe("isAutoApprovedSingleCommand", () => {
const allowedCommands = ["npm test", "npm run", "echo"]
it("matches commands case-insensitively", () => {
@@ -78,93 +191,6 @@ describe("Command Validation", () => {
expect(isAutoApprovedSingleCommand("npm test", [])).toBe(false)
})
})
-
- describe("isAutoApprovedCommand (legacy behavior)", () => {
- const allowedCommands = ["npm test", "npm run", "echo", "Select-String"]
-
- it("validates simple commands", () => {
- 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(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(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", () => {
- // 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(isAutoApprovedCommand('npm test 2>&1 | Select-String "Error"', allowedCommands)).toBe(true)
- expect(
- isAutoApprovedCommand(
- 'npm test | Select-String -NotMatch "node_modules" | Select-String "FAIL|Error"',
- allowedCommands,
- ),
- ).toBe(true)
- expect(isAutoApprovedCommand("npm test | Select-String | dangerous", allowedCommands)).toBe(false)
- })
-
- it("handles empty input", () => {
- 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(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)
- })
- })
})
/**
@@ -395,52 +421,6 @@ echo "Successfully converted $count .jsx files to .tsx"`
})
})
- describe("isAutoApprovedCommand (legacy behavior)", () => {
- it("should validate allowed commands", () => {
- const result = isAutoApprovedCommand("echo hello", ["echo"])
- expect(result).toBe(true)
- })
-
- it("should reject disallowed commands", () => {
- const result = isAutoApprovedCommand("rm -rf /", ["echo", "ls"])
- expect(result).toBe(false)
- })
-
- it("should not fail validation for commands with simple $RANDOM variable", () => {
- const commandWithRandom = "echo $RANDOM"
-
- expect(() => {
- isAutoApprovedCommand(commandWithRandom, ["echo"])
- }).not.toThrow()
- })
-
- it("should not fail validation for commands with simple array indexing using $RANDOM", () => {
- const commandWithRandomIndex = "echo ${array[$RANDOM]}"
-
- expect(() => {
- isAutoApprovedCommand(commandWithRandomIndex, ["echo"])
- }).not.toThrow()
- })
-
- 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; \\
-done`
-
- // 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", () => {
@@ -469,7 +449,7 @@ done`
})
})
- describe("Legacy isAllowedSingleCommand behavior (now using isAutoApprovedSingleCommand)", () => {
+ describe("isAutoApprovedSingleCommand", () => {
const allowedCommands = ["npm", "echo", "git"]
const deniedCommands = ["npm test", "git push"]
@@ -646,71 +626,6 @@ done`
})
})
})
-
- 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,
- )
- })
- })
- })
})
})
})
@@ -797,9 +712,19 @@ describe("Unified Command Decision Functions", () => {
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("returns auto_deny for subshell commands only when they contain denied prefixes", () => {
+ // Subshells without denied prefixes should not be auto-denied
+ expect(getCommandDecision("npm install $(echo test)", allowedCommands, deniedCommands)).toBe("auto_approve")
+ expect(getCommandDecision("npm install `echo test`", allowedCommands, deniedCommands)).toBe("auto_approve")
+
+ // Subshells with denied prefixes should be auto-denied
+ expect(getCommandDecision("npm install $(npm test)", allowedCommands, deniedCommands)).toBe("auto_deny")
+ expect(getCommandDecision("npm install `npm test --coverage`", allowedCommands, deniedCommands)).toBe(
+ "auto_deny",
+ )
+
+ // Main command with denied prefix should also be auto-denied
+ expect(getCommandDecision("npm test $(echo hello)", allowedCommands, deniedCommands)).toBe("auto_deny")
})
it("allows subshell commands when no denylist is present", () => {
@@ -846,39 +771,6 @@ describe("Unified Command Decision Functions", () => {
})
})
- 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
@@ -952,7 +844,12 @@ describe("Unified Command Decision Functions", () => {
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
+ expect(details.decision).toBe("auto_approve") // not blocked since echo doesn't match denied prefixes
+
+ // Test with denied prefix in subshell
+ const detailsWithDenied = validator.getValidationDetails("npm install $(npm test)")
+ expect(detailsWithDenied.hasSubshells).toBe(true)
+ expect(detailsWithDenied.decision).toBe("auto_deny") // blocked due to npm test in subshell
})
it("handles complex command chains", () => {
@@ -1047,6 +944,41 @@ describe("Unified Command Decision Functions", () => {
})
})
+ describe("Subshell edge cases", () => {
+ it("handles multiple subshells correctly", () => {
+ const validator = createCommandValidator(["echo", "npm"], ["rm", "sudo"])
+
+ // Multiple subshells, none with denied prefixes but subshell commands not in allowlist
+ // parseCommand extracts subshells as separate commands, so date and pwd are not allowed
+ expect(validator.validateCommand("echo $(date) $(pwd)")).toBe("ask_user")
+
+ // Multiple subshells, one with denied prefix
+ expect(validator.validateCommand("echo $(date) $(rm file)")).toBe("auto_deny")
+
+ // Nested subshells - inner commands are extracted and not in allowlist
+ expect(validator.validateCommand("echo $(echo $(date))")).toBe("ask_user")
+ expect(validator.validateCommand("echo $(echo $(rm file))")).toBe("auto_deny")
+ })
+
+ it("handles complex commands with subshells", () => {
+ const validator = createCommandValidator(["npm", "git", "echo"], ["git push", "npm publish"])
+
+ // Subshell with allowed command - git status is extracted as separate command
+ // Since "git status" starts with "git" which is allowed, it's approved
+ expect(validator.validateCommand("npm run $(git status)")).toBe("auto_approve")
+
+ // Subshell with denied command
+ expect(validator.validateCommand("npm run $(git push origin)")).toBe("auto_deny")
+
+ // Main command denied, subshell allowed
+ expect(validator.validateCommand("git push $(echo origin)")).toBe("auto_deny")
+
+ // Complex chain with subshells - need echo in allowlist
+ expect(validator.validateCommand("npm install && echo $(git status) && npm test")).toBe("auto_approve")
+ expect(validator.validateCommand("npm install && echo $(git push) && npm test")).toBe("auto_deny")
+ })
+ })
+
describe("Real-world integration scenarios", () => {
describe("Development workflow validation", () => {
let devValidator: CommandValidator
diff --git a/webview-ui/src/utils/command-validation.ts b/webview-ui/src/utils/command-validation.ts
index 1dbc722943..b403d41d8c 100644
--- a/webview-ui/src/utils/command-validation.ts
+++ b/webview-ui/src/utils/command-validation.ts
@@ -60,17 +60,41 @@ type ShellToken = string | { op: string } | { command: string }
/**
* Split a command string into individual sub-commands by
- * chaining operators (&&, ||, ;, or |).
+ * chaining operators (&&, ||, ;, or |) and newlines.
*
* Uses shell-quote to properly handle:
* - Quoted strings (preserves quotes)
* - Subshell commands ($(cmd) or `cmd`)
* - PowerShell redirections (2>&1)
* - Chain operators (&&, ||, ;, |)
+ * - Newlines as command separators
*/
export function parseCommand(command: string): string[] {
if (!command?.trim()) return []
+ // Split by newlines first (handle different line ending formats)
+ // This regex splits on \r\n (Windows), \n (Unix), or \r (old Mac)
+ const lines = command.split(/\r\n|\r|\n/)
+ const allCommands: string[] = []
+
+ for (const line of lines) {
+ // Skip empty lines
+ if (!line.trim()) continue
+
+ // Process each line through the existing parsing logic
+ const lineCommands = parseCommandLine(line)
+ allCommands.push(...lineCommands)
+ }
+
+ return allCommands
+}
+
+/**
+ * Parse a single line of commands (internal helper function)
+ */
+function parseCommandLine(command: string): string[] {
+ if (!command?.trim()) return []
+
// Storage for replaced content
const redirections: string[] = []
const subshells: string[] = []
@@ -351,56 +375,6 @@ export function isAutoDeniedSingleCommand(
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)
-
- // 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 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
*/
@@ -456,9 +430,12 @@ export function getCommandDecision(
): CommandDecision {
if (!command?.trim()) return "auto_approve"
- // Only block subshell execution attempts if there's a denylist configured
+ // Check if subshells contain denied prefixes
if ((command.includes("$(") || command.includes("`")) && deniedCommands?.length) {
- return "auto_deny"
+ const mainCommandLower = command.toLowerCase()
+ if (deniedCommands.some((denied) => mainCommandLower.includes(denied.toLowerCase()))) {
+ return "auto_deny"
+ }
}
// Parse into sub-commands (split by &&, ||, ;, |)