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:
viettranx committed 2026-04-16 14:17:48 +07:00
1 parent 64eb29e415
commit 9e5cec03ba
16 files changed
+132 -43

No files matched your search

+12
View File
@@ -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],
+8
View File
@@ -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,
+1
View File
@@ -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
+4
View File
@@ -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 {
+23 -12
View File
@@ -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,
})
+9 -8
View File
@@ -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)
}
}
+10 -1
View File
@@ -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{
+22 -14
View File
@@ -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))
}
+1
View File
@@ -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"`
+7
View File
@@ -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),
+2
View File
@@ -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),
+4
View File
@@ -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"
+12 -3
View File
@@ -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,
+4
View File
@@ -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,
+5 -5
View File
@@ -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
}
+8
View File
@@ -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 == "" {