Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, it, expect } from "vitest"
import { DEFAULT_MODES } from "../mode.js"
import type { ModeConfig } from "../mode.js"

describe("Orchestrator mode - permissions prompt guidance", () => {
const orchestratorMode = DEFAULT_MODES.find((m: ModeConfig) => m.slug === "orchestrator")

it("should have the orchestrator mode defined", () => {
expect(orchestratorMode).toBeDefined()
})

it("should include permissions guidance in customInstructions", () => {
expect(orchestratorMode!.customInstructions).toContain("permissions")
expect(orchestratorMode!.customInstructions).toContain("filePatterns")
expect(orchestratorMode!.customInstructions).toContain("commandPatterns")
expect(orchestratorMode!.customInstructions).toContain("allowedTools")
expect(orchestratorMode!.customInstructions).toContain("deniedTools")
})

it("should mention most-restrictive-wins semantics", () => {
expect(orchestratorMode!.customInstructions).toContain("most-restrictive-wins")
})

it("should provide example use cases for permissions", () => {
// Guidance about restricting file access
expect(orchestratorMode!.customInstructions).toContain("specific directory")
// Guidance about read-only research tasks
expect(orchestratorMode!.customInstructions).toContain("read-only research")
// Guidance about blocking shell access
expect(orchestratorMode!.customInstructions).toContain("shell access")
})
})
325 changes: 325 additions & 0 deletions packages/types/src/__tests__/task-permissions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,325 @@
import { describe, it, expect } from "vitest"
import {
mergeTaskPermissions,
matchesAnyPattern,
matchesAllPatternLayers,
taskPermissionsSchema,
toTaskPermissions,
isSafeRegex,
} from "../task-permissions.js"
import type { TaskPermissions } from "../task-permissions.js"

describe("TaskPermissions", () => {
describe("taskPermissionsSchema", () => {
it("validates a valid permissions object", () => {
const result = taskPermissionsSchema.safeParse({
filePatterns: ["src/components/.*"],
commandPatterns: ["npm test.*"],
allowedTools: ["read_file", "write_to_file"],
deniedTools: ["execute_command"],
})
expect(result.success).toBe(true)
})

it("validates an empty object", () => {
const result = taskPermissionsSchema.safeParse({})
expect(result.success).toBe(true)
})

it("validates partial permissions", () => {
const result = taskPermissionsSchema.safeParse({
filePatterns: ["src/.*"],
})
expect(result.success).toBe(true)
})

it("rejects non-string array values", () => {
const result = taskPermissionsSchema.safeParse({
filePatterns: [123],
})
expect(result.success).toBe(false)
})

it("rejects invalid regex patterns in filePatterns", () => {
const result = taskPermissionsSchema.safeParse({
filePatterns: ["[invalid"],
})
expect(result.success).toBe(false)
})

it("rejects invalid regex patterns in commandPatterns", () => {
const result = taskPermissionsSchema.safeParse({
commandPatterns: ["(unclosed"],
})
expect(result.success).toBe(false)
})
})

describe("toTaskPermissions", () => {
it("wraps flat filePatterns into a single layer", () => {
const input = { filePatterns: ["src/.*"] }
const result = toTaskPermissions(input)
expect(result._filePatternLayers).toEqual([["src/.*"]])
expect(result.filePatterns).toEqual(["src/.*"])
})

it("wraps flat commandPatterns into a single layer", () => {
const input = { commandPatterns: ["npm test.*"] }
const result = toTaskPermissions(input)
expect(result._commandPatternLayers).toEqual([["npm test.*"]])
})

it("leaves layers undefined when patterns are not set", () => {
const input = { allowedTools: ["read_file"] }
const result = toTaskPermissions(input)
expect(result._filePatternLayers).toBeUndefined()
expect(result._commandPatternLayers).toBeUndefined()
})
})

describe("mergeTaskPermissions", () => {
it("returns undefined when both are undefined", () => {
expect(mergeTaskPermissions(undefined, undefined)).toBeUndefined()
})

it("returns child when parent is undefined", () => {
const child: TaskPermissions = { filePatterns: ["src/.*"] }
expect(mergeTaskPermissions(undefined, child)).toEqual(child)
})

it("returns parent when child is undefined", () => {
const parent: TaskPermissions = { filePatterns: ["src/.*"] }
expect(mergeTaskPermissions(parent, undefined)).toEqual(parent)
})

it("accumulates filePatterns as separate layers when both defined", () => {
const parent = toTaskPermissions({ filePatterns: ["src/.*", "tests/.*"] })
const child = toTaskPermissions({ filePatterns: ["src/.*", "docs/.*"] })
const merged = mergeTaskPermissions(parent, child)
// Both layers are kept (AND semantics between layers)
expect(merged?._filePatternLayers).toEqual([
["src/.*", "tests/.*"],
["src/.*", "docs/.*"],
])
})

it("accumulates commandPatterns as separate layers when both defined", () => {
const parent = toTaskPermissions({ commandPatterns: ["npm test.*", "npm run lint"] })
const child = toTaskPermissions({ commandPatterns: ["npm test.*", "npm run build"] })
const merged = mergeTaskPermissions(parent, child)
expect(merged?._commandPatternLayers).toEqual([
["npm test.*", "npm run lint"],
["npm test.*", "npm run build"],
])
})

it("intersects allowedTools when both defined", () => {
const parent: TaskPermissions = { allowedTools: ["read_file", "write_to_file", "search_files"] }
const child: TaskPermissions = { allowedTools: ["read_file", "execute_command"] }
const merged = mergeTaskPermissions(parent, child)
expect(merged?.allowedTools).toEqual(["read_file"])
})

it("unions deniedTools when both defined", () => {
const parent: TaskPermissions = { deniedTools: ["execute_command"] }
const child: TaskPermissions = { deniedTools: ["write_to_file"] }
const merged = mergeTaskPermissions(parent, child)
expect(merged?.deniedTools).toEqual(["execute_command", "write_to_file"])
})

it("deduplicates deniedTools in union", () => {
const parent: TaskPermissions = { deniedTools: ["execute_command", "write_to_file"] }
const child: TaskPermissions = { deniedTools: ["execute_command", "search_files"] }
const merged = mergeTaskPermissions(parent, child)
expect(merged?.deniedTools).toEqual(["execute_command", "write_to_file", "search_files"])
})

it("uses parent filePatterns when child has none", () => {
const parent = toTaskPermissions({ filePatterns: ["src/.*"] })
const child: TaskPermissions = { deniedTools: ["execute_command"] }
const merged = mergeTaskPermissions(parent, child)
expect(merged?._filePatternLayers).toEqual([["src/.*"]])
expect(merged?.deniedTools).toEqual(["execute_command"])
})

it("returns empty array when allowedTools intersection is empty", () => {
const parent: TaskPermissions = { allowedTools: ["read_file"] }
const child: TaskPermissions = { allowedTools: ["write_to_file"] }
const merged = mergeTaskPermissions(parent, child)
expect(merged?.allowedTools).toEqual([])
})

it("handles nested delegation where child narrows scope", () => {
const grandparent = toTaskPermissions({
filePatterns: ["src/.*"],
commandPatterns: ["npm.*"],
allowedTools: ["read_file", "write_to_file", "search_files"],
deniedTools: ["execute_command"],
})
const parent = toTaskPermissions({
filePatterns: ["src/components/.*"],
allowedTools: ["read_file", "write_to_file"],
})

const merged = mergeTaskPermissions(grandparent, parent)

// Both layers are kept -- runtime enforces AND between them
expect(merged?._filePatternLayers).toEqual([["src/.*"], ["src/components/.*"]])
// allowedTools intersection: read_file and write_to_file are in both
expect(merged?.allowedTools).toEqual(["read_file", "write_to_file"])
// commandPatterns: only grandparent has them, so they pass through
expect(merged?._commandPatternLayers).toEqual([["npm.*"]])
// deniedTools: only grandparent has them, so they pass through
expect(merged?.deniedTools).toEqual(["execute_command"])
})

it("deduplicates identical pattern layers", () => {
const parent = toTaskPermissions({ filePatterns: ["src/.*"] })
const child = toTaskPermissions({ filePatterns: ["src/.*"] })
const merged = mergeTaskPermissions(parent, child)
// Identical layers are deduplicated
expect(merged?._filePatternLayers).toEqual([["src/.*"]])
})
})

describe("matchesAnyPattern", () => {
it("matches a simple regex pattern", () => {
expect(matchesAnyPattern("src/components/Button.tsx", ["src/components/.*"])).toBe(true)
})

it("does not match when no patterns match", () => {
expect(matchesAnyPattern("tests/unit/test.ts", ["src/components/.*"])).toBe(false)
})

it("matches when at least one pattern matches", () => {
expect(matchesAnyPattern("tests/unit/test.ts", ["src/.*", "tests/.*"])).toBe(true)
})

it("handles invalid regex gracefully", () => {
expect(matchesAnyPattern("test.ts", ["[invalid"])).toBe(false)
})

it("matches command patterns", () => {
expect(matchesAnyPattern("npm test -- --coverage", ["npm test.*"])).toBe(true)
})

it("does not match restricted commands", () => {
expect(matchesAnyPattern("rm -rf /", ["npm.*", "yarn.*"])).toBe(false)
})

it("anchors patterns so substrings do not match", () => {
// "src/.*" should NOT match a path that merely contains "src/" as a substring
expect(matchesAnyPattern("evil/src/components/foo.ts", ["src/.*"])).toBe(false)
// But should still match paths that start with src/
expect(matchesAnyPattern("src/components/foo.ts", ["src/.*"])).toBe(true)
})

it("respects pre-anchored patterns (starting with ^)", () => {
// A pattern already starting with ^ should not be double-wrapped
expect(matchesAnyPattern("src/foo.ts", ["^src/.*$"])).toBe(true)
expect(matchesAnyPattern("evil/src/foo.ts", ["^src/.*$"])).toBe(false)
})
})

describe("matchesAllPatternLayers", () => {
it("returns true when layers is undefined", () => {
expect(matchesAllPatternLayers("anything", undefined)).toBe(true)
})

it("returns true when layers is empty", () => {
expect(matchesAllPatternLayers("anything", [])).toBe(true)
})

it("returns true when value matches all layers", () => {
const layers = [["src/.*"], ["src/components/.*"]]
expect(matchesAllPatternLayers("src/components/Button.tsx", layers)).toBe(true)
})

it("returns false when value fails to match one layer", () => {
const layers = [["src/.*"], ["src/components/.*"]]
// Matches src/.* but not src/components/.*
expect(matchesAllPatternLayers("src/utils/helper.ts", layers)).toBe(false)
})

it("handles single layer like matchesAnyPattern", () => {
const layers = [["src/.*", "tests/.*"]]
expect(matchesAllPatternLayers("tests/unit/test.ts", layers)).toBe(true)
expect(matchesAllPatternLayers("docs/readme.md", layers)).toBe(false)
})
})

describe("isSafeRegex", () => {
it("accepts simple file path patterns", () => {
expect(isSafeRegex("src/.*")).toBe(true)
expect(isSafeRegex("src/components/.*\\.tsx")).toBe(true)
expect(isSafeRegex("npm test.*")).toBe(true)
})

it("rejects nested quantifiers (classic ReDoS)", () => {
expect(isSafeRegex("(a+)+")).toBe(false)
expect(isSafeRegex("(a*)+")).toBe(false)
expect(isSafeRegex("(a+)*")).toBe(false)
expect(isSafeRegex("(a+){2,}")).toBe(false)
})

it("rejects overlapping alternations in repeated groups", () => {
expect(isSafeRegex("(a|a)+")).toBe(false)
expect(isSafeRegex("(.|a)*")).toBe(false)
})

it("rejects patterns exceeding maximum length", () => {
const longPattern = "a".repeat(201)
expect(isSafeRegex(longPattern)).toBe(false)
})

it("accepts patterns at maximum length", () => {
const maxPattern = "a".repeat(200)
expect(isSafeRegex(maxPattern)).toBe(true)
})
})

describe("schema ReDoS rejection", () => {
it("rejects ReDoS-vulnerable patterns in filePatterns", () => {
const result = taskPermissionsSchema.safeParse({
filePatterns: ["(a+)+"],
})
expect(result.success).toBe(false)
})

it("rejects ReDoS-vulnerable patterns in commandPatterns", () => {
const result = taskPermissionsSchema.safeParse({
commandPatterns: ["(cmd|cmd)*"],
})
expect(result.success).toBe(false)
})

it("rejects overly long patterns at schema level", () => {
const result = taskPermissionsSchema.safeParse({
filePatterns: ["a".repeat(201)],
})
expect(result.success).toBe(false)
})
})

describe("persistence round-trip", () => {
it("taskPermissionsSchema can parse persisted permissions (without internal fields)", () => {
// Simulate what gets persisted: only the input-level fields
const persisted = {
filePatterns: ["src/.*"],
commandPatterns: ["npm test.*"],
allowedTools: ["read_file"],
deniedTools: ["execute_command"],
}
const result = taskPermissionsSchema.safeParse(persisted)
expect(result.success).toBe(true)
if (result.success) {
// Can be converted back to internal representation
const restored = toTaskPermissions(result.data)
expect(restored._filePatternLayers).toEqual([["src/.*"]])
expect(restored._commandPatternLayers).toEqual([["npm test.*"]])
expect(restored.allowedTools).toEqual(["read_file"])
expect(restored.deniedTools).toEqual(["execute_command"])
}
})
})
})
3 changes: 3 additions & 0 deletions packages/types/src/history.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { z } from "zod"

import { taskPermissionsSchema } from "./task-permissions.js"

/**
* HistoryItem
*/
Expand All @@ -26,6 +28,7 @@ export const historyItemSchema = z.object({
awaitingChildId: z.string().optional(), // Child currently awaited (set when delegated)
completedByChildId: z.string().optional(), // Child that completed and resumed this parent
completionResultSummary: z.string().optional(), // Summary from completed child
taskPermissions: taskPermissionsSchema.optional(), // Permission boundaries set by parent task
})

export type HistoryItem = z.infer<typeof historyItemSchema>
1 change: 1 addition & 0 deletions packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export * from "./mode.js"
export * from "./model.js"
export * from "./provider-settings.js"
export * from "./task.js"
export * from "./task-permissions.js"
export * from "./todo.js"
export * from "./skills.js"
export * from "./terminal.js"
Expand Down
2 changes: 1 addition & 1 deletion packages/types/src/mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,6 @@ export const DEFAULT_MODES: readonly ModeConfig[] = [
description: "Coordinate tasks across multiple modes",
groups: [],
customInstructions:
"Your role is to coordinate complex workflows by delegating tasks to specialized modes. As an orchestrator, you should:\n\n1. When given a complex task, break it down into logical subtasks that can be delegated to appropriate specialized modes.\n\n2. For each subtask, use the `new_task` tool to delegate. Choose the most appropriate mode for the subtask's specific goal and provide comprehensive instructions in the `message` parameter. These instructions must include:\n * All necessary context from the parent task or previous subtasks required to complete the work.\n * A clearly defined scope, specifying exactly what the subtask should accomplish.\n * An explicit statement that the subtask should *only* perform the work outlined in these instructions and not deviate.\n * An instruction for the subtask to signal completion by using the `attempt_completion` tool, providing a concise yet thorough summary of the outcome in the `result` parameter, keeping in mind that this summary will be the source of truth used to keep track of what was completed on this project.\n * A statement that these specific instructions supersede any conflicting general instructions the subtask's mode might have.\n\n3. Track and manage the progress of all subtasks. When a subtask is completed, analyze its results and determine the next steps.\n\n4. Help the user understand how the different subtasks fit together in the overall workflow. Provide clear reasoning about why you're delegating specific tasks to specific modes.\n\n5. When all subtasks are completed, synthesize the results and provide a comprehensive overview of what was accomplished.\n\n6. Ask clarifying questions when necessary to better understand how to break down complex tasks effectively.\n\n7. Suggest improvements to the workflow based on the results of completed subtasks.\n\nUse subtasks to maintain clarity. If a request significantly shifts focus or requires a different expertise (mode), consider creating a subtask rather than overloading the current one.",
'Your role is to coordinate complex workflows by delegating tasks to specialized modes. As an orchestrator, you should:\n\n1. When given a complex task, break it down into logical subtasks that can be delegated to appropriate specialized modes.\n\n2. For each subtask, use the `new_task` tool to delegate. Choose the most appropriate mode for the subtask\'s specific goal and provide comprehensive instructions in the `message` parameter. These instructions must include:\n * All necessary context from the parent task or previous subtasks required to complete the work.\n * A clearly defined scope, specifying exactly what the subtask should accomplish.\n * An explicit statement that the subtask should *only* perform the work outlined in these instructions and not deviate.\n * An instruction for the subtask to signal completion by using the `attempt_completion` tool, providing a concise yet thorough summary of the outcome in the `result` parameter, keeping in mind that this summary will be the source of truth used to keep track of what was completed on this project.\n * A statement that these specific instructions supersede any conflicting general instructions the subtask\'s mode might have.\n\n3. Track and manage the progress of all subtasks. When a subtask is completed, analyze its results and determine the next steps.\n\n4. Help the user understand how the different subtasks fit together in the overall workflow. Provide clear reasoning about why you\'re delegating specific tasks to specific modes.\n\n5. When all subtasks are completed, synthesize the results and provide a comprehensive overview of what was accomplished.\n\n6. Ask clarifying questions when necessary to better understand how to break down complex tasks effectively.\n\n7. Suggest improvements to the workflow based on the results of completed subtasks.\n\n8. When delegating subtasks, consider using the optional `permissions` parameter on `new_task` to restrict what the subtask can do. This is especially useful when:\n * The subtask should only modify files in a specific directory (use `filePatterns`, e.g. `["src/components/.*"]`).\n * The subtask should only run certain commands (use `commandPatterns`, e.g. `["npm test.*", "npm run lint"]`).\n * The subtask should be limited to specific tools (use `allowedTools`, e.g. `["read_file", "search_files"]` for read-only research tasks).\n * Certain tools should be explicitly blocked (use `deniedTools`, e.g. `["execute_command"]` to prevent shell access).\n Permissions are enforced at runtime and follow most-restrictive-wins semantics when subtasks are nested. Use them to keep subtasks focused and safe.\n\nUse subtasks to maintain clarity. If a request significantly shifts focus or requires a different expertise (mode), consider creating a subtask rather than overloading the current one.',
},
] as const
Loading
Loading