Medium severity5.4NVD Advisory· Published Apr 23, 2026· Updated Apr 29, 2026
CVE-2026-41344
CVE-2026-41344
Description
OpenClaw before 2026.3.28 contains a privilege escalation vulnerability in the chat.send endpoint that allows write-scoped gateway callers to persist admin-only verboseLevel session overrides. Attackers can exploit the /verbose parameter to bypass access controls and expose sensitive reasoning or tool output intended to be restricted to administrators.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.3.28 | 2026.3.28 |
Affected products
1Patches
1c6031235288afix(gateway): require admin for persisted verbose defaults (#55916)
5 files changed · +162 −9
src/auto-reply/reply/directive-handling.impl.ts+29 −7 modified@@ -20,10 +20,13 @@ import type { HandleDirectiveOnlyParams } from "./directive-handling.params.js"; import { maybeHandleQueueDirective } from "./directive-handling.queue-validation.js"; import { canPersistInternalExecDirective, + canPersistInternalVerboseDirective, formatDirectiveAck, formatElevatedRuntimeHint, formatElevatedUnavailableText, formatInternalExecPersistenceDeniedText, + formatInternalVerboseCurrentReplyOnlyText, + formatInternalVerbosePersistenceDeniedText, enqueueModeSwitchEvents, withOptions, } from "./directive-handling.shared.js"; @@ -99,6 +102,10 @@ export async function handleDirectiveOnly( surface: params.surface, gatewayClientScopes: params.gatewayClientScopes, }); + const allowInternalVerbosePersistence = canPersistInternalVerboseDirective({ + surface: params.surface, + gatewayClientScopes: params.gatewayClientScopes, + }); const modelInfo = await maybeHandleModelDirectiveInfo({ directives, @@ -319,7 +326,9 @@ export async function handleDirectiveOnly( const shouldPersistSessionEntry = (directives.hasThinkDirective && Boolean(directives.thinkLevel)) || (directives.hasFastDirective && directives.fastMode !== undefined) || - (directives.hasVerboseDirective && Boolean(directives.verboseLevel)) || + (directives.hasVerboseDirective && + Boolean(directives.verboseLevel) && + allowInternalVerbosePersistence) || (directives.hasReasoningDirective && Boolean(directives.reasoningLevel)) || (directives.hasElevatedDirective && Boolean(directives.elevatedLevel)) || (directives.hasExecDirective && directives.hasExecOptions && allowInternalExecPersistence) || @@ -342,7 +351,11 @@ export async function handleDirectiveOnly( if (shouldDowngradeXHigh) { sessionEntry.thinkingLevel = "high"; } - if (directives.hasVerboseDirective && directives.verboseLevel) { + if ( + directives.hasVerboseDirective && + directives.verboseLevel && + allowInternalVerbosePersistence + ) { applyVerboseOverride(sessionEntry, directives.verboseLevel); } if (directives.hasReasoningDirective && directives.reasoningLevel) { @@ -457,13 +470,22 @@ export async function handleDirectiveOnly( } if (directives.hasVerboseDirective && directives.verboseLevel) { parts.push( - directives.verboseLevel === "off" - ? formatDirectiveAck("Verbose logging disabled.") - : directives.verboseLevel === "full" - ? formatDirectiveAck("Verbose logging set to full.") - : formatDirectiveAck("Verbose logging enabled."), + !allowInternalVerbosePersistence + ? formatDirectiveAck(formatInternalVerboseCurrentReplyOnlyText()) + : directives.verboseLevel === "off" + ? formatDirectiveAck("Verbose logging disabled.") + : directives.verboseLevel === "full" + ? formatDirectiveAck("Verbose logging set to full.") + : formatDirectiveAck("Verbose logging enabled."), ); } + if ( + directives.hasVerboseDirective && + directives.verboseLevel && + !allowInternalVerbosePersistence + ) { + parts.push(formatDirectiveAck(formatInternalVerbosePersistenceDeniedText())); + } if (directives.hasReasoningDirective && directives.reasoningLevel) { parts.push( directives.reasoningLevel === "off"
src/auto-reply/reply/directive-handling.model.test.ts+71 −0 modified@@ -583,6 +583,43 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => { expect(sessionEntry.execNode).toBeUndefined(); }); + it("blocks internal operator.write verbose persistence in directive-only handling", async () => { + const directives = parseInlineDirectives("/verbose full"); + const sessionEntry = createSessionEntry(); + const sessionStore = { [sessionKey]: sessionEntry }; + const result = await handleDirectiveOnly( + createHandleParams({ + directives, + sessionEntry, + sessionStore, + surface: "webchat", + gatewayClientScopes: ["operator.write"], + }), + ); + + expect(result?.text).toContain("Verbose logging set for the current reply only."); + expect(result?.text).toContain("operator.admin"); + expect(sessionEntry.verboseLevel).toBeUndefined(); + }); + + it("allows internal operator.admin verbose persistence in directive-only handling", async () => { + const directives = parseInlineDirectives("/verbose full"); + const sessionEntry = createSessionEntry(); + const sessionStore = { [sessionKey]: sessionEntry }; + const result = await handleDirectiveOnly( + createHandleParams({ + directives, + sessionEntry, + sessionStore, + surface: "webchat", + gatewayClientScopes: ["operator.admin"], + }), + ); + + expect(result?.text).toContain("Verbose logging set to full."); + expect(sessionEntry.verboseLevel).toBe("full"); + }); + it("allows internal operator.admin exec persistence in directive-only handling", async () => { const directives = parseInlineDirectives( "/exec host=node security=allowlist ask=always node=worker-1", @@ -646,4 +683,38 @@ describe("persistInlineDirectives internal exec scope gate", () => { expect(sessionEntry.execAsk).toBeUndefined(); expect(sessionEntry.execNode).toBeUndefined(); }); + + it("skips verbose persistence for internal operator.write callers", async () => { + const allowedModelKeys = new Set(["anthropic/claude-opus-4-5", "openai/gpt-4o"]); + const directives = parseInlineDirectives("/verbose full"); + const sessionEntry = { + sessionId: "s1", + updatedAt: Date.now(), + } as SessionEntry; + const sessionStore = { "agent:main:main": sessionEntry }; + + await persistInlineDirectives({ + directives, + cfg: baseConfig(), + sessionEntry, + sessionStore, + sessionKey: "agent:main:main", + storePath: "/tmp/sessions.json", + elevatedEnabled: true, + elevatedAllowed: true, + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-5", + aliasIndex: baseAliasIndex(), + allowedModelKeys, + provider: "anthropic", + model: "claude-opus-4-5", + initialModelLabel: "anthropic/claude-opus-4-5", + formatModelSwitchEvent: (label) => `Switched to ${label}`, + agentCfg: undefined, + surface: "webchat", + gatewayClientScopes: ["operator.write"], + }); + + expect(sessionEntry.verboseLevel).toBeUndefined(); + }); });
src/auto-reply/reply/directive-handling.persist.ts+10 −1 modified@@ -16,6 +16,7 @@ import { resolveModelSelectionFromDirective } from "./directive-handling.model-s import type { InlineDirectives } from "./directive-handling.parse.js"; import { canPersistInternalExecDirective, + canPersistInternalVerboseDirective, enqueueModeSwitchEvents, } from "./directive-handling.shared.js"; import type { ElevatedLevel, ReasoningLevel } from "./directives.js"; @@ -65,6 +66,10 @@ export async function persistInlineDirectives(params: { surface: params.surface, gatewayClientScopes: params.gatewayClientScopes, }); + const allowInternalVerbosePersistence = canPersistInternalVerboseDirective({ + surface: params.surface, + gatewayClientScopes: params.gatewayClientScopes, + }); const activeAgentId = sessionKey ? resolveSessionAgentId({ sessionKey, config: cfg }) : resolveDefaultAgentId(cfg); @@ -89,7 +94,11 @@ export async function persistInlineDirectives(params: { sessionEntry.thinkingLevel = directives.thinkLevel; updated = true; } - if (directives.hasVerboseDirective && directives.verboseLevel) { + if ( + directives.hasVerboseDirective && + directives.verboseLevel && + allowInternalVerbosePersistence + ) { applyVerboseOverride(sessionEntry, directives.verboseLevel); updated = true; }
src/auto-reply/reply/directive-handling.shared.ts+10 −1 modified@@ -17,7 +17,13 @@ export const formatElevatedRuntimeHint = () => export const formatInternalExecPersistenceDeniedText = () => "Exec defaults require operator.admin for internal gateway callers; skipped persistence."; -export function canPersistInternalExecDirective(params: { +export const formatInternalVerbosePersistenceDeniedText = () => + "Verbose defaults require operator.admin for internal gateway callers; skipped persistence."; + +export const formatInternalVerboseCurrentReplyOnlyText = () => + "Verbose logging set for the current reply only."; + +function canPersistInternalDirective(params: { surface?: string; gatewayClientScopes?: string[]; }): boolean { @@ -28,6 +34,9 @@ export function canPersistInternalExecDirective(params: { return scopes.includes("operator.admin"); } +export const canPersistInternalExecDirective = canPersistInternalDirective; +export const canPersistInternalVerboseDirective = canPersistInternalDirective; + export const formatElevatedEvent = (level: ElevatedLevel) => { if (level === "full") { return "Elevated FULL — exec runs on host with auto-approval.";
src/gateway/server.chat.gateway-server-chat.test.ts+42 −0 modified@@ -14,6 +14,7 @@ import { rpcReq, testState, trackConnectChallengeNonce, + withGatewayServer, writeSessionStore, } from "./test-helpers.js"; import { agentCommand } from "./test-helpers.mocks.js"; @@ -684,6 +685,47 @@ describe("gateway server chat", () => { ]); }); + test("chat.send does not persist verboseLevel for operator.write callers", async () => { + await withGatewayServer(async ({ port }) => { + await withMainSessionStore(async () => { + let scopedWs: WebSocket | undefined; + + try { + scopedWs = new WebSocket(`ws://127.0.0.1:${port}`); + trackConnectChallengeNonce(scopedWs); + await new Promise<void>((resolve) => scopedWs?.once("open", resolve)); + await connectOk(scopedWs, { + scopes: ["operator.write"], + }); + + const sendRes = await rpcReq(scopedWs, "chat.send", { + sessionKey: "main", + message: "/verbose full", + idempotencyKey: "idem-write-scope-verbose-no-persist", + }); + expect(sendRes.ok).toBe(true); + + const waitRes = await rpcReq(scopedWs, "agent.wait", { + runId: "idem-write-scope-verbose-no-persist", + timeoutMs: 1_000, + }); + expect(waitRes.ok).toBe(true); + expect(waitRes.payload?.status).toBe("ok"); + + const raw = await fs.readFile(testState.sessionStorePath!, "utf-8"); + const stored = JSON.parse(raw) as { + "agent:main:main"?: { + verboseLevel?: string; + }; + }; + expect(stored["agent:main:main"]?.verboseLevel).toBeUndefined(); + } finally { + scopedWs?.close(); + } + }); + }); + }); + test("agent.wait resolves chat.send runs that finish without lifecycle events", async () => { await withMainSessionStore(async () => { const runId = "idem-wait-chat-1";
Vulnerability mechanics
AI mechanics synthesis has not run for this CVE yet.
References
5- github.com/advisories/GHSA-5h2w-qmfp-ggp6ghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-5h2w-qmfp-ggp6nvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-41344ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-privilege-escalation-via-chat-send-verbose-parameternvdThird Party AdvisoryWEB
- github.com/openclaw/openclaw/commit/c6031235288a8d3bdf2243bd974340d8c8045bc2ghsaWEB
News mentions
0No linked articles in our index yet.