Gateway: make dangerous node commands opt-in

This commit is contained in:
Mariano Belinky
2026-02-08 09:52:06 +01:00
parent 14b91b0a6e
commit 355b69e044
4 changed files with 173 additions and 55 deletions

View File

@@ -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 <requestId>\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." };

View File

@@ -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<Exclude<ArmGroup, "all">, string[]> = {
@@ -81,7 +93,7 @@ async function readArmState(statePath: string): Promise<ArmStateFile | null> {
try {
const raw = await fs.readFile(statePath, "utf8");
const parsed = JSON.parse(raw) as Partial<ArmStateFile>;
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<ArmStateFile | null> {
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`,
};
}

View File

@@ -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);
});
});

View File

@@ -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<string, string[]> = {
ios: [
...CANVAS_COMMANDS,
...CAMERA_COMMANDS,
...SCREEN_COMMANDS,
...LOCATION_COMMANDS,
...DEVICE_COMMANDS,
...CONTACTS_COMMANDS,
@@ -61,7 +75,6 @@ const PLATFORM_DEFAULTS: Record<string, string[]> = {
android: [
...CANVAS_COMMANDS,
...CAMERA_COMMANDS,
...SCREEN_COMMANDS,
...LOCATION_COMMANDS,
...DEVICE_COMMANDS,
...CONTACTS_COMMANDS,
@@ -69,12 +82,10 @@ const PLATFORM_DEFAULTS: Record<string, string[]> = {
...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<string, string[]> = {
],
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 {