Skip to content

fix(gateway): enforce owner-only tool policy and before-tool-call hook on MCP loopback surface#71159

Merged
drobison00 merged 4 commits into
openclaw:mainfrom
mmaps:fix/fix-517
Apr 24, 2026
Merged

fix(gateway): enforce owner-only tool policy and before-tool-call hook on MCP loopback surface#71159
drobison00 merged 4 commits into
openclaw:mainfrom
mmaps:fix/fix-517

Conversation

@mmaps

@mmaps mmaps commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Problem: McpLoopbackToolCache.resolve() called resolveGatewayScopedTools() and emitted the tool list directly as the MCP schema without passing it through applyOwnerOnlyToolPolicy. A local process holding the non-owner loopback bearer token could call owner-only tools (cron, gateway, nodes) through 127.0.0.1/mcp.
  • Why it matters: The MCP loopback surface explicitly authenticates two bearer classes (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/invoke path and the embedded agent runner both called applyOwnerOnlyToolPolicy correctly; the loopback path did not.
  • What changed:
    • mcp-http.runtime.ts — apply applyOwnerOnlyToolPolicy(next.tools, senderIsOwner === true) after resolveGatewayScopedTools; store agentId on the cache entry for hook context; normalize the cache key so senderIsOwner: undefined and false share the same (non-owner) entry.
    • mcp-http.handlers.ts — call runBeforeToolCallHook before tool.execute in the tools/call branch; accept and forward an AbortSignal so plugin-approval waits can be cancelled if the request drops.
    • mcp-http.ts — plumb hookContext (agentId + sessionKey) and an HTTP-request-scoped AbortSignal into each handleMcpJsonRpc call via a new createRequestAbortSignal helper; abort on both req.aborted and res.close (when !res.writableEnded).
  • What did NOT change: Tool resolution logic, bearer token generation/rotation, the deny-list behavior for the HTTP surface, all other MCP methods (tools/list, initialize, notifications/*).

Change Type (select all)

  • Bug fix
  • Security hardening

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens

Linked Issue/PR

  • This PR fixes a bug or regression ✓

Root Cause (if applicable)

  • Root cause: 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.
  • Missing detection / guardrail: No test asserted that the loopback tools/list response differed between owner and non-owner tokens, nor that tools/call was rejected for owner-only tools with a non-owner bearer.
  • Contributing context: tool-resolution.ts explicitly disables DEFAULT_GATEWAY_HTTP_TOOL_DENY for surface === "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 at tool-resolution.js):

  • Coverage level: Seam / integration test
  • Target file: src/gateway/mcp-http.test.ts
  • Scenarios locked in:
    1. tools/list with non-owner token does not expose name-based owner-only tools (cron) or flag-based owner-only tools (ownerOnly: true).
    2. tools/list with owner token retains all tools.
    3. tools/call for an owner-only tool with non-owner token returns isError: true and never calls execute.
    4. before_tool_call hook block is honored on the loopback path (blocked → isError: true, hook called with agentId/sessionKey/signal).
  • Why this is the smallest reliable guardrail: The existing tool-resolution.js mock gives full control over the returned tool list; the tests exercise the full HTTP round-trip through startMcpLoopbackServer without needing live config.
  • Existing coverage already sufficient for non-security paths

User-visible / Behavior Changes

Non-owner loopback callers that previously could call cron, gateway, or nodes tools will now receive Tool 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)

Before:
[non-owner bearer] -> resolveGatewayScopedTools() -> buildMcpToolSchema(tools) -> advertises cron/gateway/nodes
                                                   -> tool.execute() directly (no hook)

After:
[non-owner bearer] -> resolveGatewayScopedTools() -> applyOwnerOnlyToolPolicy(tools, false)
                                                   -> buildMcpToolSchema(filtered) -> cron/gateway/nodes absent
                                                   -> runBeforeToolCallHook() -> tool.execute()

Security Impact (required)

  • New permissions/capabilities? No — this removes an unintended elevation; no new capabilities granted.
  • Secrets/tokens handling changed? No — bearer token generation and rotation are unchanged.
  • New/changed network calls? No — loopback only.
  • Command/tool execution surface changed? Yes — owner-only tools are now unreachable from non-owner MCP callers; before-tool-call hooks now fire on this surface.
  • Data access scope changed? No — non-owner callers lose access to tools they should never have had.
  • Risk + mitigation: Any non-owner caller relying on the previously unguarded cron/gateway/nodes access will be blocked. This is the intended behavior per SECURITY.md and matches the HTTP surface. No legitimate non-owner use case exists for these tools.

Repro + Verification

Environment

  • OS: Linux (CI) / macOS 26.0.1
  • Runtime: Node 22+
  • Integration: MCP loopback (127.0.0.1/mcp)

Steps

  1. Start gateway with default config (openclaw gateway run).
  2. Extract the nonOwnerToken from the loopback runtime.
  3. POST {"jsonrpc":"2.0","id":1,"method":"tools/list"} with Authorization: Bearer <nonOwnerToken>.
  4. Observe cron, gateway, nodes absent from the response tool list (fixed) vs. present (vulnerable).
  5. POST tools/call for cron — expect isError: true / Tool not available: cron.

Expected

  • tools/list: owner-only tool names absent for non-owner callers.
  • tools/call on owner-only tool: isError: true, execute never called.

Actual (after fix)

Both pass. Before fix, both would fail (tools visible and executable).

Evidence

  • Failing test/log before + passing after — four new tests in mcp-http.test.ts were designed to fail on the unfixed path and pass after the fix; reviewed in two passes with actionable comments addressed in 84a30456cf.

Human Verification (required)

AI-assisted PR — generated and reviewed by Claude Sonnet 4.6.

  • Verified scenarios: owner-only filter applied at cache resolution; toolSchema built from filtered list; agentId threaded to hook context; AbortSignal wired from HTTP request lifecycle through to runBeforeToolCallHook; cache key normalized for senderIsOwner: undefined vs false.
  • Edge cases checked: batch JSON-RPC requests (signal shared across the batch loop); res.close fires after successful write (writableEnded guard prevents false abort); req.destroyed early-abort path.
  • What was not verified: live gateway smoke test with a real cron tool call; pnpm build [INEFFECTIVE_DYNAMIC_IMPORT] check for the new static import of pi-tools.before-tool-call.js in the handler (should be run before landing).

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

Compatibility / Migration

  • Backward compatible? Yes for owner callers. Breaking for any non-owner caller that was relying on the unguarded access (no legitimate use case documented).
  • Config/env changes? No
  • Migration needed? No

Risks and Mitigations

  • Risk: createRequestAbortSignal uses the deprecated req.aborted event (deprecated since Node 17, still emitted in Node 22).
    • Mitigation: Paired with res.close + !res.writableEnded which is the canonical replacement pattern. A follow-up can swap req.aborted for req.once("close", () => { if (!req.complete) abort(); }) without behavior change.

@openclaw-barnacle openclaw-barnacle Bot added gateway Gateway runtime size: M labels Apr 24, 2026
@greptile-apps

greptile-apps Bot commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR closes a security gap where the MCP loopback surface was advertising and executing owner-only tools (cron, gateway, nodes) to non-owner bearers because applyOwnerOnlyToolPolicy was never called on the loopback path. The fix correctly applies the policy at cache-resolution time, threads agentId and sessionKey into a HookContext, and invokes runBeforeToolCallHook before every tool.execute call, matching the behaviour of the existing HTTP and embedded-agent surfaces. An HTTP-request-scoped AbortSignal is plumbed through so plugin-approval waits can be cancelled on disconnect.

Confidence Score: 5/5

Safe 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 AI
This 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

Comment thread src/gateway/mcp-http.ts Outdated
Comment thread src/gateway/mcp-http.handlers.ts Outdated
@drobison00
drobison00 merged commit 8b76392 into openclaw:main Apr 24, 2026
65 checks passed
Angfr95 pushed a commit to Angfr95/openclaw that referenced this pull request Apr 25, 2026
…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]>
ogt-redknie pushed a commit to ogt-redknie/OPENX that referenced this pull request May 2, 2026
…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]>
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 9, 2026
…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]>
globalcaos pushed a commit to globalcaos/tinkerclaw that referenced this pull request May 13, 2026
…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]>
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 24, 2026
…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]>
jameslcowan pushed a commit to jameslcowan/openclaw that referenced this pull request Jun 2, 2026
…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]>
sablehead pushed a commit to sablehead/openclaw that referenced this pull request Jun 10, 2026
…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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gateway Gateway runtime size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants