From ea0ad9fbcb2aedee1ae1644e4ac6956fcee6fa97 Mon Sep 17 00:00:00 2001 From: Mariano Belinky Date: Sat, 7 Feb 2026 22:58:57 +0100 Subject: [PATCH] Gateway: default-deny dangerous node commands --- src/gateway/node-command-policy.test.ts | 39 +++++++++++++++++++ src/gateway/node-command-policy.ts | 41 +++++++++++++++++++- src/wizard/onboarding.gateway-config.test.ts | 8 ++++ src/wizard/onboarding.gateway-config.ts | 35 +++++++++++++++++ 4 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 src/gateway/node-command-policy.test.ts diff --git a/src/gateway/node-command-policy.test.ts b/src/gateway/node-command-policy.test.ts new file mode 100644 index 00000000000..378de37a34b --- /dev/null +++ b/src/gateway/node-command-policy.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { resolveNodeCommandAllowlist } from "./node-command-policy.js"; + +describe("resolveNodeCommandAllowlist", () => { + it("includes iOS service commands by default", () => { + const allow = resolveNodeCommandAllowlist( + {}, + { + platform: "ios 26.0", + deviceFamily: "iPhone", + }, + ); + + expect(allow.has("device.info")).toBe(true); + expect(allow.has("device.status")).toBe(true); + expect(allow.has("system.notify")).toBe(true); + expect(allow.has("contacts.search")).toBe(true); + expect(allow.has("calendar.events")).toBe(true); + expect(allow.has("reminders.list")).toBe(true); + expect(allow.has("photos.latest")).toBe(true); + expect(allow.has("motion.activity")).toBe(true); + }); + + it("applies denyCommands as exact removals", () => { + const allow = resolveNodeCommandAllowlist( + { + gateway: { + nodes: { + denyCommands: ["camera.snap", "screen.record"], + }, + }, + }, + { platform: "ios", deviceFamily: "iPhone" }, + ); + expect(allow.has("camera.snap")).toBe(false); + expect(allow.has("screen.record")).toBe(false); + expect(allow.has("camera.clip")).toBe(true); + }); +}); diff --git a/src/gateway/node-command-policy.ts b/src/gateway/node-command-policy.ts index f22611404cb..cb395f858bd 100644 --- a/src/gateway/node-command-policy.ts +++ b/src/gateway/node-command-policy.ts @@ -18,8 +18,23 @@ const SCREEN_COMMANDS = ["screen.record"]; const LOCATION_COMMANDS = ["location.get"]; +const DEVICE_COMMANDS = ["device.info", "device.status"]; + +const CONTACTS_COMMANDS = ["contacts.search", "contacts.add"]; + +const CALENDAR_COMMANDS = ["calendar.events", "calendar.add"]; + +const REMINDERS_COMMANDS = ["reminders.list", "reminders.add"]; + +const PHOTOS_COMMANDS = ["photos.latest"]; + +const MOTION_COMMANDS = ["motion.activity", "motion.pedometer"]; + const SMS_COMMANDS = ["sms.send"]; +// iOS nodes don't implement system.run/which, but they do support notifications. +const IOS_SYSTEM_COMMANDS = ["system.notify"]; + const SYSTEM_COMMANDS = [ "system.run", "system.which", @@ -30,12 +45,30 @@ const SYSTEM_COMMANDS = [ ]; const PLATFORM_DEFAULTS: Record = { - ios: [...CANVAS_COMMANDS, ...CAMERA_COMMANDS, ...SCREEN_COMMANDS, ...LOCATION_COMMANDS], + ios: [ + ...CANVAS_COMMANDS, + ...CAMERA_COMMANDS, + ...SCREEN_COMMANDS, + ...LOCATION_COMMANDS, + ...DEVICE_COMMANDS, + ...CONTACTS_COMMANDS, + ...CALENDAR_COMMANDS, + ...REMINDERS_COMMANDS, + ...PHOTOS_COMMANDS, + ...MOTION_COMMANDS, + ...IOS_SYSTEM_COMMANDS, + ], android: [ ...CANVAS_COMMANDS, ...CAMERA_COMMANDS, ...SCREEN_COMMANDS, ...LOCATION_COMMANDS, + ...DEVICE_COMMANDS, + ...CONTACTS_COMMANDS, + ...CALENDAR_COMMANDS, + ...REMINDERS_COMMANDS, + ...PHOTOS_COMMANDS, + ...MOTION_COMMANDS, ...SMS_COMMANDS, ], macos: [ @@ -43,6 +76,12 @@ const PLATFORM_DEFAULTS: Record = { ...CAMERA_COMMANDS, ...SCREEN_COMMANDS, ...LOCATION_COMMANDS, + ...DEVICE_COMMANDS, + ...CONTACTS_COMMANDS, + ...CALENDAR_COMMANDS, + ...REMINDERS_COMMANDS, + ...PHOTOS_COMMANDS, + ...MOTION_COMMANDS, ...SYSTEM_COMMANDS, ], linux: [...SYSTEM_COMMANDS], diff --git a/src/wizard/onboarding.gateway-config.test.ts b/src/wizard/onboarding.gateway-config.test.ts index 42a474f979d..7c861175a3f 100644 --- a/src/wizard/onboarding.gateway-config.test.ts +++ b/src/wizard/onboarding.gateway-config.test.ts @@ -64,5 +64,13 @@ describe("configureGatewayForOnboarding", () => { }); 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", + ]); }); }); diff --git a/src/wizard/onboarding.gateway-config.ts b/src/wizard/onboarding.gateway-config.ts index 16f80135c1a..aef746a72d1 100644 --- a/src/wizard/onboarding.gateway-config.ts +++ b/src/wizard/onboarding.gateway-config.ts @@ -10,6 +10,20 @@ import type { WizardPrompter } from "./prompts.js"; import { normalizeGatewayTokenInput, randomToken } from "../commands/onboard-helpers.js"; import { findTailscaleBinary } from "../infra/tailscale.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; @@ -236,6 +250,27 @@ export async function configureGatewayForOnboarding( }, }; + // If this is a new gateway setup (no existing gateway settings), start with a + // denylist for high-risk node commands. Users can arm these temporarily via + // /phone arm ... (phone-control plugin). + if ( + !quickstartGateway.hasExisting && + nextConfig.gateway?.nodes?.denyCommands === undefined && + nextConfig.gateway?.nodes?.allowCommands === undefined && + nextConfig.gateway?.nodes?.browser === undefined + ) { + nextConfig = { + ...nextConfig, + gateway: { + ...nextConfig.gateway, + nodes: { + ...nextConfig.gateway?.nodes, + denyCommands: [...DEFAULT_DANGEROUS_NODE_DENY_COMMANDS], + }, + }, + }; + } + return { nextConfig, settings: {