From 9e5cec03ba9e544be32b52a7005242e25544479a Mon Sep 17 00:00:00 2001 From: viettranx Date: Thu, 16 Apr 2026 13:14:00 +0700 Subject: [PATCH] feat: preserve acting sender + migrate ACTOR sites across tool chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves issue #915 (Telegram group write_file permission denied after /addwriter) by reconstructing the real sender on announce re-ingress and migrating ownership/audit call sites off the scope-coupled UserID. Propagation chain (new MetaOriginSenderID key): SubagentTask.OriginSenderID → AnnounceMetadata.OriginSenderID → InboundMessage.Metadata[MetaOriginSenderID] → subagentAnnounceRouting.SenderID → RunRequest.SenderID Same chain for delegate_tool.announceToParent, team_tool_dispatch, sessions_send, team_tasks_blocker escalation. ACTOR migration (owner/audit now uses ActorIDFromContext): - publish_skill: skill owner = individual sender - skill_manage create/patch/delete: owner = sender; ownership check accepts actor or legacy UserID for pre-migration rows - delegate_tool: DelegateRequest.UserID + DomainEvent.UserID = actor - team_tasks_blocker: blocker attribution + escalation = actor - team_tool_cache: team access-policy check = actor (fixes per-user allow/deny lists in group chats) Scope-intentional (unchanged, commented): cron.go, team_tasks_create.go. --- cmd/gateway_consumer_handlers.go | 12 +++++++++ cmd/gateway_subagent_announce_queue.go | 8 ++++++ internal/tools/announce_queue.go | 1 + internal/tools/cron.go | 4 +++ internal/tools/delegate_tool.go | 35 ++++++++++++++++--------- internal/tools/publish_skill.go | 17 ++++++------ internal/tools/sessions_send.go | 11 +++++++- internal/tools/skill_manage.go | 36 ++++++++++++++++---------- internal/tools/subagent.go | 1 + internal/tools/subagent_exec.go | 7 +++++ internal/tools/subagent_spawn.go | 2 ++ internal/tools/team_metadata_keys.go | 4 +++ internal/tools/team_tasks_blocker.go | 15 ++++++++--- internal/tools/team_tasks_create.go | 4 +++ internal/tools/team_tool_cache.go | 10 +++---- internal/tools/team_tool_dispatch.go | 8 ++++++ 16 files changed, 132 insertions(+), 43 deletions(-) diff --git a/cmd/gateway_consumer_handlers.go b/cmd/gateway_consumer_handlers.go index 10a6b54f..cff0cdac 100644 --- a/cmd/gateway_consumer_handlers.go +++ b/cmd/gateway_consumer_handlers.go @@ -109,6 +109,11 @@ func handleSubagentAnnounce( Iterations: iterations, } + // Preserve real acting sender from original turn so permission checks + // (e.g. write_file in group chat) attribute to the user, not the + // synthetic "subagent:" sender of the announce message itself (#915). + originSenderID := msg.Metadata[tools.MetaOriginSenderID] + queueKey := fmt.Sprintf("%s:%s", msg.TenantID, sessionKey) routing := subagentAnnounceRouting{ QueueKey: queueKey, @@ -120,6 +125,7 @@ func handleSubagentAnnounce( OrigPeerKind: origPeerKind, OrigLocalKey: origLocalKey, UserID: announceUserID, + SenderID: originSenderID, ParentAgent: parentAgent, ParentTraceID: parentTraceID, ParentRootSpanID: parentRootSpanID, @@ -205,6 +211,11 @@ func handleTeammateMessage( announceUserID = fmt.Sprintf("group:%s:%s", origChannel, origChatID) } + // Preserve real acting sender through teammate dispatch so permission + // checks during the teammate's turn (e.g. write_file in group chat) + // attribute to the original user (#915). + teammateSenderID := msg.Metadata[tools.MetaOriginSenderID] + outMeta := buildAnnounceOutMeta(origLocalKey) // Link member agent trace back to lead's trace for unified tracing. @@ -233,6 +244,7 @@ func handleTeammateMessage( PeerKind: origPeerKind, LocalKey: origLocalKey, UserID: announceUserID, + SenderID: teammateSenderID, // real user who triggered the teammate dispatch (#915) RunID: fmt.Sprintf("teammate-%s-%s", msg.Metadata[tools.MetaFromAgent], msg.Metadata[tools.MetaToAgent]), Stream: false, TeamTaskID: msg.Metadata[tools.MetaTeamTaskID], diff --git a/cmd/gateway_subagent_announce_queue.go b/cmd/gateway_subagent_announce_queue.go index 1aef9b95..6b10613b 100644 --- a/cmd/gateway_subagent_announce_queue.go +++ b/cmd/gateway_subagent_announce_queue.go @@ -47,6 +47,12 @@ func makeDelegateAnnounceCallback( if meta.OriginSessionKey != "" { batchMeta[tools.MetaOriginSessionKey] = meta.OriginSessionKey } + if meta.OriginSenderID != "" { + batchMeta[tools.MetaOriginSenderID] = meta.OriginSenderID + } + if meta.OriginUserID != "" { + batchMeta[tools.MetaOriginUserID] = meta.OriginUserID + } // Collect media from all items in the batch. var batchMedia []bus.MediaFile for _, item := range items { @@ -95,6 +101,7 @@ type subagentAnnounceRouting struct { OrigPeerKind string OrigLocalKey string UserID string + SenderID string // real acting sender (preserves permission attribution through re-ingress, #915) ParentAgent string ParentTraceID uuid.UUID ParentRootSpanID uuid.UUID @@ -168,6 +175,7 @@ func processSubagentAnnounceLoop( PeerKind: r.OrigPeerKind, LocalKey: r.OrigLocalKey, UserID: r.UserID, + SenderID: r.SenderID, // preserves real acting sender for permission checks (#915) RunID: fmt.Sprintf("subagent-announce-%s-%d", r.ParentAgent, len(entries)), RunKind: "announce", HideInput: true, diff --git a/internal/tools/announce_queue.go b/internal/tools/announce_queue.go index becda285..31f9e410 100644 --- a/internal/tools/announce_queue.go +++ b/internal/tools/announce_queue.go @@ -31,6 +31,7 @@ type AnnounceMetadata struct { OriginPeerKind string OriginLocalKey string // composite key with topic/thread suffix for routing OriginUserID string + OriginSenderID string // real acting sender; preserves permission attribution through re-ingress (#915) OriginSessionKey string // exact parent session key (WS uses non-standard format) OriginTenantID uuid.UUID // parent tenant for announce routing ParentAgent string diff --git a/internal/tools/cron.go b/internal/tools/cron.go index f8a59b5e..32b96a43 100644 --- a/internal/tools/cron.go +++ b/internal/tools/cron.go @@ -150,6 +150,10 @@ func (t *CronTool) Execute(ctx context.Context, args map[string]any) *Result { } agentID := resolveAgentIDString(ctx) + // SCOPE-intentional (#915 audit 2026-04-16): cron jobs follow the per-group + // memory model — all group members share the same cron_jobs rows via the + // group-scope user_id. Migrating to ActorIDFromContext would split jobs + // per-individual and break collaborative /cron list/add/remove UX. userID := store.UserIDFromContext(ctx) switch action { diff --git a/internal/tools/delegate_tool.go b/internal/tools/delegate_tool.go index 704d9797..541299a9 100644 --- a/internal/tools/delegate_tool.go +++ b/internal/tools/delegate_tool.go @@ -34,6 +34,7 @@ type DelegateRequest struct { Task string DelegationID string UserID string + SenderID string // real acting sender preserved through delegate announce re-ingress (#915) TenantID string Channel string ChatID string @@ -137,7 +138,9 @@ func (t *DelegateTool) Execute(ctx context.Context, args map[string]any) *Result } delegationID := uuid.New().String() - userID := store.UserIDFromContext(ctx) + // Audit-trail identity = actor (real sender). Groups audit actions to the + // individual user rather than the group principal (#915). + actorID := store.ActorIDFromContext(ctx) req := DelegateRequest{ FromAgentID: fromAgentID, @@ -145,7 +148,8 @@ func (t *DelegateTool) Execute(ctx context.Context, args map[string]any) *Result ToAgentKey: agentKey, Task: task, DelegationID: delegationID, - UserID: userID, + UserID: actorID, + SenderID: store.SenderIDFromContext(ctx), TenantID: store.TenantIDFromContext(ctx).String(), Channel: ToolChannelFromCtx(ctx), ChatID: ToolChatIDFromCtx(ctx), @@ -283,6 +287,21 @@ func (t *DelegateTool) announceToParent(req DelegateRequest, content string, med return } tenantUUID, _ := uuid.Parse(req.TenantID) + meta := map[string]string{ + "origin_channel": req.Channel, + "origin_peer_kind": req.PeerKind, + "origin_session_key": req.SessionKey, + "delegation_id": req.DelegationID, + "delegate_from": req.FromAgentKey, + "delegate_to": req.ToAgentKey, + MetaParentAgent: req.FromAgentKey, + } + if req.SenderID != "" { + meta[MetaOriginSenderID] = req.SenderID + } + if req.UserID != "" { + meta[MetaOriginUserID] = req.UserID + } t.msgBus.PublishInbound(bus.InboundMessage{ Channel: "system", SenderID: fmt.Sprintf("subagent:delegate:%s", req.DelegationID), @@ -291,15 +310,7 @@ func (t *DelegateTool) announceToParent(req DelegateRequest, content string, med Media: media, UserID: req.UserID, TenantID: tenantUUID, - Metadata: map[string]string{ - "origin_channel": req.Channel, - "origin_peer_kind": req.PeerKind, - "origin_session_key": req.SessionKey, - "delegation_id": req.DelegationID, - "delegate_from": req.FromAgentKey, - "delegate_to": req.ToAgentKey, - MetaParentAgent: req.FromAgentKey, - }, + Metadata: meta, }) } @@ -321,7 +332,7 @@ func (t *DelegateTool) emitEvent(ctx context.Context, eventType eventbus.EventTy Type: eventType, TenantID: store.TenantIDFromContext(ctx).String(), AgentID: store.AgentIDFromContext(ctx).String(), - UserID: store.UserIDFromContext(ctx), + UserID: store.ActorIDFromContext(ctx), // audit actor, not scope (#915) Timestamp: time.Now().UTC(), Payload: payload, }) diff --git a/internal/tools/publish_skill.go b/internal/tools/publish_skill.go index 6da67b1c..57335479 100644 --- a/internal/tools/publish_skill.go +++ b/internal/tools/publish_skill.go @@ -129,17 +129,18 @@ func (t *PublishSkillTool) Execute(ctx context.Context, args map[string]any) *Re return ErrorResult(fmt.Sprintf("failed to copy skill files: %v", err)) } - // Insert into DB - userID := store.UserIDFromContext(ctx) - if userID == "" { - userID = "system" // fallback for agent-only contexts + // Insert into DB — owner = actor (real sender) so a skill published in a + // group chat belongs to the individual user, not the group principal (#915). + ownerID := store.ActorIDFromContext(ctx) + if ownerID == "" { + ownerID = "system" // fallback for agent-only contexts } desc := description params := store.SkillCreateParams{ Name: name, Slug: slug, Description: &desc, - OwnerID: userID, + OwnerID: ownerID, Visibility: "private", Version: version, FilePath: destDir, @@ -153,12 +154,12 @@ func (t *PublishSkillTool) Execute(ctx context.Context, args map[string]any) *Re return ErrorResult(fmt.Sprintf("failed to register skill: %v", err)) } - slog.Info("skill published", "id", id, "slug", slug, "version", version, "owner", userID) + slog.Info("skill published", "id", id, "slug", slug, "version", version, "owner", ownerID) - // Auto-grant to calling agent + // Auto-grant to calling agent (granted-by = owner, same as CreateSkillManaged) agentID := store.AgentIDFromContext(ctx) if agentID != uuid.Nil { - if err := t.skills.GrantToAgent(ctx, id, agentID, version, userID); err != nil { + if err := t.skills.GrantToAgent(ctx, id, agentID, version, ownerID); err != nil { slog.Warn("publish_skill: auto-grant failed", "error", err) } } diff --git a/internal/tools/sessions_send.go b/internal/tools/sessions_send.go index 83318c1b..b5969761 100644 --- a/internal/tools/sessions_send.go +++ b/internal/tools/sessions_send.go @@ -107,7 +107,15 @@ func (t *SessionsSendTool) Execute(ctx context.Context, args map[string]any) *Re return ErrorResult("cannot send to your own current session — your response is already delivered to it") } - // Publish as an inbound message (same mechanism as channels) + // Publish as an inbound message (same mechanism as channels). + // Propagate the calling agent's real sender so the target session's turn + // can still perform user-attributed actions (e.g. write_file permission + // in a group chat) instead of being blocked by the synthetic sender + // rule at config_permission_store.go (#915). + sendMeta := map[string]string{} + if actorSender := store.SenderIDFromContext(ctx); actorSender != "" { + sendMeta[MetaOriginSenderID] = actorSender + } t.msgBus.PublishInbound(bus.InboundMessage{ Channel: "system", SenderID: "session_send_tool", @@ -115,6 +123,7 @@ func (t *SessionsSendTool) Execute(ctx context.Context, args map[string]any) *Re Content: message, PeerKind: "direct", TenantID: store.TenantIDFromContext(ctx), + Metadata: sendMeta, }) out, _ := json.Marshal(map[string]any{ diff --git a/internal/tools/skill_manage.go b/internal/tools/skill_manage.go index 8f19f628..0131231a 100644 --- a/internal/tools/skill_manage.go +++ b/internal/tools/skill_manage.go @@ -152,17 +152,18 @@ func (t *SkillManageTool) executeCreate(ctx context.Context, args map[string]any fileHash := fmt.Sprintf("%x", hasher.Sum(nil)) fileSize := int64(len(contentBytes)) - // DB insert - userID := store.UserIDFromContext(ctx) - if userID == "" { - userID = "system" + // DB insert — owner = actor (real sender) so skill belongs to the individual + // user rather than the group principal in group chats (#915). + ownerID := store.ActorIDFromContext(ctx) + if ownerID == "" { + ownerID = "system" } desc := description id, err := t.skills.CreateSkillManaged(ctx, store.SkillCreateParams{ Name: name, Slug: slug, Description: &desc, - OwnerID: userID, + OwnerID: ownerID, Visibility: "private", Version: version, FilePath: destDir, @@ -174,13 +175,13 @@ func (t *SkillManageTool) executeCreate(ctx context.Context, args map[string]any return ErrorResult(fmt.Sprintf("failed to register skill: %v", err)) } - slog.Info("skill_manage: created", "id", id, "slug", slug, "version", version, "owner", userID) + slog.Info("skill_manage: created", "id", id, "slug", slug, "version", version, "owner", ownerID) - // Auto-grant to calling agent + // Auto-grant to calling agent (granted-by = owner, same as CreateSkillManaged) granted := false agentID := store.AgentIDFromContext(ctx) if agentID != uuid.Nil { - if err := t.skills.GrantToAgent(ctx, id, agentID, version, userID); err != nil { + if err := t.skills.GrantToAgent(ctx, id, agentID, version, ownerID); err != nil { slog.Warn("skill_manage: auto-grant failed", "error", err) } else { granted = true @@ -233,9 +234,14 @@ func (t *SkillManageTool) executePatch(ctx context.Context, args map[string]any) return ErrorResult(fmt.Sprintf("cannot manage system skill %q", slug)) } - // Ownership check: only the skill owner can patch - userID := store.UserIDFromContext(ctx) - if ownerID, found := t.skills.GetSkillOwnerIDBySlug(ctx, slug); found && ownerID != userID { + // Ownership check: only the skill owner can patch. + // Accept actor (new post-#915 format) or user-scope (legacy pre-#915 rows + // where owner_id was set to the group principal). Legacy fallback lets + // any group member manage skills created before the ACTOR migration; + // they can re-publish under their individual identity to tighten ownership. + actorID := store.ActorIDFromContext(ctx) + legacyID := store.UserIDFromContext(ctx) + if ownerID, found := t.skills.GetSkillOwnerIDBySlug(ctx, slug); found && ownerID != actorID && ownerID != legacyID { return ErrorResult(fmt.Sprintf("cannot manage skill %q: you are not the owner", slug)) } @@ -323,9 +329,11 @@ func (t *SkillManageTool) executeDelete(ctx context.Context, args map[string]any return ErrorResult(fmt.Sprintf("cannot manage system skill %q", slug)) } - // Ownership check: only the skill owner can delete - deleteUserID := store.UserIDFromContext(ctx) - if ownerID, found := t.skills.GetSkillOwnerIDBySlug(ctx, slug); found && ownerID != deleteUserID { + // Ownership check: only the skill owner can delete. + // Same legacy fallback pattern as patch-flow above (#915). + deleteActorID := store.ActorIDFromContext(ctx) + deleteLegacyID := store.UserIDFromContext(ctx) + if ownerID, found := t.skills.GetSkillOwnerIDBySlug(ctx, slug); found && ownerID != deleteActorID && ownerID != deleteLegacyID { return ErrorResult(fmt.Sprintf("cannot manage skill %q: you are not the owner", slug)) } diff --git a/internal/tools/subagent.go b/internal/tools/subagent.go index 52259e83..6a4d85ee 100644 --- a/internal/tools/subagent.go +++ b/internal/tools/subagent.go @@ -55,6 +55,7 @@ type SubagentTask struct { OriginPeerKind string `json:"originPeerKind,omitempty"` // "direct" or "group" (for session key building) OriginLocalKey string `json:"originLocalKey,omitempty"` // composite key with topic/thread suffix for routing OriginUserID string `json:"originUserId,omitempty"` // parent's userID for per-user scoping propagation + OriginSenderID string `json:"originSenderId,omitempty"` // real acting sender; preserves permission attribution in announce re-ingress (#915) OriginSessionKey string `json:"originSessionKey,omitempty"` // exact parent session key for announce routing (WS uses non-standard format) CreatedAt int64 `json:"createdAt"` CompletedAt int64 `json:"completedAt,omitempty"` diff --git a/internal/tools/subagent_exec.go b/internal/tools/subagent_exec.go index 91eaa607..677a1029 100644 --- a/internal/tools/subagent_exec.go +++ b/internal/tools/subagent_exec.go @@ -42,6 +42,7 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, call OriginPeerKind: task.OriginPeerKind, OriginLocalKey: task.OriginLocalKey, OriginUserID: task.OriginUserID, + OriginSenderID: task.OriginSenderID, OriginSessionKey: task.OriginSessionKey, OriginTenantID: task.OriginTenantID, ParentAgent: task.ParentID, @@ -79,6 +80,12 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, call if task.OriginSessionKey != "" { announceMeta[MetaOriginSessionKey] = task.OriginSessionKey } + if task.OriginSenderID != "" { + announceMeta[MetaOriginSenderID] = task.OriginSenderID + } + if task.OriginUserID != "" { + announceMeta[MetaOriginUserID] = task.OriginUserID + } sm.msgBus.PublishInbound(bus.InboundMessage{ Channel: "system", SenderID: fmt.Sprintf("subagent:%s", task.ID), diff --git a/internal/tools/subagent_spawn.go b/internal/tools/subagent_spawn.go index b2f0bcc5..1dc33b7d 100644 --- a/internal/tools/subagent_spawn.go +++ b/internal/tools/subagent_spawn.go @@ -84,6 +84,7 @@ func (sm *SubagentManager) Spawn( OriginPeerKind: peerKind, OriginLocalKey: ToolLocalKeyFromCtx(ctx), OriginUserID: store.UserIDFromContext(ctx), + OriginSenderID: store.SenderIDFromContext(ctx), OriginSessionKey: ToolSessionKeyFromCtx(ctx), OriginTenantID: store.TenantIDFromContext(ctx), OriginTraceID: tracing.TraceIDFromContext(ctx), @@ -161,6 +162,7 @@ func (sm *SubagentManager) RunSync( OriginChatID: chatID, OriginLocalKey: ToolLocalKeyFromCtx(ctx), OriginUserID: store.UserIDFromContext(ctx), + OriginSenderID: store.SenderIDFromContext(ctx), OriginSessionKey: ToolSessionKeyFromCtx(ctx), OriginTenantID: store.TenantIDFromContext(ctx), OriginTraceID: tracing.TraceIDFromContext(ctx), diff --git a/internal/tools/team_metadata_keys.go b/internal/tools/team_metadata_keys.go index 9410143a..bd7ea3ab 100644 --- a/internal/tools/team_metadata_keys.go +++ b/internal/tools/team_metadata_keys.go @@ -8,6 +8,10 @@ const ( MetaOriginPeerKind = "origin_peer_kind" MetaOriginChatID = "origin_chat_id" MetaOriginUserID = "origin_user_id" + // MetaOriginSenderID carries the real acting sender through announce re-ingress + // so permission checks (e.g. CheckFileWriterPermission) attribute to the + // original user rather than a synthetic "subagent:" / "notification:system" string. + MetaOriginSenderID = "origin_sender_id" MetaOriginLocalKey = "origin_local_key" MetaOriginSessionKey = "origin_session_key" MetaOriginTraceID = "origin_trace_id" diff --git a/internal/tools/team_tasks_blocker.go b/internal/tools/team_tasks_blocker.go index b2d36dbc..a78970ec 100644 --- a/internal/tools/team_tasks_blocker.go +++ b/internal/tools/team_tasks_blocker.go @@ -60,7 +60,7 @@ func (t *TeamTasksTool) handleBlockerComment( WithTaskInfo(task.TaskNumber, task.Subject), WithOwnerAgentKey(memberKey), WithReason(reason), - WithUserID(store.UserIDFromContext(ctx)), + WithUserID(store.ActorIDFromContext(ctx)), // audit actor, not scope (#915) WithChannel(task.Channel), WithChatID(task.ChatID), WithPeerKind(blockerPeerKind), @@ -78,13 +78,22 @@ func (t *TeamTasksTool) handleBlockerComment( "Use team_tasks(action=\"retry\", task_id=\"%s\") to reopen with updated instructions.", memberKey, task.TaskNumber, task.Subject, text, taskID) + // Escalation metadata: preserve original actor sender through re-ingress + // so the leader's response turn can attribute to the real user (#915). + escalationMeta := TaskLocalKeyMetadata(task) + if escalationMeta == nil { + escalationMeta = map[string]string{} + } + if actorSender := store.SenderIDFromContext(ctx); actorSender != "" { + escalationMeta[MetaOriginSenderID] = actorSender + } if !t.manager.TryPublishInbound(bus.InboundMessage{ Channel: task.Channel, SenderID: "system:escalation", ChatID: task.ChatID, - Metadata: TaskLocalKeyMetadata(task), + Metadata: escalationMeta, Content: escalationMsg, - UserID: store.UserIDFromContext(ctx), + UserID: store.ActorIDFromContext(ctx), // audit actor (#915) PeerKind: blockerPeerKind, TenantID: store.TenantIDFromContext(ctx), AgentID: leadAg.AgentKey, diff --git a/internal/tools/team_tasks_create.go b/internal/tools/team_tasks_create.go index ceac0101..10b27baa 100644 --- a/internal/tools/team_tasks_create.go +++ b/internal/tools/team_tasks_create.go @@ -212,6 +212,10 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any) 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, diff --git a/internal/tools/team_tool_cache.go b/internal/tools/team_tool_cache.go index 8e3be1f9..01819e12 100644 --- a/internal/tools/team_tool_cache.go +++ b/internal/tools/team_tool_cache.go @@ -42,9 +42,9 @@ func (m *TeamToolManager) resolveTeam(ctx context.Context) (*store.TeamData, uui ce := entry.(*teamCacheEntry) if time.Since(ce.cachedAt) < teamCacheTTL { // Cache hit — still check access (user/channel vary per call) - userID := store.UserIDFromContext(ctx) + actorID := store.ActorIDFromContext(ctx) channel := ToolChannelFromCtx(ctx) - if err := checkTeamAccess(ce.team.Settings, userID, channel); err != nil { + if err := checkTeamAccess(ce.team.Settings, actorID, channel); err != nil { return nil, uuid.Nil, err } return ce.team, agentID, nil @@ -67,10 +67,10 @@ func (m *TeamToolManager) resolveTeam(ctx context.Context) (*store.TeamData, uui members, _ := m.teamStore.ListMembers(ctx, team.ID) m.teamCache.Store(agentID, &teamCacheEntry{team: team, members: members, cachedAt: time.Now()}) - // Check access - userID := store.UserIDFromContext(ctx) + // Check access (ACTOR = real sender, matches per-user allow/deny lists in group chats #915) + actorID := store.ActorIDFromContext(ctx) channel := ToolChannelFromCtx(ctx) - if err := checkTeamAccess(team.Settings, userID, channel); err != nil { + if err := checkTeamAccess(team.Settings, actorID, channel); err != nil { return nil, uuid.Nil, err } diff --git a/internal/tools/team_tool_dispatch.go b/internal/tools/team_tool_dispatch.go index b3f28dc2..c72de740 100644 --- a/internal/tools/team_tool_dispatch.go +++ b/internal/tools/team_tool_dispatch.go @@ -142,6 +142,11 @@ func (m *TeamToolManager) dispatchTaskToAgent(ctx context.Context, task *store.T originUserID = originChatID } + // Preserve real acting sender so permission checks on the teammate's + // turn (e.g. write_file in group chat) attribute to the original user + // rather than the synthetic "teammate:dashboard" sender (#915). + originSenderID := store.SenderIDFromContext(ctx) + // Resolve peer kind from context; fallback to task metadata, then "direct". originPeerKind := ToolPeerKindFromCtx(ctx) if originPeerKind == "" { @@ -163,6 +168,9 @@ func (m *TeamToolManager) dispatchTaskToAgent(ctx context.Context, task *store.T MetaTeamTaskID: task.ID.String(), MetaTeamID: teamID.String(), } + if originSenderID != "" { + meta[MetaOriginSenderID] = originSenderID + } // Resolve local key from context; fallback to task metadata for deferred dispatches. localKey := ToolLocalKeyFromCtx(ctx) if localKey == "" {