Skip to content

Commit 392bbdd

Browse files
Security: owner-only tools + command auth hardening (#9202)
* Security: gate whatsapp_login by sender auth * Security: treat undefined senderAuthorized as unauthorized (opt-in) * fix: gate whatsapp_login to owner senders (#8768) (thanks @victormier) * fix: add explicit owner allowlist for tools (#8768) (thanks @victormier) * fix: normalize escaped newlines in send actions (#8768) (thanks @victormier) --------- Co-authored-by: Victor Mier <[email protected]>
1 parent 0cd47d8 commit 392bbdd

21 files changed

+202
-10
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ Docs: https://docs.openclaw.ai
3131
- Web UI: apply button styling to the new-messages indicator.
3232
- Security: keep untrusted channel metadata out of system prompts (Slack/Discord). Thanks @KonstantinMirin.
3333
- Security: enforce sandboxed media paths for message tool attachments. (#9182) Thanks @victormier.
34+
- Security: require explicit credentials for gateway URL overrides to prevent credential leakage. (#8113) Thanks @victormier.
35+
- Security: gate `whatsapp_login` tool to owner senders and default-deny non-owner contexts. (#8768) Thanks @victormier.
3436
- Voice call: harden webhook verification with host allowlists/proxy trust and keep ngrok loopback bypass.
3537
- Voice call: add regression coverage for anonymous inbound caller IDs with allowlist policy. (#8104) Thanks @victormier.
3638
- Cron: accept epoch timestamps and 0ms durations in CLI `--at` parsing.

src/agents/pi-embedded-runner/compact.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ export type CompactEmbeddedPiSessionParams = {
8888
groupSpace?: string | null;
8989
/** Parent session key for subagent policy inheritance. */
9090
spawnedBy?: string | null;
91+
/** Whether the sender is an owner (required for owner-only tools). */
92+
senderIsOwner?: boolean;
9193
sessionFile: string;
9294
workspaceDir: string;
9395
agentDir?: string;
@@ -227,6 +229,7 @@ export async function compactEmbeddedPiSessionDirect(
227229
groupChannel: params.groupChannel,
228230
groupSpace: params.groupSpace,
229231
spawnedBy: params.spawnedBy,
232+
senderIsOwner: params.senderIsOwner,
230233
agentDir,
231234
workspaceDir: effectiveWorkspace,
232235
config: params.config,

src/agents/pi-embedded-runner/run.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,7 @@ export async function runEmbeddedPiAgent(
324324
groupChannel: params.groupChannel,
325325
groupSpace: params.groupSpace,
326326
spawnedBy: params.spawnedBy,
327+
senderIsOwner: params.senderIsOwner,
327328
currentChannelId: params.currentChannelId,
328329
currentThreadTs: params.currentThreadTs,
329330
replyToMode: params.replyToMode,
@@ -391,6 +392,7 @@ export async function runEmbeddedPiAgent(
391392
agentDir,
392393
config: params.config,
393394
skillsSnapshot: params.skillsSnapshot,
395+
senderIsOwner: params.senderIsOwner,
394396
provider,
395397
model: modelId,
396398
thinkLevel,

src/agents/pi-embedded-runner/run/attempt.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ export async function runEmbeddedAttempt(
225225
senderName: params.senderName,
226226
senderUsername: params.senderUsername,
227227
senderE164: params.senderE164,
228+
senderIsOwner: params.senderIsOwner,
228229
sessionKey: params.sessionKey ?? params.sessionId,
229230
agentDir,
230231
workspaceDir: effectiveWorkspace,

src/agents/pi-embedded-runner/run/params.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export type RunEmbeddedPiAgentParams = {
3939
senderName?: string | null;
4040
senderUsername?: string | null;
4141
senderE164?: string | null;
42+
/** Whether the sender is an owner (required for owner-only tools). */
43+
senderIsOwner?: boolean;
4244
/** Current channel ID for auto-threading (Slack). */
4345
currentChannelId?: string;
4446
/** Current thread timestamp for auto-threading (Slack). */

src/agents/pi-embedded-runner/run/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export type EmbeddedRunAttemptParams = {
3131
senderName?: string | null;
3232
senderUsername?: string | null;
3333
senderE164?: string | null;
34+
/** Whether the sender is an owner (required for owner-only tools). */
35+
senderIsOwner?: boolean;
3436
currentChannelId?: string;
3537
currentThreadTs?: string;
3638
replyToMode?: "off" | "first" | "all";

src/agents/pi-tools.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
} from "./pi-tools.read.js";
4545
import { cleanToolSchemaForGemini, normalizeToolParameters } from "./pi-tools.schema.js";
4646
import {
47+
applyOwnerOnlyToolPolicy,
4748
buildPluginToolGroups,
4849
collectExplicitAllowlist,
4950
expandPolicyWithPluginGroups,
@@ -161,6 +162,8 @@ export function createOpenClawCodingTools(options?: {
161162
requireExplicitMessageTarget?: boolean;
162163
/** If true, omit the message tool from the tool list. */
163164
disableMessageTool?: boolean;
165+
/** Whether the sender is an owner (required for owner-only tools). */
166+
senderIsOwner?: boolean;
164167
}): AnyAgentTool[] {
165168
const execToolName = "exec";
166169
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
@@ -357,14 +360,17 @@ export function createOpenClawCodingTools(options?: {
357360
requesterAgentIdOverride: agentId,
358361
}),
359362
];
363+
// Security: treat unknown/undefined as unauthorized (opt-in, not opt-out)
364+
const senderIsOwner = options?.senderIsOwner === true;
365+
const toolsByAuthorization = applyOwnerOnlyToolPolicy(tools, senderIsOwner);
360366
const coreToolNames = new Set(
361-
tools
367+
toolsByAuthorization
362368
.filter((tool) => !getPluginToolMeta(tool))
363369
.map((tool) => normalizeToolName(tool.name))
364370
.filter(Boolean),
365371
);
366372
const pluginGroups = buildPluginToolGroups({
367-
tools,
373+
tools: toolsByAuthorization,
368374
toolMeta: (tool) => getPluginToolMeta(tool),
369375
});
370376
const resolvePolicy = (policy: typeof profilePolicy, label: string) => {
@@ -401,8 +407,8 @@ export function createOpenClawCodingTools(options?: {
401407
const subagentPolicyExpanded = expandPolicyWithPluginGroups(subagentPolicy, pluginGroups);
402408

403409
const toolsFiltered = profilePolicyExpanded
404-
? filterToolsByPolicy(tools, profilePolicyExpanded)
405-
: tools;
410+
? filterToolsByPolicy(toolsByAuthorization, profilePolicyExpanded)
411+
: toolsByAuthorization;
406412
const providerProfileFiltered = providerProfileExpanded
407413
? filterToolsByPolicy(toolsFiltered, providerProfileExpanded)
408414
: toolsFiltered;
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import "./test-helpers/fast-coding-tools.js";
3+
import { createOpenClawCodingTools } from "./pi-tools.js";
4+
5+
vi.mock("./channel-tools.js", () => {
6+
const stubTool = (name: string) => ({
7+
name,
8+
description: `${name} stub`,
9+
parameters: { type: "object", properties: {} },
10+
execute: vi.fn(),
11+
});
12+
return {
13+
listChannelAgentTools: () => [stubTool("whatsapp_login")],
14+
};
15+
});
16+
17+
describe("whatsapp_login tool gating", () => {
18+
it("removes whatsapp_login for unauthorized senders", () => {
19+
const tools = createOpenClawCodingTools({ senderIsOwner: false });
20+
const toolNames = tools.map((tool) => tool.name);
21+
expect(toolNames).not.toContain("whatsapp_login");
22+
});
23+
24+
it("keeps whatsapp_login for authorized senders", () => {
25+
const tools = createOpenClawCodingTools({ senderIsOwner: true });
26+
const toolNames = tools.map((tool) => tool.name);
27+
expect(toolNames).toContain("whatsapp_login");
28+
});
29+
30+
it("defaults to removing whatsapp_login when owner status is unknown", () => {
31+
const tools = createOpenClawCodingTools();
32+
const toolNames = tools.map((tool) => tool.name);
33+
expect(toolNames).not.toContain("whatsapp_login");
34+
});
35+
});

src/agents/tool-policy.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { AnyAgentTool } from "./tools/common.js";
2+
13
export type ToolProfileId = "minimal" | "coding" | "messaging" | "full";
24

35
type ToolProfilePolicy = {
@@ -56,6 +58,8 @@ export const TOOL_GROUPS: Record<string, string[]> = {
5658
],
5759
};
5860

61+
const OWNER_ONLY_TOOL_NAMES = new Set<string>(["whatsapp_login"]);
62+
5963
const TOOL_PROFILES: Record<ToolProfileId, ToolProfilePolicy> = {
6064
minimal: {
6165
allow: ["session_status"],
@@ -80,6 +84,31 @@ export function normalizeToolName(name: string) {
8084
return TOOL_NAME_ALIASES[normalized] ?? normalized;
8185
}
8286

87+
export function isOwnerOnlyToolName(name: string) {
88+
return OWNER_ONLY_TOOL_NAMES.has(normalizeToolName(name));
89+
}
90+
91+
export function applyOwnerOnlyToolPolicy(tools: AnyAgentTool[], senderIsOwner: boolean) {
92+
const withGuard = tools.map((tool) => {
93+
if (!isOwnerOnlyToolName(tool.name)) {
94+
return tool;
95+
}
96+
if (senderIsOwner || !tool.execute) {
97+
return tool;
98+
}
99+
return {
100+
...tool,
101+
execute: async () => {
102+
throw new Error("Tool restricted to owner senders.");
103+
},
104+
};
105+
});
106+
if (senderIsOwner) {
107+
return withGuard;
108+
}
109+
return withGuard.filter((tool) => !isOwnerOnlyToolName(tool.name));
110+
}
111+
83112
export function normalizeToolList(list?: string[]) {
84113
if (!list) {
85114
return [];

src/auto-reply/command-auth.ts

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export type CommandAuthorization = {
99
providerId?: ChannelId;
1010
ownerList: string[];
1111
senderId?: string;
12+
senderIsOwner: boolean;
1213
isAuthorizedSender: boolean;
1314
from?: string;
1415
to?: string;
@@ -83,6 +84,47 @@ function normalizeAllowFromEntry(params: {
8384
return normalized.filter((entry) => entry.trim().length > 0);
8485
}
8586

87+
function resolveOwnerAllowFromList(params: {
88+
dock?: ChannelDock;
89+
cfg: OpenClawConfig;
90+
accountId?: string | null;
91+
providerId?: ChannelId;
92+
}): string[] {
93+
const raw = params.cfg.commands?.ownerAllowFrom;
94+
if (!Array.isArray(raw) || raw.length === 0) {
95+
return [];
96+
}
97+
const filtered: string[] = [];
98+
for (const entry of raw) {
99+
const trimmed = String(entry ?? "").trim();
100+
if (!trimmed) {
101+
continue;
102+
}
103+
const separatorIndex = trimmed.indexOf(":");
104+
if (separatorIndex > 0) {
105+
const prefix = trimmed.slice(0, separatorIndex);
106+
const channel = normalizeAnyChannelId(prefix);
107+
if (channel) {
108+
if (params.providerId && channel !== params.providerId) {
109+
continue;
110+
}
111+
const remainder = trimmed.slice(separatorIndex + 1).trim();
112+
if (remainder) {
113+
filtered.push(remainder);
114+
}
115+
continue;
116+
}
117+
}
118+
filtered.push(trimmed);
119+
}
120+
return formatAllowFromList({
121+
dock: params.dock,
122+
cfg: params.cfg,
123+
accountId: params.accountId,
124+
allowFrom: filtered,
125+
});
126+
}
127+
86128
function resolveSenderCandidates(params: {
87129
dock?: ChannelDock;
88130
providerId?: ChannelId;
@@ -141,22 +183,31 @@ export function resolveCommandAuthorization(params: {
141183
accountId: ctx.AccountId,
142184
allowFrom: Array.isArray(allowFromRaw) ? allowFromRaw : [],
143185
});
186+
const ownerAllowFromList = resolveOwnerAllowFromList({
187+
dock,
188+
cfg,
189+
accountId: ctx.AccountId,
190+
providerId,
191+
});
144192
const allowAll =
145193
allowFromList.length === 0 || allowFromList.some((entry) => entry.trim() === "*");
146194

147-
const ownerCandidates = allowAll ? [] : allowFromList.filter((entry) => entry !== "*");
148-
if (!allowAll && ownerCandidates.length === 0 && to) {
195+
const ownerCandidatesForCommands = allowAll ? [] : allowFromList.filter((entry) => entry !== "*");
196+
if (!allowAll && ownerCandidatesForCommands.length === 0 && to) {
149197
const normalizedTo = normalizeAllowFromEntry({
150198
dock,
151199
cfg,
152200
accountId: ctx.AccountId,
153201
value: to,
154202
});
155203
if (normalizedTo.length > 0) {
156-
ownerCandidates.push(...normalizedTo);
204+
ownerCandidatesForCommands.push(...normalizedTo);
157205
}
158206
}
159-
const ownerList = Array.from(new Set(ownerCandidates));
207+
const explicitOwners = ownerAllowFromList.filter((entry) => entry !== "*");
208+
const ownerList = Array.from(
209+
new Set(explicitOwners.length > 0 ? explicitOwners : ownerCandidatesForCommands),
210+
);
160211

161212
const senderCandidates = resolveSenderCandidates({
162213
dock,
@@ -170,16 +221,25 @@ export function resolveCommandAuthorization(params: {
170221
const matchedSender = ownerList.length
171222
? senderCandidates.find((candidate) => ownerList.includes(candidate))
172223
: undefined;
224+
const matchedCommandOwner = ownerCandidatesForCommands.length
225+
? senderCandidates.find((candidate) => ownerCandidatesForCommands.includes(candidate))
226+
: undefined;
173227
const senderId = matchedSender ?? senderCandidates[0];
174228

175229
const enforceOwner = Boolean(dock?.commands?.enforceOwnerForCommands);
176-
const isOwner = !enforceOwner || allowAll || ownerList.length === 0 || Boolean(matchedSender);
177-
const isAuthorizedSender = commandAuthorized && isOwner;
230+
const senderIsOwner = Boolean(matchedSender);
231+
const isOwnerForCommands =
232+
!enforceOwner ||
233+
allowAll ||
234+
ownerCandidatesForCommands.length === 0 ||
235+
Boolean(matchedCommandOwner);
236+
const isAuthorizedSender = commandAuthorized && isOwnerForCommands;
178237

179238
return {
180239
providerId,
181240
ownerList,
182241
senderId: senderId || undefined,
242+
senderIsOwner,
183243
isAuthorizedSender,
184244
from: from || undefined,
185245
to: to || undefined,

0 commit comments

Comments
 (0)