fix(agent-teams): authorize task tools by team session
This commit is contained in:
committed by
YeonGyu-Kim
parent
2a57feb810
commit
a9d4cefdfe
@@ -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",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user