Skip to content

Commit 8b76392

Browse files
mmapsdrobison00
andauthored
fix(gateway): enforce owner-only tool policy and before-tool-call hook on MCP loopback surface (#71159)
* fix: address issue * fix: address review feedback * fix: address PR review feedback * changelog: PR #71159 MCP loopback owner-only policy + before-tool-call hook --------- Co-authored-by: Devin Robison <[email protected]>
1 parent 8cae2ed commit 8b76392

5 files changed

Lines changed: 345 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ Docs: https://docs.openclaw.ai
149149
- Webhooks/security: re-resolve `SecretRef`-backed webhook route secrets on each request so `openclaw secrets reload` revokes the previous secret immediately instead of waiting for a gateway restart. (#70727) Thanks @drobison00.
150150
- Memory/dreaming: decouple the managed dreaming cron from heartbeat by running it as an isolated lightweight agent turn, so dreaming runs even when heartbeat is disabled for the default agent and is no longer skipped by `heartbeat.activeHours`. `openclaw doctor --fix` migrates stale main-session dreaming jobs in persisted cron configs to the new shape. Fixes #69811, #67397, #68972. (#70737) Thanks @jalehman.
151151
- Agents/CLI: keep `--agent` plus `--session-id` lookup scoped to the requested agent store, so explicit agent resumes cannot select another agent's session. (#70985) Thanks @frankekn.
152+
- Gateway/MCP loopback: apply owner-only tool policy and run before-tool-call hooks on `127.0.0.1/mcp` `tools/list` and `tools/call`, so non-owner bearer callers can no longer see or invoke owner-only tools such as `cron`, `gateway`, and `nodes`, matching the existing HTTP `/tools/invoke` and embedded-agent paths. (#71159) Thanks @mmaps.
152153

153154
## 2026.4.22
154155

src/gateway/mcp-http.handlers.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import crypto from "node:crypto";
2+
import { runBeforeToolCallHook, type HookContext } from "../agents/pi-tools.before-tool-call.js";
23
import { formatErrorMessage } from "../infra/errors.js";
34
import {
45
MCP_LOOPBACK_SERVER_NAME,
@@ -35,6 +36,8 @@ export async function handleMcpJsonRpc(params: {
3536
message: JsonRpcRequest;
3637
tools: McpLoopbackTool[];
3738
toolSchema: McpToolSchemaEntry[];
39+
hookContext?: HookContext;
40+
signal?: AbortSignal;
3841
}): Promise<object | null> {
3942
const { id, method, params: methodParams } = params.message;
4043

@@ -70,7 +73,20 @@ export async function handleMcpJsonRpc(params: {
7073
}
7174
const toolCallId = `mcp-${crypto.randomUUID()}`;
7275
try {
73-
const result = await tool.execute(toolCallId, toolArgs);
76+
const hookResult = await runBeforeToolCallHook({
77+
toolName,
78+
params: toolArgs,
79+
toolCallId,
80+
ctx: params.hookContext,
81+
signal: params.signal,
82+
});
83+
if (hookResult.blocked) {
84+
return jsonRpcResult(id, {
85+
content: [{ type: "text", text: hookResult.reason }],
86+
isError: true,
87+
});
88+
}
89+
const result = await tool.execute(toolCallId, hookResult.params, params.signal);
7490
return jsonRpcResult(id, {
7591
content: normalizeToolCallContent(result),
7692
isError: false,

src/gateway/mcp-http.runtime.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { applyOwnerOnlyToolPolicy } from "../agents/tool-policy.js";
12
import type { OpenClawConfig } from "../config/types.openclaw.js";
23
import {
34
clearActiveMcpLoopbackRuntimeByOwnerToken,
@@ -16,6 +17,7 @@ const TOOL_CACHE_TTL_MS = 30_000;
1617
const NATIVE_TOOL_EXCLUDE = new Set(["read", "write", "edit", "apply_patch", "exec", "process"]);
1718

1819
type CachedScopedTools = {
20+
agentId: string | undefined;
1921
tools: McpLoopbackTool[];
2022
toolSchema: McpToolSchemaEntry[];
2123
configRef: OpenClawConfig;
@@ -36,7 +38,7 @@ export class McpLoopbackToolCache {
3638
params.sessionKey,
3739
params.messageProvider ?? "",
3840
params.accountId ?? "",
39-
params.senderIsOwner === true ? "owner" : params.senderIsOwner === false ? "non-owner" : "",
41+
params.senderIsOwner === true ? "owner" : "non-owner",
4042
].join("\u0000");
4143
const now = Date.now();
4244
const cached = this.#entries.get(cacheKey);
@@ -53,9 +55,11 @@ export class McpLoopbackToolCache {
5355
surface: "loopback",
5456
excludeToolNames: NATIVE_TOOL_EXCLUDE,
5557
});
58+
const tools = applyOwnerOnlyToolPolicy(next.tools, params.senderIsOwner === true);
5659
const nextEntry: CachedScopedTools = {
57-
tools: next.tools,
58-
toolSchema: buildMcpToolSchema(next.tools),
60+
agentId: next.agentId,
61+
tools,
62+
toolSchema: buildMcpToolSchema(tools),
5963
configRef: params.cfg,
6064
time: now,
6165
};

src/gateway/mcp-http.test.ts

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ type MockGatewayTool = {
55
name: string;
66
description: string;
77
parameters: Record<string, unknown>;
8+
ownerOnly?: boolean;
89
execute: (...args: unknown[]) => Promise<{ content: Array<{ type: string; text: string }> }>;
910
};
1011

@@ -13,6 +14,19 @@ type MockGatewayScopedTools = {
1314
tools: MockGatewayTool[];
1415
};
1516

17+
type MockBeforeToolCallHookResult =
18+
| { blocked: true; reason: string }
19+
| { blocked: false; params: unknown };
20+
21+
const runBeforeToolCallHookMock = vi.hoisted(() =>
22+
vi.fn(
23+
async (args: { params: unknown }): Promise<MockBeforeToolCallHookResult> => ({
24+
blocked: false,
25+
params: args.params,
26+
}),
27+
),
28+
);
29+
1630
const resolveGatewayScopedToolsMock = vi.hoisted(() =>
1731
vi.fn<(...args: unknown[]) => MockGatewayScopedTools>(() => ({
1832
agentId: "main",
@@ -37,6 +51,11 @@ vi.mock("../config/sessions.js", () => ({
3751
resolveMainSessionKey: () => "agent:main:main",
3852
}));
3953

54+
vi.mock("../agents/pi-tools.before-tool-call.js", () => ({
55+
runBeforeToolCallHook: (...args: Parameters<typeof runBeforeToolCallHookMock>) =>
56+
runBeforeToolCallHookMock(...args),
57+
}));
58+
4059
vi.mock("./tool-resolution.js", () => ({
4160
resolveGatewayScopedTools: (...args: Parameters<typeof resolveGatewayScopedToolsMock>) =>
4261
resolveGatewayScopedToolsMock(...args),
@@ -71,6 +90,13 @@ async function sendRaw(params: {
7190

7291
beforeEach(() => {
7392
resolveGatewayScopedToolsMock.mockClear();
93+
runBeforeToolCallHookMock.mockClear();
94+
runBeforeToolCallHookMock.mockImplementation(
95+
async (args: { params: unknown }): Promise<MockBeforeToolCallHookResult> => ({
96+
blocked: false,
97+
params: args.params,
98+
}),
99+
);
74100
resolveGatewayScopedToolsMock.mockReturnValue({
75101
agentId: "main",
76102
tools: [
@@ -231,6 +257,256 @@ describe("mcp loopback server", () => {
231257
);
232258
});
233259

260+
it("filters owner-only tools from non-owner tool lists", async () => {
261+
resolveGatewayScopedToolsMock.mockReturnValue({
262+
agentId: "main",
263+
tools: [
264+
{
265+
name: "message",
266+
description: "send a message",
267+
parameters: { type: "object", properties: {} },
268+
execute: async () => ({
269+
content: [{ type: "text", text: "ok" }],
270+
}),
271+
},
272+
{
273+
name: "cron",
274+
description: "manage schedules",
275+
parameters: { type: "object", properties: {} },
276+
execute: async () => ({
277+
content: [{ type: "text", text: "cron" }],
278+
}),
279+
},
280+
{
281+
name: "owner_probe",
282+
description: "owner-only by flag",
283+
parameters: { type: "object", properties: {} },
284+
ownerOnly: true,
285+
execute: async () => ({
286+
content: [{ type: "text", text: "owner" }],
287+
}),
288+
},
289+
],
290+
});
291+
server = await startMcpLoopbackServer(0);
292+
const runtime = getActiveMcpLoopbackRuntime();
293+
294+
const response = await sendRaw({
295+
port: server.port,
296+
token: runtime ? resolveMcpLoopbackBearerToken(runtime, false) : undefined,
297+
headers: {
298+
"content-type": "application/json",
299+
"x-session-key": "agent:main:main",
300+
},
301+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
302+
});
303+
const payload = (await response.json()) as {
304+
result?: { tools?: Array<{ name: string }> };
305+
};
306+
const names = (payload.result?.tools ?? []).map((tool) => tool.name);
307+
308+
expect(response.status).toBe(200);
309+
expect(names).toContain("message");
310+
expect(names).not.toContain("cron");
311+
expect(names).not.toContain("owner_probe");
312+
});
313+
314+
it("keeps owner-only tools available to owner loopback callers", async () => {
315+
resolveGatewayScopedToolsMock.mockReturnValue({
316+
agentId: "main",
317+
tools: [
318+
{
319+
name: "message",
320+
description: "send a message",
321+
parameters: { type: "object", properties: {} },
322+
execute: async () => ({
323+
content: [{ type: "text", text: "ok" }],
324+
}),
325+
},
326+
{
327+
name: "cron",
328+
description: "manage schedules",
329+
parameters: { type: "object", properties: {} },
330+
execute: async () => ({
331+
content: [{ type: "text", text: "cron" }],
332+
}),
333+
},
334+
],
335+
});
336+
server = await startMcpLoopbackServer(0);
337+
const runtime = getActiveMcpLoopbackRuntime();
338+
339+
const response = await sendRaw({
340+
port: server.port,
341+
token: runtime ? resolveMcpLoopbackBearerToken(runtime, true) : undefined,
342+
headers: {
343+
"content-type": "application/json",
344+
"x-session-key": "agent:main:main",
345+
},
346+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
347+
});
348+
const payload = (await response.json()) as {
349+
result?: { tools?: Array<{ name: string }> };
350+
};
351+
const names = (payload.result?.tools ?? []).map((tool) => tool.name);
352+
353+
expect(response.status).toBe(200);
354+
expect(names).toContain("message");
355+
expect(names).toContain("cron");
356+
});
357+
358+
it("does not execute owner-only tools for non-owner callers", async () => {
359+
const cronExecute = vi.fn(async () => ({
360+
content: [{ type: "text", text: "CRON_EXECUTED" }],
361+
}));
362+
resolveGatewayScopedToolsMock.mockReturnValue({
363+
agentId: "main",
364+
tools: [
365+
{
366+
name: "message",
367+
description: "send a message",
368+
parameters: { type: "object", properties: {} },
369+
execute: async () => ({
370+
content: [{ type: "text", text: "ok" }],
371+
}),
372+
},
373+
{
374+
name: "cron",
375+
description: "manage schedules",
376+
parameters: { type: "object", properties: {} },
377+
execute: cronExecute,
378+
},
379+
],
380+
});
381+
server = await startMcpLoopbackServer(0);
382+
const runtime = getActiveMcpLoopbackRuntime();
383+
384+
const response = await sendRaw({
385+
port: server.port,
386+
token: runtime ? resolveMcpLoopbackBearerToken(runtime, false) : undefined,
387+
headers: {
388+
"content-type": "application/json",
389+
"x-session-key": "agent:main:main",
390+
},
391+
body: JSON.stringify({
392+
jsonrpc: "2.0",
393+
id: 1,
394+
method: "tools/call",
395+
params: { name: "cron", arguments: {} },
396+
}),
397+
});
398+
const payload = (await response.json()) as {
399+
result?: { content?: Array<{ text?: string }>; isError?: boolean };
400+
};
401+
402+
expect(response.status).toBe(200);
403+
expect(cronExecute).not.toHaveBeenCalled();
404+
expect(payload.result?.isError).toBe(true);
405+
expect(payload.result?.content?.[0]?.text).toBe("Tool not available: cron");
406+
});
407+
408+
it("honors before-tool-call hook blocks before loopback tool execution", async () => {
409+
const execute = vi.fn(async () => ({
410+
content: [{ type: "text", text: "EXECUTED" }],
411+
}));
412+
runBeforeToolCallHookMock.mockResolvedValueOnce({
413+
blocked: true,
414+
reason: "blocked by hook",
415+
});
416+
resolveGatewayScopedToolsMock.mockReturnValue({
417+
agentId: "main",
418+
tools: [
419+
{
420+
name: "message",
421+
description: "send a message",
422+
parameters: { type: "object", properties: {} },
423+
execute,
424+
},
425+
],
426+
});
427+
server = await startMcpLoopbackServer(0);
428+
const runtime = getActiveMcpLoopbackRuntime();
429+
430+
const response = await sendRaw({
431+
port: server.port,
432+
token: runtime ? resolveMcpLoopbackBearerToken(runtime, false) : undefined,
433+
headers: {
434+
"content-type": "application/json",
435+
"x-session-key": "agent:main:main",
436+
},
437+
body: JSON.stringify({
438+
jsonrpc: "2.0",
439+
id: 1,
440+
method: "tools/call",
441+
params: { name: "message", arguments: { body: "hello" } },
442+
}),
443+
});
444+
const payload = (await response.json()) as {
445+
result?: { content?: Array<{ text?: string }>; isError?: boolean };
446+
};
447+
448+
expect(response.status).toBe(200);
449+
expect(runBeforeToolCallHookMock).toHaveBeenCalledWith(
450+
expect.objectContaining({
451+
toolName: "message",
452+
params: { body: "hello" },
453+
ctx: expect.objectContaining({
454+
agentId: "main",
455+
sessionKey: "agent:main:main",
456+
}),
457+
signal: expect.any(AbortSignal),
458+
}),
459+
);
460+
expect(execute).not.toHaveBeenCalled();
461+
expect(payload.result?.isError).toBe(true);
462+
expect(payload.result?.content?.[0]?.text).toBe("blocked by hook");
463+
});
464+
465+
it("forwards the request abort signal to loopback tool execution", async () => {
466+
const execute = vi.fn(async () => ({
467+
content: [{ type: "text", text: "EXECUTED" }],
468+
}));
469+
resolveGatewayScopedToolsMock.mockReturnValue({
470+
agentId: "main",
471+
tools: [
472+
{
473+
name: "message",
474+
description: "send a message",
475+
parameters: { type: "object", properties: {} },
476+
execute,
477+
},
478+
],
479+
});
480+
server = await startMcpLoopbackServer(0);
481+
const runtime = getActiveMcpLoopbackRuntime();
482+
483+
const response = await sendRaw({
484+
port: server.port,
485+
token: runtime ? resolveMcpLoopbackBearerToken(runtime, false) : undefined,
486+
headers: {
487+
"content-type": "application/json",
488+
"x-session-key": "agent:main:main",
489+
},
490+
body: JSON.stringify({
491+
jsonrpc: "2.0",
492+
id: 1,
493+
method: "tools/call",
494+
params: { name: "message", arguments: { body: "hello" } },
495+
}),
496+
});
497+
const payload = (await response.json()) as {
498+
result?: { isError?: boolean };
499+
};
500+
501+
expect(response.status).toBe(200);
502+
expect(payload.result?.isError).toBe(false);
503+
expect(execute).toHaveBeenCalledWith(
504+
expect.stringMatching(/^mcp-/),
505+
{ body: "hello" },
506+
expect.any(AbortSignal),
507+
);
508+
});
509+
234510
it("tracks the active runtime only while the server is running", async () => {
235511
server = await startMcpLoopbackServer(0);
236512
const active = getActiveMcpLoopbackRuntime();

0 commit comments

Comments
 (0)