fix(security): centralize owner-only tool gating and scope maps

This commit is contained in:
Peter Steinberger
2026-02-19 15:27:45 +01:00
parent 9130fd2b06
commit 3d7ad1cfca
16 changed files with 372 additions and 251 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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 () => {

View File

@@ -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,

View File

@@ -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", () => {

View File

@@ -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<string, string[]> = {
],
};
const OWNER_ONLY_TOOL_NAMES = new Set<string>(["whatsapp_login", "cron", "gateway"]);
const OWNER_ONLY_TOOL_NAME_FALLBACKS = new Set<string>(["whatsapp_login", "cron", "gateway"]);
const TOOL_PROFILES: Record<ToolProfileId, ToolProfilePolicy> = {
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[]) {

View File

@@ -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<any, unknown>;
export type AnyAgentTool = AgentTool<any, unknown> & {
ownerOnly?: boolean;
};
export type StringParamOptions = {
required?: boolean;
@@ -210,10 +212,19 @@ export function jsonResult(payload: unknown): AgentToolResult<unknown> {
};
}
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: {

View File

@@ -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([

View File

@@ -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<string, unknown>;
const action = readStringParam(params, "action", { required: true });
const gatewayOpts: GatewayCallOptions = {

View File

@@ -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<string, unknown>;
const action = readStringParam(params, "action", { required: true });
if (action === "restart") {

View File

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

View File

@@ -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.

View File

@@ -12,7 +12,9 @@ export type ChannelId = ChatChannelId | (string & {});
export type ChannelOutboundTargetMode = "explicit" | "implicit" | "heartbeat";
export type ChannelAgentTool = AgentTool<TSchema, unknown>;
export type ChannelAgentTool = AgentTool<TSchema, unknown> & {
ownerOnly?: boolean;
};
export type ChannelAgentToolFactory = (params: { cfg?: OpenClawConfig }) => ChannelAgentTool[];

View File

@@ -186,95 +186,153 @@ export function buildGatewayConnectionDetails(
};
}
async function callGatewayWithScopes<T = Record<string, unknown>>(
opts: CallGatewayBaseOptions,
scopes: OperatorScope[],
): Promise<T> {
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<string | undefined> {
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<T>(params: {
opts: CallGatewayBaseOptions;
scopes: OperatorScope[];
url: string;
token?: string;
password?: string;
tlsFingerprint?: string;
timeoutMs: number;
safeTimerTimeoutMs: number;
connectionDetails: GatewayConnectionDetails;
}): Promise<T> {
const { opts, scopes, url, token, password, tlsFingerprint, timeoutMs, safeTimerTimeoutMs } =
params;
return await new Promise<T>((resolve, reject) => {
let settled = false;
let ignoreClose = false;
@@ -327,20 +385,54 @@ async function callGatewayWithScopes<T = Record<string, unknown>>(
}
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<T = Record<string, unknown>>(
opts: CallGatewayBaseOptions,
scopes: OperatorScope[],
): Promise<T> {
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<T>({
opts,
scopes,
url,
token,
password,
tlsFingerprint,
timeoutMs,
safeTimerTimeoutMs,
connectionDetails,
});
}
export async function callGatewayScoped<T = Record<string, unknown>>(
opts: CallGatewayScopedOptions,
): Promise<T> {

View File

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

View File

@@ -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<OperatorScope, readonly string[]> = {
[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<string, OperatorScope>(
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;
}