fix(executor): enforce MCP tool_permissions at the tool-call boundary - #2080
Open
kgabryje wants to merge 4 commits into
Open
fix(executor): enforce MCP tool_permissions at the tool-call boundary#2080kgabryje wants to merge 4 commits into
kgabryje wants to merge 4 commits into
Conversation
`mcp_servers.data.tool_permissions` was persisted and settable but never consulted by any handler — a tool set to `deny` still executed. Claude: the resolved per-tool permissions now reach `canUseTool`, which refuses `deny` before dispatch and routes `ask` into the existing permission prompt instead of the attached-server auto-approve fast path. Denied tools are also added to `disallowedTools`, and gated tools are kept off the MCP allowlist so an allowlist hit cannot skip the prompt. Gemini and Codex have no interactive approval channel, so both `deny` and `ask` fail closed there via `excludeTools` / `disabled_tools`, which drop the tool at discovery time. Namespacing is bridged in a shared `base/mcp-tool-permissions` index: `tool_permissions` is keyed on the bare tool name while SDKs surface `mcp__<server>__<tool>` (Claude) or bare / `<server>__<tool>` (Gemini). Co-Authored-By: Claude <noreply@anthropic.com>
…kipped `canUseTool` fires only after the SDK has matched settings.json permission rules, and Agor writes those rules itself when a user picks "remember". A tool approved once and later switched to `ask` therefore kept running on the stale allow rule without ever prompting. A PreToolUse hook now decides first — it runs ahead of rule matching, and its `ask` decision routes back through canUseTool into the existing permission-request flow. `bypassPermissions` skips canUseTool entirely, which left `ask` degrading to `allow`. The approval channel is now resolved once up front, and where none exists `ask` is blocked alongside `deny` — the same fail-closed rule already applied to Gemini and Codex. Co-Authored-By: Claude <noreply@anthropic.com>
`allowedTools` means "run without asking" and matches built-in tool names. It was being fed `server.tools[].name`, which is whatever string a remote MCP server returns from `tools/list` and is stored verbatim. An attached server advertising a tool called `Bash` therefore switched off the permission prompt for the built-in Bash. The list never worked for its stated purpose either — Claude names MCP tools `mcp__<server>__<tool>`, so a bare name matched none of them. Removed. The built-in Agor server's config key is now reserved. It carries the session's daemon bearer token and is auto-approved by name, but user-configured servers were spread over it, so one named `agor` inherited both. Codex already guarded this; Claude had the spread backwards. Server-name aliases used to match a permission entry are narrowed to the tool-name alphabet — a character left in that the SDK rewrites made the lookup miss, and a miss reads as "unconfigured", i.e. allow. Finally, `getMcpServersForSession` now requires each caller to declare what it can enforce and withholds any server whose `tool_permissions` that handler cannot honour. Six handlers resolve MCP servers; three enforced, and the rest kept reporting success while ignoring every `deny`. Cursor and OpenCode now refuse such a server outright, and Copilot gained real enforcement via its per-server include-list. Declaring the capability is required, so a new handler cannot repeat the omission silently. Co-Authored-By: Claude <noreply@anthropic.com>
…enials Server names are indexed under every form an SDK might rewrite them to, so that a deny still matches whatever name the CLI emits. Two servers can rewrite to the same form though — "foo.bar" and "foo_bar" both become "foo_bar" — and the index overwrote the shared entry, silently discarding the first server's denials. A lookup then found no entry and read as unconfigured, which means allowed. Entries now merge, most restrictive winning, so the result no longer depends on server ordering. The SDK-layer disallow list emits the same alias set, since a rule built from only the raw name cannot match a rewritten tool name. A DB server claiming the reserved "agor" name had its config discarded but its tool_permissions were still read, letting an untrusted row gate the genuine bearer-token-backed built-in server's tools. Reserved names are now filtered before anything reads the list. Also drops two pieces of machinery that were carrying no weight: the `interactiveApproval` capability, declared at every call site and read nowhere, and the bare tool-name index, unreachable from the only caller (which always passes `mcp__`-qualified names) and able to deny an unrelated tool on a coincidental match. Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
mcp_servers.data.tool_permissions(Record<string, 'ask' | 'allow' | 'deny'>) was persisted, settable viaagor_mcp_servers_update, and typed inpackages/core/src/types/mcp.ts— but never read by any handler. Write-only config: setwrite_file: denyand the executor calledwrite_fileanyway.What this does
Enforces it at the point each SDK decides whether a tool call proceeds. The mechanism is necessarily per-handler — the tool list a model sees is discovered live from the MCP server by each SDK, not read from the cached
server.toolssnapshot, so the three SDKs expose three non-unifiable filter primitives.denyaskdisallowedTools+PreToolUsehook +canUseToolexcludeTools(dropped at discovery)disabled_toolstoolsinclude-listallowand unlisted tools are unchanged throughout.Namespacing.
tool_permissionsis keyed on the bare tool name; SDKs qualify differently — Claudemcp__<server>__<tool>, Gemini bare (or<server>__<tool>on collision), Codex per-server config.base/mcp-tool-permissions.tsbridges this with longest-server-prefix matching and sanitized/lowercased name aliases.Admission gate.
getMcpServersForSessionnow takes a requiredHandlerPermissionCapabilitiesand withholds any server whose permissions the calling handler cannot honour, reusing the existing template-resolution drop pattern. Six call sites resolve MCP servers; three originally enforced. Making the argument required means a new handler can't repeat that omission silently.Security fixes found during architectural review
Both predate this branch and are independent of the original bug:
allowedToolswas a permission-rule injection sink. It was fedserver.tools[].name— whatever a remote MCP server returns fromtools/list, stored verbatim. The SDK definesallowedToolsas "auto-allowed without prompting" and it matches built-in tool names, so an attached server advertising a tool calledBashsilently disabled the permission prompt for the built-in Bash. It was also inert for its stated purpose, since bare names never matchmcp__server__tool. Removed; the falseMCPTool.namecomment that made it look correct is fixed.agorcould hijack the built-in Agor MCP namespace. User servers were spread over the built-in config, so a server namedagorinherited the session's daemon bearer token andpermission-hooks' unconditional auto-approve. Codex already guarded this; Claude had the spread backwards. The key is now reserved.Also closed: a stale
settings.jsonallow rule (written by Agor itself on "remember") could skip anaskgate, since the SDK matches rules beforecanUseTool— aPreToolUsehook now decides first. Andaskno longer degrades toallowunderbypassPermissions.Testing
44 new tests. The deny path is asserted at the dispatch boundary — calls are routed through a stand-in for the SDK's forward step that only reaches the server on
allow, so a deny that still dispatched would fail. Coverage: deny / ask / allow / unlisted-default, namespaced-vs-bare mapping, the admission gate across all three capability shapes,bypassPermissions, and both security fixes above.pnpm checkpasses (typecheck 11/11, lint, shortid, multitenancy-boundaries, builds 7/7). Executor 660/660, core 2908 passed / 15 skipped.Known gaps — not fixed here
caps.interactiveApprovalis declared at all six call sites and read nowhere. The admission gate switches only ontoolFiltering. Each handler still computes its own prompt-channel decision independently, so behaviour is correct, but the field is currently dead config — the same category as the bug this PR fixes. Should either be consumed by the shared resolver or deleted.interactiveApproval: truebut behaves asfalse. Its permission handler never consultstool_permissions, so itsisAttachedMcpServerauto-approve fast path — the hole closed on Claude — is still open at runtime. Config-level enforcement works; the runtime per-tool check does not exist.disallowedToolsand hooks reach Task-tool sidechains could not be confirmed from the minified CLI bundle. Wants one integration test.tool_permissions(only agents can set it today), no validation of keys against the discovered tool list (a typo'd key is a silently ineffective deny), and no server-name validation at the REST boundary.🤖 Generated with Claude Code