fix(gateway): override synthetic senders with MetaOriginSenderID + propagate through dashboard dispatch

Closes the two last unpropagated paths identified by the #915 e2e audit.

processNormalMessage now prefers MetaOriginSenderID when the on-wire
SenderID is a synthetic internal sender (notification:, ticker:,
session_send_tool, system:escalation, etc.). Real user senders pass
through unchanged. Defense-in-depth: MetaOriginSenderID is ignored if
it itself looks synthetic, preventing a propagation bug from turning
into a permission bypass.

teams_tasks_mutations dashboard dispatch now injects MetaOriginSenderID
into the teammate InboundMessage metadata, preferring the WS caller's
sender (live dashboard) and falling back to origin_sender_id stored on
the task (for deferred dispatches). Without this, dashboard-triggered
team tasks in group chats would hit the empty-sender DENY rule.

team_tasks_create persists origin_sender_id into task.Metadata at
creation time so the deferred dashboard dispatch path can reconstruct
the original actor identity.
This commit is contained in:
viettranx committed 2026-04-16 14:17:48 +07:00
1 parent 2fcb9a9ee0
commit 1bd90123c6
3 files changed
+36 -1

No files matched your search

+19 -1
View File
@@ -346,6 +346,24 @@ func processNormalMessage(
schedCtx = tools.WithRunKind(schedCtx, rk)
}
// Resolve effective sender: prefer MetaOriginSenderID when the on-wire
// SenderID is an internal/synthetic one (e.g. "notification:progress",
// "ticker:system", "system:escalation", "session_send_tool"). This lets
// system-initiated turns that DO have a real user behind them (because
// they propagated the origin via metadata) attribute actions to that user
// — e.g. for CheckFileWriterPermission in group chats (#915). Synthetic
// senders without propagation keep their on-wire value and hit F1's
// deny-in-group rule (safe default).
effectiveSenderID := msg.SenderID
if bus.IsInternalSender(effectiveSenderID) {
// Defense-in-depth: if a propagation bug ever writes a synthetic
// value into MetaOriginSenderID, do NOT honour it. We want only real
// user senders to override the on-wire synthetic.
if realSender := msg.Metadata[tools.MetaOriginSenderID]; realSender != "" && !bus.IsInternalSender(realSender) {
effectiveSenderID = realSender
}
}
// Schedule through main lane (per-session concurrency controlled by maxConcurrent)
outCh := deps.Sched.ScheduleWithOpts(schedCtx, "main", agent.RunRequest{
SessionKey: sessionKey,
@@ -359,7 +377,7 @@ func processNormalMessage(
PeerKind: peerKind,
LocalKey: msg.Metadata["local_key"],
UserID: userID,
SenderID: msg.SenderID,
SenderID: effectiveSenderID,
SenderName: resolveSenderName(msg),
RunID: runID,
Stream: enableStream,
@@ -398,6 +398,18 @@ func (m *TeamsMethods) dispatchTaskToAgent(ctx context.Context, task *store.Team
meta["origin_local_key"] = lk
}
}
// Preserve acting sender through dashboard dispatch: prefer the live WS
// caller's sender (rarely set) then fall back to the stored origin sender
// from the task at creation time. Without this, group-scope tasks
// dispatched from the dashboard would hit the empty-sender DENY rule in
// CheckFileWriterPermission (#915 Flow F).
if dispatchSender := store.SenderIDFromContext(ctx); dispatchSender != "" {
meta["origin_sender_id"] = dispatchSender
} else if task.Metadata != nil {
if taskSender, _ := task.Metadata["origin_sender_id"].(string); taskSender != "" {
meta["origin_sender_id"] = taskSender
}
}
m.msgBus.PublishInbound(bus.InboundMessage{
Channel: "system",
+5
View File
@@ -204,6 +204,11 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any)
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
}
task := &store.TeamTaskData{
TeamID: team.ID,