Advisory Details
Title: Non-owner command-authorized senders can access owner-only gateway and cron tools in openclaw-cn
Description:
Summary
openclaw-cn exposes owner-only embedded-agent control-plane tools to chat senders who are allowed to enter the command flow but are not configured as owners. The authorization layer correctly distinguishes isAuthorizedSender=true from senderIsOwner=false, but the live embedded-agent tool builder returns the final tool surface without applying the repository's existing owner-only filter. As a result, a lower-privileged chat actor can reach tools that open operator-scoped Gateway RPC sessions and perform administrative actions such as configuration reads and cron mutation.
Details
The bug is an improper privilege management flaw in the embedded agent tool exposure path.
resolveCommandAuthorization() in src/auto-reply/command-auth.ts already computes the lower-privilege sender state:
const senderIsOwner = Boolean(matchedSender);
const isOwnerForCommands =
!enforceOwner ||
allowAll ||
ownerCandidatesForCommands.length === 0 ||
Boolean(matchedCommandOwner);
const isAuthorizedSender = commandAuthorized && isOwnerForCommands;
This means a deployment can intentionally allow a sender into the command surface while still treating that sender as a non-owner.
The reply/embedded runner preserves that distinction and forwards senderIsOwner into the embedded run context:
senderId: sessionCtx.SenderId?.trim() || undefined,
senderName: sessionCtx.SenderName?.trim() || undefined,
senderUsername: sessionCtx.SenderUsername?.trim() || undefined,
senderE164: sessionCtx.SenderE164?.trim() || undefined,
senderIsOwner: command.senderIsOwner,
The embedded runner then passes that value into the tool-builder call:
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
senderIsOwner: params.senderIsOwner,
sessionKey: params.sessionKey ?? params.sessionId,
However, createOpenClawCodingTools() does not declare a senderIsOwner option, assembles the complete tool list including createOpenClawTools(), and returns it directly:
export function createOpenClawCodingTools(options?: {
exec?: ExecToolDefaults & ProcessToolDefaults;
messageProvider?: string;
agentAccountId?: string;
...
senderId?: string | null;
senderName?: string | null;
senderUsername?: string | null;
senderE164?: string | null;
...
}): AnyAgentTool[] {
const tools: AnyAgentTool[] = [
...base,
...(applyPatchTool ? [applyPatchTool as unknown as AnyAgentTool] : []),
execTool as unknown as AnyAgentTool,
processTool as unknown as AnyAgentTool,
...listChannelAgentTools({ cfg: options?.config }),
...createOpenClawTools({
agentSessionKey: options?.sessionKey,
agentChannel: resolveGatewayMessageChannel(options?.messageProvider),
...
}),
];
...
return withAbort;
The repository already contains the intended mitigation in src/agents/tool-policy.ts:
export function applyOwnerOnlyToolPolicy(tools: AnyAgentTool[], senderIsOwner: boolean) {
const withGuard = tools.map((tool) => {
if (!isOwnerOnlyTool(tool)) {
return tool;
}
return wrapOwnerOnlyToolExecution(tool, senderIsOwner);
});
if (senderIsOwner) {
return withGuard;
}
return withGuard.filter((tool) => !isOwnerOnlyTool(tool));
}
That filter is not applied in the vulnerable path, so the owner-only gateway and cron tools remain visible to a non-owner tool set. Once selected, those tools invoke callGatewayTool() and reach callGateway():
return await callGateway<T>({
url: gateway.url,
token: gateway.token,
method,
params,
timeoutMs: gateway.timeoutMs,
expectFinal: extra?.expectFinal,
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
clientDisplayName: "agent",
mode: GATEWAY_CLIENT_MODES.BACKEND,
});
role: "operator",
scopes: ["operator.admin", "operator.approvals", "operator.pairing"],
This was reproduced with the live authorization helper, the live createOpenClawCodingTools() implementation, the real gateway and cron tool execute() methods, a real loopback gateway, and independent observation through the real cron store. The reproduction is integration-level rather than full chat-to-model end-to-end, but it still demonstrates a real privilege-boundary violation in production code.
PoC
Prerequisites
- A checkout of the affected
openclaw-cn repository.
pnpm, tsx, and python3 available locally.
- A sender state where the chat actor is command-authorized but not an owner, for example:
commands.ownerAllowFrom = ["whatsapp:+15550000000"]
channels.whatsapp.allowFrom = ["*"]
- No external network service is required. The PoC starts a localhost gateway and observes the actual cron store and authorization behavior.
Reproduction Steps
- Download the TypeScript driver from: driver.ts
- Download the verification harness from: verification_test.py
- Download the control harness from: control-owner-only-policy.py
- From the repository root, run the experiment:
python3 llm-enhance/cve-finding/similar/improper-privilege-management/Advisory-GHSA-2hm8-rqrm-xfjq-non-owner-agent-tool-scope-bypass-exp/verification_test.py
- Confirm that the reported authorization state is
auth.isAuthorizedSender: True and auth.senderIsOwner: False, while the effective tool set still contains both gateway and cron.
- Confirm that the real gateway rejects a plain
operator.write client for cron.add, but the non-owner tool path still succeeds with:
gateway tool config.get succeeded: True
cron tool cron.add succeeded: True
canary cron job listed after non-owner tool execution: True
- Run the control:
python3 llm-enhance/cve-finding/similar/improper-privilege-management/Advisory-GHSA-2hm8-rqrm-xfjq-non-owner-agent-tool-scope-bypass-exp/control-owner-only-policy.py
- Confirm that explicitly applying
applyOwnerOnlyToolPolicy() removes the tools and prevents the cron write:
effective tool list includes gateway: False
effective tool list includes cron: False
canary cron job listed after control execution: False
Log of Evidence
Verification run:
[Verification Mode] Integration-Test
Reachability path: non-owner command-authorized chat sender -> resolveCommandAuthorization(senderIsOwner=false, isAuthorizedSender=true) -> embedded agent createOpenClawCodingTools(...) -> gateway/cron tools visible -> tool.execute() -> real localhost gateway -> admin-only methods
gateway url: ws://127.0.0.1:43101
loaded config port: 43101
auth.isAuthorizedSender: True
auth.senderIsOwner: False
control blocked by operator.admin requirement: True
control error: missing scope: operator.admin
raw tool list includes gateway: True
raw tool list includes cron: True
effective tool list includes gateway: True
effective tool list includes cron: True
gateway tool config.get succeeded: True
cron tool cron.add succeeded: True
admin cron.list succeeded: True
canary cron job listed after non-owner tool execution: True
canary job name: ghsa-2hm8-non-owner-tool-scope-1783280881363
[DEFECT CONFIRMED WITH LIMITATIONS]
Control run:
[Control Mode] Integration-Test
Control action: applyOwnerOnlyToolPolicy(rawTools, senderIsOwner=false) before exposing the embedded tool surface.
raw tool list includes gateway: True
raw tool list includes cron: True
effective tool list includes gateway: False
effective tool list includes cron: False
gateway execution blocked: True
cron execution blocked: True
canary cron job listed after control execution: False
[CONTROL PROTECTED]
Impact
This is a privilege-boundary bypass in the chat control plane. It impacts deployments that intentionally allow a broader set of chat senders to use commands or embedded-agent flows while reserving gateway, cron, and similar operational tools for owners. A non-owner but command-authorized sender can be offered those tools, and if the model or driver selects them, the tool opens an operator-scoped gateway session and performs administrative RPC calls. That exposes configuration contents and enables cron mutation, with obvious knock-on risk for later message delivery, scheduled agent execution, and control-plane state manipulation.
Affected products
- Ecosystem: npm
- Package name: openclaw-cn
- Affected versions: <= 0.2.1
- Patched versions:
Severity
- Severity: Medium
- Vector string: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:L
Weaknesses
- CWE: CWE-269: Improper Privilege Management
Occurrences
| Permalink |
Description |
|
const enforceOwner = Boolean(dock?.commands?.enforceOwnerForCommands); |
|
const senderIsOwner = Boolean(matchedSender); |
|
const isOwnerForCommands = |
|
!enforceOwner || |
|
allowAll || |
|
ownerCandidatesForCommands.length === 0 || |
|
Boolean(matchedCommandOwner); |
|
const isAuthorizedSender = commandAuthorized && isOwnerForCommands; |
|
resolveCommandAuthorization() explicitly distinguishes senderIsOwner from isAuthorizedSender, creating the lower-privileged but still accepted sender state required for exploitation. |
|
senderId: sessionCtx.SenderId?.trim() || undefined, |
|
senderName: sessionCtx.SenderName?.trim() || undefined, |
|
senderUsername: sessionCtx.SenderUsername?.trim() || undefined, |
|
senderE164: sessionCtx.SenderE164?.trim() || undefined, |
|
senderIsOwner: command.senderIsOwner, |
|
The reply runner preserves and forwards senderIsOwner into the embedded agent run context instead of collapsing it into generic command authorization. |
|
export function createOpenClawCodingTools(options?: { |
|
exec?: ExecToolDefaults & ProcessToolDefaults; |
|
messageProvider?: string; |
|
agentAccountId?: string; |
|
messageTo?: string; |
|
messageThreadId?: string | number; |
|
sandbox?: SandboxContext | null; |
|
sessionKey?: string; |
|
agentDir?: string; |
|
workspaceDir?: string; |
|
config?: ClawdbotConfig; |
|
abortSignal?: AbortSignal; |
|
/** |
|
* Provider of the currently selected model (used for provider-specific tool quirks). |
|
* Example: "anthropic", "openai", "google", "openai-codex". |
|
*/ |
|
modelProvider?: string; |
|
/** Model id for the current provider (used for model-specific tool gating). */ |
|
modelId?: string; |
|
/** |
|
* Auth mode for the current provider. We only need this for Anthropic OAuth |
|
* tool-name blocking quirks. |
|
*/ |
|
modelAuthMode?: ModelAuthMode; |
|
/** Current channel ID for auto-threading (Slack). */ |
|
currentChannelId?: string; |
|
/** Current thread timestamp for auto-threading (Slack). */ |
|
currentThreadTs?: string; |
|
/** Group id for channel-level tool policy resolution. */ |
|
groupId?: string | null; |
|
/** Group channel label (e.g. #general) for channel-level tool policy resolution. */ |
|
groupChannel?: string | null; |
|
/** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */ |
|
groupSpace?: string | null; |
|
/** Parent session key for subagent group policy inheritance. */ |
|
spawnedBy?: string | null; |
|
senderId?: string | null; |
|
senderName?: string | null; |
|
senderUsername?: string | null; |
|
senderE164?: string | null; |
|
/** Reply-to mode for Slack auto-threading. */ |
|
replyToMode?: "off" | "first" | "all"; |
|
/** Mutable ref to track if a reply was sent (for "first" mode). */ |
|
hasRepliedRef?: { value: boolean }; |
|
/** If true, the model has native vision capability */ |
|
modelHasVision?: boolean; |
|
/** Require explicit message targets (no implicit last-route sends). */ |
|
requireExplicitMessageTarget?: boolean; |
|
/** If true, omit the message tool from the tool list. */ |
|
disableMessageTool?: boolean; |
|
}): AnyAgentTool[] { |
|
The tool-builder contract omits senderIsOwner, so the owner/non-owner distinction is not modeled in the function that decides which tools the embedded agent may see. |
|
const tools: AnyAgentTool[] = [ |
|
...base, |
|
...(sandboxRoot |
|
? allowWorkspaceWrites |
|
? [createSandboxedEditTool(sandboxRoot), createSandboxedWriteTool(sandboxRoot)] |
|
: [] |
|
: []), |
|
...(applyPatchTool ? [applyPatchTool as unknown as AnyAgentTool] : []), |
|
execTool as unknown as AnyAgentTool, |
|
// @ts-ignore -- cherry-pick upstream type mismatch |
|
processTool as unknown as AnyAgentTool, |
|
// Channel docking: include channel-defined agent tools (login, etc.). |
|
...listChannelAgentTools({ cfg: options?.config }), |
|
...createOpenClawTools({ |
|
// @ts-ignore -- cherry-pick upstream type mismatch |
|
sandboxBrowserBridgeUrl: sandbox?.browser?.bridgeUrl, |
|
allowHostBrowserControl: sandbox ? sandbox.browserAllowHostControl : true, |
|
agentSessionKey: options?.sessionKey, |
|
agentChannel: resolveGatewayMessageChannel(options?.messageProvider), |
|
agentAccountId: options?.agentAccountId, |
|
agentTo: options?.messageTo, |
|
agentThreadId: options?.messageThreadId, |
|
agentGroupId: options?.groupId ?? null, |
|
agentGroupChannel: options?.groupChannel ?? null, |
|
agentGroupSpace: options?.groupSpace ?? null, |
|
agentDir: options?.agentDir, |
|
sandboxRoot, |
|
workspaceDir: options?.workspaceDir, |
|
sandboxed: !!sandbox, |
|
config: options?.config, |
|
pluginToolAllowlist: collectExplicitAllowlist([ |
|
profilePolicy, |
|
providerProfilePolicy, |
|
globalPolicy, |
|
globalProviderPolicy, |
|
agentPolicy, |
|
agentProviderPolicy, |
|
groupPolicy, |
|
sandbox?.tools, |
|
subagentPolicy, |
|
]), |
|
currentChannelId: options?.currentChannelId, |
|
currentThreadTs: options?.currentThreadTs, |
|
replyToMode: options?.replyToMode, |
|
hasRepliedRef: options?.hasRepliedRef, |
|
modelHasVision: options?.modelHasVision, |
|
requireExplicitMessageTarget: options?.requireExplicitMessageTarget, |
|
disableMessageTool: options?.disableMessageTool, |
|
requesterAgentIdOverride: agentId, |
|
}), |
|
createOpenClawCodingTools() assembles the final tool list and includes createOpenClawTools(), which contributes the owner-only gateway and cron tools to the embedded agent surface. |
|
const withAbort = options?.abortSignal |
|
? withHooks.map((tool) => wrapToolWithAbortSignal(tool, options.abortSignal)) |
|
: withHooks; |
|
|
|
// NOTE: Keep canonical (lowercase) tool names here. |
|
// pi-ai's Anthropic OAuth transport remaps tool names to Claude Code-style names |
|
// on the wire and maps them back for tool dispatch. |
|
return withAbort; |
|
The tool builder returns withAbort directly without applying applyOwnerOnlyToolPolicy(), which is the core omission that leaves owner-only tools exposed to non-owner senders. |
|
export function applyOwnerOnlyToolPolicy(tools: AnyAgentTool[], senderIsOwner: boolean) { |
|
const withGuard = tools.map((tool) => { |
|
if (!isOwnerOnlyTool(tool)) { |
|
return tool; |
|
} |
|
return wrapOwnerOnlyToolExecution(tool, senderIsOwner); |
|
}); |
|
if (senderIsOwner) { |
|
return withGuard; |
|
} |
|
return withGuard.filter((tool) => !isOwnerOnlyTool(tool)); |
|
The repository already contains the intended mitigation. Applying this helper removes gateway and cron for non-owner senders and serves as the validated control condition. |
|
const gateway = resolveGatewayOptions(opts); |
|
return await callGateway<T>({ |
|
url: gateway.url, |
|
token: gateway.token, |
|
method, |
|
params, |
|
timeoutMs: gateway.timeoutMs, |
|
expectFinal: extra?.expectFinal, |
|
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, |
|
clientDisplayName: "agent", |
|
mode: GATEWAY_CLIENT_MODES.BACKEND, |
|
callGatewayTool() forwards agent-selected gateway methods without attaching least-privilege method-specific scopes, so any exposed owner-only tool inherits the broader default connection privileges. |
|
clientName: opts.clientName ?? GATEWAY_CLIENT_NAMES.CLI, |
|
clientDisplayName: opts.clientDisplayName, |
|
clientVersion: opts.clientVersion ?? "dev", |
|
platform: opts.platform, |
|
mode: opts.mode ?? GATEWAY_CLIENT_MODES.CLI, |
|
role: "operator", |
|
scopes: ["operator.admin", "operator.approvals", "operator.pairing"], |
|
deviceIdentity: loadOrCreateDeviceIdentity(), |
|
callGateway() opens the WebSocket session as role operator with operator.admin, operator.approvals, and operator.pairing, which turns accidental tool exposure into a real control-plane privilege problem. |
Advisory Details
Title: Non-owner command-authorized senders can access owner-only
gatewayandcrontools inopenclaw-cnDescription:
Summary
openclaw-cnexposes owner-only embedded-agent control-plane tools to chat senders who are allowed to enter the command flow but are not configured as owners. The authorization layer correctly distinguishesisAuthorizedSender=truefromsenderIsOwner=false, but the live embedded-agent tool builder returns the final tool surface without applying the repository's existing owner-only filter. As a result, a lower-privileged chat actor can reach tools that open operator-scoped Gateway RPC sessions and perform administrative actions such as configuration reads and cron mutation.Details
The bug is an improper privilege management flaw in the embedded agent tool exposure path.
resolveCommandAuthorization()insrc/auto-reply/command-auth.tsalready computes the lower-privilege sender state:This means a deployment can intentionally allow a sender into the command surface while still treating that sender as a non-owner.
The reply/embedded runner preserves that distinction and forwards
senderIsOwnerinto the embedded run context:The embedded runner then passes that value into the tool-builder call:
However,
createOpenClawCodingTools()does not declare asenderIsOwneroption, assembles the complete tool list includingcreateOpenClawTools(), and returns it directly:The repository already contains the intended mitigation in
src/agents/tool-policy.ts:That filter is not applied in the vulnerable path, so the owner-only
gatewayandcrontools remain visible to a non-owner tool set. Once selected, those tools invokecallGatewayTool()and reachcallGateway():This was reproduced with the live authorization helper, the live
createOpenClawCodingTools()implementation, the realgatewayandcrontoolexecute()methods, a real loopback gateway, and independent observation through the real cron store. The reproduction is integration-level rather than full chat-to-model end-to-end, but it still demonstrates a real privilege-boundary violation in production code.PoC
Prerequisites
openclaw-cnrepository.pnpm,tsx, andpython3available locally.commands.ownerAllowFrom = ["whatsapp:+15550000000"]channels.whatsapp.allowFrom = ["*"]Reproduction Steps
python3 llm-enhance/cve-finding/similar/improper-privilege-management/Advisory-GHSA-2hm8-rqrm-xfjq-non-owner-agent-tool-scope-bypass-exp/verification_test.pyauth.isAuthorizedSender: Trueandauth.senderIsOwner: False, while the effective tool set still contains bothgatewayandcron.operator.writeclient forcron.add, but the non-owner tool path still succeeds with:gateway tool config.get succeeded: Truecron tool cron.add succeeded: Truecanary cron job listed after non-owner tool execution: Truepython3 llm-enhance/cve-finding/similar/improper-privilege-management/Advisory-GHSA-2hm8-rqrm-xfjq-non-owner-agent-tool-scope-bypass-exp/control-owner-only-policy.pyapplyOwnerOnlyToolPolicy()removes the tools and prevents the cron write:effective tool list includes gateway: Falseeffective tool list includes cron: Falsecanary cron job listed after control execution: FalseLog of Evidence
Verification run:
Control run:
Impact
This is a privilege-boundary bypass in the chat control plane. It impacts deployments that intentionally allow a broader set of chat senders to use commands or embedded-agent flows while reserving
gateway,cron, and similar operational tools for owners. A non-owner but command-authorized sender can be offered those tools, and if the model or driver selects them, the tool opens an operator-scoped gateway session and performs administrative RPC calls. That exposes configuration contents and enables cron mutation, with obvious knock-on risk for later message delivery, scheduled agent execution, and control-plane state manipulation.Affected products
Severity
Weaknesses
Occurrences
openclaw-cn/src/auto-reply/command-auth.ts
Lines 213 to 220 in 558f272
resolveCommandAuthorization()explicitly distinguishessenderIsOwnerfromisAuthorizedSender, creating the lower-privileged but still accepted sender state required for exploitation.openclaw-cn/src/auto-reply/reply/get-reply-run.ts
Lines 376 to 380 in 558f272
senderIsOwnerinto the embedded agent run context instead of collapsing it into generic command authorization.openclaw-cn/src/agents/pi-tools.ts
Lines 114 to 164 in 558f272
senderIsOwner, so the owner/non-owner distinction is not modeled in the function that decides which tools the embedded agent may see.openclaw-cn/src/agents/pi-tools.ts
Lines 318 to 367 in 558f272
createOpenClawCodingTools()assembles the final tool list and includescreateOpenClawTools(), which contributes the owner-onlygatewayandcrontools to the embedded agent surface.openclaw-cn/src/agents/pi-tools.ts
Lines 448 to 455 in 558f272
withAbortdirectly without applyingapplyOwnerOnlyToolPolicy(), which is the core omission that leaves owner-only tools exposed to non-owner senders.openclaw-cn/src/agents/tool-policy.ts
Lines 95 to 105 in 558f272
gatewayandcronfor non-owner senders and serves as the validated control condition.openclaw-cn/src/agents/tools/gateway.ts
Lines 102 to 112 in 558f272
callGatewayTool()forwards agent-selected gateway methods without attaching least-privilege method-specific scopes, so any exposed owner-only tool inherits the broader default connection privileges.openclaw-cn/src/gateway/call.ts
Lines 248 to 255 in 558f272
callGateway()opens the WebSocket session as roleoperatorwithoperator.admin,operator.approvals, andoperator.pairing, which turns accidental tool exposure into a real control-plane privilege problem.