Skip to content

Outbound message ownership can split when agentId and sessionKey disagree #96075

Description

@youngting520

Severity: medium — latent / defense-in-depth. No known reachable trigger from a legitimate caller today; this hardens an outbound ownership invariant that lower helpers currently trust callers to uphold.

Problem

Outbound message paths can accept an explicit agentId together with an agent-scoped sessionKey owned by a different agent. When that happens, the same send/action may use one agent for media roots, account policy, or action dispatch, while using another agent's session key for hooks, transcript mirroring, or session bookkeeping.

For example, a request shaped like:

agentId: "work"
sessionKey: "agent:main:..."

can leave outbound delivery treating the active agent as work, while session-bound behavior still records or correlates against main.

This is not limited to the Gateway send / message.action surface. Lower outbound helpers can also receive or construct the same inconsistent ownership metadata, including:

  • src/infra/outbound/message-action-runner.ts
  • src/infra/outbound/message.ts
  • src/infra/outbound/outbound-send-service.ts
  • src/infra/outbound/deliver.ts

Because these helpers sit below the Gateway, internal callers can theoretically bypass the Gateway validation and still create split-owner outbound operations.

Why This Matters

OutboundSessionContext.key documents the ownership contract that this breaks in src/infra/outbound/session-context.ts: the canonical session key used for internal hook dispatch must equal the agent runtime's params.sessionKey, because plugins observing both agent-loop events and outbound message_sending / message_sent hooks rely on that equality to correlate per-turn state.

A mismatched agentId + agent-scoped sessionKey can produce an outbound context whose explicit agent owner and canonical session owner belong to different agents. That can split owner-scoped behavior from session-scoped behavior inside one send.

Expected Behavior

Any outbound API that accepts both an explicit agent owner and an agent-scoped session key should reject the request when the owners disagree.

The check should compare only the agent owner portion of agent:<owner>:..., not the full session key.

These should remain valid:

  • omitted or blank agentId
  • non-agent / legacy session keys
  • different conversation keys owned by the same agent
  • requesterSessionKey and mirror.sessionKey that differ but both belong to the same agent
  • delivery policyKey values that intentionally describe a different policy/origin session, such as routed ACP delivery

policyKey must not be treated as a canonical owner key for equality checks, because it can intentionally represent a separate policy/origin session while session.key / mirror.sessionKey remain the control or transcript owner keys.

Affected Behavior

A contradictory pair like:

agentId: "work"
sessionKey: "agent:main:slack:channel:c1"

should be rejected before:

  • plugin action dispatch
  • outbound media policy resolution
  • durable delivery queueing
  • transcript mirroring
  • internal hook correlation

Proposed Fix

Add a shared owner-mismatch helper based on parseAgentSessionKey() and normalizeAgentId().

Apply it at the ownership boundaries that can combine explicit agent IDs with session keys:

  • Gateway send
  • Gateway message.action
  • runMessageAction()
  • sendMessage()
  • executeSendAction()
  • executePollAction()
  • deliverOutboundPayloads()

For deliverOutboundPayloads(), compare the owner fields that actually exist there: session.agentId against session.key, and mirror.agentId against mirror.sessionKey. Do not compare session.policyKey.

Implementation branch: bug/outbound-owner-mismatch.

Tests

Add regression coverage for:

  • Gateway send rejects mismatched agentId + sessionKey
  • Gateway message.action rejects mismatched agentId + sessionKey
  • runMessageAction() rejects before plugin dispatch
  • sendMessage() rejects mismatched agentId + requesterSessionKey
  • sendMessage() rejects mismatched agentId / mirror.agentId + mirror.sessionKey
  • executeSendAction() and executePollAction() reject before plugin/core dispatch
  • deliverOutboundPayloads() rejects before queueing/sending/mirroring
  • same-agent but different conversation keys remain accepted
  • legacy/non-agent session keys remain accepted
  • cross-agent policyKey delivery remains accepted

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Normal backlog priority with limited blast radius.clawsweeper:linked-pr-openClawSweeper found an open linked pull request for this issue.clawsweeper:needs-security-reviewClawSweeper marked this issue as needing security-sensitive review.clawsweeper:no-new-fix-prClawSweeper does not recommend queueing a new automated fix PR for this issue.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:securitySecurity boundary, credential, authz, sandbox, or sensitive-data risk.impact:session-stateSession, memory, transcript, context, or agent state can drift or corrupt.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions