fix: harden agent gateway authorization scopes

This commit is contained in:
Peter Steinberger
2026-02-19 14:37:56 +01:00
parent 165c18819e
commit a40c10d3e2
19 changed files with 319 additions and 111 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<T = Record<string, unknown>>(
extra?: { expectFinal?: boolean },
) {
const gateway = resolveGatewayOptions(opts);
const scopes = resolveLeastPrivilegeOperatorScopesForMethod(method);
return await callGateway<T>({
url: gateway.url,
token: gateway.token,
@@ -119,5 +121,6 @@ export async function callGatewayTool<T = Record<string, unknown>>(
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
clientDisplayName: "agent",
mode: GATEWAY_CLIENT_MODES.BACKEND,
scopes,
});
}