VYPR
Medium severity6.5GHSA Advisory· Published May 5, 2026· Updated May 5, 2026

CVE-2026-42433

CVE-2026-42433

Description

OpenClaw before 2026.4.10 contains an authorization bypass vulnerability allowing operator.write message-tool paths to access Matrix profile persistence requiring admin-level authority. Attackers can exploit insufficient access controls to mutate persistent profile configuration through non-owner message-tool runs.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.4.102026.4.10

Affected products

1

Patches

1
fe0f686c9228

Gate Matrix profile updates for non-owner message tool runs (#62662)

https://github.com/openclaw/openclawAgustin RiveraApr 10, 2026via ghsa
38 files changed · +596 152
  • CHANGELOG.md+3 0 modified
    @@ -288,6 +288,9 @@ Docs: https://docs.openclaw.ai
     - CLI/tasks: `openclaw tasks cancel` now records operator cancellation for CLI runtime tasks instead of returning "Task runtime does not support cancellation yet", so stuck `running` CLI tasks can be cleared. (#62419) Thanks @neeravmakwana.
     - Sessions/context: resolve context window limits using the active provider plus model (not bare model id alone) when persisting session usage, applying inline directives, and sizing memory-flush / preflight compaction thresholds, so duplicate model ids across providers no longer leak the wrong `contextTokens` into the session store or `/status`. (#62472) Thanks @neeravmakwana.
     - Channels/setup: exclude workspace shadow entries from channel setup catalog lookups and align trust checks with auto-enable so workspace-scoped overrides no longer bypass the trusted catalog. (`GHSA-82qx-6vj7-p8m2`) Thanks @zsxsoft.
    +- Reply execution: prefer the active runtime snapshot over stale queued reply config during embedded reply and follow-up execution so SecretRef-backed reply turns stop crashing after secrets have already resolved. (#62693) Thanks @mbelinky.
    +- Android/manual connect: allow blank port input only for TLS manual gateway endpoints so standard HTTPS Tailscale hosts default to `443` without silently changing cleartext manual connects. (#63134) Thanks @Tyler-RNG.
    +- Matrix/agents: hide owner-only `set-profile` from embedded agent channel-action discovery so non-owner runs stop advertising profile updates they cannot execute. (#62662) Thanks @eleqtrizit.
     
     ## 2026.4.5
     
    
  • extensions/matrix/src/actions.account-propagation.test.ts+41 0 modified
    @@ -91,6 +91,7 @@ describe("matrixMessageActions account propagation", () => {
         await matrixMessageActions.handleAction?.(
           createContext({
             action: profileAction,
    +        senderIsOwner: true,
             accountId: "ops",
             params: {
               displayName: "Ops Bot",
    @@ -111,10 +112,50 @@ describe("matrixMessageActions account propagation", () => {
         );
       });
     
    +  it("rejects self-profile updates for non-owner callers", async () => {
    +    await expect(
    +      matrixMessageActions.handleAction?.(
    +        createContext({
    +          action: profileAction,
    +          senderIsOwner: false,
    +          accountId: "ops",
    +          params: {
    +            displayName: "Ops Bot",
    +          },
    +        }),
    +      ),
    +    ).rejects.toMatchObject({
    +      name: "ToolAuthorizationError",
    +      message: "Matrix profile updates require owner access.",
    +    });
    +
    +    expect(mocks.handleMatrixAction).not.toHaveBeenCalled();
    +  });
    +
    +  it("rejects self-profile updates when owner status is unknown", async () => {
    +    await expect(
    +      matrixMessageActions.handleAction?.(
    +        createContext({
    +          action: profileAction,
    +          accountId: "ops",
    +          params: {
    +            displayName: "Ops Bot",
    +          },
    +        }),
    +      ),
    +    ).rejects.toMatchObject({
    +      name: "ToolAuthorizationError",
    +      message: "Matrix profile updates require owner access.",
    +    });
    +
    +    expect(mocks.handleMatrixAction).not.toHaveBeenCalled();
    +  });
    +
       it("forwards local avatar paths for self-profile updates", async () => {
         await matrixMessageActions.handleAction?.(
           createContext({
             action: profileAction,
    +        senderIsOwner: true,
             accountId: "ops",
             params: {
               path: "/tmp/avatar.jpg",
    
  • extensions/matrix/src/actions.test.ts+26 0 modified
    @@ -78,6 +78,7 @@ describe("matrixMessageActions", () => {
     
         const discovery = describeMessageTool({
           cfg: createConfiguredMatrixConfig(),
    +      senderIsOwner: true,
         } as never);
         if (!discovery) {
           throw new Error("describeMessageTool returned null");
    @@ -96,6 +97,31 @@ describe("matrixMessageActions", () => {
         expect(properties.avatarPath).toBeDefined();
       });
     
    +  it("hides self-profile updates for non-owner discovery", () => {
    +    const discovery = matrixMessageActions.describeMessageTool({
    +      cfg: createConfiguredMatrixConfig(),
    +      senderIsOwner: false,
    +    } as never);
    +    if (!discovery) {
    +      throw new Error("describeMessageTool returned null");
    +    }
    +
    +    expect(discovery.actions).not.toContain(profileAction);
    +    expect(discovery.schema).toBeNull();
    +  });
    +
    +  it("hides self-profile updates when owner status is unknown", () => {
    +    const discovery = matrixMessageActions.describeMessageTool({
    +      cfg: createConfiguredMatrixConfig(),
    +    } as never);
    +    if (!discovery) {
    +      throw new Error("describeMessageTool returned null");
    +    }
    +
    +    expect(discovery.actions).not.toContain(profileAction);
    +    expect(discovery.schema).toBeNull();
    +  });
    +
       it("hides gated actions when the default Matrix account disables them", () => {
         const discovery = matrixMessageActions.describeMessageTool({
           cfg: {
    
  • extensions/matrix/src/actions.ts+9 4 modified
    @@ -7,11 +7,11 @@ import {
       createActionGate,
       readNumberParam,
       readStringParam,
    +  ToolAuthorizationError,
       type ChannelMessageActionAdapter,
       type ChannelMessageActionContext,
       type ChannelMessageActionName,
       type ChannelMessageToolDiscovery,
    -  type ChannelToolSend,
     } from "./runtime-api.js";
     import type { CoreConfig } from "./types.js";
     
    @@ -35,6 +35,7 @@ const MATRIX_PLUGIN_HANDLED_ACTIONS = new Set<ChannelMessageActionName>([
     function createMatrixExposedActions(params: {
       gate: ReturnType<typeof createActionGate>;
       encryptionEnabled: boolean;
    +  senderIsOwner?: boolean;
     }) {
       const actions = new Set<ChannelMessageActionName>(["poll", "poll-vote"]);
       if (params.gate("messages")) {
    @@ -52,7 +53,7 @@ function createMatrixExposedActions(params: {
         actions.add("unpin");
         actions.add("list-pins");
       }
    -  if (params.gate("profile")) {
    +  if (params.gate("profile") && params.senderIsOwner === true) {
         actions.add("set-profile");
       }
       if (params.gate("memberInfo")) {
    @@ -109,7 +110,7 @@ function buildMatrixProfileToolSchema(): NonNullable<ChannelMessageToolDiscovery
     }
     
     export const matrixMessageActions: ChannelMessageActionAdapter = {
    -  describeMessageTool: ({ cfg, accountId }) => {
    +  describeMessageTool: ({ cfg, accountId, senderIsOwner }) => {
         const resolvedCfg = cfg as CoreConfig;
         if (!accountId && requiresExplicitMatrixDefaultAccount(resolvedCfg)) {
           return { actions: [], capabilities: [] };
    @@ -125,6 +126,7 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
         const actions = createMatrixExposedActions({
           gate,
           encryptionEnabled: account.config.encryption === true,
    +      senderIsOwner,
         });
         const listedActions = Array.from(actions);
         return {
    @@ -134,7 +136,7 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
         };
       },
       supportsAction: ({ action }) => MATRIX_PLUGIN_HANDLED_ACTIONS.has(action),
    -  extractToolSend: ({ args }): ChannelToolSend | null => {
    +  extractToolSend: ({ args }) => {
         return extractToolSend(args, "sendMessage");
       },
       handleAction: async (ctx: ChannelMessageActionContext) => {
    @@ -259,6 +261,9 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
         }
     
         if (action === "set-profile") {
    +      if (ctx.senderIsOwner !== true) {
    +        throw new ToolAuthorizationError("Matrix profile updates require owner access.");
    +      }
           const avatarPath =
             readStringParam(params, "avatarPath") ??
             readStringParam(params, "path") ??
    
  • extensions/matrix/src/runtime-api.ts+1 0 modified
    @@ -10,6 +10,7 @@ export {
       readReactionParams,
       readStringArrayParam,
       readStringParam,
    +  ToolAuthorizationError,
     } from "openclaw/plugin-sdk/channel-actions";
     export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-primitives";
     export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
    
  • src/agents/channel-tools.ts+2 0 modified
    @@ -41,6 +41,7 @@ export function listChannelSupportedActions(params: {
       sessionId?: string | null;
       agentId?: string | null;
       requesterSenderId?: string | null;
    +  senderIsOwner?: boolean;
     }): ChannelMessageActionName[] {
       const channelId = resolveMessageActionDiscoveryChannelId(params.channel);
       if (!channelId) {
    @@ -71,6 +72,7 @@ export function listAllChannelSupportedActions(params: {
       sessionId?: string | null;
       agentId?: string | null;
       requesterSenderId?: string | null;
    +  senderIsOwner?: boolean;
     }): ChannelMessageActionName[] {
       const actions = new Set<ChannelMessageActionName>();
       for (const plugin of listChannelPlugins()) {
    
  • src/agents/cli-runner/bundle-mcp.test.ts+5 2 modified
    @@ -213,12 +213,14 @@ describe("prepareCliBundleMcpConfig", () => {
           env: {
             OPENCLAW_MCP_TOKEN: "loopback-token-123",
             OPENCLAW_MCP_SESSION_KEY: "agent:main:telegram:group:chat123",
    +        OPENCLAW_MCP_SENDER_IS_OWNER: "false",
           },
         });
     
         expect(prepared.env).toEqual({
           OPENCLAW_MCP_TOKEN: "loopback-token-123",
           OPENCLAW_MCP_SESSION_KEY: "agent:main:telegram:group:chat123",
    +      OPENCLAW_MCP_SENDER_IS_OWNER: "false",
         });
     
         await prepared.cleanup?.();
    @@ -256,6 +258,7 @@ describe("prepareCliBundleMcpConfig", () => {
                 headers: {
                   Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}",
                   "x-session-key": "${OPENCLAW_MCP_SESSION_KEY}",
    +              "x-openclaw-sender-is-owner": "${OPENCLAW_MCP_SENDER_IS_OWNER}",
                 },
               },
             },
    @@ -266,14 +269,14 @@ describe("prepareCliBundleMcpConfig", () => {
           "exec",
           "--json",
           "-c",
    -      'mcp_servers={ openclaw = { url = "http://127.0.0.1:23119/mcp", bearer_token_env_var = "OPENCLAW_MCP_TOKEN", env_http_headers = { x-session-key = "OPENCLAW_MCP_SESSION_KEY" } } }',
    +      'mcp_servers={ openclaw = { url = "http://127.0.0.1:23119/mcp", bearer_token_env_var = "OPENCLAW_MCP_TOKEN", env_http_headers = { x-session-key = "OPENCLAW_MCP_SESSION_KEY", x-openclaw-sender-is-owner = "OPENCLAW_MCP_SENDER_IS_OWNER" } } }',
         ]);
         expect(prepared.backend.resumeArgs).toEqual([
           "exec",
           "resume",
           "{sessionId}",
           "-c",
    -      'mcp_servers={ openclaw = { url = "http://127.0.0.1:23119/mcp", bearer_token_env_var = "OPENCLAW_MCP_TOKEN", env_http_headers = { x-session-key = "OPENCLAW_MCP_SESSION_KEY" } } }',
    +      'mcp_servers={ openclaw = { url = "http://127.0.0.1:23119/mcp", bearer_token_env_var = "OPENCLAW_MCP_TOKEN", env_http_headers = { x-session-key = "OPENCLAW_MCP_SESSION_KEY", x-openclaw-sender-is-owner = "OPENCLAW_MCP_SENDER_IS_OWNER" } } }',
         ]);
         expect(prepared.cleanup).toBeUndefined();
       });
    
  • src/agents/cli-runner/prepare.ts+1 0 modified
    @@ -132,6 +132,7 @@ export async function prepareCliRunContext(
               OPENCLAW_MCP_ACCOUNT_ID: params.agentAccountId ?? "",
               OPENCLAW_MCP_SESSION_KEY: params.sessionKey ?? "",
               OPENCLAW_MCP_MESSAGE_CHANNEL: params.messageProvider ?? "",
    +          OPENCLAW_MCP_SENDER_IS_OWNER: params.senderIsOwner === true ? "true" : "false",
             }
           : undefined,
         warn: (message) => cliBackendLog.warn(message),
    
  • src/agents/cli-runner.spawn.test.ts+68 0 modified
    @@ -2,15 +2,21 @@ import fs from "node:fs/promises";
     import os from "node:os";
     import path from "node:path";
     import { beforeEach, describe, expect, it, vi } from "vitest";
    +import {
    +  clearActiveMcpLoopbackRuntime,
    +  setActiveMcpLoopbackRuntime,
    +} from "../gateway/mcp-http.loopback-runtime.js";
     import { onAgentEvent, resetAgentEventsForTest } from "../infra/agent-events.js";
     import {
       makeBootstrapWarn as realMakeBootstrapWarn,
       resolveBootstrapContextForRun as realResolveBootstrapContextForRun,
     } from "./bootstrap-files.js";
    +import { runClaudeCliAgent } from "./cli-runner.js";
     import {
       createManagedRun,
       mockSuccessfulCliRun,
       restoreCliRunnerPrepareTestDeps,
    +  setupCliRunnerTestRegistry,
       supervisorSpawnMock,
     } from "./cli-runner.test-support.js";
     import { buildCliEnvAuthLog, executePreparedCliRun } from "./cli-runner/execute.js";
    @@ -97,6 +103,19 @@ function buildPreparedCliRunContext(params: {
       };
     }
     
    +function createClaudeSuccessRun(sessionId: string) {
    +  return createManagedRun({
    +    reason: "exit",
    +    exitCode: 0,
    +    exitSignal: null,
    +    durationMs: 50,
    +    stdout: JSON.stringify({ message: "ok", session_id: sessionId }),
    +    stderr: "",
    +    timedOut: false,
    +    noOutputTimedOut: false,
    +  });
    +}
    +
     describe("runCliAgent spawn path", () => {
       it("does not inject hardcoded 'Tools are disabled' text into CLI arguments", async () => {
         supervisorSpawnMock.mockResolvedValueOnce(
    @@ -367,6 +386,55 @@ describe("runCliAgent spawn path", () => {
         }
       });
     
    +  it("ignores legacy claudeSessionId on the compat wrapper", async () => {
    +    setupCliRunnerTestRegistry();
    +    supervisorSpawnMock.mockResolvedValueOnce(createClaudeSuccessRun("sid-wrapper"));
    +
    +    await runClaudeCliAgent({
    +      sessionId: "openclaw-session",
    +      sessionFile: "/tmp/session.jsonl",
    +      workspaceDir: "/tmp",
    +      prompt: "hi",
    +      model: "opus",
    +      timeoutMs: 1_000,
    +      runId: "run-claude-legacy-wrapper",
    +      claudeSessionId: "c9d7b831-1c31-4d22-80b9-1e50ca207d4b",
    +    });
    +
    +    const input = supervisorSpawnMock.mock.calls[0]?.[0] as { argv?: string[]; input?: string };
    +    expect(input.argv).not.toContain("--resume");
    +    expect(input.argv).not.toContain("c9d7b831-1c31-4d22-80b9-1e50ca207d4b");
    +    expect(input.argv).toContain("--session-id");
    +    expect(input.input).toContain("hi");
    +  });
    +
    +  it("forwards senderIsOwner through the compat wrapper into bundle MCP env", async () => {
    +    setupCliRunnerTestRegistry();
    +    setActiveMcpLoopbackRuntime({ port: 23119, token: "loopback-token-123" });
    +    try {
    +      supervisorSpawnMock.mockResolvedValueOnce(createClaudeSuccessRun("sid-owner"));
    +
    +      await runClaudeCliAgent({
    +        sessionId: "openclaw-session",
    +        sessionKey: "agent:main:matrix:room:123",
    +        sessionFile: "/tmp/session.jsonl",
    +        workspaceDir: "/tmp",
    +        prompt: "hi",
    +        model: "opus",
    +        timeoutMs: 1_000,
    +        runId: "run-claude-owner-wrapper",
    +        senderIsOwner: false,
    +      });
    +
    +      const input = supervisorSpawnMock.mock.calls[0]?.[0] as {
    +        env?: Record<string, string | undefined>;
    +      };
    +      expect(input.env?.OPENCLAW_MCP_SENDER_IS_OWNER).toBe("false");
    +    } finally {
    +      clearActiveMcpLoopbackRuntime("loopback-token-123");
    +    }
    +  });
    +
       it("runs CLI through supervisor and returns payload", async () => {
         supervisorSpawnMock.mockResolvedValueOnce(
           createManagedRun({
    
  • src/agents/cli-runner.ts+10 20 modified
    @@ -1,6 +1,3 @@
    -import type { ImageContent } from "@mariozechner/pi-ai";
    -import type { ThinkLevel } from "../auto-reply/thinking.js";
    -import type { OpenClawConfig } from "../config/config.js";
     import { formatErrorMessage } from "../infra/errors.js";
     import { executePreparedCliRun } from "./cli-runner/execute.js";
     import { prepareCliRunContext } from "./cli-runner/prepare.js";
    @@ -95,24 +92,14 @@ export async function runPreparedCliAgent(
       }
     }
     
    -export async function runClaudeCliAgent(params: {
    -  sessionId: string;
    -  sessionKey?: string;
    -  agentId?: string;
    -  sessionFile: string;
    -  workspaceDir: string;
    -  config?: OpenClawConfig;
    -  prompt: string;
    +export type RunClaudeCliAgentParams = Omit<RunCliAgentParams, "provider" | "cliSessionId"> & {
       provider?: string;
    -  model?: string;
    -  thinkLevel?: ThinkLevel;
    -  timeoutMs: number;
    -  runId: string;
    -  extraSystemPrompt?: string;
    -  ownerNumbers?: string[];
       claudeSessionId?: string;
    -  images?: ImageContent[];
    -}): Promise<EmbeddedPiRunResult> {
    +};
    +
    +export async function runClaudeCliAgent(
    +  params: RunClaudeCliAgentParams,
    +): Promise<EmbeddedPiRunResult> {
       return runCliAgent({
         sessionId: params.sessionId,
         sessionKey: params.sessionKey,
    @@ -128,7 +115,10 @@ export async function runClaudeCliAgent(params: {
         runId: params.runId,
         extraSystemPrompt: params.extraSystemPrompt,
         ownerNumbers: params.ownerNumbers,
    -    cliSessionId: params.claudeSessionId,
    +    // Legacy `claudeSessionId` callers predate the shared CLI session contract.
    +    // Ignore it here so the compatibility wrapper does not accidentally resume
    +    // an incompatible Claude session on the generic runner path.
         images: params.images,
    +    senderIsOwner: params.senderIsOwner,
       });
     }
    
  • src/agents/cli-runner/types.ts+1 0 modified
    @@ -35,6 +35,7 @@ export type RunCliAgentParams = {
       skillsSnapshot?: SkillSnapshot;
       messageProvider?: string;
       agentAccountId?: string;
    +  senderIsOwner?: boolean;
       abortSignal?: AbortSignal;
       replyOperation?: ReplyOperation;
     };
    
  • src/agents/command/attempt-execution.ts+1 0 modified
    @@ -395,6 +395,7 @@ export function runAgentAttempt(params: {
             streamParams: params.opts.streamParams,
             messageProvider: params.messageChannel,
             agentAccountId: params.runContext.accountId,
    +        senderIsOwner: params.opts.senderIsOwner,
           });
         return runCliWithSession(cliSessionBinding?.sessionId).catch(async (err) => {
           if (
    
  • src/agents/openclaw-tools.ts+1 0 modified
    @@ -206,6 +206,7 @@ export function createOpenClawTools(
             sandboxRoot: options?.sandboxRoot,
             requireExplicitTarget: options?.requireExplicitMessageTarget,
             requesterSenderId: options?.requesterSenderId ?? undefined,
    +        senderIsOwner: options?.senderIsOwner,
           });
       const nodesToolBase = createNodesTool({
         agentSessionKey: options?.agentSessionKey,
    
  • src/agents/pi-embedded-runner/compact.ts+1 0 modified
    @@ -677,6 +677,7 @@ export async function compactEmbeddedPiSessionDirect(
                 sessionId: params.sessionId,
                 agentId: sessionAgentId,
                 senderId: params.senderId,
    +            senderIsOwner: params.senderIsOwner,
               }),
             )
           : undefined;
    
  • src/agents/pi-embedded-runner/message-action-discovery-input.test.ts+25 0 modified
    @@ -14,6 +14,7 @@ describe("buildEmbeddedMessageActionDiscoveryInput", () => {
             sessionId: "session-1",
             agentId: "main",
             senderId: "user-123",
    +        senderIsOwner: false,
           }),
         ).toEqual({
           cfg: undefined,
    @@ -26,6 +27,7 @@ describe("buildEmbeddedMessageActionDiscoveryInput", () => {
           sessionId: "session-1",
           agentId: "main",
           requesterSenderId: "user-123",
    +      senderIsOwner: false,
         });
       });
     
    @@ -41,6 +43,7 @@ describe("buildEmbeddedMessageActionDiscoveryInput", () => {
             sessionId: null,
             agentId: null,
             senderId: null,
    +        senderIsOwner: false,
           }),
         ).toEqual({
           cfg: undefined,
    @@ -53,6 +56,28 @@ describe("buildEmbeddedMessageActionDiscoveryInput", () => {
           sessionId: undefined,
           agentId: undefined,
           requesterSenderId: undefined,
    +      senderIsOwner: false,
    +    });
    +  });
    +
    +  it("preserves owner authorization for downstream channel action gating", () => {
    +    expect(
    +      buildEmbeddedMessageActionDiscoveryInput({
    +        channel: "matrix",
    +        senderIsOwner: true,
    +      }),
    +    ).toEqual({
    +      cfg: undefined,
    +      channel: "matrix",
    +      currentChannelId: undefined,
    +      currentThreadTs: undefined,
    +      currentMessageId: undefined,
    +      accountId: undefined,
    +      sessionKey: undefined,
    +      sessionId: undefined,
    +      agentId: undefined,
    +      requesterSenderId: undefined,
    +      senderIsOwner: true,
         });
       });
     });
    
  • src/agents/pi-embedded-runner/message-action-discovery-input.ts+2 0 modified
    @@ -11,6 +11,7 @@ export function buildEmbeddedMessageActionDiscoveryInput(params: {
       sessionId?: string | null;
       agentId?: string | null;
       senderId?: string | null;
    +  senderIsOwner?: boolean;
     }) {
       return {
         cfg: params.cfg,
    @@ -23,5 +24,6 @@ export function buildEmbeddedMessageActionDiscoveryInput(params: {
         sessionId: params.sessionId ?? undefined,
         agentId: params.agentId ?? undefined,
         requesterSenderId: params.senderId ?? undefined,
    +    senderIsOwner: params.senderIsOwner,
       };
     }
    
  • src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-injection.test.ts+40 1 modified
    @@ -1,5 +1,5 @@
     import type { AgentMessage } from "@mariozechner/pi-agent-core";
    -import { describe, expect, it, vi } from "vitest";
    +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
     import { filterHeartbeatPairs } from "../../../auto-reply/heartbeat-filter.js";
     import { HEARTBEAT_PROMPT } from "../../../auto-reply/heartbeat.js";
     import { limitHistoryTurns } from "../history.js";
    @@ -8,6 +8,15 @@ import {
       type AttemptContextEngine,
       resolveAttemptBootstrapContext,
     } from "./attempt.context-engine-helpers.js";
    +import {
    +  cleanupTempPaths,
    +  createContextEngineAttemptRunner,
    +  getHoisted,
    +  resetEmbeddedAttemptHarness,
    +} from "./attempt.spawn-workspace.test-support.js";
    +
    +const hoisted = getHoisted();
    +const tempPaths: string[] = [];
     
     async function resolveBootstrapContext(params: {
       contextInjectionMode?: "always" | "continuation-skip";
    @@ -37,6 +46,14 @@ async function resolveBootstrapContext(params: {
     }
     
     describe("embedded attempt context injection", () => {
    +  beforeEach(() => {
    +    resetEmbeddedAttemptHarness();
    +  });
    +
    +  afterEach(async () => {
    +    await cleanupTempPaths(tempPaths);
    +  });
    +
       it("skips bootstrap reinjection on safe continuation turns when configured", async () => {
         const { result, hasCompletedBootstrapTurn, resolveBootstrapContextForRun } =
           await resolveBootstrapContext({
    @@ -69,6 +86,28 @@ describe("embedded attempt context injection", () => {
         expect(resolver).toHaveBeenCalledTimes(1);
       });
     
    +  it("forwards senderIsOwner into embedded message-action discovery", async () => {
    +    await createContextEngineAttemptRunner({
    +      contextEngine: {
    +        assemble: async ({ messages }) => ({ messages, estimatedTokens: 1 }),
    +      },
    +      attemptOverrides: {
    +        messageChannel: "matrix",
    +        messageProvider: "matrix",
    +        senderIsOwner: false,
    +      },
    +      sessionKey: "agent:main",
    +      tempPaths,
    +    });
    +
    +    expect(hoisted.buildEmbeddedMessageActionDiscoveryInputMock).toHaveBeenCalledWith(
    +      expect.objectContaining({
    +        channel: "matrix",
    +        senderIsOwner: false,
    +      }),
    +    );
    +  });
    +
       it("never skips heartbeat bootstrap filtering", async () => {
         const { result, hasCompletedBootstrapTurn, resolveBootstrapContextForRun } =
           await resolveBootstrapContext({
    
  • src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts+8 1 modified
    @@ -47,6 +47,7 @@ type AttemptSpawnWorkspaceHoisted = {
       createAgentSessionMock: UnknownMock;
       sessionManagerOpenMock: UnknownMock;
       resolveSandboxContextMock: UnknownMock;
    +  buildEmbeddedMessageActionDiscoveryInputMock: UnknownMock;
       subscribeEmbeddedPiSessionMock: Mock<SubscribeEmbeddedPiSessionFn>;
       acquireSessionWriteLockMock: Mock<AcquireSessionWriteLockFn>;
       installToolResultContextGuardMock: UnknownMock;
    @@ -70,6 +71,7 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => {
       const createAgentSessionMock = vi.fn();
       const sessionManagerOpenMock = vi.fn();
       const resolveSandboxContextMock = vi.fn();
    +  const buildEmbeddedMessageActionDiscoveryInputMock = vi.fn((params: unknown) => params);
       const installToolResultContextGuardMock = vi.fn(() => () => {});
       const flushPendingToolResultsAfterIdleMock = vi.fn(async () => {});
       const releaseWsSessionMock = vi.fn(() => {});
    @@ -128,6 +130,7 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => {
         createAgentSessionMock,
         sessionManagerOpenMock,
         resolveSandboxContextMock,
    +    buildEmbeddedMessageActionDiscoveryInputMock,
         subscribeEmbeddedPiSessionMock,
         acquireSessionWriteLockMock,
         installToolResultContextGuardMock,
    @@ -527,7 +530,8 @@ vi.mock("../logger.js", () => ({
     }));
     
     vi.mock("../message-action-discovery-input.js", () => ({
    -  buildEmbeddedMessageActionDiscoveryInput: () => undefined,
    +  buildEmbeddedMessageActionDiscoveryInput: (...args: unknown[]) =>
    +    hoisted.buildEmbeddedMessageActionDiscoveryInputMock(...args),
     }));
     
     vi.mock("../model.js", () => ({
    @@ -669,6 +673,9 @@ export function resetEmbeddedAttemptHarness(
       hoisted.createAgentSessionMock.mockReset();
       hoisted.sessionManagerOpenMock.mockReset().mockReturnValue(hoisted.sessionManager);
       hoisted.resolveSandboxContextMock.mockReset();
    +  hoisted.buildEmbeddedMessageActionDiscoveryInputMock
    +    .mockReset()
    +    .mockImplementation((params) => params);
       hoisted.subscribeEmbeddedPiSessionMock
         .mockReset()
         .mockImplementation(() => createSubscriptionMock());
    
  • src/agents/pi-embedded-runner/run/attempt.ts+1 0 modified
    @@ -655,6 +655,7 @@ export async function runEmbeddedAttempt(
                 sessionId: params.sessionId,
                 agentId: sessionAgentId,
                 senderId: params.senderId,
    +            senderIsOwner: params.senderIsOwner,
               }),
             )
           : undefined;
    
  • src/agents/tools/message-tool.test.ts+69 0 modified
    @@ -260,6 +260,7 @@ async function executeSend(params: {
             params?: Record<string, unknown>;
             sandboxRoot?: string;
             requesterSenderId?: string;
    +        senderIsOwner?: boolean;
           }
         | undefined;
     }
    @@ -800,6 +801,52 @@ describe("message tool schema scoping", () => {
           }),
         );
       });
    +
    +  it("forwards senderIsOwner into plugin action discovery", () => {
    +    const seenContexts: Record<string, unknown>[] = [];
    +    const ownerAwarePlugin = createChannelPlugin({
    +      id: "matrix",
    +      label: "Matrix",
    +      docsPath: "/channels/matrix",
    +      blurb: "Matrix owner-aware plugin.",
    +      describeMessageTool: (ctx) => {
    +        seenContexts.push(ctx);
    +        return {
    +          actions: ctx.senderIsOwner === false ? ["send"] : ["send", "set-profile"],
    +        };
    +      },
    +    });
    +
    +    setActivePluginRegistry(
    +      createTestRegistry([{ pluginId: "matrix", source: "test", plugin: ownerAwarePlugin }]),
    +    );
    +
    +    const ownerTool = createMessageTool({
    +      config: {} as never,
    +      currentChannelProvider: "matrix",
    +      senderIsOwner: true,
    +    });
    +    const nonOwnerTool = createMessageTool({
    +      config: {} as never,
    +      currentChannelProvider: "matrix",
    +      senderIsOwner: false,
    +    });
    +
    +    expect(getActionEnum(getToolProperties(ownerTool))).toContain("set-profile");
    +    expect(getActionEnum(getToolProperties(nonOwnerTool))).not.toContain("set-profile");
    +    expect(seenContexts).toContainEqual(expect.objectContaining({ senderIsOwner: true }));
    +    expect(seenContexts).toContainEqual(expect.objectContaining({ senderIsOwner: false }));
    +  });
    +
    +  it("keeps core send and broadcast actions in unscoped schemas", () => {
    +    const tool = createMessageTool({
    +      config: {} as never,
    +    });
    +
    +    expect(getActionEnum(getToolProperties(tool))).toEqual(
    +      expect.arrayContaining(["send", "broadcast"]),
    +    );
    +  });
     });
     
     describe("message tool description", () => {
    @@ -1003,6 +1050,14 @@ describe("message tool description", () => {
         expect(tool.description).toContain("Supports actions:");
         expect(tool.description).toContain('Use action="read" with threadId');
       });
    +
    +  it("includes broadcast in the generic fallback description", () => {
    +    const tool = createMessageTool({
    +      config: {} as never,
    +    });
    +
    +    expect(tool.description).toContain("Supports actions: send, broadcast.");
    +  });
     });
     
     describe("message tool reasoning tag sanitization", () => {
    @@ -1082,4 +1137,18 @@ describe("message tool sandbox passthrough", () => {
     
         expect(call?.requesterSenderId).toBe("1234567890");
       });
    +
    +  it("forwards senderIsOwner to runMessageAction", async () => {
    +    mockSendResult({ to: "discord:123" });
    +
    +    const call = await executeSend({
    +      toolOptions: { senderIsOwner: false },
    +      action: {
    +        target: "discord:123",
    +        message: "hi",
    +      },
    +    });
    +
    +    expect(call?.senderIsOwner).toBe(false);
    +  });
     });
    
  • src/agents/tools/message-tool.ts+77 117 modified
    @@ -3,7 +3,7 @@ import { listChannelPlugins } from "../../channels/plugins/index.js";
     import {
       channelSupportsMessageCapability,
       channelSupportsMessageCapabilityForChannel,
    -  listChannelMessageActions,
    +  type ChannelMessageActionDiscoveryInput,
       resolveChannelMessageToolSchemaProperties,
     } from "../../channels/plugins/message-action-discovery.js";
     import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js";
    @@ -24,7 +24,7 @@ import { normalizeOptionalString } from "../../shared/string-coerce.js";
     import { stripReasoningTagsFromText } from "../../shared/text/reasoning-tags.js";
     import { normalizeMessageChannel } from "../../utils/message-channel.js";
     import { resolveSessionAgentId } from "../agent-scope.js";
    -import { listChannelSupportedActions } from "../channel-tools.js";
    +import { listAllChannelSupportedActions, listChannelSupportedActions } from "../channel-tools.js";
     import { channelTargetSchema, channelTargetsSchema, stringEnum } from "../schema/typebox.js";
     import type { AnyAgentTool } from "./common.js";
     import { jsonResult, readNumberParam, readStringParam } from "./common.js";
    @@ -411,9 +411,10 @@ type MessageToolOptions = {
       sandboxRoot?: string;
       requireExplicitTarget?: boolean;
       requesterSenderId?: string;
    +  senderIsOwner?: boolean;
     };
     
    -function resolveMessageToolSchemaActions(params: {
    +type MessageToolDiscoveryParams = {
       cfg: OpenClawConfig;
       currentChannelProvider?: string;
       currentChannelId?: string;
    @@ -424,126 +425,84 @@ function resolveMessageToolSchemaActions(params: {
       sessionId?: string;
       agentId?: string;
       requesterSenderId?: string;
    -}): string[] {
    +  senderIsOwner?: boolean;
    +};
    +
    +function buildMessageActionDiscoveryInput(
    +  params: MessageToolDiscoveryParams,
    +  channel?: string,
    +): ChannelMessageActionDiscoveryInput {
    +  return {
    +    cfg: params.cfg,
    +    ...(channel ? { channel } : {}),
    +    currentChannelId: params.currentChannelId,
    +    currentThreadTs: params.currentThreadTs,
    +    currentMessageId: params.currentMessageId,
    +    accountId: params.currentAccountId,
    +    sessionKey: params.sessionKey,
    +    sessionId: params.sessionId,
    +    agentId: params.agentId,
    +    requesterSenderId: params.requesterSenderId,
    +    senderIsOwner: params.senderIsOwner,
    +  };
    +}
    +
    +function resolveMessageToolSchemaActions(params: MessageToolDiscoveryParams): string[] {
       const currentChannel = normalizeMessageChannel(params.currentChannelProvider);
       if (currentChannel) {
    -    const scopedActions = listChannelSupportedActions({
    -      cfg: params.cfg,
    -      channel: currentChannel,
    -      currentChannelId: params.currentChannelId,
    -      currentThreadTs: params.currentThreadTs,
    -      currentMessageId: params.currentMessageId,
    -      accountId: params.currentAccountId,
    -      sessionKey: params.sessionKey,
    -      sessionId: params.sessionId,
    -      agentId: params.agentId,
    -      requesterSenderId: params.requesterSenderId,
    -    });
    +    const scopedActions = listChannelSupportedActions(
    +      buildMessageActionDiscoveryInput(params, currentChannel),
    +    );
         const allActions = new Set<string>(["send", ...scopedActions]);
         // Include actions from other configured channels so isolated/cron agents
         // can invoke cross-channel actions without validation errors.
         for (const plugin of listChannelPlugins()) {
           if (plugin.id === currentChannel) {
             continue;
           }
    -      for (const action of listChannelSupportedActions({
    -        cfg: params.cfg,
    -        channel: plugin.id,
    -        currentChannelId: params.currentChannelId,
    -        currentThreadTs: params.currentThreadTs,
    -        currentMessageId: params.currentMessageId,
    -        accountId: params.currentAccountId,
    -        sessionKey: params.sessionKey,
    -        sessionId: params.sessionId,
    -        agentId: params.agentId,
    -        requesterSenderId: params.requesterSenderId,
    -      })) {
    +      for (const action of listChannelSupportedActions(
    +        buildMessageActionDiscoveryInput(params, plugin.id),
    +      )) {
             allActions.add(action);
           }
         }
         return Array.from(allActions);
       }
    -  const actions = listChannelMessageActions(params.cfg);
    -  return actions.length > 0 ? actions : ["send"];
    +  return listAllMessageToolActions(params);
    +}
    +
    +function listAllMessageToolActions(params: MessageToolDiscoveryParams): ChannelMessageActionName[] {
    +  const pluginActions = listAllChannelSupportedActions(buildMessageActionDiscoveryInput(params));
    +  return Array.from(new Set<ChannelMessageActionName>(["send", "broadcast", ...pluginActions]));
     }
     
     function resolveIncludeCapability(
    -  params: {
    -    cfg: OpenClawConfig;
    -    currentChannelProvider?: string;
    -    currentChannelId?: string;
    -    currentThreadTs?: string;
    -    currentMessageId?: string | number;
    -    currentAccountId?: string;
    -    sessionKey?: string;
    -    sessionId?: string;
    -    agentId?: string;
    -    requesterSenderId?: string;
    -  },
    +  params: MessageToolDiscoveryParams,
       capability: ChannelMessageCapability,
     ): boolean {
       const currentChannel = normalizeMessageChannel(params.currentChannelProvider);
       if (currentChannel) {
         return channelSupportsMessageCapabilityForChannel(
    -      {
    -        cfg: params.cfg,
    -        channel: currentChannel,
    -        currentChannelId: params.currentChannelId,
    -        currentThreadTs: params.currentThreadTs,
    -        currentMessageId: params.currentMessageId,
    -        accountId: params.currentAccountId,
    -        sessionKey: params.sessionKey,
    -        sessionId: params.sessionId,
    -        agentId: params.agentId,
    -        requesterSenderId: params.requesterSenderId,
    -      },
    +      buildMessageActionDiscoveryInput(params, currentChannel),
           capability,
         );
       }
       return channelSupportsMessageCapability(params.cfg, capability);
     }
     
    -function resolveIncludeInteractive(params: {
    -  cfg: OpenClawConfig;
    -  currentChannelProvider?: string;
    -  currentChannelId?: string;
    -  currentThreadTs?: string;
    -  currentMessageId?: string | number;
    -  currentAccountId?: string;
    -  sessionKey?: string;
    -  sessionId?: string;
    -  agentId?: string;
    -  requesterSenderId?: string;
    -}): boolean {
    +function resolveIncludeInteractive(params: MessageToolDiscoveryParams): boolean {
       return resolveIncludeCapability(params, "interactive");
     }
     
    -function buildMessageToolSchema(params: {
    -  cfg: OpenClawConfig;
    -  currentChannelProvider?: string;
    -  currentChannelId?: string;
    -  currentThreadTs?: string;
    -  currentMessageId?: string | number;
    -  currentAccountId?: string;
    -  sessionKey?: string;
    -  sessionId?: string;
    -  agentId?: string;
    -  requesterSenderId?: string;
    -}) {
    +function buildMessageToolSchema(params: MessageToolDiscoveryParams) {
       const actions = resolveMessageToolSchemaActions(params);
       const includeInteractive = resolveIncludeInteractive(params);
    -  const extraProperties = resolveChannelMessageToolSchemaProperties({
    -    cfg: params.cfg,
    -    channel: normalizeMessageChannel(params.currentChannelProvider),
    -    currentChannelId: params.currentChannelId,
    -    currentThreadTs: params.currentThreadTs,
    -    currentMessageId: params.currentMessageId,
    -    accountId: params.currentAccountId,
    -    sessionKey: params.sessionKey,
    -    sessionId: params.sessionId,
    -    agentId: params.agentId,
    -    requesterSenderId: params.requesterSenderId,
    -  });
    +  const extraProperties = resolveChannelMessageToolSchemaProperties(
    +    buildMessageActionDiscoveryInput(
    +      params,
    +      normalizeMessageChannel(params.currentChannelProvider),
    +    ),
    +  );
       return buildMessageToolSchemaFromActions(actions.length > 0 ? actions : ["send"], {
         includeInteractive,
         extraProperties,
    @@ -569,25 +528,32 @@ function buildMessageToolDescription(options?: {
       sessionId?: string;
       agentId?: string;
       requesterSenderId?: string;
    +  senderIsOwner?: boolean;
     }): string {
       const baseDescription = "Send, delete, and manage messages via channel plugins.";
       const resolvedOptions = options ?? {};
       const currentChannel = normalizeMessageChannel(resolvedOptions.currentChannel);
    +  const messageToolDiscoveryParams = resolvedOptions.config
    +    ? {
    +        cfg: resolvedOptions.config,
    +        currentChannelProvider: resolvedOptions.currentChannel,
    +        currentChannelId: resolvedOptions.currentChannelId,
    +        currentThreadTs: resolvedOptions.currentThreadTs,
    +        currentMessageId: resolvedOptions.currentMessageId,
    +        currentAccountId: resolvedOptions.currentAccountId,
    +        sessionKey: resolvedOptions.sessionKey,
    +        sessionId: resolvedOptions.sessionId,
    +        agentId: resolvedOptions.agentId,
    +        requesterSenderId: resolvedOptions.requesterSenderId,
    +        senderIsOwner: resolvedOptions.senderIsOwner,
    +      }
    +    : undefined;
     
       // If we have a current channel, show its actions and list other configured channels
    -  if (currentChannel) {
    -    const channelActions = listChannelSupportedActions({
    -      cfg: resolvedOptions.config,
    -      channel: currentChannel,
    -      currentChannelId: resolvedOptions.currentChannelId,
    -      currentThreadTs: resolvedOptions.currentThreadTs,
    -      currentMessageId: resolvedOptions.currentMessageId,
    -      accountId: resolvedOptions.currentAccountId,
    -      sessionKey: resolvedOptions.sessionKey,
    -      sessionId: resolvedOptions.sessionId,
    -      agentId: resolvedOptions.agentId,
    -      requesterSenderId: resolvedOptions.requesterSenderId,
    -    });
    +  if (currentChannel && messageToolDiscoveryParams) {
    +    const channelActions = listChannelSupportedActions(
    +      buildMessageActionDiscoveryInput(messageToolDiscoveryParams, currentChannel),
    +    );
         if (channelActions.length > 0) {
           // Always include "send" as a base action
           const allActions = new Set<ChannelMessageActionName | "send">(["send", ...channelActions]);
    @@ -600,18 +566,9 @@ function buildMessageToolDescription(options?: {
             if (plugin.id === currentChannel) {
               continue;
             }
    -        const actions = listChannelSupportedActions({
    -          cfg: resolvedOptions.config,
    -          channel: plugin.id,
    -          currentChannelId: resolvedOptions.currentChannelId,
    -          currentThreadTs: resolvedOptions.currentThreadTs,
    -          currentMessageId: resolvedOptions.currentMessageId,
    -          accountId: resolvedOptions.currentAccountId,
    -          sessionKey: resolvedOptions.sessionKey,
    -          sessionId: resolvedOptions.sessionId,
    -          agentId: resolvedOptions.agentId,
    -          requesterSenderId: resolvedOptions.requesterSenderId,
    -        });
    +        const actions = listChannelSupportedActions(
    +          buildMessageActionDiscoveryInput(messageToolDiscoveryParams, plugin.id),
    +        );
             if (actions.length > 0) {
               const all = new Set<ChannelMessageActionName | "send">(["send", ...actions]);
               otherChannels.push(`${plugin.id} (${Array.from(all).toSorted().join(", ")})`);
    @@ -629,8 +586,8 @@ function buildMessageToolDescription(options?: {
       }
     
       // Fallback to generic description with all configured actions
    -  if (resolvedOptions.config) {
    -    const actions = listChannelMessageActions(resolvedOptions.config);
    +  if (messageToolDiscoveryParams) {
    +    const actions = listAllMessageToolActions(messageToolDiscoveryParams);
         if (actions.length > 0) {
           return appendMessageToolReadHint(
             `${baseDescription} Supports actions: ${actions.join(", ")}.`,
    @@ -678,6 +635,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
             sessionId: options.sessionId,
             agentId: resolvedAgentId,
             requesterSenderId: options.requesterSenderId,
    +        senderIsOwner: options.senderIsOwner,
           })
         : MessageToolSchema;
       const description = buildMessageToolDescription({
    @@ -691,6 +649,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
         sessionId: options?.sessionId,
         agentId: resolvedAgentId,
         requesterSenderId: options?.requesterSenderId,
    +    senderIsOwner: options?.senderIsOwner,
       });
     
       return {
    @@ -810,6 +769,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
             params,
             defaultAccountId: accountId ?? undefined,
             requesterSenderId: options?.requesterSenderId,
    +        senderIsOwner: options?.senderIsOwner,
             gateway,
             toolContext,
             sessionKey: options?.agentSessionKey,
    
  • src/auto-reply/reply/agent-runner-execution.ts+1 0 modified
    @@ -848,6 +848,7 @@ export async function runAgentTurnWithFallback(params: {
                       skillsSnapshot: params.followupRun.run.skillsSnapshot,
                       messageProvider: params.followupRun.run.messageProvider,
                       agentAccountId: params.followupRun.run.agentAccountId,
    +                  senderIsOwner: params.followupRun.run.senderIsOwner,
                       abortSignal: params.replyOperation?.abortSignal ?? params.opts?.abortSignal,
                       replyOperation: params.replyOperation,
                     });
    
  • src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts+58 0 modified
    @@ -436,4 +436,62 @@ describe("handleInlineActions", () => {
         );
         expect(toolExecute).toHaveBeenCalled();
       });
    +
    +  it("passes senderIsOwner into inline tool runtimes before owner-only filtering", async () => {
    +    const typing = createTypingController();
    +    const toolExecute = vi.fn(async () => ({ text: "updated" }));
    +    createOpenClawToolsMock.mockReturnValue([
    +      {
    +        name: "message",
    +        execute: toolExecute,
    +      },
    +    ]);
    +
    +    const ctx = buildTestCtx({
    +      Body: "/set_profile display name",
    +      CommandBody: "/set_profile display name",
    +    });
    +    const skillCommands: SkillCommandSpec[] = [
    +      {
    +        name: "set_profile",
    +        skillName: "matrix-profile",
    +        description: "Set Matrix profile",
    +        dispatch: {
    +          kind: "tool",
    +          toolName: "message",
    +          argMode: "raw",
    +        },
    +        sourceFilePath: "/tmp/plugin/commands/set-profile.md",
    +      },
    +    ];
    +
    +    const result = await handleInlineActions(
    +      createHandleInlineActionsInput({
    +        ctx,
    +        typing,
    +        cleanedBody: "/set_profile display name",
    +        command: {
    +          isAuthorizedSender: true,
    +          senderId: "sender-1",
    +          senderIsOwner: true,
    +          abortKey: "sender-1",
    +          rawBodyNormalized: "/set_profile display name",
    +          commandBodyNormalized: "/set_profile display name",
    +        },
    +        overrides: {
    +          cfg: { commands: { text: true } },
    +          allowTextCommands: true,
    +          skillCommands,
    +        },
    +      }),
    +    );
    +
    +    expect(result).toEqual({ kind: "reply", reply: { text: "✅ Done." } });
    +    expect(createOpenClawToolsMock).toHaveBeenCalledWith(
    +      expect.objectContaining({
    +        senderIsOwner: true,
    +      }),
    +    );
    +    expect(toolExecute).toHaveBeenCalled();
    +  });
     });
    
  • src/auto-reply/reply/get-reply-inline-actions.ts+1 0 modified
    @@ -239,6 +239,7 @@ export async function handleInlineActions(params: {
             workspaceDir,
             config: cfg,
             allowGatewaySubagentBinding: true,
    +        senderIsOwner: command.senderIsOwner,
           });
           const authorizedTools = applyOwnerOnlyToolPolicy(tools, command.senderIsOwner);
     
    
  • src/channels/plugins/message-action-discovery.ts+4 0 modified
    @@ -25,6 +25,7 @@ export type ChannelMessageActionDiscoveryInput = {
       sessionId?: string | null;
       agentId?: string | null;
       requesterSenderId?: string | null;
    +  senderIsOwner?: boolean;
     };
     
     type ChannelActions = NonNullable<NonNullable<ReturnType<typeof getChannelPlugin>>["actions"]>;
    @@ -52,6 +53,7 @@ export function createMessageActionDiscoveryContext(
         sessionId: params.sessionId,
         agentId: params.agentId,
         requesterSenderId: params.requesterSenderId,
    +    senderIsOwner: params.senderIsOwner,
       };
     }
     
    @@ -184,6 +186,7 @@ export function listChannelMessageCapabilitiesForChannel(params: {
       sessionId?: string | null;
       agentId?: string | null;
       requesterSenderId?: string | null;
    +  senderIsOwner?: boolean;
     }): ChannelMessageCapability[] {
       const channelId = resolveMessageActionDiscoveryChannelId(params.channel);
       if (!channelId) {
    @@ -227,6 +230,7 @@ export function resolveChannelMessageToolSchemaProperties(params: {
       sessionId?: string | null;
       agentId?: string | null;
       requesterSenderId?: string | null;
    +  senderIsOwner?: boolean;
     }): Record<string, TSchema> {
       const properties: Record<string, TSchema> = {};
       const currentChannel = resolveMessageActionDiscoveryChannelId(params.channel);
    
  • src/channels/plugins/types.core.ts+2 0 modified
    @@ -46,6 +46,7 @@ export type ChannelMessageActionDiscoveryContext = {
       sessionId?: string | null;
       agentId?: string | null;
       requesterSenderId?: string | null;
    +  senderIsOwner?: boolean;
     };
     
     /**
    @@ -600,6 +601,7 @@ export type ChannelMessageActionContext = {
        * never be sourced from tool/model-controlled params.
        */
       requesterSenderId?: string | null;
    +  senderIsOwner?: boolean;
       sessionKey?: string | null;
       sessionId?: string | null;
       agentId?: string | null;
    
  • src/commands/message.default-agent.test.ts+37 0 modified
    @@ -140,4 +140,41 @@ describe("messageCommand agent routing", () => {
           }),
         );
       });
    +
    +  it.each([
    +    {
    +      name: "defaults senderIsOwner to true for local message runs",
    +      opts: {},
    +      expected: true,
    +    },
    +    {
    +      name: "honors explicit senderIsOwner override",
    +      opts: { senderIsOwner: false },
    +      expected: false,
    +    },
    +  ])("$name", async ({ opts, expected }) => {
    +    const runtime: RuntimeEnv = {
    +      log: vi.fn(),
    +      error: vi.fn(),
    +      exit: vi.fn(),
    +    };
    +    await messageCommand(
    +      {
    +        action: "send",
    +        channel: "telegram",
    +        target: "123456",
    +        message: "hi",
    +        json: true,
    +        ...opts,
    +      },
    +      {} as CliDeps,
    +      runtime,
    +    );
    +
    +    expect(runMessageAction).toHaveBeenCalledWith(
    +      expect.objectContaining({
    +        senderIsOwner: expected,
    +      }),
    +    );
    +  });
     });
    
  • src/commands/message.ts+2 0 modified
    @@ -56,6 +56,7 @@ export async function messageCommand(
       const action = actionMatch as ChannelMessageActionName;
     
       const outboundDeps: OutboundSendDeps = createOutboundSendDeps(deps);
    +  const senderIsOwner = typeof opts.senderIsOwner === "boolean" ? opts.senderIsOwner : true;
     
       const run = async () =>
         await runMessageAction({
    @@ -64,6 +65,7 @@ export async function messageCommand(
           params: opts,
           deps: outboundDeps,
           agentId: resolveDefaultAgentId(cfg),
    +      senderIsOwner,
           gateway: {
             clientName: GATEWAY_CLIENT_NAMES.CLI,
             mode: GATEWAY_CLIENT_MODES.CLI,
    
  • src/cron/isolated-agent/run-executor.ts+1 0 modified
    @@ -119,6 +119,7 @@ export function createCronPromptExecutor(params: {
                 skillsSnapshot: params.skillsSnapshot,
                 bootstrapPromptWarningSignaturesSeen,
                 bootstrapPromptWarningSignature,
    +            senderIsOwner: true,
               });
               bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen(
                 result.meta?.systemPromptReport,
    
  • src/gateway/mcp-http.loopback-runtime.ts+1 0 modified
    @@ -31,6 +31,7 @@ export function createMcpLoopbackServerConfig(port: number) {
               "x-openclaw-agent-id": "${OPENCLAW_MCP_AGENT_ID}",
               "x-openclaw-account-id": "${OPENCLAW_MCP_ACCOUNT_ID}",
               "x-openclaw-message-channel": "${OPENCLAW_MCP_MESSAGE_CHANNEL}",
    +          "x-openclaw-sender-is-owner": "${OPENCLAW_MCP_SENDER_IS_OWNER}",
             },
           },
         },
    
  • src/gateway/mcp-http.request.ts+10 1 modified
    @@ -1,7 +1,10 @@
     import type { IncomingMessage, ServerResponse } from "node:http";
     import { loadConfig } from "../config/config.js";
     import { resolveMainSessionKey } from "../config/sessions.js";
    -import { normalizeOptionalString } from "../shared/string-coerce.js";
    +import {
    +  normalizeOptionalLowercaseString,
    +  normalizeOptionalString,
    +} from "../shared/string-coerce.js";
     import { normalizeMessageChannel } from "../utils/message-channel.js";
     import { getHeader } from "./http-utils.js";
     
    @@ -11,6 +14,7 @@ export type McpRequestContext = {
       sessionKey: string;
       messageProvider: string | undefined;
       accountId: string | undefined;
    +  senderIsOwner: boolean | undefined;
     };
     
     function resolveScopedSessionKey(
    @@ -92,10 +96,15 @@ export function resolveMcpRequestContext(
       req: IncomingMessage,
       cfg: ReturnType<typeof loadConfig>,
     ): McpRequestContext {
    +  const senderIsOwnerRaw = normalizeOptionalLowercaseString(
    +    getHeader(req, "x-openclaw-sender-is-owner"),
    +  );
       return {
         sessionKey: resolveScopedSessionKey(cfg, getHeader(req, "x-session-key")),
         messageProvider:
           normalizeMessageChannel(getHeader(req, "x-openclaw-message-channel")) ?? undefined,
         accountId: normalizeOptionalString(getHeader(req, "x-openclaw-account-id")),
    +    senderIsOwner:
    +      senderIsOwnerRaw === "true" ? true : senderIsOwnerRaw === "false" ? false : undefined,
       };
     }
    
  • src/gateway/mcp-http.runtime.ts+8 3 modified
    @@ -30,10 +30,14 @@ export class McpLoopbackToolCache {
         sessionKey: string;
         messageProvider: string | undefined;
         accountId: string | undefined;
    +    senderIsOwner: boolean | undefined;
       }): CachedScopedTools {
    -    const cacheKey = [params.sessionKey, params.messageProvider ?? "", params.accountId ?? ""].join(
    -      "\u0000",
    -    );
    +    const cacheKey = [
    +      params.sessionKey,
    +      params.messageProvider ?? "",
    +      params.accountId ?? "",
    +      params.senderIsOwner === true ? "owner" : params.senderIsOwner === false ? "non-owner" : "",
    +    ].join("\u0000");
         const now = Date.now();
         const cached = this.#entries.get(cacheKey);
         if (cached && cached.configRef === params.cfg && now - cached.time < TOOL_CACHE_TTL_MS) {
    @@ -45,6 +49,7 @@ export class McpLoopbackToolCache {
           sessionKey: params.sessionKey,
           messageProvider: params.messageProvider,
           accountId: params.accountId,
    +      senderIsOwner: params.senderIsOwner,
           surface: "loopback",
           excludeToolNames: NATIVE_TOOL_EXCLUDE,
         });
    
  • src/gateway/mcp-http.test.ts+45 0 modified
    @@ -103,6 +103,48 @@ describe("mcp loopback server", () => {
             sessionKey: "agent:main:telegram:group:chat123",
             accountId: "work",
             messageProvider: "telegram",
    +        senderIsOwner: undefined,
    +        surface: "loopback",
    +      }),
    +    );
    +  });
    +
    +  it("threads senderIsOwner through loopback request context and cache separation", async () => {
    +    server = await startMcpLoopbackServer(0);
    +    const runtime = getActiveMcpLoopbackRuntime();
    +
    +    const sendToolsList = async (senderIsOwner: "true" | "false") =>
    +      await sendRaw({
    +        port: server.port,
    +        token: runtime?.token,
    +        headers: {
    +          "content-type": "application/json",
    +          "x-session-key": "agent:main:matrix:dm:test",
    +          "x-openclaw-message-channel": "matrix",
    +          "x-openclaw-sender-is-owner": senderIsOwner,
    +        },
    +        body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
    +      });
    +
    +    expect((await sendToolsList("true")).status).toBe(200);
    +    expect((await sendToolsList("false")).status).toBe(200);
    +
    +    expect(resolveGatewayScopedToolsMock).toHaveBeenCalledTimes(2);
    +    expect(resolveGatewayScopedToolsMock).toHaveBeenNthCalledWith(
    +      1,
    +      expect.objectContaining({
    +        sessionKey: "agent:main:matrix:dm:test",
    +        messageProvider: "matrix",
    +        senderIsOwner: true,
    +        surface: "loopback",
    +      }),
    +    );
    +    expect(resolveGatewayScopedToolsMock).toHaveBeenNthCalledWith(
    +      2,
    +      expect.objectContaining({
    +        sessionKey: "agent:main:matrix:dm:test",
    +        messageProvider: "matrix",
    +        senderIsOwner: false,
             surface: "loopback",
           }),
         );
    @@ -154,5 +196,8 @@ describe("createMcpLoopbackServerConfig", () => {
         expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-message-channel"]).toBe(
           "${OPENCLAW_MCP_MESSAGE_CHANNEL}",
         );
    +    expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-sender-is-owner"]).toBe(
    +      "${OPENCLAW_MCP_SENDER_IS_OWNER}",
    +    );
       });
     });
    
  • src/gateway/mcp-http.ts+1 0 modified
    @@ -46,6 +46,7 @@ export async function startMcpLoopbackServer(port = 0): Promise<{
               sessionKey: requestContext.sessionKey,
               messageProvider: requestContext.messageProvider,
               accountId: requestContext.accountId,
    +          senderIsOwner: requestContext.senderIsOwner,
             });
     
             const messages = Array.isArray(parsed) ? parsed : [parsed];
    
  • src/gateway/tool-resolution.ts+2 0 modified
    @@ -35,6 +35,7 @@ export function resolveGatewayScopedTools(params: {
       surface?: GatewayScopedToolSurface;
       excludeToolNames?: Iterable<string>;
       disablePluginTools?: boolean;
    +  senderIsOwner?: boolean;
     }) {
       const {
         agentId,
    @@ -77,6 +78,7 @@ export function resolveGatewayScopedTools(params: {
         allowGatewaySubagentBinding: params.allowGatewaySubagentBinding,
         allowMediaInvokeCommands: params.allowMediaInvokeCommands,
         disablePluginTools: params.disablePluginTools,
    +    senderIsOwner: params.senderIsOwner,
         config: params.cfg,
         workspaceDir,
         pluginToolAllowlist: collectExplicitAllowlist([
    
  • src/gateway/tools-invoke-http.test.ts+22 0 modified
    @@ -476,6 +476,28 @@ describe("POST /tools/invoke", () => {
         expect(body.result).toEqual({ ok: true, result: [] });
       });
     
    +  it("threads senderIsOwner into tool creation before owner-only filtering", async () => {
    +    setMainAllowedTools({ allow: ["session_status", "owner_only_test"] });
    +
    +    const writeRes = await invokeTool({
    +      port: sharedPort,
    +      headers: gatewayAuthHeaders(),
    +      tool: "session_status",
    +      sessionKey: "main",
    +    });
    +    expect(writeRes.status).toBe(200);
    +    expect(lastCreateOpenClawToolsContext?.senderIsOwner).toBe(false);
    +
    +    const adminRes = await invokeTool({
    +      port: sharedPort,
    +      headers: gatewayAdminHeaders(),
    +      tool: "session_status",
    +      sessionKey: "main",
    +    });
    +    expect(adminRes.status).toBe(200);
    +    expect(lastCreateOpenClawToolsContext?.senderIsOwner).toBe(true);
    +  });
    +
       it("uses before_tool_call adjusted params for HTTP tool execution", async () => {
         setMainAllowedTools({ allow: ["tools_invoke_test"] });
         hookMocks.runBeforeToolCallHook.mockImplementationOnce(async () => ({
    
  • src/gateway/tools-invoke-http.ts+7 3 modified
    @@ -231,6 +231,12 @@ export async function handleToolsInvokeHttpRequest(
       const accountId = normalizeOptionalString(getHeader(req, "x-openclaw-account-id"));
       const agentTo = normalizeOptionalString(getHeader(req, "x-openclaw-message-to"));
       const agentThreadId = normalizeOptionalString(getHeader(req, "x-openclaw-thread-id"));
    +  // Owner semantics intentionally follow the same shared-secret HTTP contract
    +  // on this direct tool surface; SECURITY.md documents this as designed-as-is.
    +  // Computed before resolveGatewayScopedTools so the message tool is created
    +  // with the correct owner context and channel-action gates (e.g. Matrix set-profile)
    +  // work correctly for both owner and non-owner callers.
    +  const senderIsOwner = resolveOpenAiCompatibleHttpSenderIsOwner(req, requestAuth);
       const { agentId, tools } = resolveGatewayScopedTools({
         cfg,
         sessionKey,
    @@ -242,10 +248,8 @@ export async function handleToolsInvokeHttpRequest(
         allowMediaInvokeCommands: true,
         surface: "http",
         disablePluginTools: isKnownCoreToolId(toolName),
    +    senderIsOwner,
       });
    -  // Owner semantics intentionally follow the same shared-secret HTTP contract
    -  // on this direct tool surface; SECURITY.md documents this as designed-as-is.
    -  const senderIsOwner = resolveOpenAiCompatibleHttpSenderIsOwner(req, requestAuth);
       const gatewayFiltered = applyOwnerOnlyToolPolicy(tools, senderIsOwner);
     
       const tool = gatewayFiltered.find((t) => t.name === toolName);
    
  • src/infra/outbound/message-action-runner.ts+2 0 modified
    @@ -79,6 +79,7 @@ export type RunMessageActionParams = {
       params: Record<string, unknown>;
       defaultAccountId?: string;
       requesterSenderId?: string | null;
    +  senderIsOwner?: boolean;
       sessionId?: string;
       toolContext?: ChannelThreadingToolContext;
       gateway?: MessageActionRunnerGateway;
    @@ -702,6 +703,7 @@ async function handlePluginAction(ctx: ResolvedActionContext): Promise<MessageAc
         mediaReadFile: mediaAccess.readFile,
         accountId: accountId ?? undefined,
         requesterSenderId: input.requesterSenderId ?? undefined,
    +    senderIsOwner: input.senderIsOwner,
         sessionKey: input.sessionKey,
         sessionId: input.sessionId,
         agentId,
    

Vulnerability mechanics

AI mechanics synthesis has not run for this CVE yet.

References

6

News mentions

0

No linked articles in our index yet.