fix(gateway): enforce owner-only tool policy and before-tool-call hook on MCP loopback surface#71159
Conversation
Greptile SummaryThis PR closes a security gap where the MCP loopback surface was advertising and executing owner-only tools ( Confidence Score: 5/5Safe to merge — the security fix is correct and all remaining findings are minor P2 notes that are already acknowledged or tracked as follow-ups. The core policy enforcement (applyOwnerOnlyToolPolicy + runBeforeToolCallHook) is applied correctly, the cache key normalisation matches the security semantics, and four integration tests lock in all four advertised scenarios. The two P2 comments (deprecated 'aborted' event, signal not forwarded to tool.execute) are acknowledged in the PR description or are pre-existing type limitations, neither of which affects the correctness of the security fix. No files require special attention. Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/gateway/mcp-http.ts
Line: 70
Comment:
**Deprecated `"aborted"` event listener**
`req.once("aborted", abort)` relies on an event deprecated since Node 17. In Node 22 it is still emitted for backward-compat, but the canonical replacement is `req.once("close", () => { if (!req.complete) abort(); })`. The PR description notes this and defers the fix; just flagging it here for the follow-up. The `res.close` + `!res.writableEnded` guard does provide a safety net, but swapping the `"aborted"` listener will make the intent clearer and future-proof the helper.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/gateway/mcp-http.handlers.ts
Line: 89
Comment:
**`AbortSignal` not forwarded to `tool.execute`**
The `signal` is correctly threaded into `runBeforeToolCallHook` (enabling cancellation of plugin approval waits), but the call to `tool.execute(toolCallId, hookResult.params)` receives no signal. Any long-running tool (polling, streaming, etc.) on the loopback surface will therefore run to completion even after the HTTP client disconnects. This is currently constrained by the `McpLoopbackTool.execute` signature, so a type change is required to close the gap — worth tracking as a follow-up to this PR.
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "fix: address review feedback" | Re-trigger Greptile |
…k on MCP loopback surface (openclaw#71159) * fix: address issue * fix: address review feedback * fix: address PR review feedback * changelog: PR openclaw#71159 MCP loopback owner-only policy + before-tool-call hook --------- Co-authored-by: Devin Robison <[email protected]>
…k on MCP loopback surface (openclaw#71159) * fix: address issue * fix: address review feedback * fix: address PR review feedback * changelog: PR openclaw#71159 MCP loopback owner-only policy + before-tool-call hook --------- Co-authored-by: Devin Robison <[email protected]>
…k on MCP loopback surface (openclaw#71159) * fix: address issue * fix: address review feedback * fix: address PR review feedback * changelog: PR openclaw#71159 MCP loopback owner-only policy + before-tool-call hook --------- Co-authored-by: Devin Robison <[email protected]>
…k on MCP loopback surface (openclaw#71159) * fix: address issue * fix: address review feedback * fix: address PR review feedback * changelog: PR openclaw#71159 MCP loopback owner-only policy + before-tool-call hook --------- Co-authored-by: Devin Robison <[email protected]>
…k on MCP loopback surface (openclaw#71159) * fix: address issue * fix: address review feedback * fix: address PR review feedback * changelog: PR openclaw#71159 MCP loopback owner-only policy + before-tool-call hook --------- Co-authored-by: Devin Robison <[email protected]>
…k on MCP loopback surface (openclaw#71159) * fix: address issue * fix: address review feedback * fix: address PR review feedback * changelog: PR openclaw#71159 MCP loopback owner-only policy + before-tool-call hook --------- Co-authored-by: Devin Robison <[email protected]>
…k on MCP loopback surface (openclaw#71159) * fix: address issue * fix: address review feedback * fix: address PR review feedback * changelog: PR openclaw#71159 MCP loopback owner-only policy + before-tool-call hook --------- Co-authored-by: Devin Robison <[email protected]>
Summary
McpLoopbackToolCache.resolve()calledresolveGatewayScopedTools()and emitted the tool list directly as the MCP schema without passing it throughapplyOwnerOnlyToolPolicy. A local process holding the non-owner loopback bearer token could call owner-only tools (cron,gateway,nodes) through127.0.0.1/mcp.ownerToken/nonOwnerToken) to establish an owner/non-owner boundary. Without the policy filter, that boundary was unenforced — the identity was computed but never acted on. The HTTP/tools/invokepath and the embedded agent runner both calledapplyOwnerOnlyToolPolicycorrectly; the loopback path did not.mcp-http.runtime.ts— applyapplyOwnerOnlyToolPolicy(next.tools, senderIsOwner === true)afterresolveGatewayScopedTools; storeagentIdon the cache entry for hook context; normalize the cache key sosenderIsOwner: undefinedandfalseshare the same (non-owner) entry.mcp-http.handlers.ts— callrunBeforeToolCallHookbeforetool.executein thetools/callbranch; accept and forward anAbortSignalso plugin-approval waits can be cancelled if the request drops.mcp-http.ts— plumbhookContext(agentId+sessionKey) and an HTTP-request-scopedAbortSignalinto eachhandleMcpJsonRpccall via a newcreateRequestAbortSignalhelper; abort on bothreq.abortedandres.close(when!res.writableEnded).tools/list,initialize,notifications/*).Change Type (select all)
Scope (select all touched areas)
Linked Issue/PR
Root Cause (if applicable)
McpLoopbackToolCache.resolve()was introduced when the two-token loopback bearer scheme landed, but the accompanying owner-only policy application that the HTTP and embedded-agent paths already perform was not added to the loopback path.tools/listresponse differed between owner and non-owner tokens, nor thattools/callwas rejected for owner-only tools with a non-owner bearer.tool-resolution.tsexplicitly disablesDEFAULT_GATEWAY_HTTP_TOOL_DENYforsurface === "loopback", so there was no secondary deny-list fallback to catch the missing policy call.Regression Test Plan (if applicable)
Four new integration tests added in
mcp-http.test.ts(reuses the existing mock seam attool-resolution.js):src/gateway/mcp-http.test.tstools/listwith non-owner token does not expose name-based owner-only tools (cron) or flag-based owner-only tools (ownerOnly: true).tools/listwith owner token retains all tools.tools/callfor an owner-only tool with non-owner token returnsisError: trueand never callsexecute.before_tool_callhook block is honored on the loopback path (blocked →isError: true, hook called withagentId/sessionKey/signal).tool-resolution.jsmock gives full control over the returned tool list; the tests exercise the full HTTP round-trip throughstartMcpLoopbackServerwithout needing live config.User-visible / Behavior Changes
Non-owner loopback callers that previously could call
cron,gateway, ornodestools will now receiveTool not available: <tool>(isError: true). Owner callers are unaffected. Before-tool-call hooks that were previously bypassed on the loopback surface now fire.Diagram (if applicable)
Security Impact (required)
cron/gateway/nodesaccess will be blocked. This is the intended behavior perSECURITY.mdand matches the HTTP surface. No legitimate non-owner use case exists for these tools.Repro + Verification
Environment
127.0.0.1/mcp)Steps
openclaw gateway run).nonOwnerTokenfrom the loopback runtime.{"jsonrpc":"2.0","id":1,"method":"tools/list"}withAuthorization: Bearer <nonOwnerToken>.cron,gateway,nodesabsent from the response tool list (fixed) vs. present (vulnerable).tools/callforcron— expectisError: true/Tool not available: cron.Expected
tools/list: owner-only tool names absent for non-owner callers.tools/callon owner-only tool:isError: true, execute never called.Actual (after fix)
Both pass. Before fix, both would fail (tools visible and executable).
Evidence
mcp-http.test.tswere designed to fail on the unfixed path and pass after the fix; reviewed in two passes with actionable comments addressed in84a30456cf.Human Verification (required)
toolSchemabuilt from filtered list;agentIdthreaded to hook context;AbortSignalwired from HTTP request lifecycle through torunBeforeToolCallHook; cache key normalized forsenderIsOwner: undefinedvsfalse.res.closefires after successful write (writableEndedguard prevents false abort);req.destroyedearly-abort path.crontool call;pnpm build[INEFFECTIVE_DYNAMIC_IMPORT]check for the new static import ofpi-tools.before-tool-call.jsin the handler (should be run before landing).Review Conversations
Compatibility / Migration
Risks and Mitigations
createRequestAbortSignaluses the deprecatedreq.abortedevent (deprecated since Node 17, still emitted in Node 22).res.close+!res.writableEndedwhich is the canonical replacement pattern. A follow-up can swapreq.abortedforreq.once("close", () => { if (!req.complete) abort(); })without behavior change.