diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 5a00ea9cd..c522ddd5b 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2861,6 +2861,14 @@ Trusted proxies: - When a connection comes from one of these IPs, OpenClaw uses `x-forwarded-for` (or `x-real-ip`) to determine the client IP for local pairing checks and HTTP auth/local checks. - Only list proxies you fully control, and ensure they **overwrite** incoming `x-forwarded-for`. +Device auto-approve: +- `gateway.devices.autoApprove` controls whether new device pairing requests are auto-approved. +- Valid values: `"none"` (default) or `"tailscale"`. +- `"none"`: only local (loopback) connections are auto-approved; remote connections require manual approval. +- `"tailscale"`: auto-approve when the connection authenticates via Tailscale Serve identity (verified via `tailscale whois`). +- This is useful for personal tailnets where you trust all machines on your network. +- For shared or corporate tailnets, consider keeping `"none"` and approving devices manually. + Notes: - `openclaw gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag). - `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI). diff --git a/src/config/config.gateway-devices-autoapprove.test.ts b/src/config/config.gateway-devices-autoapprove.test.ts new file mode 100644 index 000000000..2ca92b68e --- /dev/null +++ b/src/config/config.gateway-devices-autoapprove.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; + +import { MoltbotSchema } from "./zod-schema.js"; + +describe("gateway.devices.autoApprove config", () => { + it("accepts valid autoApprove=none", () => { + const result = MoltbotSchema.safeParse({ + gateway: { + devices: { + autoApprove: "none", + }, + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.gateway?.devices?.autoApprove).toBe("none"); + } + }); + + it("accepts valid autoApprove=tailscale", () => { + const result = MoltbotSchema.safeParse({ + gateway: { + devices: { + autoApprove: "tailscale", + }, + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.gateway?.devices?.autoApprove).toBe("tailscale"); + } + }); + + it("accepts omitted autoApprove (defaults to none)", () => { + const result = MoltbotSchema.safeParse({ + gateway: { + devices: {}, + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.gateway?.devices?.autoApprove).toBeUndefined(); + } + }); + + it("accepts omitted devices config entirely", () => { + const result = MoltbotSchema.safeParse({ + gateway: { + port: 18789, + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.gateway?.devices).toBeUndefined(); + } + }); + + it("rejects invalid autoApprove values", () => { + const invalidValues = ["all", "always", "local", "open", "", "TAILSCALE", "Tailscale"]; + for (const value of invalidValues) { + const result = MoltbotSchema.safeParse({ + gateway: { + devices: { + autoApprove: value, + }, + }, + }); + expect(result.success, `should reject autoApprove=${value}`).toBe(false); + } + }); + + it("rejects extra properties in devices config", () => { + const result = MoltbotSchema.safeParse({ + gateway: { + devices: { + autoApprove: "none", + unknownProperty: "value", + }, + }, + }); + expect(result.success).toBe(false); + }); +}); diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index c6525ad82..e29806f5e 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -102,4 +102,12 @@ describe("config schema", () => { expect(defaultsHint?.help).toContain("last"); expect(listHint?.help).toContain("bluebubbles"); }); + + it("includes gateway.devices.autoApprove ui hints", () => { + const res = buildConfigSchema(); + const autoApproveHint = res.uiHints["gateway.devices.autoApprove"]; + expect(autoApproveHint?.label).toBe("Device Auto-Approve Policy"); + expect(autoApproveHint?.help).toContain("none"); + expect(autoApproveHint?.help).toContain("tailscale"); + }); }); diff --git a/src/config/schema.ts b/src/config/schema.ts index 1401b0574..ba40f1c7c 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -209,6 +209,7 @@ const FIELD_LABELS: Record = { "gateway.nodes.browser.node": "Gateway Node Browser Pin", "gateway.nodes.allowCommands": "Gateway Node Allowlist (Extra Commands)", "gateway.nodes.denyCommands": "Gateway Node Denylist", + "gateway.devices.autoApprove": "Device Auto-Approve Policy", "nodeHost.browserProxy.enabled": "Node Browser Proxy Enabled", "nodeHost.browserProxy.allowProfiles": "Node Browser Proxy Allowed Profiles", "skills.load.watch": "Watch Skills", @@ -399,6 +400,8 @@ const FIELD_HELP: Record = { "Extra node.invoke commands to allow beyond the gateway defaults (array of command strings).", "gateway.nodes.denyCommands": "Commands to block even if present in node claims or default allowlist.", + "gateway.devices.autoApprove": + 'Auto-approve policy for new device pairing requests ("none" or "tailscale").', "nodeHost.browserProxy.enabled": "Expose the local browser control server via node proxy.", "nodeHost.browserProxy.allowProfiles": "Optional allowlist of browser profile names exposed via the node proxy.", diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index d7943acce..bf1a57bfc 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -1,5 +1,12 @@ export type GatewayBindMode = "auto" | "lan" | "loopback" | "custom" | "tailnet"; +export type GatewayDeviceAutoApproveMode = "none" | "tailscale"; + +export type GatewayDevicesConfig = { + /** Auto-approve policy for new device pairing requests. */ + autoApprove?: GatewayDeviceAutoApproveMode; +}; + export type GatewayTlsConfig = { /** Enable TLS for the gateway server. */ enabled?: boolean; @@ -235,6 +242,8 @@ export type GatewayConfig = { tls?: GatewayTlsConfig; http?: GatewayHttpConfig; nodes?: GatewayNodesConfig; + /** Device pairing settings. */ + devices?: GatewayDevicesConfig; /** * IPs of trusted reverse proxies (e.g. Traefik, nginx). When a connection * arrives from one of these IPs, the Gateway trusts `x-forwarded-for` (or diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 961ba8ecb..3f1476afc 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -443,6 +443,12 @@ export const OpenClawSchema = z }) .strict() .optional(), + devices: z + .object({ + autoApprove: z.enum(["none", "tailscale"]).optional(), + }) + .strict() + .optional(), }) .strict() .optional(), diff --git a/src/gateway/server-methods/devices.ts b/src/gateway/server-methods/devices.ts index ebf7d7f94..8ff6b2ab4 100644 --- a/src/gateway/server-methods/devices.ts +++ b/src/gateway/server-methods/devices.ts @@ -84,6 +84,8 @@ export const deviceHandlers: GatewayRequestHandlers = { deviceId: approved.device.deviceId, decision: "approved", ts: Date.now(), + autoApproved: false, + autoApproveReason: null, }, { dropIfSlow: true }, ); @@ -116,6 +118,8 @@ export const deviceHandlers: GatewayRequestHandlers = { deviceId: rejected.deviceId, decision: "rejected", ts: Date.now(), + autoApproved: false, + autoApproveReason: null, }, { dropIfSlow: true }, ); diff --git a/src/gateway/server/ws-connection/device-auto-approve.test.ts b/src/gateway/server/ws-connection/device-auto-approve.test.ts new file mode 100644 index 000000000..bbf210a3d --- /dev/null +++ b/src/gateway/server/ws-connection/device-auto-approve.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; + +import type { GatewayDeviceAutoApproveMode } from "../../../config/types.gateway.js"; + +/** + * Replicates the shouldAutoApprove logic from message-handler.ts for testing. + * This must match the implementation in message-handler.ts exactly. + */ +function computeShouldAutoApprove(params: { + isLocalClient: boolean; + deviceAutoApprove: GatewayDeviceAutoApproveMode; + authMethod: string; +}): boolean { + const { isLocalClient, deviceAutoApprove, authMethod } = params; + return isLocalClient || (deviceAutoApprove === "tailscale" && authMethod === "tailscale"); +} + +describe("device auto-approve logic", () => { + describe("local client", () => { + it("auto-approves local clients regardless of config", () => { + expect( + computeShouldAutoApprove({ + isLocalClient: true, + deviceAutoApprove: "none", + authMethod: "token", + }), + ).toBe(true); + }); + + it("auto-approves local clients even with tailscale config", () => { + expect( + computeShouldAutoApprove({ + isLocalClient: true, + deviceAutoApprove: "tailscale", + authMethod: "token", + }), + ).toBe(true); + }); + }); + + describe("config=none (default)", () => { + it("does NOT auto-approve remote clients with token auth", () => { + expect( + computeShouldAutoApprove({ + isLocalClient: false, + deviceAutoApprove: "none", + authMethod: "token", + }), + ).toBe(false); + }); + + it("does NOT auto-approve remote clients with password auth", () => { + expect( + computeShouldAutoApprove({ + isLocalClient: false, + deviceAutoApprove: "none", + authMethod: "password", + }), + ).toBe(false); + }); + + it("does NOT auto-approve remote clients with tailscale auth when config is none", () => { + expect( + computeShouldAutoApprove({ + isLocalClient: false, + deviceAutoApprove: "none", + authMethod: "tailscale", + }), + ).toBe(false); + }); + + it("does NOT auto-approve remote clients with device-token auth", () => { + expect( + computeShouldAutoApprove({ + isLocalClient: false, + deviceAutoApprove: "none", + authMethod: "device-token", + }), + ).toBe(false); + }); + }); + + describe("config=tailscale", () => { + it("auto-approves remote clients with tailscale auth", () => { + expect( + computeShouldAutoApprove({ + isLocalClient: false, + deviceAutoApprove: "tailscale", + authMethod: "tailscale", + }), + ).toBe(true); + }); + + it("does NOT auto-approve remote clients with token auth", () => { + expect( + computeShouldAutoApprove({ + isLocalClient: false, + deviceAutoApprove: "tailscale", + authMethod: "token", + }), + ).toBe(false); + }); + + it("does NOT auto-approve remote clients with password auth", () => { + expect( + computeShouldAutoApprove({ + isLocalClient: false, + deviceAutoApprove: "tailscale", + authMethod: "password", + }), + ).toBe(false); + }); + + it("does NOT auto-approve remote clients with device-token auth", () => { + expect( + computeShouldAutoApprove({ + isLocalClient: false, + deviceAutoApprove: "tailscale", + authMethod: "device-token", + }), + ).toBe(false); + }); + }); + + describe("security invariants", () => { + it("never auto-approves non-tailscale remote auth when config is tailscale", () => { + const nonTailscaleAuthMethods = ["token", "password", "device-token", "unknown", ""]; + for (const authMethod of nonTailscaleAuthMethods) { + expect( + computeShouldAutoApprove({ + isLocalClient: false, + deviceAutoApprove: "tailscale", + authMethod, + }), + ).toBe(false); + } + }); + + it("never auto-approves any remote auth when config is none", () => { + const allAuthMethods = ["token", "password", "device-token", "tailscale", "unknown", ""]; + for (const authMethod of allAuthMethods) { + expect( + computeShouldAutoApprove({ + isLocalClient: false, + deviceAutoApprove: "none", + authMethod, + }), + ).toBe(false); + } + }); + }); +}); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 948d6cefb..3b1594e97 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -623,6 +623,10 @@ export function attachGatewayWsMessageHandler(params: { const skipPairing = allowControlUiBypass && hasSharedAuth; if (device && devicePublicKey && !skipPairing) { + const deviceAutoApprove = configSnapshot.gateway?.devices?.autoApprove ?? "none"; + const shouldAutoApprove = + isLocalClient || (deviceAutoApprove === "tailscale" && authMethod === "tailscale"); + const requirePairing = async (reason: string, _paired?: { deviceId: string }) => { const pairing = await requestDevicePairing({ deviceId: device.id, @@ -634,7 +638,7 @@ export function attachGatewayWsMessageHandler(params: { role, scopes, remoteIp: reportedClientIp, - silent: isLocalClient, + silent: shouldAutoApprove, }); const context = buildRequestContext(); if (pairing.request.silent === true) { @@ -650,6 +654,8 @@ export function attachGatewayWsMessageHandler(params: { deviceId: approved.device.deviceId, decision: "approved", ts: Date.now(), + autoApproved: true, + autoApproveReason: isLocalClient ? "local" : "tailscale", }, { dropIfSlow: true }, ); diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 0896e2a4f..541517cb9 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -104,6 +104,55 @@ describe("security audit", () => { ); }); + it("emits info when device auto-approve is set to tailscale", async () => { + const cfg: MoltbotConfig = { + gateway: { + devices: { + autoApprove: "tailscale", + }, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "gateway.devices.auto_approve_tailscale", + severity: "info", + }), + ]), + ); + }); + + it("does NOT emit device auto-approve finding when set to none", async () => { + const cfg: MoltbotConfig = { + gateway: { + devices: { + autoApprove: "none", + }, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expect(res.findings).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "gateway.devices.auto_approve_tailscale", + }), + ]), + ); + }); + it("flags logging.redactSensitive=off", async () => { const cfg: OpenClawConfig = { logging: { redactSensitive: "off" }, diff --git a/src/security/audit.ts b/src/security/audit.ts index e983301e6..3f28398be 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -320,6 +320,18 @@ function collectGatewayConfigFindings( }); } + const deviceAutoApprove = cfg.gateway?.devices?.autoApprove ?? "none"; + if (deviceAutoApprove === "tailscale") { + findings.push({ + checkId: "gateway.devices.auto_approve_tailscale", + severity: "info", + title: "Device auto-approve via Tailscale enabled", + detail: + 'gateway.devices.autoApprove="tailscale" auto-approves device pairing for Tailscale-authenticated connections. ' + + 'Safe for personal tailnets; for shared/corporate tailnets, consider keeping autoApprove="none".', + }); + } + if (cfg.gateway?.controlUi?.allowInsecureAuth === true) { findings.push({ checkId: "gateway.control_ui.insecure_auth",