Skip to content

fix(executor): enforce MCP tool_permissions at the tool-call boundary - #2080

Open
kgabryje wants to merge 4 commits into
mainfrom
mcp-tool-permissions-enforcement
Open

fix(executor): enforce MCP tool_permissions at the tool-call boundary#2080
kgabryje wants to merge 4 commits into
mainfrom
mcp-tool-permissions-enforcement

Conversation

@kgabryje

@kgabryje kgabryje commented Jul 29, 2026

Copy link
Copy Markdown
Member

Problem

mcp_servers.data.tool_permissions (Record<string, 'ask' | 'allow' | 'deny'>) was persisted, settable via agor_mcp_servers_update, and typed in packages/core/src/types/mcp.ts — but never read by any handler. Write-only config: set write_file: deny and the executor called write_file anyway.

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.tools snapshot, so the three SDKs expose three non-unifiable filter primitives.

Handler deny ask
Claude disallowedTools + PreToolUse hook + canUseTool routes to the existing permission prompt
Gemini excludeTools (dropped at discovery) fails closed — no prompt channel
Codex disabled_tools fails closed — no prompt channel
Copilot per-server tools include-list fails closed (see caveat below)
Cursor / OpenCode server withheld entirely server withheld entirely

allow and unlisted tools are unchanged throughout.

Namespacing. tool_permissions is keyed on the bare tool name; SDKs qualify differently — Claude mcp__<server>__<tool>, Gemini bare (or <server>__<tool> on collision), Codex per-server config. base/mcp-tool-permissions.ts bridges this with longest-server-prefix matching and sanitized/lowercased name aliases.

Admission gate. getMcpServersForSession now takes a required HandlerPermissionCapabilities and 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:

  • allowedTools was a permission-rule injection sink. It was fed server.tools[].name — whatever a remote MCP server returns from tools/list, stored verbatim. The SDK defines allowedTools as "auto-allowed without prompting" and it matches built-in tool names, so an attached server advertising a tool called Bash silently disabled the permission prompt for the built-in Bash. It was also inert for its stated purpose, since bare names never match mcp__server__tool. Removed; the false MCPTool.name comment that made it look correct is fixed.
  • A DB server named agor could hijack the built-in Agor MCP namespace. User servers were spread over the built-in config, so a server named agor inherited the session's daemon bearer token and permission-hooks' unconditional auto-approve. Codex already guarded this; Claude had the spread backwards. The key is now reserved.

Also closed: a stale settings.json allow rule (written by Agor itself on "remember") could skip an ask gate, since the SDK matches rules before canUseTool — a PreToolUse hook now decides first. And ask no longer degrades to allow under bypassPermissions.

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 check passes (typecheck 11/11, lint, shortid, multitenancy-boundaries, builds 7/7). Executor 660/660, core 2908 passed / 15 skipped.

Known gaps — not fixed here

  • caps.interactiveApproval is declared at all six call sites and read nowhere. The admission gate switches only on toolFiltering. 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.
  • Copilot declares interactiveApproval: true but behaves as false. Its permission handler never consults tool_permissions, so its isAttachedMcpServer auto-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.
  • Subagent propagation unverified. Whether disallowedTools and hooks reach Task-tool sidechains could not be confirmed from the minified CLI bundle. Wants one integration test.
  • Out of scope per the brief: no UI for 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

kgabryje and others added 4 commits July 28, 2026 15:16
`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>
@kgabryje kgabryje added the ai-reviewed-gpt-5-5 Reviewed by Codex GPT-5.5 with no substantive issues label Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-reviewed-gpt-5-5 Reviewed by Codex GPT-5.5 with no substantive issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant