Skip to content

Commit 129f273

Browse files
committed
Add slugRegex for mode restriction on subtask/switch
1 parent b7c9805 commit 129f273

File tree

23 files changed

+587
-36
lines changed

23 files changed

+587
-36
lines changed

src/exports/roo-code.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ type GlobalSettings = {
266266
"read" | "edit" | "browser" | "command" | "mcp" | "subtask" | "switch" | "followup",
267267
{
268268
fileRegex?: string | undefined
269+
slugRegex?: string | undefined
269270
description?: string | undefined
270271
},
271272
]

src/exports/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ type GlobalSettings = {
269269
"read" | "edit" | "browser" | "command" | "mcp" | "subtask" | "switch" | "followup",
270270
{
271271
fileRegex?: string | undefined
272+
slugRegex?: string | undefined
272273
description?: string | undefined
273274
},
274275
]

src/schemas/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,23 @@ export const groupOptionsSchema = z.object({
186186
},
187187
{ message: "Invalid regular expression pattern" },
188188
),
189+
slugRegex: z
190+
.string()
191+
.optional()
192+
.refine(
193+
(pattern) => {
194+
if (!pattern) return true // Optional, valid if not provided
195+
try {
196+
new RegExp(pattern)
197+
return true
198+
} catch {
199+
return false
200+
}
201+
},
202+
// Use i18n for the message
203+
// Using plain string due to persistent TS errors with t() here.
204+
{ message: "Invalid regular expression pattern for slugRegex" },
205+
),
189206
description: z.string().optional(),
190207
})
191208

src/shared/__tests__/modes.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,118 @@ describe("isToolAllowedForMode", () => {
244244
expect(isToolAllowedForMode("use_mcp_tool", "architect", [])).toBe(true)
245245
})
246246
})
247+
describe("slugRegex restrictions", () => {
248+
const modesWithSlugRegex: ModeConfig[] = [
249+
{
250+
slug: "subtask-runner",
251+
name: "Subtask Runner",
252+
roleDefinition: "Runs subtasks",
253+
groups: [["subtask", { slugRegex: "^subtask-target-.*" }]],
254+
},
255+
{
256+
slug: "mode-switcher",
257+
name: "Mode Switcher",
258+
roleDefinition: "Switches modes",
259+
groups: [["switch", { slugRegex: "^switch-target-.*" }]],
260+
},
261+
{
262+
slug: "subtask-no-regex",
263+
name: "Subtask No Regex",
264+
roleDefinition: "Runs any subtask",
265+
groups: ["subtask"], // No slugRegex defined
266+
},
267+
{
268+
slug: "switch-no-regex",
269+
name: "Switch No Regex",
270+
roleDefinition: "Switches to any mode",
271+
groups: ["switch"], // No slugRegex defined
272+
},
273+
]
274+
275+
// --- subtask tool tests ---
276+
it("subtask: allows when slugRegex matches toolParams.mode", () => {
277+
expect(
278+
isToolAllowedForMode("new_task", "subtask-runner", modesWithSlugRegex, undefined, {
279+
mode: "subtask-target-allowed",
280+
message: "test",
281+
}),
282+
).toBe(true) // Should FAIL until implemented
283+
})
284+
285+
it("subtask: rejects when slugRegex does not match toolParams.mode", () => {
286+
expect(
287+
isToolAllowedForMode("new_task", "subtask-runner", modesWithSlugRegex, undefined, {
288+
mode: "other-mode",
289+
message: "test",
290+
}),
291+
).toBe(false) // Should PASS (as false is default), but confirms logic path
292+
})
293+
294+
it("subtask: allows when slugRegex matches toolParams.mode_slug (legacy)", () => {
295+
// Test legacy parameter name if needed
296+
expect(
297+
isToolAllowedForMode("new_task", "subtask-runner", modesWithSlugRegex, undefined, {
298+
mode_slug: "subtask-target-allowed",
299+
message: "test",
300+
}),
301+
).toBe(true) // Should FAIL until implemented
302+
})
303+
304+
it("subtask: rejects when slugRegex does not match toolParams.mode_slug (legacy)", () => {
305+
expect(
306+
isToolAllowedForMode("new_task", "subtask-runner", modesWithSlugRegex, undefined, {
307+
mode_slug: "other-mode",
308+
message: "test",
309+
}),
310+
).toBe(false) // Should PASS (as false is default), but confirms logic path
311+
})
312+
313+
it("subtask: allows when group exists but no slugRegex is defined", () => {
314+
expect(
315+
isToolAllowedForMode("new_task", "subtask-no-regex", modesWithSlugRegex, undefined, {
316+
mode: "any-mode",
317+
message: "test",
318+
}),
319+
).toBe(true) // Should PASS (current behavior)
320+
})
321+
322+
it("subtask: allows when slugRegex exists but toolParams are missing", () => {
323+
// If toolParams are missing, the regex check shouldn't run/fail
324+
expect(isToolAllowedForMode("new_task", "subtask-runner", modesWithSlugRegex)).toBe(true) // Should PASS (current behavior)
325+
})
326+
327+
// --- switch_mode tool tests ---
328+
it("switch: allows when slugRegex matches toolParams.mode_slug", () => {
329+
expect(
330+
isToolAllowedForMode("switch_mode", "mode-switcher", modesWithSlugRegex, undefined, {
331+
mode_slug: "switch-target-allowed",
332+
}),
333+
).toBe(true) // Should FAIL until implemented
334+
})
335+
336+
it("switch: rejects when slugRegex does not match toolParams.mode_slug", () => {
337+
expect(
338+
isToolAllowedForMode("switch_mode", "mode-switcher", modesWithSlugRegex, undefined, {
339+
mode_slug: "other-mode",
340+
}),
341+
).toBe(false) // Should PASS (as false is default), but confirms logic path
342+
})
343+
344+
// Note: switch_mode only uses mode_slug, no need to test 'mode' param
345+
346+
it("switch: allows when group exists but no slugRegex is defined", () => {
347+
expect(
348+
isToolAllowedForMode("switch_mode", "switch-no-regex", modesWithSlugRegex, undefined, {
349+
mode_slug: "any-mode",
350+
}),
351+
).toBe(true) // Should PASS (current behavior)
352+
})
353+
354+
it("switch: allows when slugRegex exists but toolParams are missing", () => {
355+
// If toolParams are missing, the regex check shouldn't run/fail
356+
expect(isToolAllowedForMode("switch_mode", "mode-switcher", modesWithSlugRegex)).toBe(true) // Should PASS (current behavior)
357+
})
358+
})
247359

248360
it("handles non-existent modes", () => {
249361
expect(isToolAllowedForMode("write_to_file", "non-existent", customModes)).toBe(false)

src/shared/modes.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -211,26 +211,61 @@ export function isToolAllowedForMode(
211211
continue
212212
}
213213

214-
// If there are no options, allow the tool
215-
if (!options) {
216-
return true
214+
// Check slugRegex for subtask and switch groups
215+
if ((groupName === "subtask" || groupName === "switch") && options?.slugRegex) {
216+
let targetSlug: string | undefined
217+
218+
if (tool === "new_task") {
219+
// Prefer 'mode', fall back to legacy 'mode_slug'
220+
targetSlug = toolParams?.mode ?? toolParams?.mode_slug
221+
} else if (tool === "switch_mode") {
222+
targetSlug = toolParams?.mode_slug
223+
}
224+
225+
// Only apply slugRegex check if the target slug is actually provided
226+
if (targetSlug) {
227+
try {
228+
const regex = new RegExp(options.slugRegex)
229+
if (!regex.test(targetSlug)) {
230+
// If the target slug does not match the regex, deny the tool
231+
console.warn(
232+
`Target slug '${targetSlug}' for tool '${tool}' in mode '${modeSlug}' does not match required pattern: ${options.slugRegex}`,
233+
)
234+
return false // Deny because regex failed
235+
}
236+
// If regex matches, proceed to the general group check below (implicitly by not returning false here)
237+
} catch (error) {
238+
console.error(
239+
`Invalid slugRegex pattern '${options.slugRegex}' for group '${groupName}' in mode '${modeSlug}':`,
240+
error,
241+
)
242+
return false // Deny if regex is invalid
243+
}
244+
}
245+
// If targetSlug was not provided, we skip the regex check and proceed.
246+
// The tool is allowed based on group membership, even if the regex couldn't be checked.
217247
}
218248

249+
// If the tool is in the group and passed all relevant checks (like slugRegex), check for fileRegex
219250
// For the edit group, check file regex if specified
220-
if (groupName === "edit" && options.fileRegex) {
251+
if (groupName === "edit" && options?.fileRegex) {
221252
const filePath = toolParams?.path
222253
if (
223254
filePath &&
224-
(toolParams.diff || toolParams.content || toolParams.operations) &&
255+
(toolParams.diff || toolParams.content || toolParams.operations) && // Check if it's an edit operation
225256
!doesFileMatchRegex(filePath, options.fileRegex)
226257
) {
258+
// Throw error only if it's an actual edit operation on a restricted file
227259
throw new FileRestrictionError(mode.name, options.fileRegex, options.description, filePath)
228260
}
261+
// If it's not an edit operation (e.g., just reading) or the file matches, allow based on group membership below
229262
}
230263

264+
// If the tool is in this group and passed all relevant checks, allow it
231265
return true
232266
}
233267

268+
// If the tool was not found in any allowed group for the mode
234269
return false
235270
}
236271

webview-ui/src/components/prompts/PromptsView.tsx

Lines changed: 106 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,25 @@ function getGroupName(group: GroupEntry): ToolGroup {
4747
const PromptsView = ({ onDone }: PromptsViewProps) => {
4848
const { t } = useAppTranslation()
4949

50+
// Helper function to get matching mode names from a slugRegex
51+
const getMatchingModeNames = (regex: string, allModes: readonly ModeConfig[]): string[] => {
52+
const getModeName = (slug: string): string => {
53+
const mode = allModes.find((m) => m.slug === slug)
54+
// Simply return the found name or the slug as fallback
55+
return mode ? mode.name : slug
56+
}
57+
58+
try {
59+
const regexObj = new RegExp(regex)
60+
const matchingModes = allModes.filter((mode) => regexObj.test(mode.slug))
61+
return matchingModes.map((mode) => getModeName(mode.slug))
62+
} catch (e) {
63+
console.error("Invalid slugRegex:", regex, e)
64+
// Indicate error state by returning an empty array
65+
return []
66+
}
67+
}
68+
5069
const {
5170
customModePrompts,
5271
customSupportPrompts,
@@ -673,46 +692,102 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
673692
onChange={handleGroupChange(group, Boolean(isCustomMode), customMode)}
674693
disabled={isDisabled}>
675694
{t(`prompts:tools.toolNames.${group}`)}
676-
{group === "edit" && (
677-
<div className="text-xs text-vscode-descriptionForeground mt-0.5">
678-
{t("prompts:tools.allowedFiles")}{" "}
679-
{(() => {
680-
const currentMode = getCurrentMode()
681-
const editGroup = currentMode?.groups?.find(
682-
(g) =>
683-
Array.isArray(g) &&
684-
g[0] === "edit" &&
685-
g[1]?.fileRegex,
686-
)
687-
if (!Array.isArray(editGroup)) return t("prompts:allFiles")
688-
return (
689-
editGroup[1].description ||
690-
`/${editGroup[1].fileRegex}/`
691-
)
692-
})()}
693-
</div>
694-
)}
695+
{(() => {
696+
const currentMode = getCurrentMode()
697+
const groupEntry = currentMode?.groups?.find(
698+
(g) => getGroupName(g) === group,
699+
)
700+
const options = Array.isArray(groupEntry)
701+
? groupEntry[1]
702+
: undefined
703+
let description = ""
704+
705+
if (group === "edit" && options?.fileRegex) {
706+
description = options.description || `/${options.fileRegex}/`
707+
return (
708+
<div
709+
className="text-xs text-vscode-descriptionForeground mt-0.5"
710+
data-testid={`tool-permission-${group}-description`}>
711+
{t("prompts:tools.allowedFiles")} {description}
712+
</div>
713+
)
714+
} else if (
715+
(group === "subtask" || group === "switch") &&
716+
options?.slugRegex
717+
) {
718+
// Get matching mode names using the refactored function
719+
const matchingNames = getMatchingModeNames(
720+
options.slugRegex,
721+
modes,
722+
)
723+
if (matchingNames.length > 0) {
724+
// Format for expanded view: Allowed modes: Name1, Name2
725+
description = `${t("prompts:tools.allowedModes")}: ${matchingNames.join(", ")}`
726+
} else {
727+
// Handle no matches or invalid regex - show nothing for now
728+
description = ""
729+
}
730+
return (
731+
<div
732+
className="text-xs text-vscode-descriptionForeground mt-0.5"
733+
data-testid={`tool-permission-${group}-description`}>
734+
{description}
735+
</div>
736+
)
737+
}
738+
return null
739+
})()}
695740
</VSCodeCheckbox>
696741
)
697742
})}
698743
</div>
699744
) : (
700-
<div className="text-sm text-vscode-foreground mb-2 leading-relaxed">
745+
<div className="text-sm text-vscode-foreground mb-2 leading-relaxed flex flex-col gap-1">
701746
{(() => {
702747
const currentMode = getCurrentMode()
703748
const enabledGroups = currentMode?.groups || []
704-
return enabledGroups
705-
.map((group) => {
706-
const groupName = getGroupName(group)
707-
const displayName = t(`prompts:tools.toolNames.${groupName}`)
708-
if (Array.isArray(group) && group[1]?.fileRegex) {
709-
const description =
710-
group[1].description || `/${group[1].fileRegex}/`
711-
return `${displayName} (${description})`
749+
if (enabledGroups.length === 0) {
750+
return (
751+
<div className="text-vscode-descriptionForeground">
752+
{t("prompts:tools.noToolsEnabled")}
753+
</div>
754+
)
755+
}
756+
return enabledGroups.map((group) => {
757+
const groupName = getGroupName(group)
758+
const options = Array.isArray(group) ? group[1] : undefined
759+
const displayName = t(`prompts:tools.toolNames.${groupName}`)
760+
let description = ""
761+
762+
if (groupName === "edit" && options?.fileRegex) {
763+
description = options.description || `/${options.fileRegex}/`
764+
} else if (
765+
(groupName === "subtask" || groupName === "switch") &&
766+
options?.slugRegex
767+
) {
768+
// Get matching mode names using the refactored function
769+
const matchingNames = getMatchingModeNames(options.slugRegex, modes)
770+
if (matchingNames.length > 0) {
771+
// Format for collapsed view: (Name1, Name2)
772+
description = `(${matchingNames.join(", ")})`
773+
} else {
774+
// Handle no matches or invalid regex - show nothing for now
775+
description = ""
712776
}
713-
return displayName
714-
})
715-
.join(", ")
777+
}
778+
779+
return (
780+
<div key={groupName} data-testid={`tool-group-label-${groupName}`}>
781+
<span>{displayName}</span>
782+
{/* Render description directly if it exists (already includes parentheses) */}
783+
{description && (
784+
<span className="ml-1 text-xs text-vscode-descriptionForeground">
785+
{description}
786+
</span>
787+
)}
788+
</div>
789+
)
790+
})
716791
})()}
717792
</div>
718793
)}

0 commit comments

Comments
 (0)