openclaw - 💡(How to fix) Fix [Feature]: `sendPolicy.peerEquals: "inboundPeer"` — relational predicate to constrain outbound to the current turn's inbound peer

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

Add a relational sendPolicy predicate peerEquals: "inboundPeer" that constrains outbound delivery to match the peer that triggered the current agent turn — closing the residual cross-channel / cross-identity leak gap that prefix-based keyPrefix rules cannot express on their own.

Error Message

  • Severity: medium-to-high. The leak path is silent (no error surfaces if the wrong peer is contacted) and the consequence is a privacy/identity breach across channels. Severity caps at the operator's threat model: in personal-assistant configs (one human, multiple channels) it's an embarrassment; in multi-tenant or shared-channel deployments it can be a compliance incident.

Root Cause

Add a relational sendPolicy predicate peerEquals: "inboundPeer" that constrains outbound delivery to match the peer that triggered the current agent turn — closing the residual cross-channel / cross-identity leak gap that prefix-based keyPrefix rules cannot express on their own.

Fix Action

Fix / Workaround

  • Status quo: prefix-based rules + dmScope: per-account-channel-peer. Sufficient for static constraints (per #66938 closure). Insufficient for the relational "outbound peer == inbound peer of this turn" constraint, which is the case this proposal targets.

  • Userland plugin (the workaround we are running today). A message_sending veto hook that snapshots event.senderId in before_agent_run into Map<runId, {peer, channel}> (TTL'd, lazy-GC'd) and compares against it on outbound. Implemented and unit-tested (26/26 cases green by direct handler invocation). However:

    • On 2026.5.19 npm-installed, user-plugin hook handlers do not dispatch at runtime — see <A_ISSUE_URL>. This makes the plugin workaround currently non-viable on the most common install path. Until that bug is resolved, no userland plugin can enforce this constraint.
    • Even once dispatch is fixed, the workaround still requires every operator to rediscover the runId-as-only-shared-context field and to implement TTL'd shared state correctly. A native predicate sidesteps the rediscovery problem entirely.
    • The hook approach cannot compose with the rest of sendPolicy. A predicate can.
  • Channel-plugin-side validation. Asking each channel plugin to assert "outbound to matches the most recent inbound from." Pushes per-channel-plugin maintenance, fans out to every channel implementer (current and future), and doesn't help when the channel plugin itself is the source of drift (#79308 territory).

  • Renaming dmScope / changing identityLinks semantics. Out of scope. The session-key behavior is intentional; this proposal is about giving operators a way to constrain outbound under it.

  • Affected: operators using identityLinks together with dmScope: "per-peer" or "per-account-channel-peer" who need outbound to follow the turn's inbound peer. Also operators worried about channel-plugin to-label drift (e.g. anyone tracking #79308 or expecting similar future regressions across new channels).

  • Severity: medium-to-high. The leak path is silent (no error surfaces if the wrong peer is contacted) and the consequence is a privacy/identity breach across channels. Severity caps at the operator's threat model: in personal-assistant configs (one human, multiple channels) it's an embarrassment; in multi-tenant or shared-channel deployments it can be a compliance incident.

  • Frequency: latent. Triggers when a channel plugin mislabels ctx.senderId, when the agent emits an outbound with a peer chosen from past context, or when group-vs-DM routing drifts (the #79308 family). Each individual case is rare but the absence of a policy-level invariant means there is no defense-in-depth.

  • Consequence: today, operators in this configuration either accept the latent risk, or maintain a per-install userland plugin to lock the turn — re-implementing the same pattern in every deployment, and (on 2026.5.19) currently inert due to the hook-dispatch bug.

  • Prior art that established the design: #66938 (closed) — confirmed sendPolicy is prefix-based by design and that dmScope is the recommended mechanism for static per-account/per-peer targeting. This proposal acknowledges that closure and explicitly does not ask for accountId/peer scalar fields on match; it asks for a relational predicate that sits alongside the existing prefix-based ones.

  • Related open bug illustrating the same class of routing drift: #79308 (open) — "Telegram group replies sent to wrong chat_id." A peerEquals: "inboundPeer" predicate would prevent that misroute at policy-evaluation time.

  • Blocker on the userland workaround: https://github.com/openclaw/openclaw/issues/85174 — user-plugin hook handlers do not dispatch on 2026.5.19, so the natural "implement as a plugin" answer is currently unavailable.

  • Workaround source / smoke test available on request: the existing userland implementation (reply-only-hook) with its 26-case smoke test (covers TTL eviction, runId-keyed lookup, agent_end clear, fallback to ctx.senderId when no lock present, cancel-reason annotation for forensics). Happy to share publicly if it would help the design discussion — the smoke test's contract-probe portion will be useful for whoever lands the predicate, since it asserts the public hook contract this depends on.

Code Example

{
  "session": {
    "sendPolicy": {
      "default": "allow",
      "rules": [
        { "action": "deny", "match": { "peerEquals": "inboundPeer", "invert": true } }
      ]
    }
  }
}

---

{
  "action": "deny",
  "match": {
    "allOf": [
      { "peerEquals": "inboundPeer", "invert": true },
      { "channel": "whatsapp" }
    ]
  }
}
RAW_BUFFERClick to expand / collapse

Summary

Add a relational sendPolicy predicate peerEquals: "inboundPeer" that constrains outbound delivery to match the peer that triggered the current agent turn — closing the residual cross-channel / cross-identity leak gap that prefix-based keyPrefix rules cannot express on their own.

Problem to solve

SessionSendPolicy today supports declarative predicates (channel, chatType, keyPrefix, rawKeyPrefix). Combined with session.dmScope: "per-account-channel-peer", these are sufficient to express static per-account / per-peer constraints — as established when #66938 was closed.

What is not expressible today is a relational constraint: "the outbound peer must equal the inbound peer of this turn." Static prefix rules can enumerate allowed peers, but they cannot encode "the right peer depends on which inbound triggered the turn." The only place that state lives is inside the policy evaluator already (it's the source before_agent_run's event.senderId is derived from), but it isn't exposed as a predicate.

Why this matters in practice:

  • identityLinks + dmScope: "per-peer" (a supported, documented combo for unifying multi-channel transcripts under one identity) produces session keys without a channel component. An agent then sees inbound from whatsapp:+A and telegram:+B for the same human, in one shared session. If the agent (or a downstream tool / channel-plugin labeling bug) emits an outbound with the wrong peer, prefix rules cannot catch it: from the policy's view, all peers in the link are equally "valid" for the session.
  • Even with dmScope: "per-account-channel-peer", the session-key isolation prevents conversation-state mixing but does not validate that event.to at outbound matches the turn's inbound peer. The channel plugin's send receives event.to (chosen by the agent) and ctx.senderId (set by the channel plugin) — if either drifts, outbound goes to the wrong peer despite session keying being correct.
  • Reference real-world drift in adjacent territory: #79308 (open) — "Telegram group replies sent to wrong chat_id." The same predicate would prevent that class of misrouting at policy-evaluation time, regardless of which channel-plugin path produced the drift.

Proposed solution

A new SessionSendPolicy predicate, evaluated at outbound-send time against the policy evaluator's already-authoritative inbound-peer state for the current turn (the same source feeding PluginHookAgentRunEvent.senderId):

{
  "session": {
    "sendPolicy": {
      "default": "allow",
      "rules": [
        { "action": "deny", "match": { "peerEquals": "inboundPeer", "invert": true } }
      ]
    }
  }
}

Or with composition alongside existing predicates:

{
  "action": "deny",
  "match": {
    "allOf": [
      { "peerEquals": "inboundPeer", "invert": true },
      { "channel": "whatsapp" }
    ]
  }
}

Semantics:

  • "inboundPeer" resolves to the senderId of the inbound message that triggered the current agent turn, sourced from the same field that feeds PluginHookAgentRunEvent.senderId today.
  • The predicate is evaluated by the policy evaluator. It does not depend on ctx.senderId being correctly labeled by the channel plugin at outbound time.
  • No inbound peer for the current turn (agent-initiated turn from scheduler, heartbeat, internal hook) → predicate evaluates to false (i.e. the rule does not match a denied outbound). Operators wanting "deny all outbound that has no inbound to match against" can compose with an explicit allOf plus a hasInboundPeer predicate (or whatever shape upstream prefers; flagging the semantics for design discussion).
  • Multi-channel inbound within a turn (if a single turn can be triggered by more than one inbound — uncertain how the data model handles this today): the predicate matches if event.to equals any of the turn's inbound peers. Open question worth nailing down in this issue's design discussion.
  • Group / thread contexts: out of scope for v1. Predicate compares peer identity, not thread. A follow-up predicate (threadEquals) would mirror this pattern for thread-level lock.

Why a relational predicate and not a third-party plugin:

  1. The relational state needed (turn's inbound peer) is already in the policy evaluator. A plugin would have to mirror that state across hooks using runId as the join key — which is the only field present on both PluginHookAgentContext and PluginHookMessageContext (sessionKey is on the former but not the latter). That asymmetry is not documented anywhere we could find — it's only visible by reading dist/plugin-sdk/.../hook-types.d.ts and hook-message.types.d.ts side-by-side. Operators hitting this case currently rediscover the asymmetry one at a time.
  2. Default-safe ergonomics: a schema-level predicate is discoverable from the config schema. A plugin pattern is folklore.
  3. Composability: the predicate composes with keyPrefix, channel, chatType, etc. via allOf/anyOf. A hook can only veto-or-allow outside the evaluator.
  4. No ctx.senderId trust at outbound: the evaluator reads the authoritative inbound-peer state directly from the same source before_agent_run sees, removing dependence on the channel plugin keeping ctx.senderId consistent through the turn.

Alternatives considered

  • Status quo: prefix-based rules + dmScope: per-account-channel-peer. Sufficient for static constraints (per #66938 closure). Insufficient for the relational "outbound peer == inbound peer of this turn" constraint, which is the case this proposal targets.
  • Userland plugin (the workaround we are running today). A message_sending veto hook that snapshots event.senderId in before_agent_run into Map<runId, {peer, channel}> (TTL'd, lazy-GC'd) and compares against it on outbound. Implemented and unit-tested (26/26 cases green by direct handler invocation). However:
    • On 2026.5.19 npm-installed, user-plugin hook handlers do not dispatch at runtime — see <A_ISSUE_URL>. This makes the plugin workaround currently non-viable on the most common install path. Until that bug is resolved, no userland plugin can enforce this constraint.
    • Even once dispatch is fixed, the workaround still requires every operator to rediscover the runId-as-only-shared-context field and to implement TTL'd shared state correctly. A native predicate sidesteps the rediscovery problem entirely.
    • The hook approach cannot compose with the rest of sendPolicy. A predicate can.
  • Channel-plugin-side validation. Asking each channel plugin to assert "outbound to matches the most recent inbound from." Pushes per-channel-plugin maintenance, fans out to every channel implementer (current and future), and doesn't help when the channel plugin itself is the source of drift (#79308 territory).
  • Renaming dmScope / changing identityLinks semantics. Out of scope. The session-key behavior is intentional; this proposal is about giving operators a way to constrain outbound under it.

Impact

  • Affected: operators using identityLinks together with dmScope: "per-peer" or "per-account-channel-peer" who need outbound to follow the turn's inbound peer. Also operators worried about channel-plugin to-label drift (e.g. anyone tracking #79308 or expecting similar future regressions across new channels).
  • Severity: medium-to-high. The leak path is silent (no error surfaces if the wrong peer is contacted) and the consequence is a privacy/identity breach across channels. Severity caps at the operator's threat model: in personal-assistant configs (one human, multiple channels) it's an embarrassment; in multi-tenant or shared-channel deployments it can be a compliance incident.
  • Frequency: latent. Triggers when a channel plugin mislabels ctx.senderId, when the agent emits an outbound with a peer chosen from past context, or when group-vs-DM routing drifts (the #79308 family). Each individual case is rare but the absence of a policy-level invariant means there is no defense-in-depth.
  • Consequence: today, operators in this configuration either accept the latent risk, or maintain a per-install userland plugin to lock the turn — re-implementing the same pattern in every deployment, and (on 2026.5.19) currently inert due to the hook-dispatch bug.

Evidence / examples

  • Prior art that established the design: #66938 (closed) — confirmed sendPolicy is prefix-based by design and that dmScope is the recommended mechanism for static per-account/per-peer targeting. This proposal acknowledges that closure and explicitly does not ask for accountId/peer scalar fields on match; it asks for a relational predicate that sits alongside the existing prefix-based ones.
  • Related open bug illustrating the same class of routing drift: #79308 (open) — "Telegram group replies sent to wrong chat_id." A peerEquals: "inboundPeer" predicate would prevent that misroute at policy-evaluation time.
  • Blocker on the userland workaround: https://github.com/openclaw/openclaw/issues/85174 — user-plugin hook handlers do not dispatch on 2026.5.19, so the natural "implement as a plugin" answer is currently unavailable.
  • Workaround source / smoke test available on request: the existing userland implementation (reply-only-hook) with its 26-case smoke test (covers TTL eviction, runId-keyed lookup, agent_end clear, fallback to ctx.senderId when no lock present, cancel-reason annotation for forensics). Happy to share publicly if it would help the design discussion — the smoke test's contract-probe portion will be useful for whoever lands the predicate, since it asserts the public hook contract this depends on.

Additional information

  • Acceptance criteria (rough):
    • SessionSendPolicySchema accepts peerEquals: "inboundPeer" (string literal union to start; expand later if other relational targets are added).
    • Predicate sources inbound-peer from the same field feeding PluginHookAgentRunEvent.senderId.
    • Cancel uses a structured cancelReason including expected and actual peer (matching the forensic detail the hook surface provides today, so operators can move from the plugin pattern to native without losing observability).
    • Docs example showing the predicate alongside identityLinks + dmScope, since that's the trigger case.
    • Migration note: existing message_sending plugin pattern can be removed once the predicate ships (or kept as an extra defense layer — they compose fine).
  • Implementation surface should be small: one new schema branch, one evaluator clause, one piece of test coverage. The relational state needed is already in scope of the evaluator.
  • Backward compatibility: additive only — new predicate name, no changes to existing predicates. Configs that don't reference peerEquals keep their current behavior.
  • Naming bikeshed welcome: peerEquals, matchTurnPeer, boundToInboundPeer, etc. The semantics matter more than the spelling.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING