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

Commit d5b45ff

Browse files
committed
feat: add TaskContext and TaskPermissions for Phase 3a task isolation
Introduces the foundation for isolated task execution (Phase 3a of #12330): - TaskContext: immutable snapshot of mode, API config, and workspace for each task, replacing runtime reads from shared ClineProvider state - TaskPermissions: fine-grained permission boundaries (file patterns, command restrictions, read-only mode, tool allowlists) that the orchestrator can attach when spawning subtasks - TaskContextBuilder: factory functions to build TaskContext from provider state and to derive child contexts with merged permissions - Task constructor now accepts optional taskContext, using it for mode and API config initialization instead of provider.getState() - delegateParentAndOpenChild builds and passes a TaskContext to child tasks - Permission merging follows most-restrictive-wins semantics This is a pure refactor with no behavioral change -- tasks still execute sequentially, but they now carry their own isolated context. Enforcement of permission boundaries is deferred to Phase 3b/3d. Ref: #12330
1 parent 8922418 commit d5b45ff

9 files changed

Lines changed: 608 additions & 8 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { describe, it, expect } from "vitest"
2+
3+
import {
4+
taskPermissionsSchema,
5+
taskContextSchema,
6+
mergePermissions,
7+
type TaskPermissions,
8+
type TaskContext,
9+
} from "../task-context.js"
10+
11+
describe("TaskPermissions schema", () => {
12+
it("accepts empty object", () => {
13+
const result = taskPermissionsSchema.parse({})
14+
expect(result).toEqual({})
15+
})
16+
17+
it("accepts full permissions object", () => {
18+
const permissions: TaskPermissions = {
19+
fileReadPatterns: ["docs/**", "src/**"],
20+
fileWritePatterns: ["docs/**"],
21+
allowedCommands: ["npm test"],
22+
blockedCommands: ["rm -rf"],
23+
readOnly: true,
24+
allowedTools: ["read_file", "list_files"],
25+
}
26+
const result = taskPermissionsSchema.parse(permissions)
27+
expect(result).toEqual(permissions)
28+
})
29+
30+
it("accepts partial permissions", () => {
31+
const result = taskPermissionsSchema.parse({ readOnly: true })
32+
expect(result).toEqual({ readOnly: true })
33+
})
34+
35+
it("rejects invalid types", () => {
36+
expect(() => taskPermissionsSchema.parse({ readOnly: "yes" })).toThrow()
37+
expect(() => taskPermissionsSchema.parse({ fileReadPatterns: "docs/**" })).toThrow()
38+
})
39+
})
40+
41+
describe("TaskContext schema", () => {
42+
it("accepts minimal context", () => {
43+
const context: TaskContext = { mode: "code" }
44+
const result = taskContextSchema.parse(context)
45+
expect(result.mode).toBe("code")
46+
})
47+
48+
it("accepts full context", () => {
49+
const context: TaskContext = {
50+
mode: "architect",
51+
apiConfigName: "gpt-4",
52+
permissions: {
53+
readOnly: true,
54+
fileReadPatterns: ["docs/**"],
55+
},
56+
inheritSkills: true,
57+
skillOverrides: ["custom-skill"],
58+
workspacePath: "/workspace/project",
59+
parentTaskId: "parent-123",
60+
rootTaskId: "root-456",
61+
}
62+
const result = taskContextSchema.parse(context)
63+
expect(result).toEqual(context)
64+
})
65+
66+
it("rejects missing mode", () => {
67+
expect(() => taskContextSchema.parse({})).toThrow()
68+
})
69+
})
70+
71+
describe("mergePermissions", () => {
72+
it("returns undefined when both are undefined", () => {
73+
expect(mergePermissions(undefined, undefined)).toBeUndefined()
74+
})
75+
76+
it("returns child when parent is undefined", () => {
77+
const child: TaskPermissions = { readOnly: true }
78+
expect(mergePermissions(undefined, child)).toEqual(child)
79+
})
80+
81+
it("returns parent when child is undefined", () => {
82+
const parent: TaskPermissions = { readOnly: true }
83+
expect(mergePermissions(parent, undefined)).toEqual(parent)
84+
})
85+
86+
it("merges readOnly with OR logic", () => {
87+
expect(mergePermissions({ readOnly: true }, { readOnly: false })).toMatchObject({ readOnly: true })
88+
expect(mergePermissions({ readOnly: false }, { readOnly: true })).toMatchObject({ readOnly: true })
89+
expect(mergePermissions({ readOnly: false }, { readOnly: false })).toMatchObject({})
90+
})
91+
92+
it("intersects fileWritePatterns", () => {
93+
const parent: TaskPermissions = { fileWritePatterns: ["docs/**", "src/**", "package.json"] }
94+
const child: TaskPermissions = { fileWritePatterns: ["docs/**", "package.json"] }
95+
const result = mergePermissions(parent, child)
96+
expect(result?.fileWritePatterns).toEqual(["docs/**", "package.json"])
97+
})
98+
99+
it("intersects allowedTools", () => {
100+
const parent: TaskPermissions = { allowedTools: ["read_file", "list_files", "search_files"] }
101+
const child: TaskPermissions = { allowedTools: ["read_file", "search_files", "write_to_file"] }
102+
const result = mergePermissions(parent, child)
103+
expect(result?.allowedTools).toEqual(["read_file", "search_files"])
104+
})
105+
106+
it("unions blockedCommands", () => {
107+
const parent: TaskPermissions = { blockedCommands: ["rm -rf"] }
108+
const child: TaskPermissions = { blockedCommands: ["git push", "rm -rf"] }
109+
const result = mergePermissions(parent, child)
110+
expect(result?.blockedCommands).toEqual(["rm -rf", "git push"])
111+
})
112+
113+
it("returns defined array when only one side specifies it", () => {
114+
const parent: TaskPermissions = { fileReadPatterns: ["docs/**"] }
115+
const child: TaskPermissions = {}
116+
const result = mergePermissions(parent, child)
117+
expect(result?.fileReadPatterns).toEqual(["docs/**"])
118+
})
119+
120+
it("returns empty array when intersection is empty", () => {
121+
const parent: TaskPermissions = { allowedTools: ["read_file"] }
122+
const child: TaskPermissions = { allowedTools: ["write_to_file"] }
123+
const result = mergePermissions(parent, child)
124+
expect(result?.allowedTools).toEqual([])
125+
})
126+
})

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-context.js"
2324
export * from "./todo.js"
2425
export * from "./skills.js"
2526
export * from "./terminal.js"

packages/types/src/task-context.ts

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import { z } from "zod"
2+
3+
/**
4+
* TaskPermissions defines fine-grained permission boundaries for a subtask.
5+
*
6+
* These permissions allow the orchestrator (or parent task) to restrict what
7+
* a child task can do, making parallel execution safer by preventing
8+
* unintended side effects across task boundaries.
9+
*
10+
* ## Design Notes
11+
*
12+
* Phase 3a introduces the types and plumbing. Enforcement is deferred to
13+
* Phase 3b (read-only parallelism) and Phase 3d (write parallelism).
14+
*
15+
* The permission model is intentionally additive: if no permissions are
16+
* specified, the task inherits full capabilities from its mode. Permissions
17+
* can only *restrict*, never *expand* beyond what the mode allows.
18+
*/
19+
export const taskPermissionsSchema = z.object({
20+
/**
21+
* Glob patterns restricting which files the task may read.
22+
* If empty or undefined, the task can read any file (subject to mode restrictions).
23+
* Examples: ["docs/**", "src/utils/**"]
24+
*/
25+
fileReadPatterns: z.array(z.string()).optional(),
26+
27+
/**
28+
* Glob patterns restricting which files the task may write/edit.
29+
* If empty or undefined, the task can write any file (subject to mode restrictions).
30+
* Examples: ["docs/**", "package.json"]
31+
*/
32+
fileWritePatterns: z.array(z.string()).optional(),
33+
34+
/**
35+
* Allowlist of shell commands the task may execute.
36+
* If empty or undefined, the task can execute any command (subject to mode restrictions).
37+
* Matched as prefixes against the command string.
38+
* Examples: ["npm test", "npx vitest", "git status"]
39+
*/
40+
allowedCommands: z.array(z.string()).optional(),
41+
42+
/**
43+
* Blocklist of shell commands the task may NOT execute.
44+
* Takes precedence over allowedCommands.
45+
* Examples: ["rm -rf", "git push"]
46+
*/
47+
blockedCommands: z.array(z.string()).optional(),
48+
49+
/**
50+
* Whether the task is restricted to read-only operations.
51+
* When true, the task cannot use write tools (write_to_file, apply_diff,
52+
* execute_command, etc.). This is the primary mechanism for Phase 3b
53+
* read-only parallelism.
54+
*/
55+
readOnly: z.boolean().optional(),
56+
57+
/**
58+
* Explicit list of tool names the task is allowed to use.
59+
* If empty or undefined, all tools available to the mode are allowed.
60+
* Examples: ["read_file", "list_files", "search_files"]
61+
*/
62+
allowedTools: z.array(z.string()).optional(),
63+
})
64+
65+
export type TaskPermissions = z.infer<typeof taskPermissionsSchema>
66+
67+
/**
68+
* TaskContext encapsulates all per-task configuration that a Task needs
69+
* to operate independently of the ClineProvider's shared mutable state.
70+
*
71+
* ## Purpose
72+
*
73+
* Today, Task reads mode, API config, and other settings from the provider
74+
* via `provider.getState()` at construction time and during execution.
75+
* This couples Task execution to the provider's current state, which
76+
* prevents multiple tasks from running concurrently (since they'd all
77+
* read/write the same shared state).
78+
*
79+
* TaskContext captures a snapshot of everything a Task needs at creation
80+
* time, so the Task can operate with its own isolated configuration.
81+
*
82+
* ## Lifecycle
83+
*
84+
* 1. Built by the parent (orchestrator or provider) when creating a subtask
85+
* 2. Passed to the Task constructor as an immutable snapshot
86+
* 3. The Task uses this context instead of reaching back to the provider
87+
* for mode/config/permissions during execution
88+
*
89+
* ## Phase 3a Scope
90+
*
91+
* In Phase 3a, TaskContext is optional -- tasks that don't receive one
92+
* fall back to the existing provider.getState() behavior. This ensures
93+
* full backward compatibility while enabling incremental adoption.
94+
*/
95+
export const taskContextSchema = z.object({
96+
/**
97+
* The mode slug for this task (e.g., "code", "architect", "ask").
98+
* Snapshot at task creation time -- does not change if the provider's
99+
* mode changes later.
100+
*/
101+
mode: z.string(),
102+
103+
/**
104+
* The API configuration profile name for this task.
105+
* Allows subtasks to use different models (including local ones)
106+
* from the parent task.
107+
*/
108+
apiConfigName: z.string().optional(),
109+
110+
/**
111+
* Permission boundaries for this task.
112+
* If undefined, the task inherits full capabilities from its mode.
113+
*/
114+
permissions: taskPermissionsSchema.optional(),
115+
116+
/**
117+
* Whether this task should inherit skills from the parent.
118+
* Defaults to true if not specified.
119+
*/
120+
inheritSkills: z.boolean().optional(),
121+
122+
/**
123+
* Additional skill overrides for this task.
124+
* These are merged with (or replace) inherited skills depending
125+
* on the inheritSkills setting.
126+
*/
127+
skillOverrides: z.array(z.string()).optional(),
128+
129+
/**
130+
* The workspace path for this task.
131+
* Allows subtasks to operate in different workspace roots.
132+
*/
133+
workspacePath: z.string().optional(),
134+
135+
/**
136+
* ID of the parent task that created this context.
137+
* Used for lineage tracking and result aggregation.
138+
*/
139+
parentTaskId: z.string().optional(),
140+
141+
/**
142+
* ID of the root task in the delegation chain.
143+
* Used for hierarchical task management.
144+
*/
145+
rootTaskId: z.string().optional(),
146+
})
147+
148+
export type TaskContext = z.infer<typeof taskContextSchema>
149+
150+
/**
151+
* Merge two TaskPermissions objects, producing the most restrictive
152+
* combination. This is used when a parent task's permissions should
153+
* further constrain a child task's permissions.
154+
*
155+
* Rules:
156+
* - readOnly: true if either is true
157+
* - allowedTools: intersection if both specified, otherwise the one that's specified
158+
* - fileReadPatterns / fileWritePatterns: intersection if both specified
159+
* - allowedCommands: intersection if both specified
160+
* - blockedCommands: union (all blocked commands from both)
161+
*/
162+
export function mergePermissions(
163+
parent: TaskPermissions | undefined,
164+
child: TaskPermissions | undefined,
165+
): TaskPermissions | undefined {
166+
if (!parent && !child) {
167+
return undefined
168+
}
169+
170+
if (!parent) {
171+
return child
172+
}
173+
174+
if (!child) {
175+
return parent
176+
}
177+
178+
return {
179+
readOnly: parent.readOnly || child.readOnly || undefined,
180+
181+
fileReadPatterns: intersectArrays(parent.fileReadPatterns, child.fileReadPatterns),
182+
183+
fileWritePatterns: intersectArrays(parent.fileWritePatterns, child.fileWritePatterns),
184+
185+
allowedCommands: intersectArrays(parent.allowedCommands, child.allowedCommands),
186+
187+
blockedCommands: unionArrays(parent.blockedCommands, child.blockedCommands),
188+
189+
allowedTools: intersectArrays(parent.allowedTools, child.allowedTools),
190+
}
191+
}
192+
193+
/** Return intersection of two optional arrays, or the defined one if only one exists. */
194+
function intersectArrays(a: string[] | undefined, b: string[] | undefined): string[] | undefined {
195+
if (!a && !b) {
196+
return undefined
197+
}
198+
199+
if (!a) {
200+
return b
201+
}
202+
203+
if (!b) {
204+
return a
205+
}
206+
207+
const setB = new Set(b)
208+
const result = a.filter((item) => setB.has(item))
209+
return result.length > 0 ? result : []
210+
}
211+
212+
/** Return union of two optional arrays. */
213+
function unionArrays(a: string[] | undefined, b: string[] | undefined): string[] | undefined {
214+
if (!a && !b) {
215+
return undefined
216+
}
217+
218+
if (!a) {
219+
return b
220+
}
221+
222+
if (!b) {
223+
return a
224+
}
225+
226+
return Array.from(new Set([...a, ...b]))
227+
}

packages/types/src/task.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { RooCodeSettings } from "./global-settings.js"
55
import type { ClineMessage, QueuedMessage, TokenUsage } from "./message.js"
66
import type { ToolUsage, ToolName } from "./tool.js"
77
import type { TodoItem } from "./todo.js"
8+
import type { TaskContext } from "./task-context.js"
89

910
/**
1011
* TaskProviderLike
@@ -94,6 +95,12 @@ 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+
/**
99+
* Optional isolated task context containing mode, API config, and permissions.
100+
* When provided, the task uses this context instead of reading from the provider.
101+
* Phase 3a foundation for concurrent task execution.
102+
*/
103+
taskContext?: TaskContext
97104
}
98105

99106
export enum TaskStatus {

0 commit comments

Comments
 (0)