fix(agent-teams): authorize task tools by team session

This commit is contained in:
Nguyen Khac Trung Kien
2026-02-08 14:01:27 +07:00
committed by YeonGyu-Kim
parent 2a57feb810
commit a9d4cefdfe
3 changed files with 175 additions and 14 deletions

View File

@@ -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<string, unknown> {
function buildTaskAssignmentPayload(task: TeamTask, assignedBy: string): Record<string, unknown> {
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<string, unknown>): Promise<string> => {
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
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<string, unknown>): Promise<string> => {
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
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<string, unknown>): Promise<string> => {
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
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",
)
}

View File

@@ -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<string, unknown>): Promise<string> => {
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
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)

View File

@@ -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()