diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index cb3c14404a4c..bf8b9008d691 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -542,7 +542,7 @@ See [Inferred commitments](/concepts/commitments). tools: { // Additional /tools/invoke HTTP denies deny: ["browser"], - // Remove tools from the default HTTP deny list + // Remove tools from the default HTTP deny list for owner/admin callers allow: ["gateway"], }, push: { @@ -610,7 +610,10 @@ See [Inferred commitments](/concepts/commitments). - `gateway.nodes.pairing.autoApproveCidrs`: optional CIDR/IP allowlist for auto-approving first-time node device pairing with no requested scopes. It is disabled when unset. This does not auto-approve operator/browser/Control UI/WebChat pairing, and it does not auto-approve role, scope, metadata, or public-key upgrades. - `gateway.nodes.allowCommands` / `gateway.nodes.denyCommands`: global allow/deny shaping for declared node commands after pairing and platform allowlist evaluation. Use `allowCommands` to opt into dangerous node commands such as `camera.snap`, `camera.clip`, and `screen.record`; `denyCommands` removes a command even if a platform default or explicit allow would otherwise include it. After a node changes its declared command list, reject and re-approve that device pairing so the gateway stores the updated command snapshot. - `gateway.tools.deny`: extra tool names blocked for HTTP `POST /tools/invoke` (extends default deny list). -- `gateway.tools.allow`: remove tool names from the default HTTP deny list. +- `gateway.tools.allow`: remove tool names from the default HTTP deny list for + owner/admin callers. This does not upgrade identity-bearing `operator.write` + callers into owner/admin access; `cron`, `gateway`, and `nodes` remain + unavailable to non-owner callers even when allowlisted. diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index d24415f18993..ce6e23bd31ac 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -580,6 +580,9 @@ terminal summary, and sanitized error text. `idempotencyKey` are optional. - If both `sessionKey` and `agentId` are present, the resolved session agent must match `agentId`. + - Owner-only core wrappers such as `cron`, `gateway`, and `nodes` require + owner/admin identity (`operator.admin`) even though the `tools.invoke` + method itself is `operator.write`. - The response is an SDK-facing envelope with `ok`, `toolName`, optional `output`, and typed `error` fields. Approval or policy refusals return `ok:false` in the payload rather than bypassing the gateway tool policy pipeline. diff --git a/docs/gateway/security/audit-checks.md b/docs/gateway/security/audit-checks.md index 5dc1666e5661..394f1271edd3 100644 --- a/docs/gateway/security/audit-checks.md +++ b/docs/gateway/security/audit-checks.md @@ -39,7 +39,7 @@ exhaustive): | `gateway.trusted_proxies_missing` | warn | Reverse-proxy headers are present but not trusted | `gateway.trustedProxies` | no | | `gateway.http.no_auth` | warn/critical | Gateway HTTP APIs reachable with `auth.mode="none"` | `gateway.auth.mode`, `gateway.http.endpoints.*`, `plugins.entries.admin-http-rpc` | no | | `gateway.http.session_key_override_enabled` | info | HTTP API callers can override `sessionKey` | `gateway.http.allowSessionKeyOverride` | no | -| `gateway.tools_invoke_http.dangerous_allow` | warn/critical | Re-enables dangerous tools over HTTP API | `gateway.tools.allow` | no | +| `gateway.tools_invoke_http.dangerous_allow` | warn/critical | Re-enables dangerous tools over HTTP API for owner/admin callers | `gateway.tools.allow` | no | | `gateway.nodes.allow_commands_dangerous` | warn/critical | Enables high-impact node commands (camera/screen/contacts/calendar/SMS) | `gateway.nodes.allowCommands` | no | | `gateway.nodes.deny_commands_ineffective` | warn | Pattern-like deny entries do not match shell text or groups | `gateway.nodes.denyCommands` | no | | `gateway.tailscale_funnel` | critical | Public internet exposure | `gateway.tailscale.mode` | no | diff --git a/docs/gateway/tools-invoke-http-api.md b/docs/gateway/tools-invoke-http-api.md index 3437dd75c76f..25c04354b246 100644 --- a/docs/gateway/tools-invoke-http-api.md +++ b/docs/gateway/tools-invoke-http-api.md @@ -128,13 +128,19 @@ You can customize this deny list via `gateway.tools`: tools: { // Additional tools to block over HTTP /tools/invoke deny: ["browser"], - // Remove tools from the default deny list + // Remove tools from the default deny list for owner/admin callers allow: ["gateway"], }, }, } ``` +`gateway.tools.allow` is an exposure override, not a scope upgrade. In +identity-bearing HTTP modes, `cron`, `gateway`, and `nodes` remain unavailable +to callers that do not have owner/admin identity (`operator.admin`) even when +they are listed in `gateway.tools.allow`. Shared-secret bearer auth still follows +the full trusted-operator rule above. + To help group policies resolve context, you can optionally set: - `x-openclaw-message-channel: ` (example: `slack`, `telegram`) diff --git a/src/gateway/server-methods/tools-invoke.ts b/src/gateway/server-methods/tools-invoke.ts index 19bcdb804c9d..7926300b86d0 100644 --- a/src/gateway/server-methods/tools-invoke.ts +++ b/src/gateway/server-methods/tools-invoke.ts @@ -36,7 +36,7 @@ function resolveRpcErrorCode(params: { /** Handles `tools.invoke` with protocol-shaped success and failure payloads. */ export const toolsInvokeHandlers: GatewayRequestHandlers = { - "tools.invoke": async ({ params, respond, context }) => { + "tools.invoke": async ({ params, respond, context, client }) => { if (!validateToolsInvokeParams(params)) { respond( false, @@ -61,6 +61,7 @@ export const toolsInvokeHandlers: GatewayRequestHandlers = { const outcome = await invokeGatewayTool({ cfg: context.getRuntimeConfig(), input: params, + senderIsOwner: client?.connect?.scopes?.includes("operator.admin"), toolCallIdPrefix: "rpc", approvalMode: params.confirm === true ? "request" : "report", }); diff --git a/src/gateway/tool-resolution.ts b/src/gateway/tool-resolution.ts index 8218f9281b87..0ae5b60bfee8 100644 --- a/src/gateway/tool-resolution.ts +++ b/src/gateway/tool-resolution.ts @@ -29,7 +29,10 @@ import type { InboundEventKind } from "../channels/inbound-event/kind.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { logWarn } from "../logger.js"; import { getPluginToolMeta } from "../plugins/tools.js"; -import { DEFAULT_GATEWAY_HTTP_TOOL_DENY } from "../security/dangerous-tools.js"; +import { + DEFAULT_GATEWAY_HTTP_TOOL_DENY, + GATEWAY_HTTP_OWNER_ONLY_CORE_TOOLS, +} from "../security/dangerous-tools.js"; type GatewayScopedToolSurface = "http" | "loopback"; @@ -113,6 +116,10 @@ export function resolveGatewayScopedTools(params: { surface === "http" ? DEFAULT_GATEWAY_HTTP_TOOL_DENY.filter((name) => !gatewayToolsCfg?.allow?.includes(name)) : []; + const ownerOnlyGatewayDeny = + surface === "http" && params.senderIsOwner !== true + ? [...GATEWAY_HTTP_OWNER_ONLY_CORE_TOOLS] + : []; // HTTP callers start with a stricter denylist than loopback callers because they cross auth only. const workspaceDir = resolveAgentWorkspaceDir( params.cfg, @@ -129,6 +136,7 @@ export function resolveGatewayScopedTools(params: { subagentPolicy, inheritedToolPolicy, defaultGatewayDeny.length > 0 ? { deny: defaultGatewayDeny } : undefined, + ownerOnlyGatewayDeny.length > 0 ? { deny: ownerOnlyGatewayDeny } : undefined, Array.isArray(gatewayToolsCfg?.deny) ? { deny: gatewayToolsCfg.deny } : undefined, ]); const inheritedToolDenylist = [...explicitDenylist]; @@ -210,6 +218,7 @@ export function resolveGatewayScopedTools(params: { const gatewayDenySet = new Set([ ...defaultGatewayDeny, + ...ownerOnlyGatewayDeny, ...(Array.isArray(gatewayToolsCfg?.deny) ? gatewayToolsCfg.deny : []), ...excludedToolNames, ]); diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts index 101c7a6abb8f..8b3508f7d2cf 100644 --- a/src/gateway/tools-invoke-http.test.ts +++ b/src/gateway/tools-invoke-http.test.ts @@ -108,6 +108,7 @@ vi.mock("../agents/openclaw-tools.js", () => { agentTo: lastCreateOpenClawToolsContext?.agentTo, agentThreadId: lastCreateOpenClawToolsContext?.agentThreadId, }, + inheritedToolDenylist: lastCreateOpenClawToolsContext?.inheritedToolDenylist, }), }, { @@ -122,6 +123,11 @@ vi.mock("../agents/openclaw-tools.js", () => { throw toolInputError("invalid args"); }, }, + { + name: "cron", + parameters: { type: "object", properties: {} }, + execute: async () => ({ ok: true, result: "cron" }), + }, { name: "exec", parameters: { type: "object", properties: {} }, @@ -692,6 +698,34 @@ describe("POST /tools/invoke", () => { }); }); + it("propagates owner-only HTTP denies into spawned session inheritance", async () => { + cfg = { + ...cfg, + agents: { + list: [ + { + id: "main", + default: true, + tools: { allow: ["sessions_spawn", "cron", "gateway", "nodes"] }, + }, + ], + }, + gateway: { tools: { allow: ["sessions_spawn", "cron", "gateway", "nodes"] } }, + }; + + const res = await invokeTool({ + port: sharedPort, + headers: gatewayAuthHeaders(), + tool: "sessions_spawn", + sessionKey: "main", + }); + + const body = await expectOkInvokeResponse(res); + expect(body.result?.inheritedToolDenylist).toEqual( + expect.arrayContaining(["cron", "gateway", "nodes"]), + ); + }); + it("denies sessions_send via HTTP gateway", async () => { setMainAllowedTools({ allow: ["sessions_send"] }); @@ -730,6 +764,47 @@ describe("POST /tools/invoke", () => { expect(body.error?.type).toBe("tool_error"); }); + it("keeps owner-only tools unavailable to non-owner HTTP callers despite gateway.tools.allow", async () => { + setMainAllowedTools({ + allow: ["cron", "gateway", "nodes"], + gatewayAllow: ["cron", "gateway", "nodes"], + }); + + for (const tool of ["cron", "gateway", "nodes"]) { + const res = await invokeToolAuthed({ + tool, + sessionKey: "main", + }); + + expect(res.status, tool).toBe(404); + const body = await res.json(); + expect(body.ok, tool).toBe(false); + expect(body.error?.type, tool).toBe("not_found"); + } + }); + + it("keeps shared-secret bearer auth as owner for explicitly allowed owner-only tools", async () => { + setMainAllowedTools({ allow: ["nodes"], gatewayAllow: ["nodes"] }); + vi.mocked(authorizeHttpGatewayConnect).mockResolvedValueOnce({ + ok: true, + method: "token", + }); + + const res = await invokeTool({ + port: sharedPort, + headers: { + authorization: "Bearer secret", + "x-openclaw-scopes": "operator.write", + }, + tool: "nodes", + sessionKey: "main", + }); + + const body = await expectOkInvokeResponse(res); + expect(body.result).toEqual({ ok: true, result: "nodes" }); + expect(lastCreateOpenClawToolsContext?.senderIsOwner).toBe(true); + }); + it("treats gateway.tools.deny as higher priority than gateway.tools.allow", async () => { setMainAllowedTools({ allow: ["gateway"], @@ -995,6 +1070,47 @@ describe("tools.invoke Gateway RPC", () => { expect(hookCtx.sessionKey).toBe("agent:main:main"); }); + it("keeps owner-only tools unavailable to non-owner RPC callers despite gateway.tools.allow", async () => { + setMainAllowedTools({ + allow: ["cron", "gateway", "nodes"], + gatewayAllow: ["cron", "gateway", "nodes"], + }); + + for (const tool of ["cron", "gateway", "nodes"]) { + const call = await invokeToolsRpc({ + name: tool, + args: {}, + sessionKey: "main", + }); + + expect(call?.[0], tool).toBe(true); + expect(call?.[1]?.ok, tool).toBe(false); + expect(call?.[1]?.toolName, tool).toBe(tool); + const error = call?.[1]?.error as { code?: string; message?: string } | undefined; + expect(error?.code, tool).toBe("not_found"); + } + expect(lastCreateOpenClawToolsContext?.senderIsOwner).toBe(false); + }); + + it("keeps operator.admin RPC callers as owner for explicitly allowed owner-only tools", async () => { + setMainAllowedTools({ allow: ["nodes"], gatewayAllow: ["nodes"] }); + + const call = await invokeToolsRpc( + { + name: "nodes", + args: {}, + sessionKey: "main", + }, + ["operator.admin"], + ); + + expect(call?.[0]).toBe(true); + expect(call?.[1]?.ok).toBe(true); + expect(call?.[1]?.toolName).toBe("nodes"); + expect(call?.[1]?.output).toEqual({ ok: true, result: "nodes" }); + expect(lastCreateOpenClawToolsContext?.senderIsOwner).toBe(true); + }); + it("returns typed approval-needed refusal when the policy hook blocks", async () => { setMainAllowedTools({ allow: ["tools_invoke_test"] }); hookMocks.runBeforeToolCallHook.mockResolvedValueOnce({ diff --git a/src/security/dangerous-tools.ts b/src/security/dangerous-tools.ts index a36a5685670e..14eff240f88d 100644 --- a/src/security/dangerous-tools.ts +++ b/src/security/dangerous-tools.ts @@ -32,3 +32,10 @@ export const DEFAULT_GATEWAY_HTTP_TOOL_DENY = [ // Node command relay can reach system.run on paired hosts "nodes", ] as const; + +/** + * Core tools that require sender owner identity on Gateway HTTP `POST /tools/invoke`. + * `gateway.tools.allow` can remove the default HTTP deny only for owner/trusted-operator + * callers; non-owner identity-bearing callers must not receive server-credential wrappers. + */ +export const GATEWAY_HTTP_OWNER_ONLY_CORE_TOOLS = ["cron", "gateway", "nodes"] as const;