fix(mcp): harden MCP loopback server and scope CLI tool access to claude-cli#35676
fix(mcp): harden MCP loopback server and scope CLI tool access to claude-cli#35676mylukin wants to merge 13 commits into
Conversation
Greptile SummaryThis PR introduces a dedicated MCP loopback HTTP server ( Two issues need attention before merging:
Confidence Score: 2/5
Last reviewed commit: 5d7ad6a |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5d7ad6a490
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6e8d025bae
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
a447347 to
dba3343
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dba33438e8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
dba3343 to
9c98253
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9c98253a0a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
/review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 845ae6d4b0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (mcpConfigPath && backendResolved.id === "claude-cli") { | ||
| if (!args.includes("--strict-mcp-config")) { | ||
| args.push("--strict-mcp-config"); | ||
| } | ||
| if (!args.includes("--mcp-config")) { | ||
| args.push("--mcp-config", mcpConfigPath); |
There was a problem hiding this comment.
Skip MCP flags when loopback server is unavailable
startGatewayServer treats MCP loopback bind failures as non-fatal (src/gateway/server.impl.ts:868-873), but runCliAgent still unconditionally adds --strict-mcp-config/--mcp-config for Claude when it can write the config file. If port+1 is unavailable (for example already in use), Claude runs are still pointed at that endpoint, so OpenClaw MCP tools are silently unavailable or routed to the wrong local service instead of falling back cleanly. Gate the flag injection on confirmed MCP server availability (or disable MCP wiring after startup failure).
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0a6d467f15
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| return policyFiltered.filter( | ||
| (t) => | ||
| !NATIVE_TOOL_EXCLUDE.has(t.name) && !DEFAULT_GATEWAY_HTTP_TOOL_DENY.includes(t.name as never), |
There was a problem hiding this comment.
Honor gateway tool overrides in MCP filtering
This filter hard-codes DEFAULT_GATEWAY_HTTP_TOOL_DENY and never applies gateway.tools.allow / gateway.tools.deny overrides, unlike the existing HTTP invoke path in src/gateway/tools-invoke-http.ts. As a result, operators who configured extra HTTP-denied tools can still expose them through the new MCP loopback endpoint, and operators who explicitly allow default-denied tools cannot re-enable them for MCP, creating policy drift between two gateway HTTP tool surfaces.
Useful? React with 👍 / 👎.
🔒 Aisle Security AnalysisWe found 3 potential security issue(s) in this PR:
1. 🟡 MCP loopback server ignores session/account scoping headers, resolving tools under main session
DescriptionIn This creates an access-control / policy-scoping bypass because
By hard-coding
Notably, Vulnerable code: const cfg = loadConfig();
const reqSessionKey = resolveMainSessionKey(cfg);
const messageProvider =
normalizeMessageChannel(getHeader(req, "x-openclaw-message-channel")) ?? undefined;
const accountId = undefined; // not overrideable via headers
const { tools, toolSchema } = getTools({
cfg,
sessionKey: reqSessionKey,
messageProvider,
accountId,
});RecommendationUse the request scoping headers when resolving tools, and validate them. Minimum fix (respect headers): const cfg = loadConfig();
const headerSessionKey = getHeader(req, "x-session-key")?.trim();
const sessionKey = headerSessionKey && headerSessionKey !== "main"
? headerSessionKey
: resolveMainSessionKey(cfg);
const messageProvider =
normalizeMessageChannel(getHeader(req, "x-openclaw-message-channel")) ?? undefined;
const accountId = getHeader(req, "x-openclaw-account-id")?.trim() || undefined;
const { tools, toolSchema } = getTools({ cfg, sessionKey, messageProvider, accountId });Hardening recommendations (to prevent confused-deputy / privilege escalation):
2. 🟡 MCP loopback token can be leaked to attacker-controlled local process due to predictable port and fail-open startup
DescriptionThe gateway starts an MCP loopback HTTP server on a predictable port ( If another local process binds the MCP port first (intentional attack or accidental conflict):
Vulnerable behavior (fail-open on bind error): const mcpPort = port + MCP_PORT_OFFSET;
let mcpServer: { close: () => Promise<void> } | undefined;
try {
mcpServer = await startMcpLoopbackServer(mcpPort);
ensureMcpConfigFile(path.join(os.homedir(), ".openclaw"), mcpPort);
log.info(`MCP loopback server listening on http://127.0.0.1:${mcpPort}/mcp`);
} catch (err) {
log.warn(`MCP loopback server failed to start: ${String(err)}`);
}Security impact is amplified by the deterministic port selection ( RecommendationFail closed and/or make the MCP port non-predictable. Recommended mitigations:
Example (pick a free port and fail closed on unexpected errors): import net from "node:net";
async function pickFreePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const s = net.createServer();
s.listen(0, "127.0.0.1", () => {
const addr = s.address();
s.close(() => {
if (typeof addr === "object" && addr?.port) resolve(addr.port);
else reject(new Error("failed to resolve port"));
});
});
s.on("error", reject);
});
}
const mcpPort = await pickFreePort();
mcpServer = await startMcpLoopbackServer(mcpPort);
ensureMcpConfigFile(openclawDir, mcpPort);
3. 🔵 Symlink/race arbitrary file overwrite via predictable tmp file in ensureMcpConfigFile
Description
If an attacker can write within This is a classic local file clobbering primitive and can become privilege escalation if the process runs with elevated privileges or writes into a directory writable by less-privileged users. Vulnerable code: const tmpPath = filePath + ".tmp";
fs.writeFileSync(tmpPath, content, { encoding: "utf-8", mode: 0o600 });
fs.renameSync(tmpPath, filePath);RecommendationHarden the write to be symlink/race resistant:
Example (POSIX-focused): import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
function secureMkdir(dir: string) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
try { fs.chmodSync(dir, 0o700); } catch {}
const st = fs.lstatSync(dir);
if (!st.isDirectory() || st.isSymbolicLink()) {
throw new Error(`Unsafe openclawDir: ${dir}`);
}
}
function atomicWriteNoFollow(target: string, content: string) {
const dir = path.dirname(target);
const tmp = path.join(dir, `${path.basename(target)}.${crypto.randomUUID()}.tmp`);
// 'wx' => fail if exists (O_EXCL)
fs.writeFileSync(tmp, content, { encoding: "utf-8", mode: 0o600, flag: "wx" });
fs.renameSync(tmp, target);
}Also consider using Analyzed PR: #35676 at commit Last updated on: 2026-03-10T00:33:25Z |
|
/review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 74ef98ba0b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const gatewayPort = params.config?.gateway?.port ?? 18789; | ||
| const mcpPort = gatewayPort + MCP_PORT_OFFSET; | ||
| const openclawDir = path.join(os.homedir(), ".openclaw"); |
There was a problem hiding this comment.
Resolve MCP endpoint from runtime gateway port
This computes the MCP URL from params.config?.gateway?.port (defaulting to 18789), but the gateway can run on a different runtime port via openclaw gateway run --port ...; startGatewayServer binds MCP on runtimePort + 1, so this path can write mcp.json with the wrong port and then pass it with --strict-mcp-config, causing Claude CLI MCP startup/tool access to fail even though the loopback server is healthy. Please derive the port from the runtime source (for example OPENCLAW_GATEWAY_PORT) before writing/using MCP config.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c0a343acd0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const reqSessionKey = getHeader(req, "x-session-key")?.trim() || resolveMainSessionKey(cfg); | ||
| const messageProvider = | ||
| normalizeMessageChannel(getHeader(req, "x-openclaw-message-channel")) ?? undefined; | ||
| const accountId = getHeader(req, "x-openclaw-account-id")?.trim() || undefined; |
There was a problem hiding this comment.
Bind MCP auth token to a fixed session context
The loopback server accepts x-session-key and x-openclaw-account-id directly from request headers after only validating a single shared bearer token, so any caller that knows OPENCLAW_MCP_TOKEN can impersonate another session/account and get a different tool policy. Because runCliAgent injects that token into the Claude subprocess environment (src/agents/cli-runner.ts:327-330) and Claude has native exec, a restricted run can issue its own HTTP request to /mcp with forged headers and bypass session-scoped tool restrictions.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fb3363e44a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| * Returns the absolute path to the config file. | ||
| */ | ||
| export function ensureMcpConfigFile(openclawDir: string, mcpPort: number): string { | ||
| const filePath = path.join(openclawDir, "mcp.json"); |
There was a problem hiding this comment.
Use unique MCP config files per gateway/runtime
ensureMcpConfigFile always writes to a single shared ~/.openclaw/mcp.json, and runCliAgent passes that path via --strict-mcp-config --mcp-config for every Claude run. If two gateways are running under the same user (different ports) or Claude runs overlap when backend serialization is disabled, one invocation can rewrite the file while another process is starting, so the earlier process may connect to the wrong MCP endpoint/token and receive the wrong tool surface or fail tool calls.
Useful? React with 👍 / 👎.
…ude-cli - Add 1MB body size limit to readBody (prevents OOM) - Wrap httpServer.listen in promise for proper EADDRINUSE handling - Scope MCP config + env vars to claude-cli only; restore tools-disabled prompt for other backends - Replace Date.now() with crypto.randomUUID() for tool call IDs - Remove misleading ?. optional chaining on execute() - Add toolCache eviction sweep for expired entries - Extract MCP_PORT_OFFSET constant (replaces duplicated port+1) - Narrow ensureMcpConfigFile catch to ENOENT only - Remove unused ServerResponse import - Fix pre-existing curly lint error
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Cody <Cody.bot@easymeta.au>
fb3363e to
51d9630
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 51d9630b22
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| ? result.content.map((block: { type?: string; text?: string }) => ({ | ||
| type: (block.type ?? "text") as "text", | ||
| text: block.text ?? (typeof block === "string" ? block : JSON.stringify(block)), |
There was a problem hiding this comment.
Preserve non-text MCP tool result blocks
The tools/call success path coerces every returned content block to type: "text", which silently strips MCP media semantics for tools that legitimately return images (for example nodes camera actions emit { type: "image", data, mimeType } in src/agents/tools/nodes-tool.ts). In Claude MCP sessions this makes those media results unusable as images and can inflate token usage by serializing large binary payloads into text, so tool behavior diverges from the native OpenClaw tool path.
Useful? React with 👍 / 👎.
…, tool policy, home dir, port binding Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… compat Temp directories fail Windows ACL verification in secrets file provider. Adding allowInsecurePath: true bypasses the check for test-only paths. Co-Authored-By: Cody <Cody.bot@easymeta.au>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cbc02dea6e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const tmpPath = filePath + ".tmp"; | ||
| fs.writeFileSync(tmpPath, content, { encoding: "utf-8", mode: 0o600 }); | ||
| fs.renameSync(tmpPath, filePath); |
There was a problem hiding this comment.
Use a unique temp file for MCP config writes
ensureMcpConfigFile always stages through the same mcp-<port>.json.tmp path, so concurrent writers for the same port can race: one call renames the shared temp file first and the other renameSync then fails with ENOENT. In practice this can happen when Claude runs overlap (or multiple local processes share the same config), and runCliAgent then silently falls back without MCP tool access for that run. Generate a per-write temp filename before renaming atomically.
Useful? React with 👍 / 👎.
| const cfg = loadConfig(); | ||
| const defaultSessionKey = resolveMainSessionKey(cfg); | ||
| const { tools: initial } = getTools({ cfg, sessionKey: defaultSessionKey }); | ||
| logDebug( |
There was a problem hiding this comment.
Reject MCP startup promise when initial tool load fails
The listen callback eagerly calls loadConfig()/getTools(...) without a local guard, so an exception during initial tool construction escapes the callback instead of rejecting the surrounding startup promise. That bypasses the caller’s non-fatal MCP startup try/catch path and can surface as an uncaught exception (for example when plugin/tool loading throws), taking down the gateway instead of just disabling MCP loopback.
Useful? React with 👍 / 👎.
…MCP config - Remove x-session-key/x-openclaw-account-id header trust in mcp-http.ts; always use resolveMainSessionKey(cfg) to prevent session impersonation - Use OPENCLAW_GATEWAY_PORT env var as primary port source in cli-runner.ts, falling back to config port when env var is absent or invalid - Remove TODO(fix9) comment that acknowledged the impersonation vulnerability
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 94272d4b14
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const reqSessionKey = resolveMainSessionKey(cfg); | ||
| const messageProvider = | ||
| normalizeMessageChannel(getHeader(req, "x-openclaw-message-channel")) ?? undefined; | ||
| const accountId = undefined; // not overrideable via headers |
There was a problem hiding this comment.
Respect per-run context when resolving MCP tool policy
The MCP request path hard-codes resolveMainSessionKey(cfg) and accountId = undefined, so every Claude MCP call is evaluated as the main session/account regardless of the run context. This breaks subagent and multi-account isolation: subagent policies (resolveSubagentToolPolicy) and account-specific group policies are never applied, so restricted runs can inherit the main tool surface (or lose account-allowed tools). Use the per-run context already passed in MCP headers (or another server-side binding) before calling getTools.
Useful? React with 👍 / 👎.
|
Closing: upstream not accepting merge at this time. |
Problem
When Claude Code runs as a subprocess via OpenClaw's ACP runtime, it is an "isolated island" — it has access to its own built-in tools (file, bash, grep…) but cannot use any OpenClaw tools (message, cron, browser, nodes, etc.). This makes ACP-spawned agents significantly less capable than the main agent.
Additional issues fixed
--mcp-configdeduplication: Multiple--mcp-configflags could be passed to Claude CLI, causing config conflictsaccountIdnot threaded: The CLI runner didn't passaccountIdthrough to tool resolution, breaking account-scoped toolsFix
New
mcp-http.tsmodule (513 lines): Implements a full MCP-over-HTTP server (JSON-RPC 2.0) that:read,write,exec, etc.)DEFAULT_GATEWAY_HTTP_TOOL_DENYfor securityCLI runner hardening:
--mcp-configpointing to the loopback MCP serveraccountIdthrough to tool policy resolutionuseResumeguard fix for system prompt (see also PR fix(cli): honor systemPromptWhen=always on session resume #35705)Tests for both MCP server tool filtering and CLI runner integration
Why merge
Without MCP loopback, ACP-spawned agents (Claude Code subprocesses) cannot send messages, schedule crons, control browsers, or use any OpenClaw-specific tools. This is the critical bridge that enables full-featured ACP agent sessions. The tool policy filtering ensures security is maintained — agents only get tools their policy allows.