mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-17 08:14:33 +00:00
feat: preserve acting sender + migrate ACTOR sites across tool chain
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.
This commit is contained in:
1 parent
64eb29e415
commit
9e5cec03ba
16 files changed
+132
-43
No files matched your search
@@ -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:<id>" 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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:<id>" / "notification:system" string.
|
||||
MetaOriginSenderID = "origin_sender_id"
|
||||
MetaOriginLocalKey = "origin_local_key"
|
||||
MetaOriginSessionKey = "origin_session_key"
|
||||
MetaOriginTraceID = "origin_trace_id"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
Reference in new issue
Block a user