Skip to content

Commit 9014840

Browse files
roomote[bot]roomotemrubens
authored
Add command timeout allowlist with IPC support (#5910)
Co-authored-by: Roo Code <[email protected]> Co-authored-by: Matt Rubens <[email protected]>
1 parent b6bded9 commit 9014840

24 files changed

+264
-4
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ dist
33
out
44
out-*
55
node_modules
6+
package-lock.json
67
coverage/
78
mock/
89

packages/types/npm/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@roo-code/types",
3-
"version": "1.35.0",
3+
"version": "1.36.0",
44
"description": "TypeScript type definitions for Roo Code.",
55
"publishConfig": {
66
"access": "public",

packages/types/src/global-settings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export const globalSettingsSchema = z.object({
5858
allowedCommands: z.array(z.string()).optional(),
5959
deniedCommands: z.array(z.string()).optional(),
6060
commandExecutionTimeout: z.number().optional(),
61+
commandTimeoutAllowlist: z.array(z.string()).optional(),
6162
preventCompletionWithOpenTodos: z.boolean().optional(),
6263
allowedMaxRequests: z.number().nullish(),
6364
autoCondenseContext: z.boolean().optional(),
@@ -212,6 +213,7 @@ export const EVALS_SETTINGS: RooCodeSettings = {
212213
followupAutoApproveTimeoutMs: 0,
213214
allowedCommands: ["*"],
214215
commandExecutionTimeout: 30_000,
216+
commandTimeoutAllowlist: [],
215217
preventCompletionWithOpenTodos: false,
216218

217219
browserToolEnabled: false,

src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts

Lines changed: 224 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import * as vscode from "vscode"
55
import * as fs from "fs/promises"
6-
import { executeCommand, ExecuteCommandOptions } from "../executeCommandTool"
6+
import { executeCommand, executeCommandTool, ExecuteCommandOptions } from "../executeCommandTool"
77
import { Task } from "../../task/Task"
88
import { TerminalRegistry } from "../../../integrations/terminal/TerminalRegistry"
99

@@ -17,6 +17,20 @@ vitest.mock("vscode", () => ({
1717
vitest.mock("fs/promises")
1818
vitest.mock("../../../integrations/terminal/TerminalRegistry")
1919
vitest.mock("../../task/Task")
20+
vitest.mock("../../prompts/responses", () => ({
21+
formatResponse: {
22+
toolError: vitest.fn((msg) => `Tool Error: ${msg}`),
23+
rooIgnoreError: vitest.fn((msg) => `RooIgnore Error: ${msg}`),
24+
},
25+
}))
26+
vitest.mock("../../../utils/text-normalization", () => ({
27+
unescapeHtmlEntities: vitest.fn((text) => text),
28+
}))
29+
vitest.mock("../../../shared/package", () => ({
30+
Package: {
31+
name: "roo-cline",
32+
},
33+
}))
2034

2135
describe("Command Execution Timeout Integration", () => {
2236
let mockTask: any
@@ -186,4 +200,213 @@ describe("Command Execution Timeout Integration", () => {
186200
expect(result[0]).toBe(false) // Not rejected
187201
expect(result[1]).not.toContain("terminated after exceeding")
188202
})
203+
204+
describe("Command Timeout Allowlist", () => {
205+
let mockBlock: any
206+
let mockAskApproval: any
207+
let mockHandleError: any
208+
let mockPushToolResult: any
209+
let mockRemoveClosingTag: any
210+
211+
beforeEach(() => {
212+
// Reset mocks for allowlist tests
213+
vitest.clearAllMocks()
214+
;(fs.access as any).mockResolvedValue(undefined)
215+
;(TerminalRegistry.getOrCreateTerminal as any).mockResolvedValue(mockTerminal)
216+
217+
// Mock the executeCommandTool parameters
218+
mockBlock = {
219+
params: {
220+
command: "",
221+
cwd: undefined,
222+
},
223+
partial: false,
224+
}
225+
226+
mockAskApproval = vitest.fn().mockResolvedValue(true) // Always approve
227+
mockHandleError = vitest.fn()
228+
mockPushToolResult = vitest.fn()
229+
mockRemoveClosingTag = vitest.fn()
230+
231+
// Mock task with additional properties needed by executeCommandTool
232+
mockTask = {
233+
cwd: "/test/directory",
234+
terminalProcess: undefined,
235+
providerRef: {
236+
deref: vitest.fn().mockResolvedValue({
237+
postMessageToWebview: vitest.fn(),
238+
getState: vitest.fn().mockResolvedValue({
239+
terminalOutputLineLimit: 500,
240+
terminalShellIntegrationDisabled: false,
241+
}),
242+
}),
243+
},
244+
say: vitest.fn().mockResolvedValue(undefined),
245+
consecutiveMistakeCount: 0,
246+
recordToolError: vitest.fn(),
247+
sayAndCreateMissingParamError: vitest.fn(),
248+
rooIgnoreController: {
249+
validateCommand: vitest.fn().mockReturnValue(null),
250+
},
251+
lastMessageTs: Date.now(),
252+
ask: vitest.fn(),
253+
didRejectTool: false,
254+
}
255+
})
256+
257+
it("should skip timeout for commands in allowlist", async () => {
258+
// Mock VSCode configuration with timeout and allowlist
259+
const mockGetConfiguration = vitest.fn().mockReturnValue({
260+
get: vitest.fn().mockImplementation((key: string) => {
261+
if (key === "commandExecutionTimeout") return 1 // 1 second timeout
262+
if (key === "commandTimeoutAllowlist") return ["npm", "git"]
263+
return undefined
264+
}),
265+
})
266+
;(vscode.workspace.getConfiguration as any).mockReturnValue(mockGetConfiguration())
267+
268+
mockBlock.params.command = "npm install"
269+
270+
// Create a process that would timeout if not allowlisted
271+
const longRunningProcess = new Promise((resolve) => {
272+
setTimeout(resolve, 2000) // 2 seconds, longer than 1 second timeout
273+
})
274+
mockTerminal.runCommand.mockReturnValue(longRunningProcess)
275+
276+
await executeCommandTool(
277+
mockTask as Task,
278+
mockBlock,
279+
mockAskApproval,
280+
mockHandleError,
281+
mockPushToolResult,
282+
mockRemoveClosingTag,
283+
)
284+
285+
// Should complete successfully without timeout because "npm" is in allowlist
286+
expect(mockPushToolResult).toHaveBeenCalled()
287+
const result = mockPushToolResult.mock.calls[0][0]
288+
expect(result).not.toContain("terminated after exceeding")
289+
}, 3000)
290+
291+
it("should apply timeout for commands not in allowlist", async () => {
292+
// Mock VSCode configuration with timeout and allowlist
293+
const mockGetConfiguration = vitest.fn().mockReturnValue({
294+
get: vitest.fn().mockImplementation((key: string) => {
295+
if (key === "commandExecutionTimeout") return 1 // 1 second timeout
296+
if (key === "commandTimeoutAllowlist") return ["npm", "git"]
297+
return undefined
298+
}),
299+
})
300+
;(vscode.workspace.getConfiguration as any).mockReturnValue(mockGetConfiguration())
301+
302+
mockBlock.params.command = "sleep 10" // Not in allowlist
303+
304+
// Create a process that never resolves
305+
const neverResolvingProcess = new Promise(() => {})
306+
;(neverResolvingProcess as any).abort = vitest.fn()
307+
mockTerminal.runCommand.mockReturnValue(neverResolvingProcess)
308+
309+
await executeCommandTool(
310+
mockTask as Task,
311+
mockBlock,
312+
mockAskApproval,
313+
mockHandleError,
314+
mockPushToolResult,
315+
mockRemoveClosingTag,
316+
)
317+
318+
// Should timeout because "sleep" is not in allowlist
319+
expect(mockPushToolResult).toHaveBeenCalled()
320+
const result = mockPushToolResult.mock.calls[0][0]
321+
expect(result).toContain("terminated after exceeding")
322+
}, 3000)
323+
324+
it("should handle empty allowlist", async () => {
325+
// Mock VSCode configuration with timeout and empty allowlist
326+
const mockGetConfiguration = vitest.fn().mockReturnValue({
327+
get: vitest.fn().mockImplementation((key: string) => {
328+
if (key === "commandExecutionTimeout") return 1 // 1 second timeout
329+
if (key === "commandTimeoutAllowlist") return []
330+
return undefined
331+
}),
332+
})
333+
;(vscode.workspace.getConfiguration as any).mockReturnValue(mockGetConfiguration())
334+
335+
mockBlock.params.command = "npm install"
336+
337+
// Create a process that never resolves
338+
const neverResolvingProcess = new Promise(() => {})
339+
;(neverResolvingProcess as any).abort = vitest.fn()
340+
mockTerminal.runCommand.mockReturnValue(neverResolvingProcess)
341+
342+
await executeCommandTool(
343+
mockTask as Task,
344+
mockBlock,
345+
mockAskApproval,
346+
mockHandleError,
347+
mockPushToolResult,
348+
mockRemoveClosingTag,
349+
)
350+
351+
// Should timeout because allowlist is empty
352+
expect(mockPushToolResult).toHaveBeenCalled()
353+
const result = mockPushToolResult.mock.calls[0][0]
354+
expect(result).toContain("terminated after exceeding")
355+
}, 3000)
356+
357+
it("should match command prefixes correctly", async () => {
358+
// Mock VSCode configuration with timeout and allowlist
359+
const mockGetConfiguration = vitest.fn().mockReturnValue({
360+
get: vitest.fn().mockImplementation((key: string) => {
361+
if (key === "commandExecutionTimeout") return 1 // 1 second timeout
362+
if (key === "commandTimeoutAllowlist") return ["git log", "npm run"]
363+
return undefined
364+
}),
365+
})
366+
;(vscode.workspace.getConfiguration as any).mockReturnValue(mockGetConfiguration())
367+
368+
const longRunningProcess = new Promise((resolve) => {
369+
setTimeout(resolve, 2000) // 2 seconds
370+
})
371+
const neverResolvingProcess = new Promise(() => {})
372+
;(neverResolvingProcess as any).abort = vitest.fn()
373+
374+
// Test exact prefix match - should not timeout
375+
mockBlock.params.command = "git log --oneline"
376+
mockTerminal.runCommand.mockReturnValueOnce(longRunningProcess)
377+
378+
await executeCommandTool(
379+
mockTask as Task,
380+
mockBlock,
381+
mockAskApproval,
382+
mockHandleError,
383+
mockPushToolResult,
384+
mockRemoveClosingTag,
385+
)
386+
387+
expect(mockPushToolResult).toHaveBeenCalled()
388+
const result1 = mockPushToolResult.mock.calls[0][0]
389+
expect(result1).not.toContain("terminated after exceeding")
390+
391+
// Reset mocks for second test
392+
mockPushToolResult.mockClear()
393+
394+
// Test partial prefix match (should not match) - should timeout
395+
mockBlock.params.command = "git status" // "git" alone is not in allowlist, only "git log"
396+
mockTerminal.runCommand.mockReturnValueOnce(neverResolvingProcess)
397+
398+
await executeCommandTool(
399+
mockTask as Task,
400+
mockBlock,
401+
mockAskApproval,
402+
mockHandleError,
403+
mockPushToolResult,
404+
mockRemoveClosingTag,
405+
)
406+
407+
expect(mockPushToolResult).toHaveBeenCalled()
408+
const result2 = mockPushToolResult.mock.calls[0][0]
409+
expect(result2).toContain("terminated after exceeding")
410+
}, 5000)
411+
})
189412
})

src/core/tools/executeCommandTool.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,16 @@ export async function executeCommandTool(
7070
.getConfiguration(Package.name)
7171
.get<number>("commandExecutionTimeout", 0)
7272

73-
// Convert seconds to milliseconds for internal use
74-
const commandExecutionTimeout = commandExecutionTimeoutSeconds * 1000
73+
// Get command timeout allowlist from VSCode configuration
74+
const commandTimeoutAllowlist = vscode.workspace
75+
.getConfiguration(Package.name)
76+
.get<string[]>("commandTimeoutAllowlist", [])
77+
78+
// Check if command matches any prefix in the allowlist
79+
const isCommandAllowlisted = commandTimeoutAllowlist.some((prefix) => command!.startsWith(prefix.trim()))
80+
81+
// Convert seconds to milliseconds for internal use, but skip timeout if command is allowlisted
82+
const commandExecutionTimeout = isCommandAllowlisted ? 0 : commandExecutionTimeoutSeconds * 1000
7583

7684
const options: ExecuteCommandOptions = {
7785
executionId,

src/package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,14 @@
345345
"maximum": 600,
346346
"description": "%commands.commandExecutionTimeout.description%"
347347
},
348+
"roo-cline.commandTimeoutAllowlist": {
349+
"type": "array",
350+
"items": {
351+
"type": "string"
352+
},
353+
"default": [],
354+
"description": "%commands.commandTimeoutAllowlist.description%"
355+
},
348356
"roo-cline.preventCompletionWithOpenTodos": {
349357
"type": "boolean",
350358
"default": false,

src/package.nls.ca.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"commands.allowedCommands.description": "Ordres que es poden executar automàticament quan 'Aprova sempre les operacions d'execució' està activat",
3030
"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.",
3131
"commands.commandExecutionTimeout.description": "Temps màxim en segons per esperar que l'execució de l'ordre es completi abans d'esgotar el temps (0 = sense temps límit, 1-600s, per defecte: 0s)",
32+
"commands.commandTimeoutAllowlist.description": "Prefixos d'ordres que estan exclosos del temps límit d'execució d'ordres. Les ordres que coincideixin amb aquests prefixos s'executaran sense restriccions de temps límit.",
3233
"settings.vsCodeLmModelSelector.description": "Configuració per a l'API del model de llenguatge VSCode",
3334
"settings.vsCodeLmModelSelector.vendor.description": "El proveïdor del model de llenguatge (p. ex. copilot)",
3435
"settings.vsCodeLmModelSelector.family.description": "La família del model de llenguatge (p. ex. gpt-4)",

src/package.nls.de.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"commands.allowedCommands.description": "Befehle, die automatisch ausgeführt werden können, wenn 'Ausführungsoperationen immer genehmigen' aktiviert ist",
3030
"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.",
3131
"commands.commandExecutionTimeout.description": "Maximale Zeit in Sekunden, die auf den Abschluss der Befehlsausführung gewartet wird, bevor ein Timeout auftritt (0 = kein Timeout, 1-600s, Standard: 0s)",
32+
"commands.commandTimeoutAllowlist.description": "Befehlspräfixe, die vom Timeout der Befehlsausführung ausgeschlossen sind. Befehle, die diesen Präfixen entsprechen, werden ohne Timeout-Beschränkungen ausgeführt.",
3233
"settings.vsCodeLmModelSelector.description": "Einstellungen für die VSCode-Sprachmodell-API",
3334
"settings.vsCodeLmModelSelector.vendor.description": "Der Anbieter des Sprachmodells (z.B. copilot)",
3435
"settings.vsCodeLmModelSelector.family.description": "Die Familie des Sprachmodells (z.B. gpt-4)",

src/package.nls.es.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"commands.allowedCommands.description": "Comandos que pueden ejecutarse automáticamente cuando 'Aprobar siempre operaciones de ejecución' está activado",
3030
"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.",
3131
"commands.commandExecutionTimeout.description": "Tiempo máximo en segundos para esperar que se complete la ejecución del comando antes de que expire (0 = sin tiempo límite, 1-600s, predeterminado: 0s)",
32+
"commands.commandTimeoutAllowlist.description": "Prefijos de comandos que están excluidos del tiempo límite de ejecución de comandos. Los comandos que coincidan con estos prefijos se ejecutarán sin restricciones de tiempo límite.",
3233
"settings.vsCodeLmModelSelector.description": "Configuración para la API del modelo de lenguaje VSCode",
3334
"settings.vsCodeLmModelSelector.vendor.description": "El proveedor del modelo de lenguaje (ej. copilot)",
3435
"settings.vsCodeLmModelSelector.family.description": "La familia del modelo de lenguaje (ej. gpt-4)",

src/package.nls.fr.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"commands.allowedCommands.description": "Commandes pouvant être exécutées automatiquement lorsque 'Toujours approuver les opérations d'exécution' est activé",
3030
"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.",
3131
"commands.commandExecutionTimeout.description": "Temps maximum en secondes pour attendre que l'exécution de la commande se termine avant expiration (0 = pas de délai, 1-600s, défaut : 0s)",
32+
"commands.commandTimeoutAllowlist.description": "Préfixes de commandes qui sont exclus du délai d'exécution des commandes. Les commandes correspondant à ces préfixes s'exécuteront sans restrictions de délai.",
3233
"settings.vsCodeLmModelSelector.description": "Paramètres pour l'API du modèle de langage VSCode",
3334
"settings.vsCodeLmModelSelector.vendor.description": "Le fournisseur du modèle de langage (ex: copilot)",
3435
"settings.vsCodeLmModelSelector.family.description": "La famille du modèle de langage (ex: gpt-4)",

0 commit comments

Comments
 (0)