VYPR
Medium severity5.4NVD Advisory· Published May 11, 2026· Updated May 13, 2026

CVE-2026-44998

CVE-2026-44998

Description

OpenClaw before 2026.4.20 contains a tool policy bypass vulnerability allowing bundled MCP and LSP tools to circumvent configured tool restrictions. Attackers with local agent access can append restricted tools to the effective tool set after policy filtering, bypassing profile policies, allow/deny lists, owner-only restrictions, sandbox policies, and subagent policies.

Affected products

1

Patches

1
0e7a992d3f31

fix(agents): filter bundled tools through final policy (#68195)

https://github.com/openclaw/openclawDevin RobisonApr 17, 2026via nvd-ref
9 files changed · +468 15
  • CHANGELOG.md+1 0 modified
    @@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai
     - Cron/isolated-agent: preserve `trusted: false` on isolated cron awareness events mirrored into the main session, and forward the optional `trusted` flag through the gateway cron wrapper so explicit trust downgrades survive session-key scoping. (#68210)
     - Agents/fallback: recognize bare leading ZenMux `402 ...` quota-refresh errors without misclassifying plain numeric `402 ...` text, and keep the embedded fallback regression coverage stable. (#47579) Thanks @bwjoke.
     - Failover/google: only treat `INTERNAL` status payloads as retryable timeouts when they also carry a `500` code, so malformed non-500 payloads do not enter the retry path. (#68238) Thanks @altaywtf and @Openbling.
    +- Agents/tools: filter bundled MCP/LSP tools through the final owner-only and tool-policy pipeline after merging them into the effective tool list, so existing allowlists, deny rules, sandbox policy, subagent policy, and owner-only restrictions apply to bundled tools the same way they apply to core tools. (#68195)
     
     ## 2026.4.15
     
    
  • src/agents/pi-embedded-runner/compact.ts+28 5 modified
    @@ -106,6 +106,7 @@ import {
       compactWithSafetyTimeout,
       resolveCompactionTimeoutMs,
     } from "./compaction-safety-timeout.js";
    +import { applyFinalEffectiveToolPolicy } from "./effective-tool-policy.js";
     import { buildEmbeddedExtensionFactories } from "./extensions.js";
     import { applyExtraParamsToAgent } from "./extra-params.js";
     import { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "./history.js";
    @@ -554,11 +555,33 @@ export async function compactEmbeddedPiSessionDirect(
               ],
             })
           : undefined;
    -    const effectiveTools = [
    -      ...tools,
    -      ...(bundleMcpRuntime?.tools ?? []),
    -      ...(bundleLspRuntime?.tools ?? []),
    -    ];
    +    const filteredBundledTools = applyFinalEffectiveToolPolicy({
    +      bundledTools: [...(bundleMcpRuntime?.tools ?? []), ...(bundleLspRuntime?.tools ?? [])],
    +      config: params.config,
    +      sandboxToolPolicy: sandbox?.tools,
    +      sessionKey: sandboxSessionKey,
    +      // Intentionally omit explicit agentId: the core tools just built with
    +      // createOpenClawCodingTools(...) also omit it, so both paths resolve
    +      // agentId the same way via resolveAgentIdFromSessionKey(sessionKey).
    +      // Passing effectiveSkillAgentId here would diverge from the core-tool
    +      // policy for legacy/non-agent session keys where the two sources fall
    +      // back to different ids.
    +      modelProvider: model.provider,
    +      modelId,
    +      messageProvider: resolvedMessageProvider,
    +      agentAccountId: params.agentAccountId,
    +      groupId: params.groupId,
    +      groupChannel: params.groupChannel,
    +      groupSpace: params.groupSpace,
    +      spawnedBy: params.spawnedBy,
    +      senderId: params.senderId,
    +      senderName: params.senderName,
    +      senderUsername: params.senderUsername,
    +      senderE164: params.senderE164,
    +      senderIsOwner: params.senderIsOwner,
    +      warn: (message) => log.warn(message),
    +    });
    +    const effectiveTools = [...tools, ...filteredBundledTools];
         const allowedToolNames = collectAllowedToolNames({ tools: effectiveTools });
         logProviderToolSchemaDiagnostics({
           tools: effectiveTools,
    
  • src/agents/pi-embedded-runner/effective-tool-policy.test.ts+120 0 added
    @@ -0,0 +1,120 @@
    +import { describe, expect, it } from "vitest";
    +import type { AnyAgentTool } from "../tools/common.js";
    +import { applyFinalEffectiveToolPolicy } from "./effective-tool-policy.js";
    +
    +function makeTool(name: string, ownerOnly = false): AnyAgentTool {
    +  return {
    +    name,
    +    label: name,
    +    description: name,
    +    parameters: { type: "object", properties: {} },
    +    ownerOnly,
    +    execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
    +  };
    +}
    +
    +describe("applyFinalEffectiveToolPolicy", () => {
    +  it("filters bundled tools through the configured allowlist", () => {
    +    const filtered = applyFinalEffectiveToolPolicy({
    +      bundledTools: [makeTool("mcp__bundle__fs_delete"), makeTool("mcp__bundle__fs_read")],
    +      config: { tools: { allow: ["mcp__bundle__fs_read"] } },
    +      warn: () => {},
    +    });
    +
    +    expect(filtered.map((tool) => tool.name)).toEqual(["mcp__bundle__fs_read"]);
    +  });
    +
    +  it("applies owner-only filtering to bundled tools", () => {
    +    const filtered = applyFinalEffectiveToolPolicy({
    +      bundledTools: [makeTool("mcp__bundle__read"), makeTool("mcp__bundle__admin", true)],
    +      senderIsOwner: false,
    +      warn: () => {},
    +    });
    +
    +    expect(filtered.map((tool) => tool.name)).toEqual(["mcp__bundle__read"]);
    +  });
    +
    +  it("returns the empty array unchanged when there are no bundled tools", () => {
    +    const filtered = applyFinalEffectiveToolPolicy({
    +      bundledTools: [],
    +      config: { tools: { allow: ["message"] } },
    +      warn: () => {},
    +    });
    +
    +    expect(filtered).toEqual([]);
    +  });
    +
    +  it("drops caller-provided groupId when it disagrees with session-derived group context", () => {
    +    const warnings: string[] = [];
    +    applyFinalEffectiveToolPolicy({
    +      bundledTools: [makeTool("mcp__bundle__read")],
    +      // Session key encodes a concrete group (discord room 111); caller tries
    +      // to override with a different group id so a more permissive group
    +      // policy for group 222 could be consulted.
    +      sessionKey: "agent:alice:discord:group:111",
    +      groupId: "222",
    +      groupChannel: "#different",
    +      warn: (message) => warnings.push(message),
    +    });
    +
    +    expect(warnings).toContain(
    +      "effective tool policy: dropping caller-provided groupId that does not match session-derived group context",
    +    );
    +  });
    +
    +  it("drops caller-provided groupId when session encodes no group context (fail-closed)", () => {
    +    const warnings: string[] = [];
    +    applyFinalEffectiveToolPolicy({
    +      bundledTools: [makeTool("mcp__bundle__read")],
    +      // Direct/non-group session key: no session-derived group ids. A caller
    +      // supplying a groupId here has no server-verified ground truth; it
    +      // must be dropped so a spoofed group cannot reach a permissive policy.
    +      sessionKey: "agent:alice:main",
    +      groupId: "admin-group",
    +      groupChannel: "#admin",
    +      warn: (message) => warnings.push(message),
    +    });
    +
    +    expect(warnings).toContain(
    +      "effective tool policy: dropping caller-provided groupId that does not match session-derived group context",
    +    );
    +  });
    +
    +  it("leaves groupId untouched when caller did not supply one", () => {
    +    const warnings: string[] = [];
    +    applyFinalEffectiveToolPolicy({
    +      bundledTools: [makeTool("mcp__bundle__read")],
    +      sessionKey: "agent:alice:main",
    +      warn: (message) => warnings.push(message),
    +    });
    +
    +    expect(warnings).not.toContain(
    +      "effective tool policy: dropping caller-provided groupId that does not match session-derived group context",
    +    );
    +  });
    +
    +  it("does not emit unknown-entry warnings for core tool allowlists in the bundled pass", () => {
    +    const warnings: string[] = [];
    +    applyFinalEffectiveToolPolicy({
    +      bundledTools: [makeTool("mcp__bundle__read")],
    +      // Core tool names like `read` and `exec` are not in the bundled-only
    +      // input here, but they are valid core tools resolved by the first
    +      // pass. The bundled pass must not warn about them as "unknown".
    +      config: { tools: { allow: ["read", "exec", "mcp__bundle__read"] } },
    +      warn: (message) => warnings.push(message),
    +    });
    +
    +    expect(warnings.some((w) => w.includes("unknown entries"))).toBe(false);
    +  });
    +
    +  it("still warns on genuinely unknown entries in the bundled pass", () => {
    +    const warnings: string[] = [];
    +    applyFinalEffectiveToolPolicy({
    +      bundledTools: [makeTool("mcp__bundle__read")],
    +      config: { tools: { allow: ["mcp__bundle__read", "totally-made-up-tool"] } },
    +      warn: (message) => warnings.push(message),
    +    });
    +
    +    expect(warnings.some((w) => w.includes("totally-made-up-tool"))).toBe(true);
    +  });
    +});
    
  • src/agents/pi-embedded-runner/effective-tool-policy.ts+175 0 added
    @@ -0,0 +1,175 @@
    +import type { OpenClawConfig } from "../../config/types.openclaw.js";
    +import { getPluginToolMeta } from "../../plugins/tools.js";
    +import { isSubagentSessionKey } from "../../routing/session-key.js";
    +import {
    +  resolveEffectiveToolPolicy,
    +  resolveGroupContextFromSessionKey,
    +  resolveGroupToolPolicy,
    +  resolveSubagentToolPolicyForSession,
    +} from "../pi-tools.policy.js";
    +import {
    +  applyToolPolicyPipeline,
    +  buildDefaultToolPolicyPipelineSteps,
    +  type ToolPolicyPipelineStep,
    +} from "../tool-policy-pipeline.js";
    +import {
    +  applyOwnerOnlyToolPolicy,
    +  mergeAlsoAllowPolicy,
    +  resolveToolProfilePolicy,
    +} from "../tool-policy.js";
    +import type { AnyAgentTool } from "../tools/common.js";
    +
    +/**
    + * Identity inputs used by `resolveGroupToolPolicy` to look up channel/group
    + * tool policy. These fields are an authorization signal (they can widen
    + * bundled-tool availability via a group-scoped allowlist), so callers MUST
    + * pass values derived from server-verified session metadata (session key,
    + * inbound transport event), not from tool-call or model-controlled input.
    + * The helper cross-checks caller-provided `groupId` against session-derived
    + * group ids and drops the caller value when they disagree, but it cannot
    + * detect drift on fields that have no session-bound counterpart.
    + */
    +type FinalEffectiveToolPolicyParams = {
    +  // Tools appended to the core tool set after `createOpenClawCodingTools()`
    +  // has already applied owner-only and tool-policy filtering (e.g. bundled
    +  // MCP/LSP tools). Only these are filtered here; re-running the pipeline over
    +  // the already-filtered core tools would drop plugin tools whose WeakMap
    +  // metadata no longer survives core-tool wrapping/normalization.
    +  bundledTools: AnyAgentTool[];
    +  config?: OpenClawConfig;
    +  sandboxToolPolicy?: { allow?: string[]; deny?: string[] };
    +  sessionKey?: string;
    +  agentId?: string;
    +  modelProvider?: string;
    +  modelId?: string;
    +  messageProvider?: string;
    +  agentAccountId?: string | null;
    +  groupId?: string | null;
    +  groupChannel?: string | null;
    +  groupSpace?: string | null;
    +  spawnedBy?: string | null;
    +  senderId?: string | null;
    +  senderName?: string | null;
    +  senderUsername?: string | null;
    +  senderE164?: string | null;
    +  senderIsOwner?: boolean;
    +  warn: (message: string) => void;
    +};
    +
    +function resolveTrustedGroupId(params: FinalEffectiveToolPolicyParams): {
    +  groupId: string | null | undefined;
    +  dropped: boolean;
    +} {
    +  const callerGroupId = (params.groupId ?? "").trim();
    +  if (!callerGroupId) {
    +    return { groupId: params.groupId, dropped: false };
    +  }
    +  const sessionGroupIds = resolveGroupContextFromSessionKey(params.sessionKey).groupIds ?? [];
    +  const spawnedGroupIds = resolveGroupContextFromSessionKey(params.spawnedBy).groupIds ?? [];
    +  const trusted = [...sessionGroupIds, ...spawnedGroupIds];
    +  // Fail-closed: if the session/spawnedBy keys do not encode a group context,
    +  // we have no server-verified ground truth to compare the caller value
    +  // against. A non-group session (direct, subagent, cron) should not consult
    +  // a group-scoped tool policy at all, and accepting the caller's groupId
    +  // here would let an attacker widen bundled-tool availability by sending
    +  // an arbitrary group id.
    +  if (trusted.length === 0) {
    +    return { groupId: null, dropped: true };
    +  }
    +  if (trusted.includes(callerGroupId)) {
    +    return { groupId: params.groupId, dropped: false };
    +  }
    +  return { groupId: null, dropped: true };
    +}
    +
    +export function applyFinalEffectiveToolPolicy(
    +  params: FinalEffectiveToolPolicyParams,
    +): AnyAgentTool[] {
    +  if (params.bundledTools.length === 0) {
    +    return params.bundledTools;
    +  }
    +  const trustedGroup = resolveTrustedGroupId(params);
    +  if (trustedGroup.dropped) {
    +    params.warn(
    +      "effective tool policy: dropping caller-provided groupId that does not match session-derived group context",
    +    );
    +  }
    +  const {
    +    agentId,
    +    globalPolicy,
    +    globalProviderPolicy,
    +    agentPolicy,
    +    agentProviderPolicy,
    +    profile,
    +    providerProfile,
    +    profileAlsoAllow,
    +    providerProfileAlsoAllow,
    +  } = resolveEffectiveToolPolicy({
    +    config: params.config,
    +    sessionKey: params.sessionKey,
    +    agentId: params.agentId,
    +    modelProvider: params.modelProvider,
    +    modelId: params.modelId,
    +  });
    +
    +  const groupPolicy = resolveGroupToolPolicy({
    +    config: params.config,
    +    sessionKey: params.sessionKey,
    +    spawnedBy: params.spawnedBy,
    +    messageProvider: params.messageProvider,
    +    groupId: trustedGroup.groupId,
    +    groupChannel: trustedGroup.dropped ? null : params.groupChannel,
    +    groupSpace: trustedGroup.dropped ? null : params.groupSpace,
    +    accountId: params.agentAccountId,
    +    senderId: params.senderId,
    +    senderName: params.senderName,
    +    senderUsername: params.senderUsername,
    +    senderE164: params.senderE164,
    +  });
    +  const profilePolicy = resolveToolProfilePolicy(profile);
    +  const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
    +  const profilePolicyWithAlsoAllow = mergeAlsoAllowPolicy(profilePolicy, profileAlsoAllow);
    +  const providerProfilePolicyWithAlsoAllow = mergeAlsoAllowPolicy(
    +    providerProfilePolicy,
    +    providerProfileAlsoAllow,
    +  );
    +  const subagentPolicy =
    +    isSubagentSessionKey(params.sessionKey) && params.sessionKey
    +      ? resolveSubagentToolPolicyForSession(params.config, params.sessionKey)
    +      : undefined;
    +  const ownerFiltered = applyOwnerOnlyToolPolicy(params.bundledTools, params.senderIsOwner === true);
    +  // Suppress unavailable-core-tool warnings on every step of this pass.
    +  // `applyToolPolicyPipeline` infers `coreToolNames` from the `tools` array
    +  // it's filtering, and this pass only sees the bundled MCP/LSP subset.
    +  // Normal core allowlist entries (e.g. `tools.allow: ["read", "exec"]`)
    +  // would look "unknown" relative to that reduced set even though they are
    +  // valid core names already resolved by `createOpenClawCodingTools()` in
    +  // the first pass — keeping those warnings on would pollute logs and evict
    +  // real diagnostics from the shared warning cache. Genuinely unknown
    +  // entries (typos) still surface through the `otherEntries` path in
    +  // `applyToolPolicyPipeline`.
    +  const pipelineSteps: ToolPolicyPipelineStep[] = [
    +    ...buildDefaultToolPolicyPipelineSteps({
    +      profilePolicy: profilePolicyWithAlsoAllow,
    +      profile,
    +      profileUnavailableCoreWarningAllowlist: profilePolicy?.allow,
    +      providerProfilePolicy: providerProfilePolicyWithAlsoAllow,
    +      providerProfile,
    +      providerProfileUnavailableCoreWarningAllowlist: providerProfilePolicy?.allow,
    +      globalPolicy,
    +      globalProviderPolicy,
    +      agentPolicy,
    +      agentProviderPolicy,
    +      groupPolicy,
    +      agentId,
    +    }),
    +    { policy: params.sandboxToolPolicy, label: "sandbox tools.allow" },
    +    { policy: subagentPolicy, label: "subagent tools.allow" },
    +  ].map((step) => ({ ...step, suppressUnavailableCoreToolWarning: true }));
    +  return applyToolPolicyPipeline({
    +    tools: ownerFiltered,
    +    toolMeta: (tool) => getPluginToolMeta(tool),
    +    warn: params.warn,
    +    steps: pipelineSteps,
    +  });
    +}
    
  • src/agents/pi-embedded-runner/run/attempt.ts+23 5 modified
    @@ -125,6 +125,7 @@ import { isRunnerAbortError } from "../abort.js";
     import { isCacheTtlEligibleProvider, readLastCacheTtlTimestamp } from "../cache-ttl.js";
     import { resolveCompactionTimeoutMs } from "../compaction-safety-timeout.js";
     import { runContextEngineMaintenance } from "../context-engine-maintenance.js";
    +import { applyFinalEffectiveToolPolicy } from "../effective-tool-policy.js";
     import { buildEmbeddedExtensionFactories } from "../extensions.js";
     import { applyExtraParamsToAgent, resolveAgentTransportOverride } from "../extra-params.js";
     import { prepareGooglePromptCacheStreamFn } from "../google-prompt-cache.js";
    @@ -678,11 +679,28 @@ export async function runEmbeddedAttempt(
               ],
             })
           : undefined;
    -    const effectiveTools = [
    -      ...tools,
    -      ...(bundleMcpRuntime?.tools ?? []),
    -      ...(bundleLspRuntime?.tools ?? []),
    -    ];
    +    const filteredBundledTools = applyFinalEffectiveToolPolicy({
    +      bundledTools: [...(bundleMcpRuntime?.tools ?? []), ...(bundleLspRuntime?.tools ?? [])],
    +      config: params.config,
    +      sandboxToolPolicy: sandbox?.tools,
    +      sessionKey: sandboxSessionKey,
    +      agentId: sessionAgentId,
    +      modelProvider: params.provider,
    +      modelId: params.modelId,
    +      messageProvider: params.messageChannel ?? params.messageProvider,
    +      agentAccountId: params.agentAccountId,
    +      groupId: params.groupId,
    +      groupChannel: params.groupChannel,
    +      groupSpace: params.groupSpace,
    +      spawnedBy: params.spawnedBy,
    +      senderId: params.senderId,
    +      senderName: params.senderName,
    +      senderUsername: params.senderUsername,
    +      senderE164: params.senderE164,
    +      senderIsOwner: params.senderIsOwner,
    +      warn: (message) => log.warn(message),
    +    });
    +    const effectiveTools = [...tools, ...filteredBundledTools];
         const allowedToolNames = collectAllowedToolNames({
           tools: effectiveTools,
           clientTools,
    
  • src/agents/pi-tools.policy.ts+1 1 modified
    @@ -174,7 +174,7 @@ function buildScopedGroupIdCandidates(groupId?: string | null): string[] {
       return [raw];
     }
     
    -function resolveGroupContextFromSessionKey(sessionKey?: string | null): {
    +export function resolveGroupContextFromSessionKey(sessionKey?: string | null): {
       channel?: string;
       groupIds?: string[];
     } {
    
  • src/gateway/protocol/schema/agent.ts+4 0 modified
    @@ -70,6 +70,10 @@ export const MessageActionParamsSchema = Type.Object(
         params: Type.Record(Type.String(), Type.Unknown()),
         accountId: Type.Optional(Type.String()),
         requesterSenderId: Type.Optional(Type.String()),
    +    // Honored only when the RPC caller has the full operator scope set
    +    // (shared-secret bearer or `operator.admin`). For narrowly-scoped
    +    // callers (e.g. `operator.write`-only) the gateway forces this to
    +    // `false` regardless of the value sent here.
         senderIsOwner: Type.Optional(Type.Boolean()),
         sessionKey: Type.Optional(Type.String()),
         sessionId: Type.Optional(Type.String()),
    
  • src/gateway/server-methods/send.test.ts+96 2 modified
    @@ -156,14 +156,17 @@ async function runPollWithClient(
       return { respond };
     }
     
    -async function runMessageActionRequest(params: Record<string, unknown>) {
    +async function runMessageActionRequest(
    +  params: Record<string, unknown>,
    +  client?: { connect?: { scopes?: string[] } } | null,
    +) {
       const respond = vi.fn();
       await sendHandlers["message.action"]({
         params: params as never,
         respond,
         context: makeContext(),
         req: { type: "req", id: "1", method: "message.action" },
    -    client: null as never,
    +    client: (client ?? null) as never,
         isWebchatConnect: () => false,
       });
       return { respond };
    @@ -954,4 +957,95 @@ describe("gateway send mirroring", () => {
           { channel: "whatsapp" },
         );
       });
    +
    +  it("forces senderIsOwner=false for narrowly-scoped callers but honors it for full operators", async () => {
    +    const capture = { senderIsOwner: undefined as boolean | undefined };
    +    const reactPlugin: ChannelPlugin = {
    +      id: "whatsapp",
    +      meta: {
    +        id: "whatsapp",
    +        label: "WhatsApp",
    +        selectionLabel: "WhatsApp",
    +        docsPath: "/channels/whatsapp",
    +        blurb: "WhatsApp owner-derivation test plugin.",
    +      },
    +      capabilities: { chatTypes: ["direct"], reactions: true },
    +      config: {
    +        listAccountIds: () => ["default"],
    +        resolveAccount: () => ({ enabled: true }),
    +        isConfigured: () => true,
    +      },
    +      actions: {
    +        describeMessageTool: () => ({ actions: ["react"] }),
    +        supportsAction: ({ action }) => action === "react",
    +        handleAction: async ({ senderIsOwner }) => {
    +          capture.senderIsOwner = senderIsOwner;
    +          return jsonResult({ ok: true });
    +        },
    +      },
    +    };
    +    mocks.getChannelPlugin.mockReturnValue(reactPlugin);
    +
    +    // Narrowly-scoped caller (e.g. gateway-forwarding least-privilege path
    +    // that only requests operator.write): wire senderIsOwner=true must be
    +    // forced to false so a non-admin scoped caller cannot unlock owner-only
    +    // channel actions.
    +    setActivePluginRegistry(
    +      createTestRegistry([
    +        { pluginId: "whatsapp", source: "test", plugin: reactPlugin },
    +      ]),
    +      "send-test-owner-derive-non-admin",
    +    );
    +    await runMessageActionRequest(
    +      {
    +        channel: "whatsapp",
    +        action: "react",
    +        params: { chatJid: "+15551234567", messageId: "wamid.x", emoji: "✅" },
    +        senderIsOwner: true,
    +        idempotencyKey: "idem-owner-derive-non-admin",
    +      },
    +      { connect: { scopes: ["operator.write"] } },
    +    );
    +    expect(capture.senderIsOwner).toBe(false);
    +
    +    // Full operator (admin-scoped): the trusted runtime is allowed to
    +    // forward the real channel-sender ownership bit. Wire true → true.
    +    setActivePluginRegistry(
    +      createTestRegistry([
    +        { pluginId: "whatsapp", source: "test", plugin: reactPlugin },
    +      ]),
    +      "send-test-owner-derive-admin-true",
    +    );
    +    await runMessageActionRequest(
    +      {
    +        channel: "whatsapp",
    +        action: "react",
    +        params: { chatJid: "+15551234567", messageId: "wamid.y", emoji: "✅" },
    +        senderIsOwner: true,
    +        idempotencyKey: "idem-owner-derive-admin-true",
    +      },
    +      { connect: { scopes: ["operator.admin"] } },
    +    );
    +    expect(capture.senderIsOwner).toBe(true);
    +
    +    // Full operator forwarding a non-owner sender: wire false → false
    +    // (admin scope does not inflate ownership on its own).
    +    setActivePluginRegistry(
    +      createTestRegistry([
    +        { pluginId: "whatsapp", source: "test", plugin: reactPlugin },
    +      ]),
    +      "send-test-owner-derive-admin-false",
    +    );
    +    await runMessageActionRequest(
    +      {
    +        channel: "whatsapp",
    +        action: "react",
    +        params: { chatJid: "+15551234567", messageId: "wamid.z", emoji: "✅" },
    +        senderIsOwner: false,
    +        idempotencyKey: "idem-owner-derive-admin-false",
    +      },
    +      { connect: { scopes: ["operator.admin"] } },
    +    );
    +    expect(capture.senderIsOwner).toBe(false);
    +  });
     });
    
  • src/gateway/server-methods/send.ts+20 2 modified
    @@ -34,6 +34,7 @@ import {
       validatePollParams,
       validateSendParams,
     } from "../protocol/index.js";
    +import { ADMIN_SCOPE } from "../method-scopes.js";
     import { formatForLog } from "../ws-log.js";
     import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js";
     
    @@ -185,7 +186,7 @@ function cacheGatewayDedupeFailure(params: {
     }
     
     export const sendHandlers: GatewayRequestHandlers = {
    -  "message.action": async ({ params, respond, context }) => {
    +  "message.action": async ({ params, respond, context, client }) => {
         const p = params;
         if (!validateMessageActionParams(p)) {
           respond(
    @@ -216,6 +217,23 @@ export const sendHandlers: GatewayRequestHandlers = {
           };
           idempotencyKey: string;
         };
    +    // Owner status is an authorization signal used to unlock owner-only
    +    // channel actions and owner-only tool policy. The legitimate propagation
    +    // path is the trusted runtime forwarding a real channel-sender ownership
    +    // bit through the gateway RPC — but that wire value must not be honored
    +    // for callers who are not already full operators. Per SECURITY.md,
    +    // shared-secret bearer and admin-scoped callers get the full default
    +    // operator scope set (including `operator.admin`); those callers are
    +    // trusted to forward `senderIsOwner`. Narrowly-scoped callers
    +    // (e.g. `operator.write`-only, including the gateway-forwarding
    +    // least-privilege path) are not trusted to assert ownership, so their
    +    // wire value is forced to `false` to prevent a non-admin scoped caller
    +    // from unlocking owner-only channel actions by setting
    +    // `senderIsOwner: true` on the request.
    +    const callerScopes = client?.connect?.scopes ?? [];
    +    const callerIsFullOperator =
    +      Array.isArray(callerScopes) && callerScopes.includes(ADMIN_SCOPE);
    +    const senderIsOwner = callerIsFullOperator && request.senderIsOwner === true;
         const idem = request.idempotencyKey;
         const dedupeKey = `message.action:${idem}`;
         const cached = context.dedupe.get(dedupeKey);
    @@ -265,7 +283,7 @@ export const sendHandlers: GatewayRequestHandlers = {
               params: request.params,
               accountId: normalizeOptionalString(request.accountId) ?? undefined,
               requesterSenderId: normalizeOptionalString(request.requesterSenderId) ?? undefined,
    -          senderIsOwner: request.senderIsOwner,
    +          senderIsOwner,
               sessionKey: normalizeOptionalString(request.sessionKey) ?? undefined,
               sessionId: normalizeOptionalString(request.sessionId) ?? undefined,
               agentId: normalizeOptionalString(request.agentId) ?? undefined,
    

Vulnerability mechanics

AI mechanics synthesis has not run for this CVE yet.

References

3

News mentions

0

No linked articles in our index yet.