diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index cb21d3875fd..0360205c73c 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -395,14 +395,34 @@ export default function register(api: OpenClawPluginApi) { } if (action === "approve") { - const requestId = tokens[1]; + const requested = tokens[1]?.trim(); const list = await listDevicePairing(); - const pending = requestId - ? list.pending.find((entry) => entry.requestId === requestId) - : [...list.pending].toSorted((a, b) => (b.ts ?? 0) - (a.ts ?? 0))[0]; - if (!pending) { + if (list.pending.length === 0) { return { text: "No pending device pairing requests." }; } + + let pending: (typeof list.pending)[number] | undefined; + if (requested) { + if (requested.toLowerCase() === "latest") { + pending = [...list.pending].toSorted((a, b) => (b.ts ?? 0) - (a.ts ?? 0))[0]; + } else { + pending = list.pending.find((entry) => entry.requestId === requested); + } + } else if (list.pending.length === 1) { + pending = list.pending[0]; + } else { + return { + text: + `${formatPendingRequests(list.pending)}\n\n` + + "Multiple pending requests found. Approve one explicitly:\n" + + "/pair approve \n" + + "Or approve the most recent:\n" + + "/pair approve latest", + }; + } + if (!pending) { + return { text: "Pairing request not found." }; + } const approved = await approveDevicePairing(pending.requestId); if (!approved) { return { text: "Pairing request not found." }; diff --git a/extensions/phone-control/index.ts b/extensions/phone-control/index.ts index 0aabc4ad7bf..627a2317fad 100644 --- a/extensions/phone-control/index.ts +++ b/extensions/phone-control/index.ts @@ -4,14 +4,26 @@ import path from "node:path"; type ArmGroup = "camera" | "screen" | "writes" | "all"; -type ArmStateFile = { +type ArmStateFileV1 = { version: 1; armedAtMs: number; expiresAtMs: number | null; removedFromDeny: string[]; }; -const STATE_VERSION = 1; +type ArmStateFileV2 = { + version: 2; + armedAtMs: number; + expiresAtMs: number | null; + group: ArmGroup; + armedCommands: string[]; + addedToAllow: string[]; + removedFromDeny: string[]; +}; + +type ArmStateFile = ArmStateFileV1 | ArmStateFileV2; + +const STATE_VERSION = 2; const STATE_REL_PATH = ["plugins", "phone-control", "armed.json"] as const; const GROUP_COMMANDS: Record, string[]> = { @@ -81,7 +93,7 @@ async function readArmState(statePath: string): Promise { try { const raw = await fs.readFile(statePath, "utf8"); const parsed = JSON.parse(raw) as Partial; - if (parsed.version !== STATE_VERSION) { + if (parsed.version !== 1 && parsed.version !== 2) { return null; } if (typeof parsed.armedAtMs !== "number") { @@ -90,6 +102,33 @@ async function readArmState(statePath: string): Promise { if (!(parsed.expiresAtMs === null || typeof parsed.expiresAtMs === "number")) { return null; } + + if (parsed.version === 1) { + if ( + !Array.isArray(parsed.removedFromDeny) || + !parsed.removedFromDeny.every((v) => typeof v === "string") + ) { + return null; + } + return parsed as ArmStateFile; + } + + const group = typeof parsed.group === "string" ? parsed.group : ""; + if (group !== "camera" && group !== "screen" && group !== "writes" && group !== "all") { + return null; + } + if ( + !Array.isArray(parsed.armedCommands) || + !parsed.armedCommands.every((v) => typeof v === "string") + ) { + return null; + } + if ( + !Array.isArray(parsed.addedToAllow) || + !parsed.addedToAllow.every((v) => typeof v === "string") + ) { + return null; + } if ( !Array.isArray(parsed.removedFromDeny) || !parsed.removedFromDeny.every((v) => typeof v === "string") @@ -119,9 +158,13 @@ function normalizeDenyList(cfg: OpenClawPluginApi["config"]): string[] { return uniqSorted([...(cfg.gateway?.nodes?.denyCommands ?? [])]); } -function patchConfigDenyList( +function normalizeAllowList(cfg: OpenClawPluginApi["config"]): string[] { + return uniqSorted([...(cfg.gateway?.nodes?.allowCommands ?? [])]); +} + +function patchConfigNodeLists( cfg: OpenClawPluginApi["config"], - denyCommands: string[], + next: { allowCommands: string[]; denyCommands: string[] }, ): OpenClawPluginApi["config"] { return { ...cfg, @@ -129,7 +172,8 @@ function patchConfigDenyList( ...cfg.gateway, nodes: { ...cfg.gateway?.nodes, - denyCommands, + allowCommands: next.allowCommands, + denyCommands: next.denyCommands, }, }, }; @@ -140,28 +184,53 @@ async function disarmNow(params: { stateDir: string; statePath: string; reason: string; -}): Promise<{ changed: boolean; restored: string[] }> { +}): Promise<{ changed: boolean; restored: string[]; removed: string[] }> { const { api, stateDir, statePath, reason } = params; const state = await readArmState(statePath); if (!state) { - return { changed: false, restored: [] }; + return { changed: false, restored: [], removed: [] }; } const cfg = api.runtime.config.loadConfig(); + const allow = new Set(normalizeAllowList(cfg)); const deny = new Set(normalizeDenyList(cfg)); + const removed: string[] = []; const restored: string[] = []; - for (const cmd of state.removedFromDeny) { - if (!deny.has(cmd)) { - deny.add(cmd); - restored.push(cmd); + + if (state.version === 1) { + for (const cmd of state.removedFromDeny) { + if (!deny.has(cmd)) { + deny.add(cmd); + restored.push(cmd); + } + } + } else { + for (const cmd of state.addedToAllow) { + if (allow.delete(cmd)) { + removed.push(cmd); + } + } + for (const cmd of state.removedFromDeny) { + if (!deny.has(cmd)) { + deny.add(cmd); + restored.push(cmd); + } } } - if (restored.length > 0) { - const next = patchConfigDenyList(cfg, uniqSorted([...deny])); + + if (removed.length > 0 || restored.length > 0) { + const next = patchConfigNodeLists(cfg, { + allowCommands: uniqSorted([...allow]), + denyCommands: uniqSorted([...deny]), + }); await api.runtime.config.writeConfigFile(next); } await writeArmState(statePath, null); api.logger.info(`phone-control: disarmed (${reason}) stateDir=${stateDir}`); - return { changed: restored.length > 0, restored: uniqSorted(restored) }; + return { + changed: removed.length > 0 || restored.length > 0, + removed: uniqSorted(removed), + restored: uniqSorted(restored), + }; } function formatHelp(): string { @@ -202,7 +271,13 @@ function formatStatus(state: ArmStateFile | null): string { state.expiresAtMs == null ? "manual disarm required" : `expires in ${formatDuration(Math.max(0, state.expiresAtMs - Date.now()))}`; - const cmds = uniqSorted(state.removedFromDeny); + const cmds = uniqSorted( + state.version === 1 + ? state.removedFromDeny + : state.armedCommands.length > 0 + ? state.armedCommands + : [...state.addedToAllow, ...state.removedFromDeny], + ); const cmdLabel = cmds.length > 0 ? cmds.join(", ") : "none"; return `Phone control: armed (${until}).\nTemporarily allowed: ${cmdLabel}`; } @@ -283,8 +358,10 @@ export default function register(api: OpenClawPluginApi) { if (!res.changed) { return { text: "Phone control: disarmed." }; } + const restoredLabel = res.restored.length > 0 ? res.restored.join(", ") : "none"; + const removedLabel = res.removed.length > 0 ? res.removed.join(", ") : "none"; return { - text: `Phone control: disarmed. Restored denylist for: ${res.restored.join(", ")}`, + text: `Phone control: disarmed.\nRemoved allowlist: ${removedLabel}\nRestored denylist: ${restoredLabel}`, }; } @@ -298,31 +375,41 @@ export default function register(api: OpenClawPluginApi) { const commands = resolveCommandsForGroup(group); const cfg = api.runtime.config.loadConfig(); - const deny = normalizeDenyList(cfg); - const denySet = new Set(deny); + const allowSet = new Set(normalizeAllowList(cfg)); + const denySet = new Set(normalizeDenyList(cfg)); - const removed: string[] = []; + const addedToAllow: string[] = []; + const removedFromDeny: string[] = []; for (const cmd of commands) { + if (!allowSet.has(cmd)) { + allowSet.add(cmd); + addedToAllow.push(cmd); + } if (denySet.delete(cmd)) { - removed.push(cmd); + removedFromDeny.push(cmd); } } - const next = patchConfigDenyList(cfg, uniqSorted([...denySet])); + const next = patchConfigNodeLists(cfg, { + allowCommands: uniqSorted([...allowSet]), + denyCommands: uniqSorted([...denySet]), + }); await api.runtime.config.writeConfigFile(next); await writeArmState(statePath, { version: STATE_VERSION, armedAtMs: Date.now(), expiresAtMs, - removedFromDeny: uniqSorted(removed), + group, + armedCommands: uniqSorted(commands), + addedToAllow: uniqSorted(addedToAllow), + removedFromDeny: uniqSorted(removedFromDeny), }); - const removedLabel = - removed.length > 0 ? uniqSorted(removed).join(", ") : "none (already allowed)"; + const allowedLabel = uniqSorted(commands).join(", "); return { text: `Phone control: armed for ${formatDuration(durationMs)}.\n` + - `Temporarily allowed: ${removedLabel}\n` + + `Temporarily allowed: ${allowedLabel}\n` + `To disarm early: /phone disarm`, }; } diff --git a/src/gateway/node-command-policy.test.ts b/src/gateway/node-command-policy.test.ts index 378de37a34b..f96bd0eaf16 100644 --- a/src/gateway/node-command-policy.test.ts +++ b/src/gateway/node-command-policy.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { resolveNodeCommandAllowlist } from "./node-command-policy.js"; +import { + DEFAULT_DANGEROUS_NODE_COMMANDS, + resolveNodeCommandAllowlist, +} from "./node-command-policy.js"; describe("resolveNodeCommandAllowlist", () => { it("includes iOS service commands by default", () => { @@ -19,21 +22,25 @@ describe("resolveNodeCommandAllowlist", () => { expect(allow.has("reminders.list")).toBe(true); expect(allow.has("photos.latest")).toBe(true); expect(allow.has("motion.activity")).toBe(true); + + for (const cmd of DEFAULT_DANGEROUS_NODE_COMMANDS) { + expect(allow.has(cmd)).toBe(false); + } }); - it("applies denyCommands as exact removals", () => { + it("can explicitly allow dangerous commands via allowCommands", () => { const allow = resolveNodeCommandAllowlist( { gateway: { nodes: { - denyCommands: ["camera.snap", "screen.record"], + allowCommands: ["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); + expect(allow.has("camera.snap")).toBe(true); + expect(allow.has("screen.record")).toBe(true); + expect(allow.has("camera.clip")).toBe(false); }); }); diff --git a/src/gateway/node-command-policy.ts b/src/gateway/node-command-policy.ts index cb395f858bd..ca2ad13cbe6 100644 --- a/src/gateway/node-command-policy.ts +++ b/src/gateway/node-command-policy.ts @@ -12,25 +12,29 @@ const CANVAS_COMMANDS = [ "canvas.a2ui.reset", ]; -const CAMERA_COMMANDS = ["camera.list", "camera.snap", "camera.clip"]; +const CAMERA_COMMANDS = ["camera.list"]; +const CAMERA_DANGEROUS_COMMANDS = ["camera.snap", "camera.clip"]; -const SCREEN_COMMANDS = ["screen.record"]; +const SCREEN_DANGEROUS_COMMANDS = ["screen.record"]; const LOCATION_COMMANDS = ["location.get"]; const DEVICE_COMMANDS = ["device.info", "device.status"]; -const CONTACTS_COMMANDS = ["contacts.search", "contacts.add"]; +const CONTACTS_COMMANDS = ["contacts.search"]; +const CONTACTS_DANGEROUS_COMMANDS = ["contacts.add"]; -const CALENDAR_COMMANDS = ["calendar.events", "calendar.add"]; +const CALENDAR_COMMANDS = ["calendar.events"]; +const CALENDAR_DANGEROUS_COMMANDS = ["calendar.add"]; -const REMINDERS_COMMANDS = ["reminders.list", "reminders.add"]; +const REMINDERS_COMMANDS = ["reminders.list"]; +const REMINDERS_DANGEROUS_COMMANDS = ["reminders.add"]; const PHOTOS_COMMANDS = ["photos.latest"]; const MOTION_COMMANDS = ["motion.activity", "motion.pedometer"]; -const SMS_COMMANDS = ["sms.send"]; +const SMS_DANGEROUS_COMMANDS = ["sms.send"]; // iOS nodes don't implement system.run/which, but they do support notifications. const IOS_SYSTEM_COMMANDS = ["system.notify"]; @@ -44,11 +48,21 @@ const SYSTEM_COMMANDS = [ "browser.proxy", ]; +// "High risk" node commands. These can be enabled by explicitly adding them to +// `gateway.nodes.allowCommands` (and ensuring they're not blocked by denyCommands). +export const DEFAULT_DANGEROUS_NODE_COMMANDS = [ + ...CAMERA_DANGEROUS_COMMANDS, + ...SCREEN_DANGEROUS_COMMANDS, + ...CONTACTS_DANGEROUS_COMMANDS, + ...CALENDAR_DANGEROUS_COMMANDS, + ...REMINDERS_DANGEROUS_COMMANDS, + ...SMS_DANGEROUS_COMMANDS, +]; + const PLATFORM_DEFAULTS: Record = { ios: [ ...CANVAS_COMMANDS, ...CAMERA_COMMANDS, - ...SCREEN_COMMANDS, ...LOCATION_COMMANDS, ...DEVICE_COMMANDS, ...CONTACTS_COMMANDS, @@ -61,7 +75,6 @@ const PLATFORM_DEFAULTS: Record = { android: [ ...CANVAS_COMMANDS, ...CAMERA_COMMANDS, - ...SCREEN_COMMANDS, ...LOCATION_COMMANDS, ...DEVICE_COMMANDS, ...CONTACTS_COMMANDS, @@ -69,12 +82,10 @@ const PLATFORM_DEFAULTS: Record = { ...REMINDERS_COMMANDS, ...PHOTOS_COMMANDS, ...MOTION_COMMANDS, - ...SMS_COMMANDS, ], macos: [ ...CANVAS_COMMANDS, ...CAMERA_COMMANDS, - ...SCREEN_COMMANDS, ...LOCATION_COMMANDS, ...DEVICE_COMMANDS, ...CONTACTS_COMMANDS, @@ -86,14 +97,7 @@ const PLATFORM_DEFAULTS: Record = { ], linux: [...SYSTEM_COMMANDS], windows: [...SYSTEM_COMMANDS], - unknown: [ - ...CANVAS_COMMANDS, - ...CAMERA_COMMANDS, - ...SCREEN_COMMANDS, - ...LOCATION_COMMANDS, - ...SMS_COMMANDS, - ...SYSTEM_COMMANDS, - ], + unknown: [...CANVAS_COMMANDS, ...CAMERA_COMMANDS, ...LOCATION_COMMANDS, ...SYSTEM_COMMANDS], }; function normalizePlatformId(platform?: string, deviceFamily?: string): string {