diff --git a/CHANGELOG.md b/CHANGELOG.md index ef3f7e5782a..ccb0d5ec2f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,7 @@ 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/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/docs/gateway/security/index.md b/docs/gateway/security/index.md index fe39abf4d95..fd8d737d582 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -662,6 +662,8 @@ One “safe default” config that keeps the Gateway private, requires DM pairin If you want “safer by default” tool execution too, add a sandbox + deny dangerous tools for any non-owner agent (example below under “Per-agent access profiles”). +Built-in baseline for chat-driven agent turns: non-owner senders cannot use the `cron` or `gateway` tools. + ## Sandboxing (recommended) Dedicated doc: [Sandboxing](/gateway/sandboxing) diff --git a/src/agents/openclaw-gateway-tool.e2e.test.ts b/src/agents/openclaw-gateway-tool.e2e.test.ts index 66dfb9483e9..bf800e4b1fd 100644 --- a/src/agents/openclaw-gateway-tool.e2e.test.ts +++ b/src/agents/openclaw-gateway-tool.e2e.test.ts @@ -16,6 +16,25 @@ vi.mock("./tools/gateway.js", () => ({ })); describe("gateway tool", () => { + it("rejects non-owner callers explicitly", async () => { + const { callGatewayTool } = await import("./tools/gateway.js"); + 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(); + }); + it("schedules SIGUSR1 restart", async () => { vi.useFakeTimers(); const kill = vi.spyOn(process, "kill").mockImplementation(() => true); diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 9c9fb722405..eb2bb369dc7 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -61,6 +61,8 @@ export function createOpenClawTools(options?: { requireExplicitMessageTarget?: boolean; /** If true, omit the message tool from the tool list. */ disableMessageTool?: boolean; + /** Whether the requesting sender is an owner. */ + senderIsOwner?: boolean; }): AnyAgentTool[] { const workspaceDir = resolveWorkspaceRoot(options?.workspaceDir); const imageTool = options?.agentDir?.trim() @@ -109,6 +111,7 @@ export function createOpenClawTools(options?: { }), createCronTool({ agentSessionKey: options?.agentSessionKey, + senderIsOwner: options?.senderIsOwner, }), ...(messageTool ? [messageTool] : []), createTtsTool({ @@ -118,6 +121,7 @@ export function createOpenClawTools(options?: { createGatewayTool({ agentSessionKey: options?.agentSessionKey, config: options?.config, + senderIsOwner: options?.senderIsOwner, }), createAgentsListTool({ agentSessionKey: options?.agentSessionKey, diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 32808a2a8cc..340b3427707 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -455,6 +455,7 @@ export function createOpenClawCodingTools(options?: { requireExplicitMessageTarget: options?.requireExplicitMessageTarget, disableMessageTool: options?.disableMessageTool, requesterAgentIdOverride: agentId, + senderIsOwner: options?.senderIsOwner, }), ]; // Security: treat unknown/undefined as unauthorized (opt-in, not opt-out) diff --git a/src/agents/pi-tools.whatsapp-login-gating.e2e.test.ts b/src/agents/pi-tools.whatsapp-login-gating.e2e.test.ts index a0b6aa8a6e2..61f65fc0541 100644 --- a/src/agents/pi-tools.whatsapp-login-gating.e2e.test.ts +++ b/src/agents/pi-tools.whatsapp-login-gating.e2e.test.ts @@ -14,22 +14,28 @@ vi.mock("./channel-tools.js", () => { }; }); -describe("whatsapp_login tool gating", () => { - it("removes whatsapp_login for unauthorized senders", () => { +describe("owner-only tool gating", () => { + it("removes owner-only tools for unauthorized senders", () => { const tools = createOpenClawCodingTools({ senderIsOwner: false }); const toolNames = tools.map((tool) => tool.name); expect(toolNames).not.toContain("whatsapp_login"); + expect(toolNames).not.toContain("cron"); + expect(toolNames).not.toContain("gateway"); }); - it("keeps whatsapp_login for authorized senders", () => { + it("keeps owner-only tools for authorized senders", () => { const tools = createOpenClawCodingTools({ senderIsOwner: true }); const toolNames = tools.map((tool) => tool.name); expect(toolNames).toContain("whatsapp_login"); + expect(toolNames).toContain("cron"); + expect(toolNames).toContain("gateway"); }); - it("defaults to removing whatsapp_login when owner status is unknown", () => { + it("defaults to removing owner-only tools when owner status is unknown", () => { const tools = createOpenClawCodingTools(); const toolNames = tools.map((tool) => tool.name); expect(toolNames).not.toContain("whatsapp_login"); + expect(toolNames).not.toContain("cron"); + expect(toolNames).not.toContain("gateway"); }); }); diff --git a/src/agents/tool-policy.e2e.test.ts b/src/agents/tool-policy.e2e.test.ts index 7054dc67ab2..57396fb546e 100644 --- a/src/agents/tool-policy.e2e.test.ts +++ b/src/agents/tool-policy.e2e.test.ts @@ -20,6 +20,16 @@ function createOwnerPolicyTools() { // oxlint-disable-next-line typescript/no-explicit-any execute: async () => ({ content: [], details: {} }) as any, }, + { + name: "cron", + // oxlint-disable-next-line typescript/no-explicit-any + execute: async () => ({ content: [], details: {} }) as any, + }, + { + name: "gateway", + // oxlint-disable-next-line typescript/no-explicit-any + execute: async () => ({ content: [], details: {} }) as any, + }, { name: "whatsapp_login", // oxlint-disable-next-line typescript/no-explicit-any @@ -63,6 +73,8 @@ describe("tool-policy", () => { it("identifies owner-only tools", () => { expect(isOwnerOnlyToolName("whatsapp_login")).toBe(true); + expect(isOwnerOnlyToolName("cron")).toBe(true); + expect(isOwnerOnlyToolName("gateway")).toBe(true); expect(isOwnerOnlyToolName("read")).toBe(false); }); @@ -75,7 +87,7 @@ describe("tool-policy", () => { it("keeps owner-only tools for the owner sender", async () => { const tools = createOwnerPolicyTools(); const filtered = applyOwnerOnlyToolPolicy(tools, true); - expect(filtered.map((t) => t.name)).toEqual(["read", "whatsapp_login"]); + expect(filtered.map((t) => t.name)).toEqual(["read", "cron", "gateway", "whatsapp_login"]); }); }); diff --git a/src/agents/tool-policy.ts b/src/agents/tool-policy.ts index 310980474df..ab5faf3a491 100644 --- a/src/agents/tool-policy.ts +++ b/src/agents/tool-policy.ts @@ -60,7 +60,7 @@ export const TOOL_GROUPS: Record = { ], }; -const OWNER_ONLY_TOOL_NAMES = new Set(["whatsapp_login"]); +const OWNER_ONLY_TOOL_NAMES = new Set(["whatsapp_login", "cron", "gateway"]); const TOOL_PROFILES: Record = { minimal: { diff --git a/src/agents/tools/cron-tool.e2e.test.ts b/src/agents/tools/cron-tool.e2e.test.ts index 7b6d1310e4a..9c030280f60 100644 --- a/src/agents/tools/cron-tool.e2e.test.ts +++ b/src/agents/tools/cron-tool.e2e.test.ts @@ -39,6 +39,16 @@ 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.each([ [ "update", diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 63ac6f83fd0..b692fdd7911 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -48,6 +48,7 @@ const CronToolSchema = Type.Object({ type CronToolOptions = { agentSessionKey?: string; + senderIsOwner?: boolean; }; type ChatMessage = { @@ -259,6 +260,9 @@ 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) => { + if (opts?.senderIsOwner === false) { + throw new Error("Tool restricted to owner senders."); + } 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 ea25f81c545..1e2f2cf7f4a 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -65,6 +65,7 @@ const GatewayToolSchema = Type.Object({ export function createGatewayTool(opts?: { agentSessionKey?: string; config?: OpenClawConfig; + senderIsOwner?: boolean; }): AnyAgentTool { return { label: "Gateway", @@ -73,6 +74,9 @@ export function createGatewayTool(opts?: { "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) => { + if (opts?.senderIsOwner === false) { + throw new Error("Tool restricted to owner senders."); + } const params = args as Record; const action = readStringParam(params, "action", { required: true }); if (action === "restart") { diff --git a/src/agents/tools/gateway.e2e.test.ts b/src/agents/tools/gateway.e2e.test.ts index ad18edcc6f6..0547c6174b5 100644 --- a/src/agents/tools/gateway.e2e.test.ts +++ b/src/agents/tools/gateway.e2e.test.ts @@ -32,6 +32,40 @@ describe("gateway tool defaults", () => { url: "ws://127.0.0.1:18789", token: "t", timeoutMs: 5000, + scopes: ["operator.read"], + }), + ); + }); + + it("uses least-privilege write scope for write methods", async () => { + callGatewayMock.mockResolvedValueOnce({ ok: true }); + await callGatewayTool("wake", {}, { mode: "now", text: "hi" }); + expect(callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "wake", + scopes: ["operator.write"], + }), + ); + }); + + it("uses admin scope only for admin methods", async () => { + callGatewayMock.mockResolvedValueOnce({ ok: true }); + await callGatewayTool("cron.add", {}, { id: "job-1" }); + expect(callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "cron.add", + scopes: ["operator.admin"], + }), + ); + }); + + it("default-denies unknown methods by sending no scopes", async () => { + callGatewayMock.mockResolvedValueOnce({ ok: true }); + await callGatewayTool("nonexistent.method", {}, {}); + expect(callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "nonexistent.method", + scopes: [], }), ); }); diff --git a/src/agents/tools/gateway.ts b/src/agents/tools/gateway.ts index b987db3b8ef..d4db24ef4c3 100644 --- a/src/agents/tools/gateway.ts +++ b/src/agents/tools/gateway.ts @@ -1,5 +1,6 @@ import { loadConfig, resolveGatewayPort } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; +import { resolveLeastPrivilegeOperatorScopesForMethod } from "../../gateway/method-scopes.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; import { readStringParam } from "./common.js"; @@ -109,6 +110,7 @@ export async function callGatewayTool>( extra?: { expectFinal?: boolean }, ) { const gateway = resolveGatewayOptions(opts); + const scopes = resolveLeastPrivilegeOperatorScopesForMethod(method); return await callGateway({ url: gateway.url, token: gateway.token, @@ -119,5 +121,6 @@ export async function callGatewayTool>( clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, clientDisplayName: "agent", mode: GATEWAY_CLIENT_MODES.BACKEND, + scopes, }); } diff --git a/src/auto-reply/reply/get-reply-inline-actions.ts b/src/auto-reply/reply/get-reply-inline-actions.ts index 9fdd0caf742..be943f8604e 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.ts @@ -200,6 +200,7 @@ export async function handleInlineActions(params: { agentDir, workspaceDir, config: cfg, + senderIsOwner: command.senderIsOwner, }); const tool = tools.find((candidate) => candidate.name === dispatch.toolName); diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 8ff455a3a8a..554c5f9b312 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -10,6 +10,7 @@ let lastClientOptions: { url?: string; token?: string; password?: string; + scopes?: string[]; onHelloOk?: () => void | Promise; onClose?: (code: number, reason: string) => void; } | null = null; @@ -54,6 +55,7 @@ vi.mock("./client.js", () => ({ url?: string; token?: string; password?: string; + scopes?: string[]; onHelloOk?: () => void | Promise; onClose?: (code: number, reason: string) => void; }) { @@ -195,6 +197,32 @@ describe("callGateway url resolution", () => { expect(lastClientOptions?.url).toBe("wss://override.example/ws"); expect(lastClientOptions?.token).toBe("explicit-token"); }); + + it("keeps legacy admin scopes when call scopes are omitted", async () => { + loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback" } }); + resolveGatewayPort.mockReturnValue(18789); + pickPrimaryTailnetIPv4.mockReturnValue(undefined); + + await callGateway({ method: "health" }); + + expect(lastClientOptions?.scopes).toEqual([ + "operator.admin", + "operator.approvals", + "operator.pairing", + ]); + }); + + it("passes explicit scopes through, including empty arrays", async () => { + loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback" } }); + resolveGatewayPort.mockReturnValue(18789); + pickPrimaryTailnetIPv4.mockReturnValue(undefined); + + await callGateway({ method: "health", scopes: ["operator.read"] }); + expect(lastClientOptions?.scopes).toEqual(["operator.read"]); + + await callGateway({ method: "health", scopes: [] }); + expect(lastClientOptions?.scopes).toEqual([]); + }); }); describe("buildGatewayConnectionDetails", () => { diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 193433d8a28..85660662861 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -16,6 +16,7 @@ import { type GatewayClientName, } from "../utils/message-channel.js"; import { GatewayClient } from "./client.js"; +import type { OperatorScope } from "./method-scopes.js"; import { isSecureWebSocketUrl, pickPrimaryLanIPv4 } from "./net.js"; import { PROTOCOL_VERSION } from "./protocol/index.js"; @@ -37,6 +38,7 @@ export type CallGatewayOptions = { instanceId?: string; minProtocol?: number; maxProtocol?: number; + scopes?: OperatorScope[]; /** * Overrides the config path shown in connection error details. * Does not affect config loading; callers still control auth via opts.token/password/env/config. @@ -257,6 +259,9 @@ export async function callGateway>( }; const formatTimeoutError = () => `gateway timeout after ${timeoutMs}ms\n${connectionDetails.message}`; + const scopes = Array.isArray(opts.scopes) + ? opts.scopes + : ["operator.admin", "operator.approvals", "operator.pairing"]; return await new Promise((resolve, reject) => { let settled = false; let ignoreClose = false; @@ -285,7 +290,7 @@ export async function callGateway>( platform: opts.platform, mode: opts.mode ?? GATEWAY_CLIENT_MODES.CLI, role: "operator", - scopes: ["operator.admin", "operator.approvals", "operator.pairing"], + scopes, deviceIdentity: loadOrCreateDeviceIdentity(), minProtocol: opts.minProtocol ?? PROTOCOL_VERSION, maxProtocol: opts.maxProtocol ?? PROTOCOL_VERSION, diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts new file mode 100644 index 00000000000..699f9691e2e --- /dev/null +++ b/src/gateway/method-scopes.ts @@ -0,0 +1,154 @@ +export const ADMIN_SCOPE = "operator.admin" as const; +export const READ_SCOPE = "operator.read" as const; +export const WRITE_SCOPE = "operator.write" as const; +export const APPROVALS_SCOPE = "operator.approvals" as const; +export const PAIRING_SCOPE = "operator.pairing" as const; + +export type OperatorScope = + | typeof ADMIN_SCOPE + | typeof READ_SCOPE + | typeof WRITE_SCOPE + | typeof APPROVALS_SCOPE + | typeof 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 ADMIN_METHOD_PREFIXES = ["exec.approvals."]; + +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", + "cron.list", + "cron.status", + "cron.runs", + "system-presence", + "last-heartbeat", + "node.list", + "node.describe", + "chat.history", + "config.get", + "talk.config", +]); + +const WRITE_METHODS = new Set([ + "send", + "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", +]); + +export function isApprovalMethod(method: string): boolean { + return APPROVAL_METHODS.has(method); +} + +export function isPairingMethod(method: string): boolean { + return PAIRING_METHODS.has(method); +} + +export function isReadMethod(method: string): boolean { + return READ_METHODS.has(method); +} + +export function isWriteMethod(method: string): boolean { + return WRITE_METHODS.has(method); +} + +export function isNodeRoleMethod(method: string): boolean { + return NODE_ROLE_METHODS.has(method); +} + +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); +} + +export function resolveLeastPrivilegeOperatorScopesForMethod(method: string): OperatorScope[] { + 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]; + } + // Default-deny for unclassified methods. + return []; +} diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 1d86f4c013a..c772bc1c367 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -1,5 +1,18 @@ import { formatControlPlaneActor, resolveControlPlaneActor } from "./control-plane-audit.js"; import { consumeControlPlaneWriteBudget } from "./control-plane-rate-limit.js"; +import { + ADMIN_SCOPE, + APPROVALS_SCOPE, + isAdminOnlyMethod, + isApprovalMethod, + isNodeRoleMethod, + isPairingMethod, + isReadMethod, + isWriteMethod, + PAIRING_SCOPE, + READ_SCOPE, + WRITE_SCOPE, +} from "./method-scopes.js"; import { ErrorCodes, errorShape } from "./protocol/index.js"; import { agentHandlers } from "./server-methods/agent.js"; import { agentsHandlers } from "./server-methods/agents.js"; @@ -29,86 +42,14 @@ import { voicewakeHandlers } from "./server-methods/voicewake.js"; import { webHandlers } from "./server-methods/web.js"; import { wizardHandlers } from "./server-methods/wizard.js"; -const ADMIN_SCOPE = "operator.admin"; -const READ_SCOPE = "operator.read"; -const WRITE_SCOPE = "operator.write"; -const APPROVALS_SCOPE = "operator.approvals"; -const PAIRING_SCOPE = "operator.pairing"; - -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 ADMIN_METHOD_PREFIXES = ["exec.approvals."]; -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", - "cron.list", - "cron.status", - "cron.runs", - "system-presence", - "last-heartbeat", - "node.list", - "node.describe", - "chat.history", - "config.get", - "talk.config", -]); -const WRITE_METHODS = new Set([ - "send", - "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 CONTROL_PLANE_WRITE_METHODS = new Set(["config.apply", "config.patch", "update.run"]); - function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["client"]) { if (!client?.connect) { return null; } const role = client.connect.role ?? "operator"; const scopes = client.connect.scopes ?? []; - if (NODE_ROLE_METHODS.has(method)) { + if (isNodeRoleMethod(method)) { if (role === "node") { return null; } @@ -123,52 +64,31 @@ function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["c if (scopes.includes(ADMIN_SCOPE)) { return null; } - if (APPROVAL_METHODS.has(method) && !scopes.includes(APPROVALS_SCOPE)) { + if (isApprovalMethod(method) && !scopes.includes(APPROVALS_SCOPE)) { return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.approvals"); } - if (PAIRING_METHODS.has(method) && !scopes.includes(PAIRING_SCOPE)) { + if (isPairingMethod(method) && !scopes.includes(PAIRING_SCOPE)) { return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.pairing"); } - if (READ_METHODS.has(method) && !(scopes.includes(READ_SCOPE) || scopes.includes(WRITE_SCOPE))) { + if (isReadMethod(method) && !(scopes.includes(READ_SCOPE) || scopes.includes(WRITE_SCOPE))) { return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.read"); } - if (WRITE_METHODS.has(method) && !scopes.includes(WRITE_SCOPE)) { + if (isWriteMethod(method) && !scopes.includes(WRITE_SCOPE)) { return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.write"); } - if (APPROVAL_METHODS.has(method)) { + if (isApprovalMethod(method)) { return null; } - if (PAIRING_METHODS.has(method)) { + if (isPairingMethod(method)) { return null; } - if (READ_METHODS.has(method)) { + if (isReadMethod(method)) { return null; } - if (WRITE_METHODS.has(method)) { + if (isWriteMethod(method)) { return null; } - if (ADMIN_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix))) { - return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.admin"); - } - if ( - method.startsWith("config.") || - method.startsWith("wizard.") || - method.startsWith("update.") || - method === "channels.logout" || - method === "agents.create" || - method === "agents.update" || - method === "agents.delete" || - method === "skills.install" || - method === "skills.update" || - method === "cron.add" || - method === "cron.update" || - method === "cron.remove" || - method === "cron.run" || - method === "sessions.patch" || - method === "sessions.reset" || - method === "sessions.delete" || - method === "sessions.compact" - ) { + if (isAdminOnlyMethod(method)) { return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.admin"); } return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.admin"); diff --git a/src/infra/exec-approvals-allowlist.ts b/src/infra/exec-approvals-allowlist.ts index 77cffa3b493..03b0c48ec49 100644 --- a/src/infra/exec-approvals-allowlist.ts +++ b/src/infra/exec-approvals-allowlist.ts @@ -1,4 +1,3 @@ -import type { ExecAllowlistEntry } from "./exec-approvals.js"; import { DEFAULT_SAFE_BINS, analyzeShellCommand, @@ -10,6 +9,7 @@ import { type CommandResolution, type ExecCommandSegment, } from "./exec-approvals-analysis.js"; +import type { ExecAllowlistEntry } from "./exec-approvals.js"; import { SAFE_BIN_GENERIC_PROFILE, SAFE_BIN_PROFILES,