diff --git a/CHANGELOG.md b/CHANGELOG.md index d5f8382dd5..8b33458faa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,8 +80,8 @@ Docs: https://docs.openclaw.ai - Security/Browser: route browser URL navigation through one SSRF-guarded validation path for tab-open/CDP-target/Playwright navigation flows and block private/metadata destinations by default (configurable via `browser.ssrfPolicy`). This ships in the next npm release. Thanks @dorjoos for reporting. - Security/Exec: for the next npm release, harden safe-bin stdin-only enforcement by blocking output/recursive flags (`sort -o/--output`, grep recursion) and tightening default safe bins to remove `sort`/`grep`, preventing safe-bin allowlist bypass for file writes/recursive reads. Thanks @nedlir for reporting. - Cron/Webhooks: protect cron webhook POST delivery with SSRF-guarded outbound fetch (`fetchWithSsrFGuard`) to block private/metadata destinations before request dispatch. Thanks @Adam55A-code. -- Security/Gateway/Agents: remove implicit admin scopes from agent tool gateway calls by classifying methods to least-privilege operator scopes, and restrict `cron`/`gateway` tools to owner senders (with explicit runtime owner checks) to prevent non-owner DM privilege escalation. Ships in the next npm release. Thanks @Adam55A-code for reporting. -- Security/Gateway: centralize gateway method-scope authorization and default non-CLI gateway callers to least-privilege method scopes, with explicit CLI scope handling and regression coverage to prevent scope drift. +- Security/Gateway/Agents: remove implicit admin scopes from agent tool gateway calls by classifying methods to least-privilege operator scopes, and enforce owner-only tooling (`cron`, `gateway`, `whatsapp_login`) through centralized tool-policy wrappers plus tool metadata to prevent non-owner DM privilege escalation. Ships in the next npm release. Thanks @Adam55A-code for reporting. +- Security/Gateway: centralize gateway method-scope authorization and default non-CLI gateway callers to least-privilege method scopes, with explicit CLI scope handling, full core-handler scope classification coverage, and regression guards to prevent scope drift. - Security/Net: block SSRF bypass via NAT64 (`64:ff9b::/96`, `64:ff9b:1::/48`), 6to4 (`2002::/16`), and Teredo (`2001:0000::/32`) IPv6 transition addresses, and fail closed on IPv6 parse errors. Thanks @jackhax. ## 2026.2.17 diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 1a534ed40c..9e1ea5934a 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -7,8 +7,6 @@ import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry, } from "openclaw/plugin-sdk"; -import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js"; -import type { DynamicAgentCreationConfig } from "./types.js"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { tryRecordMessage } from "./dedup.js"; @@ -30,6 +28,8 @@ import { import { createFeishuReplyDispatcher } from "./reply-dispatcher.js"; import { getFeishuRuntime } from "./runtime.js"; import { getMessageFeishu, sendMessageFeishu } from "./send.js"; +import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js"; +import type { DynamicAgentCreationConfig } from "./types.js"; // --- Permission error extraction --- // Extract permission grant URL from Feishu API error response. diff --git a/src/agents/openclaw-gateway-tool.e2e.test.ts b/src/agents/openclaw-gateway-tool.e2e.test.ts index 04137f7dc1..77eb4d20e5 100644 --- a/src/agents/openclaw-gateway-tool.e2e.test.ts +++ b/src/agents/openclaw-gateway-tool.e2e.test.ts @@ -17,23 +17,15 @@ vi.mock("./tools/gateway.js", () => ({ })); describe("gateway tool", () => { - it("rejects non-owner callers explicitly", async () => { - const { callGatewayTool } = await import("./tools/gateway.js"); + it("marks gateway as owner-only", async () => { const tool = createOpenClawTools({ - senderIsOwner: false, config: { commands: { restart: true } }, }).find((candidate) => candidate.name === "gateway"); expect(tool).toBeDefined(); if (!tool) { throw new Error("missing gateway tool"); } - - await expect( - tool.execute("call-owner-check", { - action: "config.get", - }), - ).rejects.toThrow("Tool restricted to owner senders."); - expect(callGatewayTool).not.toHaveBeenCalled(); + expect(tool.ownerOnly).toBe(true); }); it("schedules SIGUSR1 restart", async () => { diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index fbdab56352..1c99a6dce5 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -114,7 +114,6 @@ export function createOpenClawTools(options?: { }), createCronTool({ agentSessionKey: options?.agentSessionKey, - senderIsOwner: options?.senderIsOwner, }), ...(messageTool ? [messageTool] : []), createTtsTool({ @@ -124,7 +123,6 @@ export function createOpenClawTools(options?: { createGatewayTool({ agentSessionKey: options?.agentSessionKey, config: options?.config, - senderIsOwner: options?.senderIsOwner, }), createAgentsListTool({ agentSessionKey: options?.agentSessionKey, diff --git a/src/agents/tool-policy.e2e.test.ts b/src/agents/tool-policy.e2e.test.ts index 57396fb546..cf6ab15d34 100644 --- a/src/agents/tool-policy.e2e.test.ts +++ b/src/agents/tool-policy.e2e.test.ts @@ -22,11 +22,13 @@ function createOwnerPolicyTools() { }, { name: "cron", + ownerOnly: true, // oxlint-disable-next-line typescript/no-explicit-any execute: async () => ({ content: [], details: {} }) as any, }, { name: "gateway", + ownerOnly: true, // oxlint-disable-next-line typescript/no-explicit-any execute: async () => ({ content: [], details: {} }) as any, }, @@ -89,6 +91,19 @@ describe("tool-policy", () => { const filtered = applyOwnerOnlyToolPolicy(tools, true); expect(filtered.map((t) => t.name)).toEqual(["read", "cron", "gateway", "whatsapp_login"]); }); + + it("honors ownerOnly metadata for custom tool names", async () => { + const tools = [ + { + name: "custom_admin_tool", + ownerOnly: true, + // oxlint-disable-next-line typescript/no-explicit-any + execute: async () => ({ content: [], details: {} }) as any, + }, + ] as unknown as AnyAgentTool[]; + expect(applyOwnerOnlyToolPolicy(tools, false)).toEqual([]); + expect(applyOwnerOnlyToolPolicy(tools, true)).toHaveLength(1); + }); }); describe("TOOL_POLICY_CONFORMANCE", () => { diff --git a/src/agents/tool-policy.ts b/src/agents/tool-policy.ts index 0b3edfbb39..393a110069 100644 --- a/src/agents/tool-policy.ts +++ b/src/agents/tool-policy.ts @@ -1,4 +1,4 @@ -import { OWNER_ONLY_TOOL_ERROR, type AnyAgentTool } from "./tools/common.js"; +import { type AnyAgentTool, wrapOwnerOnlyToolExecution } from "./tools/common.js"; export type ToolProfileId = "minimal" | "coding" | "messaging" | "full"; @@ -60,7 +60,7 @@ export const TOOL_GROUPS: Record = { ], }; -const OWNER_ONLY_TOOL_NAMES = new Set(["whatsapp_login", "cron", "gateway"]); +const OWNER_ONLY_TOOL_NAME_FALLBACKS = new Set(["whatsapp_login", "cron", "gateway"]); const TOOL_PROFILES: Record = { minimal: { @@ -87,28 +87,24 @@ export function normalizeToolName(name: string) { } export function isOwnerOnlyToolName(name: string) { - return OWNER_ONLY_TOOL_NAMES.has(normalizeToolName(name)); + return OWNER_ONLY_TOOL_NAME_FALLBACKS.has(normalizeToolName(name)); +} + +function isOwnerOnlyTool(tool: AnyAgentTool) { + return tool.ownerOnly === true || isOwnerOnlyToolName(tool.name); } export function applyOwnerOnlyToolPolicy(tools: AnyAgentTool[], senderIsOwner: boolean) { const withGuard = tools.map((tool) => { - if (!isOwnerOnlyToolName(tool.name)) { + if (!isOwnerOnlyTool(tool)) { return tool; } - if (senderIsOwner || !tool.execute) { - return tool; - } - return { - ...tool, - execute: async () => { - throw new Error(OWNER_ONLY_TOOL_ERROR); - }, - }; + return wrapOwnerOnlyToolExecution(tool, senderIsOwner); }); if (senderIsOwner) { return withGuard; } - return withGuard.filter((tool) => !isOwnerOnlyToolName(tool.name)); + return withGuard.filter((tool) => !isOwnerOnlyTool(tool)); } export function normalizeToolList(list?: string[]) { diff --git a/src/agents/tools/common.ts b/src/agents/tools/common.ts index 27f22be1d0..93f1db42ea 100644 --- a/src/agents/tools/common.ts +++ b/src/agents/tools/common.ts @@ -5,7 +5,9 @@ import type { ImageSanitizationLimits } from "../image-sanitization.js"; import { sanitizeToolResultImages } from "../tool-images.js"; // oxlint-disable-next-line typescript/no-explicit-any -export type AnyAgentTool = AgentTool; +export type AnyAgentTool = AgentTool & { + ownerOnly?: boolean; +}; export type StringParamOptions = { required?: boolean; @@ -210,10 +212,19 @@ export function jsonResult(payload: unknown): AgentToolResult { }; } -export function assertOwnerSender(senderIsOwner?: boolean): void { - if (senderIsOwner === false) { - throw new Error(OWNER_ONLY_TOOL_ERROR); +export function wrapOwnerOnlyToolExecution( + tool: AnyAgentTool, + senderIsOwner: boolean, +): AnyAgentTool { + if (tool.ownerOnly !== true || senderIsOwner || !tool.execute) { + return tool; } + return { + ...tool, + execute: async () => { + throw new Error(OWNER_ONLY_TOOL_ERROR); + }, + }; } export async function imageResult(params: { diff --git a/src/agents/tools/cron-tool.e2e.test.ts b/src/agents/tools/cron-tool.e2e.test.ts index fd9039d5db..713c61b9d8 100644 --- a/src/agents/tools/cron-tool.e2e.test.ts +++ b/src/agents/tools/cron-tool.e2e.test.ts @@ -30,14 +30,9 @@ describe("cron tool", () => { callGatewayMock.mockResolvedValue({ ok: true }); }); - it("rejects non-owner callers explicitly", async () => { - const tool = createCronTool({ senderIsOwner: false }); - await expect( - tool.execute("call-owner-check", { - action: "status", - }), - ).rejects.toThrow("Tool restricted to owner senders."); - expect(callGatewayMock).not.toHaveBeenCalled(); + it("marks cron as owner-only", async () => { + const tool = createCronTool(); + expect(tool.ownerOnly).toBe(true); }); it.each([ diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 8a5b51519f..35997b4e9b 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -8,7 +8,7 @@ import { extractTextFromChatContent } from "../../shared/chat-content.js"; import { isRecord, truncateUtf16Safe } from "../../utils.js"; import { resolveSessionAgentId } from "../agent-scope.js"; import { optionalStringEnum, stringEnum } from "../schema/typebox.js"; -import { assertOwnerSender, type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; +import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; import { callGatewayTool, readGatewayCallOptions, type GatewayCallOptions } from "./gateway.js"; import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js"; @@ -48,7 +48,6 @@ const CronToolSchema = Type.Object({ type CronToolOptions = { agentSessionKey?: string; - senderIsOwner?: boolean; }; type ChatMessage = { @@ -202,6 +201,7 @@ export function createCronTool(opts?: CronToolOptions): AnyAgentTool { return { label: "Cron", name: "cron", + ownerOnly: true, description: `Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. ACTIONS: @@ -260,7 +260,6 @@ WAKE MODES (for wake action): Use jobId as the canonical identifier; id is accepted for compatibility. Use contextMessages (0-10) to add previous messages as context to the job text.`, parameters: CronToolSchema, execute: async (_toolCallId, args) => { - assertOwnerSender(opts?.senderIsOwner); const params = args as Record; const action = readStringParam(params, "action", { required: true }); const gatewayOpts: GatewayCallOptions = { diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts index ee36008245..5cd59d756d 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -10,7 +10,7 @@ import { } from "../../infra/restart-sentinel.js"; import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js"; import { stringEnum } from "../schema/typebox.js"; -import { assertOwnerSender, type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; +import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; import { callGatewayTool, readGatewayCallOptions } from "./gateway.js"; const DEFAULT_UPDATE_TIMEOUT_MS = 20 * 60_000; @@ -65,16 +65,15 @@ const GatewayToolSchema = Type.Object({ export function createGatewayTool(opts?: { agentSessionKey?: string; config?: OpenClawConfig; - senderIsOwner?: boolean; }): AnyAgentTool { return { label: "Gateway", name: "gateway", + ownerOnly: true, description: "Restart, apply config, or update the gateway in-place (SIGUSR1). Use config.patch for safe partial config updates (merges with existing). Use config.apply only when replacing entire config. Both trigger restart after writing. Always pass a human-readable completion message via the `note` parameter so the system can deliver it to the user after restart.", parameters: GatewayToolSchema, execute: async (_toolCallId, args) => { - assertOwnerSender(opts?.senderIsOwner); const params = args as Record; const action = readStringParam(params, "action", { required: true }); if (action === "restart") { diff --git a/src/auto-reply/reply/get-reply-inline-actions.ts b/src/auto-reply/reply/get-reply-inline-actions.ts index be943f8604..4dc6e5e7ee 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.ts @@ -1,6 +1,7 @@ import { collectTextContentBlocks } from "../../agents/content-blocks.js"; import { createOpenClawTools } from "../../agents/openclaw-tools.js"; import type { SkillCommandSpec } from "../../agents/skills.js"; +import { applyOwnerOnlyToolPolicy } from "../../agents/tool-policy.js"; import { getChannelDock } from "../../channels/dock.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; @@ -200,10 +201,10 @@ export async function handleInlineActions(params: { agentDir, workspaceDir, config: cfg, - senderIsOwner: command.senderIsOwner, }); + const authorizedTools = applyOwnerOnlyToolPolicy(tools, command.senderIsOwner); - const tool = tools.find((candidate) => candidate.name === dispatch.toolName); + const tool = authorizedTools.find((candidate) => candidate.name === dispatch.toolName); if (!tool) { typing.cleanup(); return { kind: "reply", reply: { text: `❌ Tool not available: ${dispatch.toolName}` } }; diff --git a/src/channels/plugins/agent-tools/whatsapp-login.ts b/src/channels/plugins/agent-tools/whatsapp-login.ts index 1418dcc4fe..bba6380841 100644 --- a/src/channels/plugins/agent-tools/whatsapp-login.ts +++ b/src/channels/plugins/agent-tools/whatsapp-login.ts @@ -5,6 +5,7 @@ export function createWhatsAppLoginTool(): ChannelAgentTool { return { label: "WhatsApp Login", name: "whatsapp_login", + ownerOnly: true, description: "Generate a WhatsApp QR code for linking, or wait for the scan to complete.", // NOTE: Using Type.Unsafe for action enum instead of Type.Union([Type.Literal(...)] // because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema. diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 63fde936de..5c0b075b54 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -12,7 +12,9 @@ export type ChannelId = ChatChannelId | (string & {}); export type ChannelOutboundTargetMode = "explicit" | "implicit" | "heartbeat"; -export type ChannelAgentTool = AgentTool; +export type ChannelAgentTool = AgentTool & { + ownerOnly?: boolean; +}; export type ChannelAgentToolFactory = (params: { cfg?: OpenClawConfig }) => ChannelAgentTool[]; diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 09845b7e1c..300a556436 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -186,95 +186,153 @@ export function buildGatewayConnectionDetails( }; } -async function callGatewayWithScopes>( - opts: CallGatewayBaseOptions, - scopes: OperatorScope[], -): Promise { - const timeoutMs = - typeof opts.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : 10_000; - const safeTimerTimeoutMs = Math.max(1, Math.min(Math.floor(timeoutMs), 2_147_483_647)); - const config = opts.config ?? loadConfig(); - const isRemoteMode = config.gateway?.mode === "remote"; - const remote = isRemoteMode ? config.gateway?.remote : undefined; - const urlOverride = - typeof opts.url === "string" && opts.url.trim().length > 0 ? opts.url.trim() : undefined; - const explicitAuth = resolveExplicitGatewayAuth({ token: opts.token, password: opts.password }); - ensureExplicitGatewayAuth({ - urlOverride, - auth: explicitAuth, - errorHint: "Fix: pass --token or --password (or gatewayToken in tools).", - configPath: opts.configPath ?? resolveConfigPath(process.env, resolveStateDir(process.env)), - }); - const remoteUrl = - typeof remote?.url === "string" && remote.url.trim().length > 0 ? remote.url.trim() : undefined; - if (isRemoteMode && !urlOverride && !remoteUrl) { - const configPath = - opts.configPath ?? resolveConfigPath(process.env, resolveStateDir(process.env)); - throw new Error( - [ - "gateway remote mode misconfigured: gateway.remote.url missing", - `Config: ${configPath}`, - "Fix: set gateway.remote.url, or set gateway.mode=local.", - ].join("\n"), - ); +type GatewayRemoteSettings = { + url?: string; + token?: string; + password?: string; + tlsFingerprint?: string; +}; + +type ResolvedGatewayCallContext = { + config: OpenClawConfig; + configPath: string; + isRemoteMode: boolean; + remote?: GatewayRemoteSettings; + urlOverride?: string; + remoteUrl?: string; + explicitAuth: ExplicitGatewayAuth; +}; + +function trimToUndefined(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; } - const authToken = config.gateway?.auth?.token; - const authPassword = config.gateway?.auth?.password; - const connectionDetails = buildGatewayConnectionDetails({ - config, - url: urlOverride, - ...(opts.configPath ? { configPath: opts.configPath } : {}), - }); - const url = connectionDetails.url; - const useLocalTls = - config.gateway?.tls?.enabled === true && !urlOverride && !remoteUrl && url.startsWith("wss://"); - const tlsRuntime = useLocalTls ? await loadGatewayTlsRuntime(config.gateway?.tls) : undefined; - const remoteTlsFingerprint = - isRemoteMode && !urlOverride && remoteUrl && typeof remote?.tlsFingerprint === "string" - ? remote.tlsFingerprint.trim() - : undefined; - const overrideTlsFingerprint = - typeof opts.tlsFingerprint === "string" ? opts.tlsFingerprint.trim() : undefined; - const tlsFingerprint = - overrideTlsFingerprint || - remoteTlsFingerprint || - (tlsRuntime?.enabled ? tlsRuntime.fingerprintSha256 : undefined); + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function resolveGatewayCallTimeout(timeoutValue: unknown): { + timeoutMs: number; + safeTimerTimeoutMs: number; +} { + const timeoutMs = + typeof timeoutValue === "number" && Number.isFinite(timeoutValue) ? timeoutValue : 10_000; + const safeTimerTimeoutMs = Math.max(1, Math.min(Math.floor(timeoutMs), 2_147_483_647)); + return { timeoutMs, safeTimerTimeoutMs }; +} + +function resolveGatewayCallContext(opts: CallGatewayBaseOptions): ResolvedGatewayCallContext { + const config = opts.config ?? loadConfig(); + const configPath = + opts.configPath ?? resolveConfigPath(process.env, resolveStateDir(process.env)); + const isRemoteMode = config.gateway?.mode === "remote"; + const remote = isRemoteMode + ? (config.gateway?.remote as GatewayRemoteSettings | undefined) + : undefined; + const urlOverride = trimToUndefined(opts.url); + const remoteUrl = trimToUndefined(remote?.url); + const explicitAuth = resolveExplicitGatewayAuth({ token: opts.token, password: opts.password }); + return { config, configPath, isRemoteMode, remote, urlOverride, remoteUrl, explicitAuth }; +} + +function ensureRemoteModeUrlConfigured(context: ResolvedGatewayCallContext): void { + if (!context.isRemoteMode || context.urlOverride || context.remoteUrl) { + return; + } + throw new Error( + [ + "gateway remote mode misconfigured: gateway.remote.url missing", + `Config: ${context.configPath}`, + "Fix: set gateway.remote.url, or set gateway.mode=local.", + ].join("\n"), + ); +} + +function resolveGatewayCredentials(context: ResolvedGatewayCallContext): { + token?: string; + password?: string; +} { + const authToken = context.config.gateway?.auth?.token; + const authPassword = context.config.gateway?.auth?.password; const token = - explicitAuth.token || - (!urlOverride - ? isRemoteMode - ? typeof remote?.token === "string" && remote.token.trim().length > 0 - ? remote.token.trim() - : undefined - : process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || - process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() || - (typeof authToken === "string" && authToken.trim().length > 0 - ? authToken.trim() - : undefined) + context.explicitAuth.token || + (!context.urlOverride + ? context.isRemoteMode + ? trimToUndefined(context.remote?.token) + : trimToUndefined(process.env.OPENCLAW_GATEWAY_TOKEN) || + trimToUndefined(process.env.CLAWDBOT_GATEWAY_TOKEN) || + trimToUndefined(authToken) : undefined); const password = - explicitAuth.password || - (!urlOverride - ? process.env.OPENCLAW_GATEWAY_PASSWORD?.trim() || - process.env.CLAWDBOT_GATEWAY_PASSWORD?.trim() || - (isRemoteMode - ? typeof remote?.password === "string" && remote.password.trim().length > 0 - ? remote.password.trim() - : undefined - : typeof authPassword === "string" && authPassword.trim().length > 0 - ? authPassword.trim() - : undefined) + context.explicitAuth.password || + (!context.urlOverride + ? trimToUndefined(process.env.OPENCLAW_GATEWAY_PASSWORD) || + trimToUndefined(process.env.CLAWDBOT_GATEWAY_PASSWORD) || + (context.isRemoteMode + ? trimToUndefined(context.remote?.password) + : trimToUndefined(authPassword)) : undefined); + return { token, password }; +} - const formatCloseError = (code: number, reason: string) => { - const reasonText = reason?.trim() || "no close reason"; - const hint = - code === 1006 ? "abnormal closure (no close frame)" : code === 1000 ? "normal closure" : ""; - const suffix = hint ? ` ${hint}` : ""; - return `gateway closed (${code}${suffix}): ${reasonText}\n${connectionDetails.message}`; - }; - const formatTimeoutError = () => - `gateway timeout after ${timeoutMs}ms\n${connectionDetails.message}`; +async function resolveGatewayTlsFingerprint(params: { + opts: CallGatewayBaseOptions; + context: ResolvedGatewayCallContext; + url: string; +}): Promise { + const { opts, context, url } = params; + const useLocalTls = + context.config.gateway?.tls?.enabled === true && + !context.urlOverride && + !context.remoteUrl && + url.startsWith("wss://"); + const tlsRuntime = useLocalTls + ? await loadGatewayTlsRuntime(context.config.gateway?.tls) + : undefined; + const overrideTlsFingerprint = trimToUndefined(opts.tlsFingerprint); + const remoteTlsFingerprint = + context.isRemoteMode && !context.urlOverride && context.remoteUrl + ? trimToUndefined(context.remote?.tlsFingerprint) + : undefined; + return ( + overrideTlsFingerprint || + remoteTlsFingerprint || + (tlsRuntime?.enabled ? tlsRuntime.fingerprintSha256 : undefined) + ); +} + +function formatGatewayCloseError( + code: number, + reason: string, + connectionDetails: GatewayConnectionDetails, +): string { + const reasonText = reason?.trim() || "no close reason"; + const hint = + code === 1006 ? "abnormal closure (no close frame)" : code === 1000 ? "normal closure" : ""; + const suffix = hint ? ` ${hint}` : ""; + return `gateway closed (${code}${suffix}): ${reasonText}\n${connectionDetails.message}`; +} + +function formatGatewayTimeoutError( + timeoutMs: number, + connectionDetails: GatewayConnectionDetails, +): string { + return `gateway timeout after ${timeoutMs}ms\n${connectionDetails.message}`; +} + +async function executeGatewayRequestWithScopes(params: { + opts: CallGatewayBaseOptions; + scopes: OperatorScope[]; + url: string; + token?: string; + password?: string; + tlsFingerprint?: string; + timeoutMs: number; + safeTimerTimeoutMs: number; + connectionDetails: GatewayConnectionDetails; +}): Promise { + const { opts, scopes, url, token, password, tlsFingerprint, timeoutMs, safeTimerTimeoutMs } = + params; return await new Promise((resolve, reject) => { let settled = false; let ignoreClose = false; @@ -327,20 +385,54 @@ async function callGatewayWithScopes>( } ignoreClose = true; client.stop(); - stop(new Error(formatCloseError(code, reason))); + stop(new Error(formatGatewayCloseError(code, reason, params.connectionDetails))); }, }); const timer = setTimeout(() => { ignoreClose = true; client.stop(); - stop(new Error(formatTimeoutError())); + stop(new Error(formatGatewayTimeoutError(timeoutMs, params.connectionDetails))); }, safeTimerTimeoutMs); client.start(); }); } +async function callGatewayWithScopes>( + opts: CallGatewayBaseOptions, + scopes: OperatorScope[], +): Promise { + const { timeoutMs, safeTimerTimeoutMs } = resolveGatewayCallTimeout(opts.timeoutMs); + const context = resolveGatewayCallContext(opts); + ensureExplicitGatewayAuth({ + urlOverride: context.urlOverride, + auth: context.explicitAuth, + errorHint: "Fix: pass --token or --password (or gatewayToken in tools).", + configPath: context.configPath, + }); + ensureRemoteModeUrlConfigured(context); + const connectionDetails = buildGatewayConnectionDetails({ + config: context.config, + url: context.urlOverride, + ...(opts.configPath ? { configPath: opts.configPath } : {}), + }); + const url = connectionDetails.url; + const tlsFingerprint = await resolveGatewayTlsFingerprint({ opts, context, url }); + const { token, password } = resolveGatewayCredentials(context); + return await executeGatewayRequestWithScopes({ + opts, + scopes, + url, + token, + password, + tlsFingerprint, + timeoutMs, + safeTimerTimeoutMs, + connectionDetails, + }); +} + export async function callGatewayScoped>( opts: CallGatewayScopedOptions, ): Promise { diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index 61a80bfeaa..6a054fc64e 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; import { authorizeOperatorScopesForMethod, + isGatewayMethodClassified, resolveLeastPrivilegeOperatorScopesForMethod, } from "./method-scopes.js"; +import { coreGatewayHandlers } from "./server-methods.js"; describe("method scope resolution", () => { it("classifies sessions.resolve as read and poll as write", () => { @@ -48,3 +50,12 @@ describe("operator scope authorization", () => { }); }); }); + +describe("core gateway method classification", () => { + it("classifies every exposed core gateway handler method", () => { + const unclassified = Object.keys(coreGatewayHandlers).filter( + (method) => !isGatewayMethodClassified(method), + ); + expect(unclassified).toEqual([]); + }); +}); diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index c270a4495a..1fd9377ead 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -17,110 +17,137 @@ export const CLI_DEFAULT_OPERATOR_SCOPES: OperatorScope[] = [ PAIRING_SCOPE, ]; -const APPROVAL_METHODS = new Set([ - "exec.approval.request", - "exec.approval.waitDecision", - "exec.approval.resolve", -]); - const NODE_ROLE_METHODS = new Set(["node.invoke.result", "node.event", "skills.bins"]); -const PAIRING_METHODS = new Set([ - "node.pair.request", - "node.pair.list", - "node.pair.approve", - "node.pair.reject", - "node.pair.verify", - "device.pair.list", - "device.pair.approve", - "device.pair.reject", - "device.pair.remove", - "device.token.rotate", - "device.token.revoke", - "node.rename", -]); +const METHOD_SCOPE_GROUPS: Record = { + [APPROVALS_SCOPE]: [ + "exec.approval.request", + "exec.approval.waitDecision", + "exec.approval.resolve", + ], + [PAIRING_SCOPE]: [ + "node.pair.request", + "node.pair.list", + "node.pair.approve", + "node.pair.reject", + "node.pair.verify", + "device.pair.list", + "device.pair.approve", + "device.pair.reject", + "device.pair.remove", + "device.token.rotate", + "device.token.revoke", + "node.rename", + ], + [READ_SCOPE]: [ + "health", + "logs.tail", + "channels.status", + "status", + "usage.status", + "usage.cost", + "tts.status", + "tts.providers", + "models.list", + "agents.list", + "agent.identity.get", + "skills.status", + "voicewake.get", + "sessions.list", + "sessions.preview", + "sessions.resolve", + "sessions.usage", + "sessions.usage.timeseries", + "sessions.usage.logs", + "cron.list", + "cron.status", + "cron.runs", + "system-presence", + "last-heartbeat", + "node.list", + "node.describe", + "chat.history", + "config.get", + "talk.config", + "agents.files.list", + "agents.files.get", + ], + [WRITE_SCOPE]: [ + "send", + "poll", + "agent", + "agent.wait", + "wake", + "talk.mode", + "tts.enable", + "tts.disable", + "tts.convert", + "tts.setProvider", + "voicewake.set", + "node.invoke", + "chat.send", + "chat.abort", + "browser.request", + "push.test", + ], + [ADMIN_SCOPE]: [ + "channels.logout", + "agents.create", + "agents.update", + "agents.delete", + "skills.install", + "skills.update", + "cron.add", + "cron.update", + "cron.remove", + "cron.run", + "sessions.patch", + "sessions.reset", + "sessions.delete", + "sessions.compact", + "connect", + "chat.inject", + "web.login.start", + "web.login.wait", + "set-heartbeats", + "system-event", + "agents.files.set", + ], +}; -const ADMIN_METHOD_PREFIXES = ["exec.approvals."]; +const ADMIN_METHOD_PREFIXES = ["exec.approvals.", "config.", "wizard.", "update."] as const; -const READ_METHODS = new Set([ - "health", - "logs.tail", - "channels.status", - "status", - "usage.status", - "usage.cost", - "tts.status", - "tts.providers", - "models.list", - "agents.list", - "agent.identity.get", - "skills.status", - "voicewake.get", - "sessions.list", - "sessions.preview", - "sessions.resolve", - "cron.list", - "cron.status", - "cron.runs", - "system-presence", - "last-heartbeat", - "node.list", - "node.describe", - "chat.history", - "config.get", - "talk.config", -]); +const METHOD_SCOPE_BY_NAME = new Map( + Object.entries(METHOD_SCOPE_GROUPS).flatMap(([scope, methods]) => + methods.map((method) => [method, scope as OperatorScope]), + ), +); -const WRITE_METHODS = new Set([ - "send", - "poll", - "agent", - "agent.wait", - "wake", - "talk.mode", - "tts.enable", - "tts.disable", - "tts.convert", - "tts.setProvider", - "voicewake.set", - "node.invoke", - "chat.send", - "chat.abort", - "browser.request", - "push.test", -]); - -const ADMIN_METHODS = new Set([ - "channels.logout", - "agents.create", - "agents.update", - "agents.delete", - "skills.install", - "skills.update", - "cron.add", - "cron.update", - "cron.remove", - "cron.run", - "sessions.patch", - "sessions.reset", - "sessions.delete", - "sessions.compact", -]); +function resolveScopedMethod(method: string): OperatorScope | undefined { + const explicitScope = METHOD_SCOPE_BY_NAME.get(method); + if (explicitScope) { + return explicitScope; + } + if (ADMIN_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix))) { + return ADMIN_SCOPE; + } + return undefined; +} export function isApprovalMethod(method: string): boolean { - return APPROVAL_METHODS.has(method); + return resolveScopedMethod(method) === APPROVALS_SCOPE; } export function isPairingMethod(method: string): boolean { - return PAIRING_METHODS.has(method); + return resolveScopedMethod(method) === PAIRING_SCOPE; } export function isReadMethod(method: string): boolean { - return READ_METHODS.has(method); + return resolveScopedMethod(method) === READ_SCOPE; } export function isWriteMethod(method: string): boolean { - return WRITE_METHODS.has(method); + return resolveScopedMethod(method) === WRITE_SCOPE; } export function isNodeRoleMethod(method: string): boolean { @@ -128,36 +155,11 @@ export function isNodeRoleMethod(method: string): boolean { } export function isAdminOnlyMethod(method: string): boolean { - if (ADMIN_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix))) { - return true; - } - if ( - method.startsWith("config.") || - method.startsWith("wizard.") || - method.startsWith("update.") - ) { - return true; - } - return ADMIN_METHODS.has(method); + return resolveScopedMethod(method) === ADMIN_SCOPE; } export function resolveRequiredOperatorScopeForMethod(method: string): OperatorScope | undefined { - if (isApprovalMethod(method)) { - return APPROVALS_SCOPE; - } - if (isPairingMethod(method)) { - return PAIRING_SCOPE; - } - if (isReadMethod(method)) { - return READ_SCOPE; - } - if (isWriteMethod(method)) { - return WRITE_SCOPE; - } - if (isAdminOnlyMethod(method)) { - return ADMIN_SCOPE; - } - return undefined; + return resolveScopedMethod(method); } export function resolveLeastPrivilegeOperatorScopesForMethod(method: string): OperatorScope[] { @@ -188,3 +190,10 @@ export function authorizeOperatorScopesForMethod( } return { allowed: false, missingScope: requiredScope }; } + +export function isGatewayMethodClassified(method: string): boolean { + if (isNodeRoleMethod(method)) { + return true; + } + return resolveRequiredOperatorScopeForMethod(method) !== undefined; +}