fix(agent-teams): close latest review gaps for auth and race safety

This commit is contained in:
Nguyen Khac Trung Kien 2026-02-08 09:52:49 +07:00 committed by YeonGyu-Kim
parent 79c3823762
commit 2103061123
5 changed files with 139 additions and 9 deletions

View File

@ -212,7 +212,7 @@ export function createReadInboxTool(): ToolDefinition {
unread_only: tool.schema.boolean().optional().describe("Return only unread messages"),
mark_as_read: tool.schema.boolean().optional().describe("Mark returned messages as read"),
},
execute: async (args: Record<string, unknown>): Promise<string> => {
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
try {
const input = TeamReadInboxInputSchema.parse(args)
const teamError = validateTeamName(input.team_name)
@ -223,7 +223,15 @@ export function createReadInboxTool(): ToolDefinition {
if (agentError) {
return JSON.stringify({ error: agentError })
}
readTeamConfigOrThrow(input.team_name)
const config = readTeamConfigOrThrow(input.team_name)
const actor = resolveSenderFromContext(config, context)
if (!actor) {
return JSON.stringify({ error: "unauthorized_reader_session" })
}
if (actor !== "team-lead" && actor !== input.agent_name) {
return JSON.stringify({ error: "unauthorized_reader_session" })
}
const messages = readInbox(
input.team_name,

View File

@ -118,8 +118,15 @@ export function updateTeamTask(teamName: string, taskId: string, patch: TeamTask
if (patch.status && patch.status !== "deleted") {
ensureForwardStatusTransition(currentTask.status, patch.status)
const effectiveBlockedBy = Array.from(new Set([...(currentTask.blockedBy ?? []), ...(patch.addBlockedBy ?? [])]))
ensureDependenciesCompleted(patch.status, effectiveBlockedBy, readTask)
}
const effectiveStatus = patch.status ?? currentTask.status
const effectiveBlockedBy = Array.from(new Set([...(currentTask.blockedBy ?? []), ...(patch.addBlockedBy ?? [])]))
const shouldValidateDependencies =
(patch.status !== undefined || (patch.addBlockedBy?.length ?? 0) > 0) && effectiveStatus !== "deleted"
if (shouldValidateDependencies) {
ensureDependenciesCompleted(effectiveStatus, effectiveBlockedBy, readTask)
}
let nextTask: TeamTask = { ...currentTask }

View File

@ -90,10 +90,10 @@ export async function spawnTeammate(params: SpawnTeammateParams): Promise<TeamTe
throw new Error("teammate_create_failed")
}
ensureInbox(params.teamName, params.name)
sendPlainInboxMessage(params.teamName, "team-lead", params.name, params.prompt, "initial_prompt", teammate.color)
try {
ensureInbox(params.teamName, params.name)
sendPlainInboxMessage(params.teamName, "team-lead", params.name, params.prompt, "initial_prompt", teammate.color)
const resolvedModel = parseModel(params.model)
const launched = await params.manager.launch({
description: `[team:${params.teamName}] ${params.name}`,

View File

@ -8,7 +8,7 @@ import {
TeamToolContext,
isTeammateMember,
} from "./types"
import { getTeamMember, readTeamConfigOrThrow, removeTeammate, writeTeamConfig } from "./team-config-store"
import { getTeamMember, readTeamConfigOrThrow, removeTeammate, updateTeamConfig, writeTeamConfig } from "./team-config-store"
import { cancelTeammateRun, spawnTeammate } from "./teammate-runtime"
import { resetOwnerTasks } from "./team-task-store"
@ -130,7 +130,16 @@ export function createProcessShutdownTool(manager: BackgroundManager): ToolDefin
}
await cancelTeammateRun(manager, member)
writeTeamConfig(input.team_name, removeTeammate(config, input.agent_name))
updateTeamConfig(input.team_name, (current) => {
const refreshedMember = getTeamMember(current, input.agent_name)
if (!refreshedMember || !isTeammateMember(refreshedMember)) {
return current
}
return removeTeammate(current, input.agent_name)
})
resetOwnerTasks(input.team_name, input.agent_name)
return JSON.stringify({ success: true, message: `${input.agent_name} removed` })

View File

@ -657,6 +657,112 @@ describe("agent-teams tools functional", () => {
expect(result.error).toBe("team_not_found")
})
test("read_inbox denies cross-member access for non-lead sessions", async () => {
//#given
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext()
await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext)
await executeJsonTool(
tools,
"spawn_teammate",
{
team_name: "core",
name: "worker_1",
prompt: "Handle release prep",
},
leadContext,
)
const teammateContext = createContext("ses-worker-1")
//#when
const unauthorized = await executeJsonTool(
tools,
"read_inbox",
{
team_name: "core",
agent_name: "team-lead",
},
teammateContext,
) as { error?: string }
//#then
expect(unauthorized.error).toBe("unauthorized_reader_session")
//#when
const ownInbox = await executeJsonTool(
tools,
"read_inbox",
{
team_name: "core",
agent_name: "worker_1",
},
teammateContext,
) as unknown[]
//#then
expect(Array.isArray(ownInbox)).toBe(true)
})
test("cannot add pending blockers to already in-progress task without status change", async () => {
//#given
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const context = createContext()
await executeJsonTool(tools, "team_create", { team_name: "core" }, context)
const blocker = await executeJsonTool(
tools,
"team_task_create",
{
team_name: "core",
subject: "Blocker",
description: "Unfinished blocker",
},
context,
) as { id: string }
const mainTask = await executeJsonTool(
tools,
"team_task_create",
{
team_name: "core",
subject: "Main",
description: "Main task",
},
context,
) as { id: string }
await executeJsonTool(
tools,
"team_task_update",
{
team_name: "core",
task_id: mainTask.id,
status: "in_progress",
},
context,
)
//#when
const result = await executeJsonTool(
tools,
"team_task_update",
{
team_name: "core",
task_id: mainTask.id,
add_blocked_by: [blocker.id],
},
context,
) as { error?: string }
//#then
expect(result.error).toBe(`blocked_by_incomplete:${blocker.id}:pending`)
})
test("binds sender to calling context and rejects sender spoofing", async () => {
//#given
const { manager } = createMockManager()