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

Commit 6c51a5d

Browse files
committed
fix: three bugs in task permissions - parser, deniedTools exemption, pattern merging
1. NativeToolCallParser: Remove permissions from update_todo_list cases (was erroneously added to wrong tool case, should only be on new_task) 2. deniedTools: Exempt ALWAYS_AVAILABLE_TOOLS (attempt_completion, etc.) from deniedTools check, matching the existing allowedTools behavior. Prevents parent from trapping subtask by denying completion tools. 3. Pattern merging: Replace broken exact-string intersection with layered enforcement. filePatterns/commandPatterns from parent and child are kept as separate layers (AND between layers, OR within each layer). This correctly handles narrowing: parent ["src/.*"] + child ["src/components/.*"] now allows only files matching BOTH patterns, instead of producing an empty intersection.
1 parent 22086b5 commit 6c51a5d

6 files changed

Lines changed: 346 additions & 52 deletions

File tree

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

Lines changed: 94 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { describe, it, expect } from "vitest"
2-
import { mergeTaskPermissions, matchesAnyPattern, taskPermissionsSchema } from "../task-permissions.js"
2+
import {
3+
mergeTaskPermissions,
4+
matchesAnyPattern,
5+
matchesAllPatternLayers,
6+
taskPermissionsSchema,
7+
toTaskPermissions,
8+
} from "../task-permissions.js"
39
import type { TaskPermissions } from "../task-permissions.js"
410

511
describe("TaskPermissions", () => {
@@ -34,6 +40,28 @@ describe("TaskPermissions", () => {
3440
})
3541
})
3642

43+
describe("toTaskPermissions", () => {
44+
it("wraps flat filePatterns into a single layer", () => {
45+
const input = { filePatterns: ["src/.*"] }
46+
const result = toTaskPermissions(input)
47+
expect(result._filePatternLayers).toEqual([["src/.*"]])
48+
expect(result.filePatterns).toEqual(["src/.*"])
49+
})
50+
51+
it("wraps flat commandPatterns into a single layer", () => {
52+
const input = { commandPatterns: ["npm test.*"] }
53+
const result = toTaskPermissions(input)
54+
expect(result._commandPatternLayers).toEqual([["npm test.*"]])
55+
})
56+
57+
it("leaves layers undefined when patterns are not set", () => {
58+
const input = { allowedTools: ["read_file"] }
59+
const result = toTaskPermissions(input)
60+
expect(result._filePatternLayers).toBeUndefined()
61+
expect(result._commandPatternLayers).toBeUndefined()
62+
})
63+
})
64+
3765
describe("mergeTaskPermissions", () => {
3866
it("returns undefined when both are undefined", () => {
3967
expect(mergeTaskPermissions(undefined, undefined)).toBeUndefined()
@@ -49,18 +77,25 @@ describe("TaskPermissions", () => {
4977
expect(mergeTaskPermissions(parent, undefined)).toEqual(parent)
5078
})
5179

52-
it("intersects filePatterns when both defined", () => {
53-
const parent: TaskPermissions = { filePatterns: ["src/.*", "tests/.*"] }
54-
const child: TaskPermissions = { filePatterns: ["src/.*", "docs/.*"] }
80+
it("accumulates filePatterns as separate layers when both defined", () => {
81+
const parent = toTaskPermissions({ filePatterns: ["src/.*", "tests/.*"] })
82+
const child = toTaskPermissions({ filePatterns: ["src/.*", "docs/.*"] })
5583
const merged = mergeTaskPermissions(parent, child)
56-
expect(merged?.filePatterns).toEqual(["src/.*"])
84+
// Both layers are kept (AND semantics between layers)
85+
expect(merged?._filePatternLayers).toEqual([
86+
["src/.*", "tests/.*"],
87+
["src/.*", "docs/.*"],
88+
])
5789
})
5890

59-
it("intersects commandPatterns when both defined", () => {
60-
const parent: TaskPermissions = { commandPatterns: ["npm test.*", "npm run lint"] }
61-
const child: TaskPermissions = { commandPatterns: ["npm test.*", "npm run build"] }
91+
it("accumulates commandPatterns as separate layers when both defined", () => {
92+
const parent = toTaskPermissions({ commandPatterns: ["npm test.*", "npm run lint"] })
93+
const child = toTaskPermissions({ commandPatterns: ["npm test.*", "npm run build"] })
6294
const merged = mergeTaskPermissions(parent, child)
63-
expect(merged?.commandPatterns).toEqual(["npm test.*"])
95+
expect(merged?._commandPatternLayers).toEqual([
96+
["npm test.*", "npm run lint"],
97+
["npm test.*", "npm run build"],
98+
])
6499
})
65100

66101
it("intersects allowedTools when both defined", () => {
@@ -85,42 +120,50 @@ describe("TaskPermissions", () => {
85120
})
86121

87122
it("uses parent filePatterns when child has none", () => {
88-
const parent: TaskPermissions = { filePatterns: ["src/.*"] }
123+
const parent = toTaskPermissions({ filePatterns: ["src/.*"] })
89124
const child: TaskPermissions = { deniedTools: ["execute_command"] }
90125
const merged = mergeTaskPermissions(parent, child)
91-
expect(merged?.filePatterns).toEqual(["src/.*"])
126+
expect(merged?._filePatternLayers).toEqual([["src/.*"]])
92127
expect(merged?.deniedTools).toEqual(["execute_command"])
93128
})
94129

95-
it("returns empty array when intersection is empty", () => {
130+
it("returns empty array when allowedTools intersection is empty", () => {
96131
const parent: TaskPermissions = { allowedTools: ["read_file"] }
97132
const child: TaskPermissions = { allowedTools: ["write_to_file"] }
98133
const merged = mergeTaskPermissions(parent, child)
99134
expect(merged?.allowedTools).toEqual([])
100135
})
101136

102-
it("handles complex nested merge scenario with exact string matching", () => {
103-
const grandparent: TaskPermissions = {
137+
it("handles nested delegation where child narrows scope", () => {
138+
const grandparent = toTaskPermissions({
104139
filePatterns: ["src/.*"],
105140
commandPatterns: ["npm.*"],
106141
allowedTools: ["read_file", "write_to_file", "search_files"],
107142
deniedTools: ["execute_command"],
108-
}
109-
const parent: TaskPermissions = {
143+
})
144+
const parent = toTaskPermissions({
110145
filePatterns: ["src/components/.*"],
111146
allowedTools: ["read_file", "write_to_file"],
112-
}
147+
})
113148

114-
// Intersection uses exact string matching, so "src/components/.*" (child)
115-
// is not equal to "src/.*" (parent) -- intersection is empty
116-
const merged1 = mergeTaskPermissions(grandparent, parent)
117-
expect(merged1?.filePatterns).toEqual([])
149+
const merged = mergeTaskPermissions(grandparent, parent)
150+
151+
// Both layers are kept -- runtime enforces AND between them
152+
expect(merged?._filePatternLayers).toEqual([["src/.*"], ["src/components/.*"]])
118153
// allowedTools intersection: read_file and write_to_file are in both
119-
expect(merged1?.allowedTools).toEqual(["read_file", "write_to_file"])
154+
expect(merged?.allowedTools).toEqual(["read_file", "write_to_file"])
120155
// commandPatterns: only grandparent has them, so they pass through
121-
expect(merged1?.commandPatterns).toEqual(["npm.*"])
156+
expect(merged?._commandPatternLayers).toEqual([["npm.*"]])
122157
// deniedTools: only grandparent has them, so they pass through
123-
expect(merged1?.deniedTools).toEqual(["execute_command"])
158+
expect(merged?.deniedTools).toEqual(["execute_command"])
159+
})
160+
161+
it("deduplicates identical pattern layers", () => {
162+
const parent = toTaskPermissions({ filePatterns: ["src/.*"] })
163+
const child = toTaskPermissions({ filePatterns: ["src/.*"] })
164+
const merged = mergeTaskPermissions(parent, child)
165+
// Identical layers are deduplicated
166+
expect(merged?._filePatternLayers).toEqual([["src/.*"]])
124167
})
125168
})
126169

@@ -149,4 +192,31 @@ describe("TaskPermissions", () => {
149192
expect(matchesAnyPattern("rm -rf /", ["npm.*", "yarn.*"])).toBe(false)
150193
})
151194
})
195+
196+
describe("matchesAllPatternLayers", () => {
197+
it("returns true when layers is undefined", () => {
198+
expect(matchesAllPatternLayers("anything", undefined)).toBe(true)
199+
})
200+
201+
it("returns true when layers is empty", () => {
202+
expect(matchesAllPatternLayers("anything", [])).toBe(true)
203+
})
204+
205+
it("returns true when value matches all layers", () => {
206+
const layers = [["src/.*"], ["src/components/.*"]]
207+
expect(matchesAllPatternLayers("src/components/Button.tsx", layers)).toBe(true)
208+
})
209+
210+
it("returns false when value fails to match one layer", () => {
211+
const layers = [["src/.*"], ["src/components/.*"]]
212+
// Matches src/.* but not src/components/.*
213+
expect(matchesAllPatternLayers("src/utils/helper.ts", layers)).toBe(false)
214+
})
215+
216+
it("handles single layer like matchesAnyPattern", () => {
217+
const layers = [["src/.*", "tests/.*"]]
218+
expect(matchesAllPatternLayers("tests/unit/test.ts", layers)).toBe(true)
219+
expect(matchesAllPatternLayers("docs/readme.md", layers)).toBe(false)
220+
})
221+
})
152222
})

packages/types/src/task-permissions.ts

Lines changed: 112 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,46 @@ export const taskPermissionsSchema = z.object({
3838
deniedTools: z.array(z.string()).optional(),
3939
})
4040

41-
export type TaskPermissions = z.infer<typeof taskPermissionsSchema>
41+
/** The shape accepted as input from the model via the new_task tool. */
42+
export type TaskPermissionsInput = z.infer<typeof taskPermissionsSchema>
43+
44+
/**
45+
* Internal representation of task permissions. Extends the input shape with
46+
* layered pattern fields that accumulate across nested delegation so that
47+
* each ancestor's constraints are enforced independently (AND semantics
48+
* between layers, OR semantics within a layer).
49+
*/
50+
export interface TaskPermissions extends TaskPermissionsInput {
51+
/**
52+
* Accumulated file-pattern layers from ancestor tasks.
53+
* Each inner array is an OR-group; all layers must match (AND between layers).
54+
* Populated only by `mergeTaskPermissions` -- never set from model input.
55+
*/
56+
_filePatternLayers?: string[][]
57+
/**
58+
* Accumulated command-pattern layers from ancestor tasks.
59+
* Same semantics as `_filePatternLayers`.
60+
*/
61+
_commandPatternLayers?: string[][]
62+
}
63+
64+
/**
65+
* Convert a validated input object (flat arrays) into the internal
66+
* `TaskPermissions` representation, wrapping patterns into single layers.
67+
*/
68+
export function toTaskPermissions(input: TaskPermissionsInput): TaskPermissions {
69+
return {
70+
...input,
71+
_filePatternLayers: input.filePatterns ? [input.filePatterns] : undefined,
72+
_commandPatternLayers: input.commandPatterns ? [input.commandPatterns] : undefined,
73+
}
74+
}
4275

4376
/**
4477
* Merge two TaskPermissions using most-restrictive-wins semantics.
4578
*
46-
* - filePatterns / commandPatterns: if both define patterns, keep only patterns
47-
* present in both (intersection). If only one side defines patterns, use that.
79+
* - filePatterns / commandPatterns: accumulated as independent layers so that
80+
* a value must match at least one pattern from EACH ancestor's layer.
4881
* - allowedTools: intersection of both lists (if both defined).
4982
* - deniedTools: union of both lists (most restrictive).
5083
*
@@ -64,14 +97,77 @@ export function mergeTaskPermissions(
6497
return parent
6598
}
6699

100+
// Collect pattern layers from both sides. Each side may already carry
101+
// accumulated layers from earlier merges (_*PatternLayers) as well as
102+
// its own top-level patterns (filePatterns / commandPatterns).
103+
const filePatternLayers = collectPatternLayers(
104+
parent._filePatternLayers,
105+
parent.filePatterns,
106+
child._filePatternLayers,
107+
child.filePatterns,
108+
)
109+
110+
const commandPatternLayers = collectPatternLayers(
111+
parent._commandPatternLayers,
112+
parent.commandPatterns,
113+
child._commandPatternLayers,
114+
child.commandPatterns,
115+
)
116+
67117
return {
68-
filePatterns: intersectOptionalArrays(parent.filePatterns, child.filePatterns),
69-
commandPatterns: intersectOptionalArrays(parent.commandPatterns, child.commandPatterns),
118+
// The top-level field stores the child's own patterns (used for display /
119+
// serialization); runtime enforcement uses the layers.
120+
filePatterns: child.filePatterns ?? parent.filePatterns,
121+
commandPatterns: child.commandPatterns ?? parent.commandPatterns,
122+
_filePatternLayers: filePatternLayers.length > 0 ? filePatternLayers : undefined,
123+
_commandPatternLayers: commandPatternLayers.length > 0 ? commandPatternLayers : undefined,
70124
allowedTools: intersectOptionalArrays(parent.allowedTools, child.allowedTools),
71125
deniedTools: unionOptionalArrays(parent.deniedTools, child.deniedTools),
72126
}
73127
}
74128

129+
/**
130+
* Collect pattern layers from parent and child, deduplicating identical layers.
131+
*/
132+
function collectPatternLayers(
133+
parentLayers: string[][] | undefined,
134+
parentPatterns: string[] | undefined,
135+
childLayers: string[][] | undefined,
136+
childPatterns: string[] | undefined,
137+
): string[][] {
138+
const layers: string[][] = []
139+
const seen = new Set<string>()
140+
141+
const addLayer = (layer: string[]) => {
142+
if (layer.length === 0) return
143+
const key = JSON.stringify(layer)
144+
if (!seen.has(key)) {
145+
seen.add(key)
146+
layers.push(layer)
147+
}
148+
}
149+
150+
// Add accumulated parent layers
151+
if (parentLayers) {
152+
for (const layer of parentLayers) {
153+
addLayer(layer)
154+
}
155+
} else if (parentPatterns && parentPatterns.length > 0) {
156+
addLayer(parentPatterns)
157+
}
158+
159+
// Add accumulated child layers
160+
if (childLayers) {
161+
for (const layer of childLayers) {
162+
addLayer(layer)
163+
}
164+
} else if (childPatterns && childPatterns.length > 0) {
165+
addLayer(childPatterns)
166+
}
167+
168+
return layers
169+
}
170+
75171
/**
76172
* Check if a value matches at least one pattern in a list of regex patterns.
77173
*/
@@ -86,6 +182,17 @@ export function matchesAnyPattern(value: string, patterns: string[]): boolean {
86182
})
87183
}
88184

185+
/**
186+
* Check if a value matches ALL pattern layers (AND between layers, OR within each layer).
187+
* Returns true if there are no layers.
188+
*/
189+
export function matchesAllPatternLayers(value: string, layers: string[][] | undefined): boolean {
190+
if (!layers || layers.length === 0) {
191+
return true
192+
}
193+
return layers.every((layer) => matchesAnyPattern(value, layer))
194+
}
195+
89196
/**
90197
* Intersect two optional arrays. If both are defined, return elements present
91198
* in both. If only one is defined, return that one. If neither, return undefined.

src/core/assistant-message/NativeToolCallParser.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -551,7 +551,6 @@ export class NativeToolCallParser {
551551
if (partialArgs.todos !== undefined) {
552552
nativeArgs = {
553553
todos: partialArgs.todos,
554-
permissions: partialArgs.permissions,
555554
}
556555
}
557556
break
@@ -889,7 +888,6 @@ export class NativeToolCallParser {
889888
if (args.todos !== undefined) {
890889
nativeArgs = {
891890
todos: args.todos,
892-
permissions: args.permissions,
893891
} as NativeArgsFor<TName>
894892
}
895893
break

src/core/tools/NewTaskTool.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as vscode from "vscode"
22

33
import { TodoItem } from "@roo-code/types"
4-
import { type TaskPermissions, taskPermissionsSchema } from "@roo-code/types"
4+
import { type TaskPermissions, taskPermissionsSchema, toTaskPermissions } from "@roo-code/types"
55

66
import { Task } from "../task/Task"
77
import { getModeBySlug } from "../../shared/modes"
@@ -101,7 +101,7 @@ export class NewTaskTool extends BaseTool<"new_task"> {
101101
)
102102
return
103103
}
104-
parsedPermissions = result.data
104+
parsedPermissions = toTaskPermissions(result.data)
105105
} catch (error) {
106106
task.consecutiveMistakeCount++
107107
task.recordToolError("new_task")

0 commit comments

Comments
 (0)