Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit eb25622

Browse files
committed
fix: persist permissions in HistoryItem and add ReDoS mitigation
1. Persist taskPermissions in HistoryItem so permissions survive task restarts. Added taskPermissions field to historyItemSchema, included it in taskMetadata output, and restored it in the Task constructor when loading from history. 2. Add ReDoS mitigation for model-provided regex patterns: - isSafeRegex() heuristic rejects nested quantifiers like (a+)+ and overlapping alternations in repeated groups like (a|a)+ - Max pattern length capped at 200 characters - Both checks enforced at schema validation time via Zod refinements - 11 new tests covering ReDoS detection and persistence round-trips
1 parent d9a172d commit eb25622

5 files changed

Lines changed: 165 additions & 15 deletions

File tree

packages/types/src/__tests__/task-permissions.spec.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
matchesAllPatternLayers,
66
taskPermissionsSchema,
77
toTaskPermissions,
8+
isSafeRegex,
89
} from "../task-permissions.js"
910
import type { TaskPermissions } from "../task-permissions.js"
1011

@@ -246,4 +247,79 @@ describe("TaskPermissions", () => {
246247
expect(matchesAllPatternLayers("docs/readme.md", layers)).toBe(false)
247248
})
248249
})
250+
251+
describe("isSafeRegex", () => {
252+
it("accepts simple file path patterns", () => {
253+
expect(isSafeRegex("src/.*")).toBe(true)
254+
expect(isSafeRegex("src/components/.*\\.tsx")).toBe(true)
255+
expect(isSafeRegex("npm test.*")).toBe(true)
256+
})
257+
258+
it("rejects nested quantifiers (classic ReDoS)", () => {
259+
expect(isSafeRegex("(a+)+")).toBe(false)
260+
expect(isSafeRegex("(a*)+")).toBe(false)
261+
expect(isSafeRegex("(a+)*")).toBe(false)
262+
expect(isSafeRegex("(a+){2,}")).toBe(false)
263+
})
264+
265+
it("rejects overlapping alternations in repeated groups", () => {
266+
expect(isSafeRegex("(a|a)+")).toBe(false)
267+
expect(isSafeRegex("(.|a)*")).toBe(false)
268+
})
269+
270+
it("rejects patterns exceeding maximum length", () => {
271+
const longPattern = "a".repeat(201)
272+
expect(isSafeRegex(longPattern)).toBe(false)
273+
})
274+
275+
it("accepts patterns at maximum length", () => {
276+
const maxPattern = "a".repeat(200)
277+
expect(isSafeRegex(maxPattern)).toBe(true)
278+
})
279+
})
280+
281+
describe("schema ReDoS rejection", () => {
282+
it("rejects ReDoS-vulnerable patterns in filePatterns", () => {
283+
const result = taskPermissionsSchema.safeParse({
284+
filePatterns: ["(a+)+"],
285+
})
286+
expect(result.success).toBe(false)
287+
})
288+
289+
it("rejects ReDoS-vulnerable patterns in commandPatterns", () => {
290+
const result = taskPermissionsSchema.safeParse({
291+
commandPatterns: ["(cmd|cmd)*"],
292+
})
293+
expect(result.success).toBe(false)
294+
})
295+
296+
it("rejects overly long patterns at schema level", () => {
297+
const result = taskPermissionsSchema.safeParse({
298+
filePatterns: ["a".repeat(201)],
299+
})
300+
expect(result.success).toBe(false)
301+
})
302+
})
303+
304+
describe("persistence round-trip", () => {
305+
it("taskPermissionsSchema can parse persisted permissions (without internal fields)", () => {
306+
// Simulate what gets persisted: only the input-level fields
307+
const persisted = {
308+
filePatterns: ["src/.*"],
309+
commandPatterns: ["npm test.*"],
310+
allowedTools: ["read_file"],
311+
deniedTools: ["execute_command"],
312+
}
313+
const result = taskPermissionsSchema.safeParse(persisted)
314+
expect(result.success).toBe(true)
315+
if (result.success) {
316+
// Can be converted back to internal representation
317+
const restored = toTaskPermissions(result.data)
318+
expect(restored._filePatternLayers).toEqual([["src/.*"]])
319+
expect(restored._commandPatternLayers).toEqual([["npm test.*"]])
320+
expect(restored.allowedTools).toEqual(["read_file"])
321+
expect(restored.deniedTools).toEqual(["execute_command"])
322+
}
323+
})
324+
})
249325
})

packages/types/src/history.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { z } from "zod"
22

3+
import { taskPermissionsSchema } from "./task-permissions.js"
4+
35
/**
46
* HistoryItem
57
*/
@@ -26,6 +28,7 @@ export const historyItemSchema = z.object({
2628
awaitingChildId: z.string().optional(), // Child currently awaited (set when delegated)
2729
completedByChildId: z.string().optional(), // Child that completed and resumed this parent
2830
completionResultSummary: z.string().optional(), // Summary from completed child
31+
taskPermissions: taskPermissionsSchema.optional(), // Permission boundaries set by parent task
2932
})
3033

3134
export type HistoryItem = z.infer<typeof historyItemSchema>

packages/types/src/task-permissions.ts

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,62 @@ import { z } from "zod"
99
* more access than its parent.
1010
*/
1111

12-
/** Zod refinement that rejects strings which are not valid regular expressions. */
13-
const regexString = z.string().refine(
14-
(val) => {
15-
try {
16-
new RegExp(val)
17-
return true
18-
} catch {
19-
return false
20-
}
21-
},
22-
{ message: "Invalid regular expression" },
23-
)
12+
/** Maximum allowed length for a regex pattern to limit complexity. */
13+
const MAX_REGEX_PATTERN_LENGTH = 200
14+
15+
/**
16+
* Heuristic check for ReDoS-vulnerable patterns.
17+
* Detects common dangerous constructs like nested quantifiers:
18+
* (a+)+, (a*)+, (a+)*, (a*){2,}, etc.
19+
* These can cause catastrophic backtracking on crafted input.
20+
*/
21+
export function isSafeRegex(pattern: string): boolean {
22+
if (pattern.length > MAX_REGEX_PATTERN_LENGTH) {
23+
return false
24+
}
25+
26+
// Detect nested quantifiers: a group with a quantifier inside, followed by an outer quantifier.
27+
// Examples: (a+)+, (a+)*, (a*){2,}, (?:a+)+
28+
// This regex looks for: group containing a quantifier, followed by another quantifier
29+
const nestedQuantifierPattern = /\([^)]*[+*][^)]*\)[+*{]/
30+
if (nestedQuantifierPattern.test(pattern)) {
31+
return false
32+
}
33+
34+
// Detect overlapping alternations inside repeated groups: (a|a)+, (.|a)+
35+
// where both alternatives can match the same input
36+
const overlappingAlternationInGroup = /\([^)]*\|[^)]*\)[+*{]/
37+
if (overlappingAlternationInGroup.test(pattern)) {
38+
return false
39+
}
40+
41+
return true
42+
}
43+
44+
/**
45+
* Zod refinement that rejects strings which are not valid regular expressions,
46+
* and also rejects patterns that are vulnerable to ReDoS (catastrophic backtracking).
47+
*/
48+
const regexString = z
49+
.string()
50+
.max(MAX_REGEX_PATTERN_LENGTH, {
51+
message: `Regex pattern must be at most ${MAX_REGEX_PATTERN_LENGTH} characters`,
52+
})
53+
.refine(
54+
(val) => {
55+
try {
56+
new RegExp(val)
57+
return true
58+
} catch {
59+
return false
60+
}
61+
},
62+
{ message: "Invalid regular expression" },
63+
)
64+
.refine((val) => isSafeRegex(val), {
65+
message:
66+
"Regex pattern rejected: potentially vulnerable to ReDoS (catastrophic backtracking). Avoid nested quantifiers like (a+)+ or overlapping alternations in repeated groups.",
67+
})
2468

2569
export const taskPermissionsSchema = z.object({
2670
/**

src/core/task-persistence/taskMetadata.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import NodeCache from "node-cache"
22
import getFolderSize from "get-folder-size"
33

4-
import type { ClineMessage, HistoryItem } from "@roo-code/types"
4+
import type { ClineMessage, HistoryItem, TaskPermissionsInput } from "@roo-code/types"
55

66
import { combineApiRequests } from "../../shared/combineApiRequests"
77
import { combineCommandSequences } from "../../shared/combineCommandSequences"
@@ -25,6 +25,8 @@ export type TaskMetadataOptions = {
2525
apiConfigName?: string
2626
/** Initial status for the task (e.g., "active" for child tasks) */
2727
initialStatus?: "active" | "delegated" | "completed"
28+
/** Permission boundaries for the task, set by the parent via new_task tool */
29+
taskPermissions?: TaskPermissionsInput
2830
}
2931

3032
export async function taskMetadata({
@@ -38,6 +40,7 @@ export async function taskMetadata({
3840
mode,
3941
apiConfigName,
4042
initialStatus,
43+
taskPermissions,
4144
}: TaskMetadataOptions) {
4245
const taskDir = await getTaskDirectoryPath(globalStoragePath, id)
4346

@@ -112,6 +115,7 @@ export async function taskMetadata({
112115
mode,
113116
...(typeof apiConfigName === "string" && apiConfigName.length > 0 ? { apiConfigName } : {}),
114117
...(initialStatus && { status: initialStatus }),
118+
...(taskPermissions && { taskPermissions }),
115119
}
116120

117121
return { historyItem, tokenUsage }

src/core/task/Task.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
getApiProtocol,
4242
type TaskPermissions,
4343
mergeTaskPermissions,
44+
toTaskPermissions,
4445
getModelId,
4546
isRetiredProvider,
4647
isIdleAsk,
@@ -460,8 +461,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
460461
this.parentTaskId = historyItem ? historyItem.parentTaskId : parentTask?.taskId
461462
this.childTaskId = undefined
462463

463-
// Merge task permissions with parent (most-restrictive-wins)
464-
this.taskPermissions = mergeTaskPermissions(parentTask?.taskPermissions, taskPermissions)
464+
// Merge task permissions with parent (most-restrictive-wins).
465+
// When restoring from history, use the persisted permissions as the base;
466+
// when creating fresh, use the permissions passed via new_task tool.
467+
const effectivePermissions = historyItem?.taskPermissions
468+
? toTaskPermissions(historyItem.taskPermissions)
469+
: taskPermissions
470+
this.taskPermissions = mergeTaskPermissions(parentTask?.taskPermissions, effectivePermissions)
465471

466472
this.metadata = {
467473
task: historyItem ? historyItem.task : task,
@@ -1182,6 +1188,19 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
11821188
await this.taskApiConfigReady
11831189
}
11841190

1191+
// Serialize only the input-level permission fields for persistence
1192+
// (exclude internal _*PatternLayers fields which are runtime-only)
1193+
const persistablePermissions = this.taskPermissions
1194+
? {
1195+
...(this.taskPermissions.filePatterns && { filePatterns: this.taskPermissions.filePatterns }),
1196+
...(this.taskPermissions.commandPatterns && {
1197+
commandPatterns: this.taskPermissions.commandPatterns,
1198+
}),
1199+
...(this.taskPermissions.allowedTools && { allowedTools: this.taskPermissions.allowedTools }),
1200+
...(this.taskPermissions.deniedTools && { deniedTools: this.taskPermissions.deniedTools }),
1201+
}
1202+
: undefined
1203+
11851204
const { historyItem, tokenUsage } = await taskMetadata({
11861205
taskId: this.taskId,
11871206
rootTaskId: this.rootTaskId,
@@ -1193,6 +1212,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
11931212
mode: this._taskMode || defaultModeSlug, // Use the task's own mode, not the current provider mode.
11941213
apiConfigName: this._taskApiConfigName, // Use the task's own provider profile, not the current provider profile.
11951214
initialStatus: this.initialStatus,
1215+
taskPermissions:
1216+
persistablePermissions && Object.keys(persistablePermissions).length > 0
1217+
? persistablePermissions
1218+
: undefined,
11961219
})
11971220

11981221
// Emit token/tool usage updates using debounced function

0 commit comments

Comments
 (0)