Files
goclaw/internal/tools/team_tasks_create.go
viettranx 6a9b747b65 feat: propagate RBAC role through dispatch + bypass file-writer grant for admins
Dashboard users (and other tenant-authenticated admin/operator/owner
actors) dispatching team tasks to agents in Telegram/Discord groups
previously hit the empty-sender DENY rule when the assigned agent
tried to write files — because dashboard users have tenant identities
(e.g. "viettx") that don't match the numeric file-writer grants stored
per channel.

Two-part fix:

1. CheckFileWriterPermission / CheckCronPermission add an RBAC bypass
   at the store layer: if ctx carries RoleFromContext in {admin,
   operator, owner}, skip the per-user grant check. Tenant RBAC already
   pre-authenticated these callers at the gateway edge; requiring a
   redundant per-channel grant blocks legitimate admin work.

2. Role propagation through the full dispatch chain:
     ctx (RoleFromContext)
       → RunRequest.Role
       → SubagentTask.OriginRole / AnnounceMetadata.OriginRole
         / DelegateRequest.Role
       → InboundMessage.Metadata[MetaOriginRole]
       → subagentAnnounceRouting.Role / teammateRole
       → RunRequest.Role (re-ingress)
       → store.WithRole(ctx, ...) in loop_context
   Covers subagent announce, delegate announce, teammate dispatch
   (WS dashboard, tool), session_send, and processNormalMessage's
   synthetic-sender override path. team_tasks_create persists
   origin_role for deferred dispatches.

Viewer and empty roles fall through to the existing sender-based path
(no bypass).

Regression tests:
- A.10 AdminRoleBypass: admin/operator/owner in ctx allowed even with
  empty sender (normally DENY).
- A.11 ViewerRoleDoesNotBypass: viewer and empty roles still hit DENY.

Builds + integration + unit tests green.
2026-04-16 14:17:48 +07:00

344 lines
13 KiB
Go

package tools
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tracing"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.ResolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
// Determine if caller is a lead or a member.
isLead := agentID == team.LeadAgentID
channel := ToolChannelFromCtx(ctx)
if channel == ChannelTeammate || channel == ChannelSystem {
isLead = true // system/teammate channels act on behalf of the lead
}
taskType, _ := args["task_type"].(string)
if taskType == "" {
taskType = "general"
}
if !isLead {
// Members may only create "request" tasks when the feature is enabled.
memberCfg := ParseMemberRequestConfig(team.Settings)
if !memberCfg.Enabled {
return ErrorResult("Members cannot create tasks. Use team_tasks(action=\"comment\") to communicate.")
}
if taskType != "request" {
return ErrorResult("Members can only create task_type=\"request\". Use team_tasks(action=\"comment\") to communicate.")
}
} else if err := t.manager.RequireLead(ctx, team, agentID); err != nil {
return ErrorResult(err.Error())
}
// Gate: must list tasks before creating to prevent duplicates in concurrent group chat.
if ptd := PendingTeamDispatchFromCtx(ctx); ptd != nil && !ptd.HasListed() {
return ErrorResult("You must check existing tasks first. Call team_tasks(action=\"search\", query=\"<keywords>\") to check for similar tasks before creating — this saves tokens vs listing all. Alternatively use action=\"list\" to see the full board.")
}
subject, _ := args["subject"].(string)
if subject == "" {
return ErrorResult("subject is required for create action")
}
description, _ := args["description"].(string)
priority := 0
if p, ok := args["priority"].(float64); ok {
priority = int(p)
}
var blockedBy []uuid.UUID
if raw, ok := args["blocked_by"].([]any); ok {
for _, v := range raw {
if s, ok := v.(string); ok {
id, err := uuid.Parse(s)
if err != nil {
return ErrorResult(fmt.Sprintf("blocked_by contains invalid task ID %q — must be a real task UUID from a previous create call. Create dependency tasks first, then use their IDs.", s))
}
blockedBy = append(blockedBy, id)
}
}
}
// Validate that all blocked_by tasks belong to the same team and are not terminal.
for _, depID := range blockedBy {
depTask, err := t.manager.Store().GetTask(ctx, depID)
if err != nil {
return ErrorResult(fmt.Sprintf("blocked_by task %s not found: %v", depID, err))
}
if depTask.TeamID != team.ID {
return ErrorResult(fmt.Sprintf("blocked_by task %s belongs to a different team", depID))
}
switch depTask.Status {
case store.TeamTaskStatusCompleted, store.TeamTaskStatusCancelled, store.TeamTaskStatusFailed:
return ErrorResult(fmt.Sprintf(
"blocked_by task %s (%s) is already %s. "+
"Do not block on finished tasks — create this task without blocked_by, "+
"or pass the completed task's result in the description instead.",
depID, depTask.Subject, depTask.Status))
}
}
// Resolve assignee (agent key → UUID). Required — every task must be assigned.
assigneeKey, _ := args["assignee"].(string)
if assigneeKey == "" {
return ErrorResult("assignee is required — specify which team member should handle this task")
}
assigneeID, err := t.manager.ResolveAgentByKey(ctx, assigneeKey)
if err != nil {
return ErrorResult(fmt.Sprintf("assignee %q not found: %v", assigneeKey, err))
}
// Verify assignee is a member of this team.
members, err := t.manager.CachedListMembers(ctx, team.ID, agentID)
if err != nil {
return ErrorResult("failed to verify team membership: " + err.Error())
}
isMember := false
for _, m := range members {
if m.AgentID == assigneeID {
isMember = true
break
}
}
if !isMember {
return ErrorResult(fmt.Sprintf("agent %q is not a member of this team", assigneeKey))
}
// Prevent lead from self-assigning — causes dual-session execution + loop.
// buildCreateHint already hides the lead from the member list hint,
// but weaker models may still attempt self-assignment.
if assigneeID == team.LeadAgentID {
return ErrorResult("team lead cannot assign tasks to itself — delegate to a team member instead. You are the team lead; handle this work directly or assign to one of your members.")
}
requireApproval, _ := args["require_approval"].(bool)
status := store.TeamTaskStatusPending
if requireApproval {
status = store.TeamTaskStatusInReview
} else if len(blockedBy) > 0 {
status = store.TeamTaskStatusBlocked
}
// Assigned tasks without blockers stay pending — dispatched after the turn
// ends via post-turn processing (avoids race with blocked_by setup).
// Member requests without auto_dispatch stay pending for leader review.
memberCfgForDispatch := ParseMemberRequestConfig(team.Settings)
skipAutoDispatch := !isLead && taskType == "request" && !memberCfgForDispatch.AutoDispatch
chatID := ToolChatIDFromCtx(ctx)
// Compute team workspace via layered pipeline: tenant → team → user/chat.
shared := IsSharedWorkspace(team.Settings)
taskMeta := make(map[string]any)
teamWsDir := ResolveWorkspace(t.manager.DataDir(),
TenantLayer(store.TenantIDFromContext(ctx), store.TenantSlugFromContext(ctx)),
TeamLayer(team.ID),
UserChatLayer(chatID, shared),
)
taskMeta[TaskMetaTeamWorkspace] = teamWsDir
// Auto-collect media files from current run to team workspace.
// When leader received files from user and creates a task, copy those
// files to the team workspace so members can access them via read_file.
// Also rewrite any media paths in the description to point to the workspace copy,
// since members can't access the original .media/ paths outside their workspace.
if mediaPaths := RunMediaPathsFromCtx(ctx); len(mediaPaths) > 0 {
if wsDir, _ := taskMeta[TaskMetaTeamWorkspace].(string); wsDir != "" {
nameMap := RunMediaNamesFromCtx(ctx)
if copiedPaths := copyMediaToWorkspace(mediaPaths, wsDir, nameMap); len(copiedPaths) > 0 {
// Store as []any so type assertion works both before and after JSON round-trip.
files := make([]any, len(copiedPaths))
for i, p := range copiedPaths {
files[i] = p
}
taskMeta["attached_files"] = files
// Rewrite media paths in description so members see workspace paths.
for i, src := range mediaPaths {
if i < len(copiedPaths) {
description = strings.ReplaceAll(description, src, copiedPaths[i])
}
}
}
}
}
// Preserve original blocked_by list for blocker-result forwarding when task unblocks.
if len(blockedBy) > 0 {
ids := make([]string, len(blockedBy))
for i, id := range blockedBy {
ids[i] = id.String()
}
taskMeta["original_blocked_by"] = ids
}
// Store peer kind so dispatches preserve the correct session scope (group vs direct).
if pk := ToolPeerKindFromCtx(ctx); pk != "" {
taskMeta[TaskMetaPeerKind] = pk
}
// Store local key so forum-topic routing works on deferred/unblocked dispatches.
if lk := ToolLocalKeyFromCtx(ctx); lk != "" {
taskMeta[TaskMetaLocalKey] = lk
}
// Store origin session key so deferred dispatches route announces correctly.
// WS sessions use non-standard key format that BuildScopedSessionKey() cannot reproduce.
if sk := ToolSessionKeyFromCtx(ctx); sk != "" {
taskMeta[TaskMetaOriginSession] = sk
}
// Store leader's trace context so unblocked dispatch links back to the leader's trace.
if traceID := tracing.TraceIDFromContext(ctx); traceID != uuid.Nil {
taskMeta[TaskMetaOriginTrace] = traceID.String()
}
if rootSpanID := tracing.ParentSpanIDFromContext(ctx); rootSpanID != uuid.Nil {
taskMeta[TaskMetaOriginRootSpan] = rootSpanID.String()
}
// Persist the real acting sender so deferred/dashboard dispatches can
// restore permission attribution when the teammate runs (#915 Flow F).
if sender := store.SenderIDFromContext(ctx); sender != "" {
taskMeta["origin_sender_id"] = sender
}
// Persist caller role for RBAC-aware bypass at dispatch time.
if role := store.RoleFromContext(ctx); role != "" {
taskMeta["origin_role"] = role
}
task := &store.TeamTaskData{
TeamID: team.ID,
Subject: subject,
Description: description,
Status: status,
BlockedBy: blockedBy,
Priority: priority,
// SCOPE-intentional (#915 audit 2026-04-16): team task visibility is
// per-chat, not per-user. team_tasks_read.go filters end-user lists by
// this same UserID. Migrating to ActorIDFromContext would hide group
// members' shared work from each other.
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
TaskType: taskType,
CreatedByAgentID: &agentID,
ChatID: chatID,
Metadata: taskMeta,
}
task.OwnerAgentID = &assigneeID
// Auto-link member request to the member's current task as parent.
if !isLead && taskType == "request" {
if parentIDStr := TeamTaskIDFromCtx(ctx); parentIDStr != "" {
if parentUUID, err := uuid.Parse(parentIDStr); err == nil {
task.ParentID = &parentUUID
}
}
}
if err := t.manager.Store().CreateTask(ctx, task); err != nil {
return ErrorResult("failed to create task: " + err.Error())
}
// Auto-copy files referenced in subject+description from leader's personal workspace
// to team workspace so members can access them.
if isLead {
personalWs := ToolWorkspaceFromCtx(ctx)
searchText := subject + "\n" + description
if n := autoShareFiles(searchText, personalWs, teamWsDir); n > 0 {
slog.Info("team_tasks.create: auto-shared files", "count", n, "task_id", task.ID)
}
}
// Persist media files copied during task creation as DB attachments,
// so the UI and queries see them in team_task_attachments table.
// (Members get auto-attached via WorkspaceInterceptor, but leaders
// don't run inside a task context — handle explicitly here.)
if files, ok := taskMeta["attached_files"].([]any); ok {
for _, f := range files {
filePath, ok := f.(string)
if !ok || filePath == "" {
continue
}
var fileSize int64
if info, err := os.Stat(filePath); err == nil {
fileSize = info.Size()
}
att := &store.TeamTaskAttachmentData{
TaskID: task.ID,
TeamID: team.ID,
ChatID: chatID,
Path: filePath,
FileSize: fileSize,
MimeType: mimeFromExt(filepath.Ext(filePath)),
CreatedByAgentID: &agentID,
}
if err := t.manager.Store().AttachFileToTask(ctx, att); err != nil {
slog.Warn("executeCreate: auto-attach media failed", "task_id", task.ID, "path", filePath, "error", err)
}
}
}
agentKey := t.manager.AgentKeyFromID(ctx, agentID)
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskCreated, BuildTaskEventPayload(
team.ID.String(), task.ID.String(),
status,
"agent", agentKey,
WithSubject(subject),
WithContextInfo(ctx),
WithTimestamp(task.CreatedAt.UTC().Format("2006-01-02T15:04:05Z")),
))
// Track for post-turn dispatch. If no post-turn hook (e.g. HTTP API), dispatch immediately.
// Member requests with auto_dispatch=false stay pending for leader review — skip dispatch.
if status == store.TeamTaskStatusPending && !skipAutoDispatch {
if ptd := PendingTeamDispatchFromCtx(ctx); ptd != nil {
ptd.Add(team.ID, task.ID)
} else {
// Fallback: assign (pending → in_progress + lock) then dispatch.
if err := t.manager.Store().AssignTask(ctx, task.ID, assigneeID, team.ID); err != nil {
slog.Warn("executeCreate: fallback assign failed", "task_id", task.ID, "error", err)
} else {
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, BuildTaskEventPayload(
team.ID.String(), task.ID.String(),
store.TeamTaskStatusInProgress,
"system", "fallback_dispatch",
WithTaskInfo(task.TaskNumber, task.Subject),
WithOwnerAgentKey(t.manager.AgentKeyFromID(ctx, assigneeID)),
WithChannel(task.Channel),
WithChatID(task.ChatID),
WithPeerKind(ToolPeerKindFromCtx(ctx)),
WithLocalKey(ToolLocalKeyFromCtx(ctx)),
))
t.manager.DispatchTaskToAgent(ctx, task, team, assigneeID)
}
}
}
assigneeName := t.manager.AgentDisplayName(ctx, t.manager.AgentKeyFromID(ctx, assigneeID))
if assigneeName == "" {
assigneeName = t.manager.AgentKeyFromID(ctx, assigneeID)
}
msg := fmt.Sprintf("Task created: %s (id=%s, task_number=%d, status=%s, assignee=%s)", subject, task.ID, task.TaskNumber, status, assigneeName)
// Soft guardrail: warn if subject suggests multiple deliverables.
// Only checks subject (not description) — detailed descriptions are fine.
if subject != "" {
subjLower := strings.ToLower(subject)
hasCompound := strings.Contains(subjLower, " and ") &&
(strings.Contains(subjLower, "implement") || strings.Contains(subjLower, "create") ||
strings.Contains(subjLower, "build") || strings.Contains(subjLower, "design") ||
strings.Contains(subjLower, "write") || strings.Contains(subjLower, "develop"))
if hasCompound {
msg += "\n\nWarning: This task subject suggests multiple deliverables. Consider splitting into separate tasks if they need different skills."
}
}
return NewResult(msg)
}