mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
co-authored by
joshavant
parent
3c73ff7689
commit
2a21de6322
@@ -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>
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user