diff --git a/internal/tools/delegate_prep.go b/internal/tools/delegate_prep.go index a60b3338..368617bf 100644 --- a/internal/tools/delegate_prep.go +++ b/internal/tools/delegate_prep.go @@ -53,7 +53,11 @@ func (dm *DelegateManager) prepareDelegation(ctx context.Context, opts DelegateO // Auto-create team task when team_task_id is omitted. // This eliminates the two-step create→spawn dance that caused LLM hallucination // (LLM would call create+spawn in parallel, hallucinating the task_id). + // Only the team lead can create tasks — members must ask the lead. if team != nil && opts.TeamTaskID == uuid.Nil { + if sourceAgentID != team.LeadAgentID { + return nil, nil, fmt.Errorf("only the team lead can create team tasks — ask your lead to assign this task") + } subject := opts.Label if subject == "" { subject = opts.Task diff --git a/internal/tools/team_message_tool.go b/internal/tools/team_message_tool.go index fb6d7f42..cf955196 100644 --- a/internal/tools/team_message_tool.go +++ b/internal/tools/team_message_tool.go @@ -139,6 +139,9 @@ func (t *TeamMessageTool) executeBroadcast(ctx context.Context, args map[string] if err != nil { return ErrorResult(err.Error()) } + if err := t.manager.requireLead(ctx, team, agentID); err != nil { + return ErrorResult(err.Error()) + } text, _ := args["text"].(string) if text == "" { diff --git a/internal/tools/team_tasks_tool.go b/internal/tools/team_tasks_tool.go index f55055c6..2937fbc6 100644 --- a/internal/tools/team_tasks_tool.go +++ b/internal/tools/team_tasks_tool.go @@ -226,10 +226,13 @@ func (t *TeamTasksTool) executeSearch(ctx context.Context, args map[string]inter } func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]interface{}) *Result { - team, _, err := t.manager.resolveTeam(ctx) + team, agentID, err := t.manager.resolveTeam(ctx) if err != nil { return ErrorResult(err.Error()) } + if err := t.manager.requireLead(ctx, team, agentID); err != nil { + return ErrorResult(err.Error()) + } subject, _ := args["subject"].(string) if subject == "" { @@ -378,10 +381,13 @@ func (t *TeamTasksTool) executeCancel(ctx context.Context, args map[string]inter return ErrorResult("delegate agents cannot cancel team tasks directly") } - team, _, err := t.manager.resolveTeam(ctx) + team, agentID, err := t.manager.resolveTeam(ctx) if err != nil { return ErrorResult(err.Error()) } + if err := t.manager.requireLead(ctx, team, agentID); err != nil { + return ErrorResult(err.Error()) + } taskIDStr, _ := args["task_id"].(string) if taskIDStr == "" { diff --git a/internal/tools/team_tool_manager.go b/internal/tools/team_tool_manager.go index 3f2a8e6c..f0e3ef20 100644 --- a/internal/tools/team_tool_manager.go +++ b/internal/tools/team_tool_manager.go @@ -87,6 +87,19 @@ func (m *TeamToolManager) resolveTeam(ctx context.Context) (*store.TeamData, uui return team, agentID, nil } +// requireLead checks if the calling agent is the team lead. +// Delegate/system channels bypass this check (they act on behalf of the lead). +func (m *TeamToolManager) requireLead(ctx context.Context, team *store.TeamData, agentID uuid.UUID) error { + channel := ToolChannelFromCtx(ctx) + if channel == "delegate" || channel == "system" { + return nil + } + if agentID != team.LeadAgentID { + return fmt.Errorf("only the team lead can perform this action") + } + return nil +} + // InvalidateTeam clears all cached team data. // Called when team membership, settings, or links change. // Full clear is acceptable because team mutations are rare (admin-initiated).