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 <victormier@gmail.com>
This commit is contained in:
Gustavo Madeira Santana
2026-02-04 19:49:36 -05:00
committed by GitHub
parent 0cd47d830f
commit 392bbddf29
21 changed files with 202 additions and 10 deletions

View File

@@ -88,6 +88,8 @@ export type CompactEmbeddedPiSessionParams = {
groupSpace?: string | null;
/** Parent session key for subagent policy inheritance. */
spawnedBy?: string | null;
/** Whether the sender is an owner (required for owner-only tools). */
senderIsOwner?: boolean;
sessionFile: string;
workspaceDir: string;
agentDir?: string;
@@ -227,6 +229,7 @@ export async function compactEmbeddedPiSessionDirect(
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
spawnedBy: params.spawnedBy,
senderIsOwner: params.senderIsOwner,
agentDir,
workspaceDir: effectiveWorkspace,
config: params.config,

View File

@@ -324,6 +324,7 @@ export async function runEmbeddedPiAgent(
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
spawnedBy: params.spawnedBy,
senderIsOwner: params.senderIsOwner,
currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs,
replyToMode: params.replyToMode,
@@ -391,6 +392,7 @@ export async function runEmbeddedPiAgent(
agentDir,
config: params.config,
skillsSnapshot: params.skillsSnapshot,
senderIsOwner: params.senderIsOwner,
provider,
model: modelId,
thinkLevel,

View File

@@ -225,6 +225,7 @@ export async function runEmbeddedAttempt(
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
senderIsOwner: params.senderIsOwner,
sessionKey: params.sessionKey ?? params.sessionId,
agentDir,
workspaceDir: effectiveWorkspace,

View File

@@ -39,6 +39,8 @@ export type RunEmbeddedPiAgentParams = {
senderName?: string | null;
senderUsername?: string | null;
senderE164?: string | null;
/** Whether the sender is an owner (required for owner-only tools). */
senderIsOwner?: boolean;
/** Current channel ID for auto-threading (Slack). */
currentChannelId?: string;
/** Current thread timestamp for auto-threading (Slack). */

View File

@@ -31,6 +31,8 @@ export type EmbeddedRunAttemptParams = {
senderName?: string | null;
senderUsername?: string | null;
senderE164?: string | null;
/** Whether the sender is an owner (required for owner-only tools). */
senderIsOwner?: boolean;
currentChannelId?: string;
currentThreadTs?: string;
replyToMode?: "off" | "first" | "all";

View File

@@ -44,6 +44,7 @@ import {
} from "./pi-tools.read.js";
import { cleanToolSchemaForGemini, normalizeToolParameters } from "./pi-tools.schema.js";
import {
applyOwnerOnlyToolPolicy,
buildPluginToolGroups,
collectExplicitAllowlist,
expandPolicyWithPluginGroups,
@@ -161,6 +162,8 @@ export function createOpenClawCodingTools(options?: {
requireExplicitMessageTarget?: boolean;
/** If true, omit the message tool from the tool list. */
disableMessageTool?: boolean;
/** Whether the sender is an owner (required for owner-only tools). */
senderIsOwner?: boolean;
}): AnyAgentTool[] {
const execToolName = "exec";
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
@@ -357,14 +360,17 @@ export function createOpenClawCodingTools(options?: {
requesterAgentIdOverride: agentId,
}),
];
// Security: treat unknown/undefined as unauthorized (opt-in, not opt-out)
const senderIsOwner = options?.senderIsOwner === true;
const toolsByAuthorization = applyOwnerOnlyToolPolicy(tools, senderIsOwner);
const coreToolNames = new Set(
tools
toolsByAuthorization
.filter((tool) => !getPluginToolMeta(tool))
.map((tool) => normalizeToolName(tool.name))
.filter(Boolean),
);
const pluginGroups = buildPluginToolGroups({
tools,
tools: toolsByAuthorization,
toolMeta: (tool) => getPluginToolMeta(tool),
});
const resolvePolicy = (policy: typeof profilePolicy, label: string) => {
@@ -401,8 +407,8 @@ export function createOpenClawCodingTools(options?: {
const subagentPolicyExpanded = expandPolicyWithPluginGroups(subagentPolicy, pluginGroups);
const toolsFiltered = profilePolicyExpanded
? filterToolsByPolicy(tools, profilePolicyExpanded)
: tools;
? filterToolsByPolicy(toolsByAuthorization, profilePolicyExpanded)
: toolsByAuthorization;
const providerProfileFiltered = providerProfileExpanded
? filterToolsByPolicy(toolsFiltered, providerProfileExpanded)
: toolsFiltered;

View File

@@ -0,0 +1,35 @@
import { describe, expect, it, vi } from "vitest";
import "./test-helpers/fast-coding-tools.js";
import { createOpenClawCodingTools } from "./pi-tools.js";
vi.mock("./channel-tools.js", () => {
const stubTool = (name: string) => ({
name,
description: `${name} stub`,
parameters: { type: "object", properties: {} },
execute: vi.fn(),
});
return {
listChannelAgentTools: () => [stubTool("whatsapp_login")],
};
});
describe("whatsapp_login tool gating", () => {
it("removes whatsapp_login for unauthorized senders", () => {
const tools = createOpenClawCodingTools({ senderIsOwner: false });
const toolNames = tools.map((tool) => tool.name);
expect(toolNames).not.toContain("whatsapp_login");
});
it("keeps whatsapp_login for authorized senders", () => {
const tools = createOpenClawCodingTools({ senderIsOwner: true });
const toolNames = tools.map((tool) => tool.name);
expect(toolNames).toContain("whatsapp_login");
});
it("defaults to removing whatsapp_login when owner status is unknown", () => {
const tools = createOpenClawCodingTools();
const toolNames = tools.map((tool) => tool.name);
expect(toolNames).not.toContain("whatsapp_login");
});
});

View File

@@ -1,3 +1,5 @@
import type { AnyAgentTool } from "./tools/common.js";
export type ToolProfileId = "minimal" | "coding" | "messaging" | "full";
type ToolProfilePolicy = {
@@ -56,6 +58,8 @@ export const TOOL_GROUPS: Record<string, string[]> = {
],
};
const OWNER_ONLY_TOOL_NAMES = new Set<string>(["whatsapp_login"]);
const TOOL_PROFILES: Record<ToolProfileId, ToolProfilePolicy> = {
minimal: {
allow: ["session_status"],
@@ -80,6 +84,31 @@ export function normalizeToolName(name: string) {
return TOOL_NAME_ALIASES[normalized] ?? normalized;
}
export function isOwnerOnlyToolName(name: string) {
return OWNER_ONLY_TOOL_NAMES.has(normalizeToolName(name));
}
export function applyOwnerOnlyToolPolicy(tools: AnyAgentTool[], senderIsOwner: boolean) {
const withGuard = tools.map((tool) => {
if (!isOwnerOnlyToolName(tool.name)) {
return tool;
}
if (senderIsOwner || !tool.execute) {
return tool;
}
return {
...tool,
execute: async () => {
throw new Error("Tool restricted to owner senders.");
},
};
});
if (senderIsOwner) {
return withGuard;
}
return withGuard.filter((tool) => !isOwnerOnlyToolName(tool.name));
}
export function normalizeToolList(list?: string[]) {
if (!list) {
return [];