From 3e5762c288813724ddbc3e2e2d2e599fc5950486 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 2 Mar 2026 16:06:36 +0000 Subject: [PATCH] fix(security): harden sms.send dangerous-node defaults --- CHANGELOG.md | 1 + extensions/phone-control/index.test.ts | 110 +++++++++++++++++++ extensions/phone-control/index.ts | 2 +- src/wizard/onboarding.gateway-config.test.ts | 10 +- src/wizard/onboarding.gateway-config.ts | 17 +-- 5 files changed, 116 insertions(+), 24 deletions(-) create mode 100644 extensions/phone-control/index.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a52c2e835f5..7fad654cb6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/Node dangerous-command parity: include `sms.send` in default onboarding node `denyCommands`, share onboarding deny defaults with the gateway dangerous-command source of truth, and include `sms.send` in phone-control `/phone arm writes` handling so SMS follows the same break-glass flow as other dangerous node commands. Thanks @zpbrent. - Zalo/Pairing auth tests: add webhook regression coverage asserting DM pairing-store reads/writes remain account-scoped, preventing cross-account authorization bleed in multi-account setups. (#26121) Thanks @bmendonca3. - Logging: use local time for logged timestamps instead of UTC, aligning log output with documented local timezone behavior and avoiding confusion during local diagnostics. (#28434) Thanks @liuy. - Zalouser/Pairing auth tests: add account-scoped DM pairing-store regression coverage (`monitor.account-scope.test.ts`) to prevent cross-account allowlist bleed in multi-account setups. (#26672) Thanks @bmendonca3. diff --git a/extensions/phone-control/index.test.ts b/extensions/phone-control/index.test.ts new file mode 100644 index 00000000000..480d1390452 --- /dev/null +++ b/extensions/phone-control/index.test.ts @@ -0,0 +1,110 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import type { + OpenClawPluginApi, + OpenClawPluginCommandDefinition, + PluginCommandContext, +} from "../../src/plugins/types.js"; +import registerPhoneControl from "./index.js"; + +function createApi(params: { + stateDir: string; + getConfig: () => Record; + writeConfig: (next: Record) => Promise; + registerCommand: (command: OpenClawPluginCommandDefinition) => void; +}): OpenClawPluginApi { + return { + id: "phone-control", + name: "phone-control", + source: "test", + config: {}, + pluginConfig: {}, + runtime: { + state: { + resolveStateDir: () => params.stateDir, + }, + config: { + loadConfig: () => params.getConfig(), + writeConfigFile: (next: Record) => params.writeConfig(next), + }, + } as OpenClawPluginApi["runtime"], + logger: { info() {}, warn() {}, error() {} }, + registerTool() {}, + registerHook() {}, + registerHttpHandler() {}, + registerHttpRoute() {}, + registerChannel() {}, + registerGatewayMethod() {}, + registerCli() {}, + registerService() {}, + registerProvider() {}, + registerCommand: params.registerCommand, + resolvePath(input: string) { + return input; + }, + on() {}, + }; +} + +function createCommandContext(args: string): PluginCommandContext { + return { + channel: "test", + isAuthorizedSender: true, + commandBody: `/phone ${args}`, + args, + config: {}, + }; +} + +describe("phone-control plugin", () => { + it("arms sms.send as part of the writes group", async () => { + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-phone-control-test-")); + try { + let config: Record = { + gateway: { + nodes: { + allowCommands: [], + denyCommands: ["calendar.add", "contacts.add", "reminders.add", "sms.send"], + }, + }, + }; + const writeConfigFile = vi.fn(async (next: Record) => { + config = next; + }); + + let command: OpenClawPluginCommandDefinition | undefined; + registerPhoneControl( + createApi({ + stateDir, + getConfig: () => config, + writeConfig: writeConfigFile, + registerCommand: (nextCommand) => { + command = nextCommand; + }, + }), + ); + + expect(command?.name).toBe("phone"); + + const res = await command?.handler(createCommandContext("arm writes 30s")); + const text = String(res?.text ?? ""); + const nodes = ( + config.gateway as { nodes?: { allowCommands?: string[]; denyCommands?: string[] } } + ).nodes; + + expect(writeConfigFile).toHaveBeenCalledTimes(1); + expect(nodes?.allowCommands).toEqual([ + "calendar.add", + "contacts.add", + "reminders.add", + "sms.send", + ]); + expect(nodes?.denyCommands).toEqual([]); + expect(text).toContain("sms.send"); + } finally { + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); +}); diff --git a/extensions/phone-control/index.ts b/extensions/phone-control/index.ts index deec2958049..c101b3bd7ba 100644 --- a/extensions/phone-control/index.ts +++ b/extensions/phone-control/index.ts @@ -29,7 +29,7 @@ const STATE_REL_PATH = ["plugins", "phone-control", "armed.json"] as const; const GROUP_COMMANDS: Record, string[]> = { camera: ["camera.snap", "camera.clip"], screen: ["screen.record"], - writes: ["calendar.add", "contacts.add", "reminders.add"], + writes: ["calendar.add", "contacts.add", "reminders.add", "sms.send"], }; function uniqSorted(values: string[]): string[] { diff --git a/src/wizard/onboarding.gateway-config.test.ts b/src/wizard/onboarding.gateway-config.test.ts index ea67c23912c..5725c3a5482 100644 --- a/src/wizard/onboarding.gateway-config.test.ts +++ b/src/wizard/onboarding.gateway-config.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { createWizardPrompter as buildWizardPrompter } from "../../test/helpers/wizard-prompter.js"; +import { DEFAULT_DANGEROUS_NODE_COMMANDS } from "../gateway/node-command-policy.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter, WizardSelectParams } from "./prompts.js"; @@ -89,14 +90,7 @@ describe("configureGatewayForOnboarding", () => { const result = await runGatewayConfig(); expect(result.settings.gatewayToken).toBe("generated-token"); - expect(result.nextConfig.gateway?.nodes?.denyCommands).toEqual([ - "camera.snap", - "camera.clip", - "screen.record", - "calendar.add", - "contacts.add", - "reminders.add", - ]); + expect(result.nextConfig.gateway?.nodes?.denyCommands).toEqual(DEFAULT_DANGEROUS_NODE_COMMANDS); }); it("prefers OPENCLAW_GATEWAY_TOKEN during quickstart token setup", async () => { diff --git a/src/wizard/onboarding.gateway-config.ts b/src/wizard/onboarding.gateway-config.ts index a52c9452e56..d08f7cc4ee7 100644 --- a/src/wizard/onboarding.gateway-config.ts +++ b/src/wizard/onboarding.gateway-config.ts @@ -12,6 +12,7 @@ import { TAILSCALE_EXPOSURE_OPTIONS, TAILSCALE_MISSING_BIN_NOTE_LINES, } from "../gateway/gateway-config-prompts.shared.js"; +import { DEFAULT_DANGEROUS_NODE_COMMANDS } from "../gateway/node-command-policy.js"; import { findTailscaleBinary } from "../infra/tailscale.js"; import type { RuntimeEnv } from "../runtime.js"; import { validateIPv4AddressInput } from "../shared/net/ipv4.js"; @@ -22,20 +23,6 @@ import type { } from "./onboarding.types.js"; import type { WizardPrompter } from "./prompts.js"; -// These commands are "high risk" (privacy writes/recording) and should be -// explicitly armed by the user when they want to use them. -// -// This only affects what the gateway will accept via node.invoke; the iOS app -// still prompts for OS permissions (camera/photos/contacts/etc) on first use. -const DEFAULT_DANGEROUS_NODE_DENY_COMMANDS = [ - "camera.snap", - "camera.clip", - "screen.record", - "calendar.add", - "contacts.add", - "reminders.add", -]; - type ConfigureGatewayOptions = { flow: WizardFlow; baseConfig: OpenClawConfig; @@ -250,7 +237,7 @@ export async function configureGatewayForOnboarding( ...nextConfig.gateway, nodes: { ...nextConfig.gateway?.nodes, - denyCommands: [...DEFAULT_DANGEROUS_NODE_DENY_COMMANDS], + denyCommands: [...DEFAULT_DANGEROUS_NODE_COMMANDS], }, }, };