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

Commit 22086b5

Browse files
committed
feat: add model-driven permission control for subtasks (Phase 3b)
Adds an optional `permissions` parameter to the `new_task` tool, allowing the Orchestrator (or any parent task) to dynamically set permission boundaries for subtasks: - New `TaskPermissions` type with filePatterns, commandPatterns, allowedTools, and deniedTools - Permission merging with most-restrictive-wins semantics for nested subtask delegation - Runtime enforcement in validateToolUse() for all permission types - Full test coverage for merging logic and enforcement Addresses Issue #12330 (Phase 3b)
1 parent 8922418 commit 22086b5

13 files changed

Lines changed: 638 additions & 7 deletions

File tree

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { describe, it, expect } from "vitest"
2+
import { mergeTaskPermissions, matchesAnyPattern, taskPermissionsSchema } from "../task-permissions.js"
3+
import type { TaskPermissions } from "../task-permissions.js"
4+
5+
describe("TaskPermissions", () => {
6+
describe("taskPermissionsSchema", () => {
7+
it("validates a valid permissions object", () => {
8+
const result = taskPermissionsSchema.safeParse({
9+
filePatterns: ["src/components/.*"],
10+
commandPatterns: ["npm test.*"],
11+
allowedTools: ["read_file", "write_to_file"],
12+
deniedTools: ["execute_command"],
13+
})
14+
expect(result.success).toBe(true)
15+
})
16+
17+
it("validates an empty object", () => {
18+
const result = taskPermissionsSchema.safeParse({})
19+
expect(result.success).toBe(true)
20+
})
21+
22+
it("validates partial permissions", () => {
23+
const result = taskPermissionsSchema.safeParse({
24+
filePatterns: ["src/.*"],
25+
})
26+
expect(result.success).toBe(true)
27+
})
28+
29+
it("rejects non-string array values", () => {
30+
const result = taskPermissionsSchema.safeParse({
31+
filePatterns: [123],
32+
})
33+
expect(result.success).toBe(false)
34+
})
35+
})
36+
37+
describe("mergeTaskPermissions", () => {
38+
it("returns undefined when both are undefined", () => {
39+
expect(mergeTaskPermissions(undefined, undefined)).toBeUndefined()
40+
})
41+
42+
it("returns child when parent is undefined", () => {
43+
const child: TaskPermissions = { filePatterns: ["src/.*"] }
44+
expect(mergeTaskPermissions(undefined, child)).toEqual(child)
45+
})
46+
47+
it("returns parent when child is undefined", () => {
48+
const parent: TaskPermissions = { filePatterns: ["src/.*"] }
49+
expect(mergeTaskPermissions(parent, undefined)).toEqual(parent)
50+
})
51+
52+
it("intersects filePatterns when both defined", () => {
53+
const parent: TaskPermissions = { filePatterns: ["src/.*", "tests/.*"] }
54+
const child: TaskPermissions = { filePatterns: ["src/.*", "docs/.*"] }
55+
const merged = mergeTaskPermissions(parent, child)
56+
expect(merged?.filePatterns).toEqual(["src/.*"])
57+
})
58+
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"] }
62+
const merged = mergeTaskPermissions(parent, child)
63+
expect(merged?.commandPatterns).toEqual(["npm test.*"])
64+
})
65+
66+
it("intersects allowedTools when both defined", () => {
67+
const parent: TaskPermissions = { allowedTools: ["read_file", "write_to_file", "search_files"] }
68+
const child: TaskPermissions = { allowedTools: ["read_file", "execute_command"] }
69+
const merged = mergeTaskPermissions(parent, child)
70+
expect(merged?.allowedTools).toEqual(["read_file"])
71+
})
72+
73+
it("unions deniedTools when both defined", () => {
74+
const parent: TaskPermissions = { deniedTools: ["execute_command"] }
75+
const child: TaskPermissions = { deniedTools: ["write_to_file"] }
76+
const merged = mergeTaskPermissions(parent, child)
77+
expect(merged?.deniedTools).toEqual(["execute_command", "write_to_file"])
78+
})
79+
80+
it("deduplicates deniedTools in union", () => {
81+
const parent: TaskPermissions = { deniedTools: ["execute_command", "write_to_file"] }
82+
const child: TaskPermissions = { deniedTools: ["execute_command", "search_files"] }
83+
const merged = mergeTaskPermissions(parent, child)
84+
expect(merged?.deniedTools).toEqual(["execute_command", "write_to_file", "search_files"])
85+
})
86+
87+
it("uses parent filePatterns when child has none", () => {
88+
const parent: TaskPermissions = { filePatterns: ["src/.*"] }
89+
const child: TaskPermissions = { deniedTools: ["execute_command"] }
90+
const merged = mergeTaskPermissions(parent, child)
91+
expect(merged?.filePatterns).toEqual(["src/.*"])
92+
expect(merged?.deniedTools).toEqual(["execute_command"])
93+
})
94+
95+
it("returns empty array when intersection is empty", () => {
96+
const parent: TaskPermissions = { allowedTools: ["read_file"] }
97+
const child: TaskPermissions = { allowedTools: ["write_to_file"] }
98+
const merged = mergeTaskPermissions(parent, child)
99+
expect(merged?.allowedTools).toEqual([])
100+
})
101+
102+
it("handles complex nested merge scenario with exact string matching", () => {
103+
const grandparent: TaskPermissions = {
104+
filePatterns: ["src/.*"],
105+
commandPatterns: ["npm.*"],
106+
allowedTools: ["read_file", "write_to_file", "search_files"],
107+
deniedTools: ["execute_command"],
108+
}
109+
const parent: TaskPermissions = {
110+
filePatterns: ["src/components/.*"],
111+
allowedTools: ["read_file", "write_to_file"],
112+
}
113+
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([])
118+
// allowedTools intersection: read_file and write_to_file are in both
119+
expect(merged1?.allowedTools).toEqual(["read_file", "write_to_file"])
120+
// commandPatterns: only grandparent has them, so they pass through
121+
expect(merged1?.commandPatterns).toEqual(["npm.*"])
122+
// deniedTools: only grandparent has them, so they pass through
123+
expect(merged1?.deniedTools).toEqual(["execute_command"])
124+
})
125+
})
126+
127+
describe("matchesAnyPattern", () => {
128+
it("matches a simple regex pattern", () => {
129+
expect(matchesAnyPattern("src/components/Button.tsx", ["src/components/.*"])).toBe(true)
130+
})
131+
132+
it("does not match when no patterns match", () => {
133+
expect(matchesAnyPattern("tests/unit/test.ts", ["src/components/.*"])).toBe(false)
134+
})
135+
136+
it("matches when at least one pattern matches", () => {
137+
expect(matchesAnyPattern("tests/unit/test.ts", ["src/.*", "tests/.*"])).toBe(true)
138+
})
139+
140+
it("handles invalid regex gracefully", () => {
141+
expect(matchesAnyPattern("test.ts", ["[invalid"])).toBe(false)
142+
})
143+
144+
it("matches command patterns", () => {
145+
expect(matchesAnyPattern("npm test -- --coverage", ["npm test.*"])).toBe(true)
146+
})
147+
148+
it("does not match restricted commands", () => {
149+
expect(matchesAnyPattern("rm -rf /", ["npm.*", "yarn.*"])).toBe(false)
150+
})
151+
})
152+
})

packages/types/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export * from "./mode.js"
2020
export * from "./model.js"
2121
export * from "./provider-settings.js"
2222
export * from "./task.js"
23+
export * from "./task-permissions.js"
2324
export * from "./todo.js"
2425
export * from "./skills.js"
2526
export * from "./terminal.js"
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { z } from "zod"
2+
3+
/**
4+
* TaskPermissions defines permission boundaries that a parent task can impose
5+
* on a subtask created via the `new_task` tool.
6+
*
7+
* When nested subtasks are created, permissions are merged using
8+
* "most-restrictive-wins" semantics: a child can never grant itself
9+
* more access than its parent.
10+
*/
11+
12+
export const taskPermissionsSchema = z.object({
13+
/**
14+
* Regex patterns for allowed file paths.
15+
* When set, file operations (read/write) are restricted to paths matching
16+
* at least one of these patterns.
17+
*/
18+
filePatterns: z.array(z.string()).optional(),
19+
20+
/**
21+
* Regex patterns for allowed shell commands.
22+
* When set, command execution is restricted to commands matching
23+
* at least one of these patterns.
24+
*/
25+
commandPatterns: z.array(z.string()).optional(),
26+
27+
/**
28+
* Explicit tool allowlist. When set, only these tools may be used
29+
* by the subtask (in addition to always-available tools like
30+
* attempt_completion and ask_followup_question).
31+
*/
32+
allowedTools: z.array(z.string()).optional(),
33+
34+
/**
35+
* Explicit tool blocklist. These tools are denied regardless of
36+
* mode configuration.
37+
*/
38+
deniedTools: z.array(z.string()).optional(),
39+
})
40+
41+
export type TaskPermissions = z.infer<typeof taskPermissionsSchema>
42+
43+
/**
44+
* Merge two TaskPermissions using most-restrictive-wins semantics.
45+
*
46+
* - filePatterns / commandPatterns: if both define patterns, keep only patterns
47+
* present in both (intersection). If only one side defines patterns, use that.
48+
* - allowedTools: intersection of both lists (if both defined).
49+
* - deniedTools: union of both lists (most restrictive).
50+
*
51+
* @returns merged permissions, or undefined if both inputs are undefined.
52+
*/
53+
export function mergeTaskPermissions(
54+
parent: TaskPermissions | undefined,
55+
child: TaskPermissions | undefined,
56+
): TaskPermissions | undefined {
57+
if (!parent && !child) {
58+
return undefined
59+
}
60+
if (!parent) {
61+
return child
62+
}
63+
if (!child) {
64+
return parent
65+
}
66+
67+
return {
68+
filePatterns: intersectOptionalArrays(parent.filePatterns, child.filePatterns),
69+
commandPatterns: intersectOptionalArrays(parent.commandPatterns, child.commandPatterns),
70+
allowedTools: intersectOptionalArrays(parent.allowedTools, child.allowedTools),
71+
deniedTools: unionOptionalArrays(parent.deniedTools, child.deniedTools),
72+
}
73+
}
74+
75+
/**
76+
* Check if a value matches at least one pattern in a list of regex patterns.
77+
*/
78+
export function matchesAnyPattern(value: string, patterns: string[]): boolean {
79+
return patterns.some((pattern) => {
80+
try {
81+
return new RegExp(pattern).test(value)
82+
} catch {
83+
// Invalid regex -- treat as non-match
84+
return false
85+
}
86+
})
87+
}
88+
89+
/**
90+
* Intersect two optional arrays. If both are defined, return elements present
91+
* in both. If only one is defined, return that one. If neither, return undefined.
92+
*/
93+
function intersectOptionalArrays(a: string[] | undefined, b: string[] | undefined): string[] | undefined {
94+
if (!a && !b) {
95+
return undefined
96+
}
97+
if (!a) {
98+
return b
99+
}
100+
if (!b) {
101+
return a
102+
}
103+
104+
const setB = new Set(b)
105+
const result = a.filter((item) => setB.has(item))
106+
return result.length > 0 ? result : []
107+
}
108+
109+
/**
110+
* Union two optional arrays, deduplicating entries.
111+
*/
112+
function unionOptionalArrays(a: string[] | undefined, b: string[] | undefined): string[] | undefined {
113+
if (!a && !b) {
114+
return undefined
115+
}
116+
if (!a) {
117+
return b
118+
}
119+
if (!b) {
120+
return a
121+
}
122+
123+
return [...new Set([...a, ...b])]
124+
}

packages/types/src/task.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { RooCodeEventName } from "./events.js"
44
import type { RooCodeSettings } from "./global-settings.js"
55
import type { ClineMessage, QueuedMessage, TokenUsage } from "./message.js"
66
import type { ToolUsage, ToolName } from "./tool.js"
7+
import type { TaskPermissions } from "./task-permissions.js"
78
import type { TodoItem } from "./todo.js"
89

910
/**
@@ -94,6 +95,9 @@ export interface CreateTaskOptions {
9495
/** Whether to start the task loop immediately (default: true).
9596
* When false, the caller must invoke `task.start()` manually. */
9697
startTask?: boolean
98+
/** Permission boundaries for the task, set by the parent via new_task tool.
99+
* When set, restricts what file paths, commands, and tools the task may use. */
100+
taskPermissions?: TaskPermissions
97101
}
98102

99103
export enum TaskStatus {

src/core/assistant-message/NativeToolCallParser.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,7 @@ export class NativeToolCallParser {
551551
if (partialArgs.todos !== undefined) {
552552
nativeArgs = {
553553
todos: partialArgs.todos,
554+
permissions: partialArgs.permissions,
554555
}
555556
}
556557
break
@@ -633,6 +634,7 @@ export class NativeToolCallParser {
633634
mode: partialArgs.mode,
634635
message: partialArgs.message,
635636
todos: partialArgs.todos,
637+
permissions: partialArgs.permissions,
636638
}
637639
}
638640
break
@@ -887,6 +889,7 @@ export class NativeToolCallParser {
887889
if (args.todos !== undefined) {
888890
nativeArgs = {
889891
todos: args.todos,
892+
permissions: args.permissions,
890893
} as NativeArgsFor<TName>
891894
}
892895
break
@@ -982,6 +985,7 @@ export class NativeToolCallParser {
982985
mode: args.mode,
983986
message: args.message,
984987
todos: args.todos,
988+
permissions: args.permissions,
985989
} as NativeArgsFor<TName>
986990
}
987991
break

src/core/assistant-message/presentAssistantMessage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,7 @@ export async function presentAssistantMessage(cline: Task) {
589589
block.params,
590590
stateExperiments,
591591
includedTools,
592+
cline.taskPermissions,
592593
)
593594
} catch (error) {
594595
cline.consecutiveMistakeCount++

src/core/prompts/tools/native-tools/new_task.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ const MESSAGE_PARAMETER_DESCRIPTION = `Initial user instructions or context for
1010

1111
const TODOS_PARAMETER_DESCRIPTION = `Optional initial todo list written as a markdown checklist; required when the workspace mandates todos`
1212

13+
const PERMISSIONS_PARAMETER_DESCRIPTION = `Optional JSON object defining permission boundaries for the subtask. Allows the parent to restrict the subtask's access. Supports: filePatterns (array of regex patterns for allowed file paths), commandPatterns (array of regex patterns for allowed commands), allowedTools (array of tool names the subtask may use), deniedTools (array of tool names the subtask may NOT use). Example: {"filePatterns":["src/components/.*"],"commandPatterns":["npm test.*"],"deniedTools":["execute_command"]}`
14+
1315
export default {
1416
type: "function",
1517
function: {
@@ -31,6 +33,10 @@ export default {
3133
type: ["string", "null"],
3234
description: TODOS_PARAMETER_DESCRIPTION,
3335
},
36+
permissions: {
37+
type: ["string", "null"],
38+
description: PERMISSIONS_PARAMETER_DESCRIPTION,
39+
},
3440
},
3541
required: ["mode", "message", "todos"],
3642
additionalProperties: false,

src/core/task/Task.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ import {
3939
TaskStatus,
4040
TodoItem,
4141
getApiProtocol,
42+
type TaskPermissions,
43+
mergeTaskPermissions,
4244
getModelId,
4345
isRetiredProvider,
4446
isIdleAsk,
@@ -159,6 +161,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
159161
readonly taskId: string
160162
readonly rootTaskId?: string
161163
readonly parentTaskId?: string
164+
readonly taskPermissions?: TaskPermissions
162165
childTaskId?: string
163166
pendingNewTaskToolCallId?: string
164167

@@ -430,6 +433,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
430433
initialTodos,
431434
workspacePath,
432435
initialStatus,
436+
taskPermissions,
433437
}: TaskOptions) {
434438
super()
435439

@@ -456,6 +460,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
456460
this.parentTaskId = historyItem ? historyItem.parentTaskId : parentTask?.taskId
457461
this.childTaskId = undefined
458462

463+
// Merge task permissions with parent (most-restrictive-wins)
464+
this.taskPermissions = mergeTaskPermissions(parentTask?.taskPermissions, taskPermissions)
465+
459466
this.metadata = {
460467
task: historyItem ? historyItem.task : task,
461468
images: historyItem ? [] : images,

0 commit comments

Comments
 (0)