diff --git a/src/tools/agent-teams/team-task-tools.ts b/src/tools/agent-teams/team-task-tools.ts index 2febdeed..6db05f86 100644 --- a/src/tools/agent-teams/team-task-tools.ts +++ b/src/tools/agent-teams/team-task-tools.ts @@ -3,23 +3,36 @@ import { sendStructuredInboxMessage } from "./inbox-store" import { readTeamConfigOrThrow } from "./team-config-store" import { validateAgentNameOrLead, validateTaskId, validateTeamName } from "./name-validation" import { + TeamConfig, TeamTaskCreateInputSchema, TeamTaskGetInputSchema, TeamTaskListInputSchema, TeamTask, + TeamToolContext, + isTeammateMember, } from "./types" import { createTeamTask, listTeamTasks, readTeamTask } from "./team-task-store" -function buildTaskAssignmentPayload(task: TeamTask): Record { +function buildTaskAssignmentPayload(task: TeamTask, assignedBy: string): Record { return { type: "task_assignment", taskId: task.id, subject: task.subject, description: task.description, + assignedBy, timestamp: new Date().toISOString(), } } +export function resolveTaskActorFromContext(config: TeamConfig, context: TeamToolContext): "team-lead" | string | null { + if (context.sessionID === config.leadSessionId) { + return "team-lead" + } + + const matchedMember = config.members.find((member) => isTeammateMember(member) && member.sessionID === context.sessionID) + return matchedMember?.name ?? null +} + export function createTeamTaskCreateTool(): ToolDefinition { return tool({ description: "Create a task in team-scoped storage.", @@ -30,14 +43,18 @@ export function createTeamTaskCreateTool(): ToolDefinition { active_form: tool.schema.string().optional().describe("Present-continuous form"), metadata: tool.schema.record(tool.schema.string(), tool.schema.unknown()).optional().describe("Task metadata"), }, - execute: async (args: Record): Promise => { + execute: async (args: Record, context: TeamToolContext): 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 config = readTeamConfigOrThrow(input.team_name) + const actor = resolveTaskActorFromContext(config, context) + if (!actor) { + return JSON.stringify({ error: "unauthorized_task_session" }) + } const task = createTeamTask( input.team_name, @@ -61,14 +78,18 @@ export function createTeamTaskListTool(): ToolDefinition { args: { team_name: tool.schema.string().describe("Team name"), }, - execute: async (args: Record): Promise => { + execute: async (args: Record, context: TeamToolContext): Promise => { try { const input = TeamTaskListInputSchema.parse(args) const teamError = validateTeamName(input.team_name) if (teamError) { return JSON.stringify({ error: teamError }) } - readTeamConfigOrThrow(input.team_name) + const config = readTeamConfigOrThrow(input.team_name) + const actor = resolveTaskActorFromContext(config, context) + if (!actor) { + return JSON.stringify({ error: "unauthorized_task_session" }) + } return JSON.stringify(listTeamTasks(input.team_name)) } catch (error) { return JSON.stringify({ error: error instanceof Error ? error.message : "team_task_list_failed" }) @@ -84,7 +105,7 @@ export function createTeamTaskGetTool(): ToolDefinition { team_name: tool.schema.string().describe("Team name"), task_id: tool.schema.string().describe("Task id"), }, - execute: async (args: Record): Promise => { + execute: async (args: Record, context: TeamToolContext): Promise => { try { const input = TeamTaskGetInputSchema.parse(args) const teamError = validateTeamName(input.team_name) @@ -95,7 +116,11 @@ export function createTeamTaskGetTool(): ToolDefinition { if (taskIdError) { return JSON.stringify({ error: taskIdError }) } - readTeamConfigOrThrow(input.team_name) + const config = readTeamConfigOrThrow(input.team_name) + const actor = resolveTaskActorFromContext(config, context) + if (!actor) { + return JSON.stringify({ error: "unauthorized_task_session" }) + } const task = readTeamTask(input.team_name, input.task_id) if (!task) { return JSON.stringify({ error: "team_task_not_found" }) @@ -108,7 +133,7 @@ export function createTeamTaskGetTool(): ToolDefinition { }) } -export function notifyOwnerAssignment(teamName: string, task: TeamTask): void { +export function notifyOwnerAssignment(teamName: string, task: TeamTask, assignedBy: string): void { if (!task.owner || task.status === "deleted") { return } @@ -121,11 +146,15 @@ export function notifyOwnerAssignment(teamName: string, task: TeamTask): void { return } + if (validateAgentNameOrLead(assignedBy)) { + return + } + sendStructuredInboxMessage( teamName, - "team-lead", + assignedBy, task.owner, - buildTaskAssignmentPayload(task), + buildTaskAssignmentPayload(task, assignedBy), "task_assignment", ) } diff --git a/src/tools/agent-teams/team-task-update-tool.ts b/src/tools/agent-teams/team-task-update-tool.ts index 367dce52..2b4e34eb 100644 --- a/src/tools/agent-teams/team-task-update-tool.ts +++ b/src/tools/agent-teams/team-task-update-tool.ts @@ -1,9 +1,9 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" import { readTeamConfigOrThrow } from "./team-config-store" import { validateAgentNameOrLead, validateTaskId, validateTeamName } from "./name-validation" -import { TeamTaskUpdateInputSchema } from "./types" +import { TeamTaskUpdateInputSchema, TeamToolContext } from "./types" import { updateTeamTask } from "./team-task-update" -import { notifyOwnerAssignment } from "./team-task-tools" +import { notifyOwnerAssignment, resolveTaskActorFromContext } from "./team-task-tools" export function createTeamTaskUpdateTool(): ToolDefinition { return tool({ @@ -20,7 +20,7 @@ export function createTeamTaskUpdateTool(): ToolDefinition { add_blocked_by: tool.schema.array(tool.schema.string()).optional().describe("Add blocker task ids"), metadata: tool.schema.record(tool.schema.string(), tool.schema.unknown()).optional().describe("Metadata patch (null removes key)"), }, - execute: async (args: Record): Promise => { + execute: async (args: Record, context: TeamToolContext): Promise => { try { const input = TeamTaskUpdateInputSchema.parse(args) const teamError = validateTeamName(input.team_name) @@ -33,6 +33,11 @@ export function createTeamTaskUpdateTool(): ToolDefinition { } const config = readTeamConfigOrThrow(input.team_name) + const actor = resolveTaskActorFromContext(config, context) + if (!actor) { + return JSON.stringify({ error: "unauthorized_task_session" }) + } + const memberNames = new Set(config.members.map((member) => member.name)) if (input.owner !== undefined) { if (input.owner !== "") { @@ -74,7 +79,7 @@ export function createTeamTaskUpdateTool(): ToolDefinition { }) if (input.owner !== undefined) { - notifyOwnerAssignment(input.team_name, task) + notifyOwnerAssignment(input.team_name, task, actor) } return JSON.stringify(task) diff --git a/src/tools/agent-teams/tools.functional.test.ts b/src/tools/agent-teams/tools.functional.test.ts index 9a1b7d37..c399e367 100644 --- a/src/tools/agent-teams/tools.functional.test.ts +++ b/src/tools/agent-teams/tools.functional.test.ts @@ -299,6 +299,133 @@ describe("agent-teams tools functional", () => { expect(clearedOwnerTask.owner).toBeUndefined() }) + test("task tools reject sessions outside the team", async () => { + //#given + const { manager } = createMockManager() + const tools = createAgentTeamsTools(manager) + const leadContext = createContext("ses-main") + await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext) + + const createdTask = await executeJsonTool( + tools, + "team_task_create", + { + team_name: "core", + subject: "Draft release notes", + description: "Prepare release notes for next publish.", + }, + leadContext, + ) as { id: string } + + const unknownContext = createContext("ses-unknown") + + //#when + const createUnauthorized = await executeJsonTool( + tools, + "team_task_create", + { + team_name: "core", + subject: "Unauthorized create", + description: "Should fail", + }, + unknownContext, + ) as { error?: string } + + const listUnauthorized = await executeJsonTool( + tools, + "team_task_list", + { team_name: "core" }, + unknownContext, + ) as { error?: string } + + const getUnauthorized = await executeJsonTool( + tools, + "team_task_get", + { team_name: "core", task_id: createdTask.id }, + unknownContext, + ) as { error?: string } + + const updateUnauthorized = await executeJsonTool( + tools, + "team_task_update", + { team_name: "core", task_id: createdTask.id, status: "in_progress" }, + unknownContext, + ) as { error?: string } + + //#then + expect(createUnauthorized.error).toBe("unauthorized_task_session") + expect(listUnauthorized.error).toBe("unauthorized_task_session") + expect(getUnauthorized.error).toBe("unauthorized_task_session") + expect(updateUnauthorized.error).toBe("unauthorized_task_session") + }) + + test("team_task_update assignment notification sender follows actor session", async () => { + //#given + const { manager } = createMockManager() + const tools = createAgentTeamsTools(manager) + const leadContext = createContext("ses-main") + await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext) + await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_1", + prompt: "Handle release prep", + category: "quick", + }, + leadContext, + ) + await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_2", + prompt: "Handle QA", + category: "quick", + }, + leadContext, + ) + + const task = await executeJsonTool( + tools, + "team_task_create", + { + team_name: "core", + subject: "Validate rollout", + description: "Run preflight checks", + }, + leadContext, + ) as { id: string } + + //#when + const updated = await executeJsonTool( + tools, + "team_task_update", + { team_name: "core", task_id: task.id, owner: "worker_2" }, + createContext("ses-worker-1"), + ) as { owner?: string } + + const workerInbox = await executeJsonTool( + tools, + "read_inbox", + { + team_name: "core", + agent_name: "worker_2", + unread_only: true, + mark_as_read: false, + }, + leadContext, + ) as Array<{ summary?: string; from: string; text: string }> + + //#then + expect(updated.owner).toBe("worker_2") + const assignment = workerInbox.find((message) => message.summary === "task_assignment") + expect(assignment).toBeDefined() + expect(assignment?.from).toBe("worker_1") + }) + test("spawns teammate using category resolution like delegate-task", async () => { //#given const { manager, launchCalls } = createMockManager()