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.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.4.10 | 2026.4.10 |
Affected products
1Patches
1fe0f686c9228Gate Matrix profile updates for non-owner message tool runs (#62662)
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- github.com/advisories/GHSA-7jp6-r74r-995qghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-42433ghsaADVISORY
- github.com/openclaw/openclaw/commit/fe0f686c9228fffcec6de4011da45e69a6e23e54nvdWEB
- github.com/openclaw/openclaw/pull/62662ghsaWEB
- github.com/openclaw/openclaw/security/advisories/GHSA-7jp6-r74r-995qnvdWEB
- www.vulncheck.com/advisories/openclaw-unauthorized-matrix-profile-config-persistence-access-via-operator-write-message-toolsnvdWEB
News mentions
0No linked articles in our index yet.