fix: gate owner-only HTTP tools (#90261)

* fix: gate owner-only HTTP tools

* fix: inherit HTTP owner tool denies

* fix: use mutable HTTP owner deny policy

* fix: preserve RPC owner tool access

* docs: clarify owner-only gateway tool allowlist

---------

Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
This commit is contained in:
Pavan Kumar Gondhi
2026-06-07 17:26:12 -05:00
committed by GitHub
co-authored by joshavant
parent 3c73ff7689
commit 2a21de6322
8 changed files with 151 additions and 6 deletions
+5 -2
View File
@@ -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.
</Accordion>
+3
View File
@@ -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.
+1 -1
View File
@@ -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 |
+7 -1
View File
@@ -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: <channel>` (example: `slack`, `telegram`)
+2 -1
View File
@@ -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",
});
+10 -1
View File
@@ -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,
]);
+116
View File
@@ -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({
+7
View File
@@ -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;