diff --git a/src/tools/agent-teams/team-task-store.test.ts b/src/tools/agent-teams/team-task-store.test.ts new file mode 100644 index 000000000..9314f3b94 --- /dev/null +++ b/src/tools/agent-teams/team-task-store.test.ts @@ -0,0 +1,44 @@ +/// +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { createTeamConfig, deleteTeamData } from "./team-config-store" +import { createTeamTask, deleteTeamTaskFile, readTeamTask } from "./team-task-store" + +describe("agent-teams task store", () => { + let originalCwd: string + let tempProjectDir: string + + beforeEach(() => { + originalCwd = process.cwd() + tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-task-store-")) + process.chdir(tempProjectDir) + createTeamConfig("core", "Core team", "ses-main", tempProjectDir, "sisyphus") + }) + + afterEach(() => { + deleteTeamData("core") + process.chdir(originalCwd) + rmSync(tempProjectDir, { recursive: true, force: true }) + }) + + test("creates and reads a task", () => { + //#given + const created = createTeamTask("core", "Subject", "Description") + + //#when + const loaded = readTeamTask("core", created.id) + + //#then + expect(loaded?.id).toBe(created.id) + expect(loaded?.subject).toBe("Subject") + }) + + test("rejects invalid team name and task id", () => { + //#then + expect(() => readTeamTask("../../etc", "T-1")).toThrow("team_name_invalid") + expect(() => readTeamTask("core", "../../passwd")).toThrow("task_id_invalid") + expect(() => deleteTeamTaskFile("core", "../../passwd")).toThrow("task_id_invalid") + }) +}) diff --git a/src/tools/agent-teams/team-task-store.ts b/src/tools/agent-teams/team-task-store.ts index abc2d8691..6fb599b6d 100644 --- a/src/tools/agent-teams/team-task-store.ts +++ b/src/tools/agent-teams/team-task-store.ts @@ -9,8 +9,24 @@ import { } from "../../features/claude-tasks/storage" import { getTeamTaskDir, getTeamTaskPath } from "./paths" import { TeamTask, TeamTaskSchema } from "./types" +import { validateTaskId, validateTeamName } from "./name-validation" + +function assertValidTeamName(teamName: string): void { + const validationError = validateTeamName(teamName) + if (validationError) { + throw new Error(validationError) + } +} + +function assertValidTaskId(taskId: string): void { + const validationError = validateTaskId(taskId) + if (validationError) { + throw new Error(validationError) + } +} function withTaskLock(teamName: string, operation: () => T): T { + assertValidTeamName(teamName) const taskDir = getTeamTaskDir(teamName) ensureDir(taskDir) const lock = acquireLock(taskDir) @@ -26,6 +42,8 @@ function withTaskLock(teamName: string, operation: () => T): T { } export function readTeamTask(teamName: string, taskId: string): TeamTask | null { + assertValidTeamName(teamName) + assertValidTaskId(taskId) return readJsonSafe(getTeamTaskPath(teamName, taskId), TeamTaskSchema) } @@ -38,6 +56,7 @@ export function readTeamTaskOrThrow(teamName: string, taskId: string): TeamTask } export function listTeamTasks(teamName: string): TeamTask[] { + assertValidTeamName(teamName) const taskDir = getTeamTaskDir(teamName) if (!existsSync(taskDir)) { return [] @@ -50,6 +69,9 @@ export function listTeamTasks(teamName: string): TeamTask[] { const tasks: TeamTask[] = [] for (const file of files) { const taskId = file.replace(/\.json$/, "") + if (validateTaskId(taskId)) { + continue + } const task = readTeamTask(teamName, taskId) if (task) { tasks.push(task) @@ -66,6 +88,7 @@ export function createTeamTask( activeForm?: string, metadata?: Record, ): TeamTask { + assertValidTeamName(teamName) if (!subject.trim()) { throw new Error("team_task_subject_required") } @@ -89,12 +112,16 @@ export function createTeamTask( } export function writeTeamTask(teamName: string, task: TeamTask): TeamTask { + assertValidTeamName(teamName) + assertValidTaskId(task.id) const validated = TeamTaskSchema.parse(task) writeJsonAtomic(getTeamTaskPath(teamName, validated.id), validated) return validated } export function deleteTeamTaskFile(teamName: string, taskId: string): void { + assertValidTeamName(teamName) + assertValidTaskId(taskId) const taskPath = getTeamTaskPath(teamName, taskId) if (existsSync(taskPath)) { unlinkSync(taskPath) @@ -102,10 +129,12 @@ export function deleteTeamTaskFile(teamName: string, taskId: string): void { } export function readTaskFromDirectory(taskDir: string, taskId: string): TeamTask | null { + assertValidTaskId(taskId) return readJsonSafe(join(taskDir, `${taskId}.json`), TeamTaskSchema) } export function resetOwnerTasks(teamName: string, ownerName: string): void { + assertValidTeamName(teamName) withTaskLock(teamName, () => { const tasks = listTeamTasks(teamName) for (const task of tasks) { @@ -123,5 +152,6 @@ export function resetOwnerTasks(teamName: string, ownerName: string): void { } export function withTeamTaskLock(teamName: string, operation: () => T): T { + assertValidTeamName(teamName) return withTaskLock(teamName, operation) } diff --git a/src/tools/agent-teams/team-task-tools.ts b/src/tools/agent-teams/team-task-tools.ts index 36a2f0dcf..d2aff70c8 100644 --- a/src/tools/agent-teams/team-task-tools.ts +++ b/src/tools/agent-teams/team-task-tools.ts @@ -1,6 +1,7 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" import { sendStructuredInboxMessage } from "./inbox-store" import { readTeamConfigOrThrow } from "./team-config-store" +import { validateAgentName, validateTaskId, validateTeamName } from "./name-validation" import { TeamTaskCreateInputSchema, TeamTaskGetInputSchema, @@ -32,6 +33,10 @@ export function createTeamTaskCreateTool(): ToolDefinition { execute: async (args: Record): Promise => { try { const input = TeamTaskCreateInputSchema.parse(args) + const teamError = validateTeamName(input.team_name) + if (teamError) { + return JSON.stringify({ error: teamError }) + } readTeamConfigOrThrow(input.team_name) const task = createTeamTask( @@ -59,6 +64,10 @@ export function createTeamTaskListTool(): ToolDefinition { execute: async (args: Record): Promise => { try { const input = TeamTaskListInputSchema.parse(args) + const teamError = validateTeamName(input.team_name) + if (teamError) { + return JSON.stringify({ error: teamError }) + } readTeamConfigOrThrow(input.team_name) return JSON.stringify(listTeamTasks(input.team_name)) } catch (error) { @@ -78,6 +87,14 @@ export function createTeamTaskGetTool(): ToolDefinition { execute: async (args: Record): Promise => { try { const input = TeamTaskGetInputSchema.parse(args) + const teamError = validateTeamName(input.team_name) + if (teamError) { + return JSON.stringify({ error: teamError }) + } + const taskIdError = validateTaskId(input.task_id) + if (taskIdError) { + return JSON.stringify({ error: taskIdError }) + } readTeamConfigOrThrow(input.team_name) const task = readTeamTask(input.team_name, input.task_id) if (!task) { @@ -96,6 +113,14 @@ export function notifyOwnerAssignment(teamName: string, task: TeamTask): void { return } + if (validateTeamName(teamName)) { + return + } + + if (validateAgentName(task.owner)) { + return + } + sendStructuredInboxMessage( teamName, "team-lead", diff --git a/src/tools/agent-teams/team-task-update-tool.ts b/src/tools/agent-teams/team-task-update-tool.ts index 95bf3bb94..3518b0a49 100644 --- a/src/tools/agent-teams/team-task-update-tool.ts +++ b/src/tools/agent-teams/team-task-update-tool.ts @@ -1,5 +1,6 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" import { readTeamConfigOrThrow } from "./team-config-store" +import { validateAgentName, validateTaskId, validateTeamName } from "./name-validation" import { TeamTaskUpdateInputSchema } from "./types" import { updateTeamTask } from "./team-task-update" import { notifyOwnerAssignment } from "./team-task-tools" @@ -22,6 +23,36 @@ export function createTeamTaskUpdateTool(): ToolDefinition { execute: async (args: Record): Promise => { try { const input = TeamTaskUpdateInputSchema.parse(args) + const teamError = validateTeamName(input.team_name) + if (teamError) { + return JSON.stringify({ error: teamError }) + } + const taskIdError = validateTaskId(input.task_id) + if (taskIdError) { + return JSON.stringify({ error: taskIdError }) + } + if (input.owner !== undefined) { + const ownerError = validateAgentName(input.owner) + if (ownerError) { + return JSON.stringify({ error: ownerError }) + } + } + if (input.add_blocks) { + for (const blockerId of input.add_blocks) { + const blockerError = validateTaskId(blockerId) + if (blockerError) { + return JSON.stringify({ error: blockerError }) + } + } + } + if (input.add_blocked_by) { + for (const dependencyId of input.add_blocked_by) { + const dependencyError = validateTaskId(dependencyId) + if (dependencyError) { + return JSON.stringify({ error: dependencyError }) + } + } + } readTeamConfigOrThrow(input.team_name) const task = updateTeamTask(input.team_name, input.task_id, { diff --git a/src/tools/agent-teams/team-task-update.ts b/src/tools/agent-teams/team-task-update.ts index d7171ee73..2298b496f 100644 --- a/src/tools/agent-teams/team-task-update.ts +++ b/src/tools/agent-teams/team-task-update.ts @@ -1,6 +1,7 @@ import { existsSync, readdirSync, unlinkSync } from "node:fs" import { join } from "node:path" import { readJsonSafe, writeJsonAtomic } from "../../features/claude-tasks/storage" +import { validateTaskId, validateTeamName } from "./name-validation" import { getTeamTaskDir, getTeamTaskPath } from "./paths" import { addPendingEdge, @@ -23,11 +24,40 @@ export interface TeamTaskUpdatePatch { metadata?: Record } +function assertValidTeamName(teamName: string): void { + const validationError = validateTeamName(teamName) + if (validationError) { + throw new Error(validationError) + } +} + +function assertValidTaskId(taskId: string): void { + const validationError = validateTaskId(taskId) + if (validationError) { + throw new Error(validationError) + } +} + function writeTaskToPath(path: string, task: TeamTask): void { writeJsonAtomic(path, TeamTaskSchema.parse(task)) } export function updateTeamTask(teamName: string, taskId: string, patch: TeamTaskUpdatePatch): TeamTask { + assertValidTeamName(teamName) + assertValidTaskId(taskId) + + if (patch.addBlocks) { + for (const blockedTaskId of patch.addBlocks) { + assertValidTaskId(blockedTaskId) + } + } + + if (patch.addBlockedBy) { + for (const blockerId of patch.addBlockedBy) { + assertValidTaskId(blockerId) + } + } + return withTeamTaskLock(teamName, () => { const taskDir = getTeamTaskDir(teamName) const taskPath = getTeamTaskPath(teamName, taskId)