Skip to content

fix(mcp): gate MCP write tools behind the user confirmation prompt - #2846

Merged
kovtcharov-amd merged 4 commits into
mainfrom
security/mcp-confirmation-gate
Aug 11, 2026
Merged

fix(mcp): gate MCP write tools behind the user confirmation prompt#2846
kovtcharov-amd merged 4 commits into
mainfrom
security/mcp-confirmation-gate

Conversation

@kovtcharov-amd

@kovtcharov-amd kovtcharov-amd commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Connecting any MCP server to GAIA gave every one of its tools a free pass on the confirmation prompt — including file writes, repo deletes and shell execution. The guardrail matches a fixed list of tool names, and MCP tool names are supplied by the connected server, so no MCP tool could ever match it. That made a prompt injection in a document, email or web page the agent was asked to read a direct path to a write the user never approved. MCP tools now carry their own risk classification and prompt unless the server proves the tool is read-only, so they are gated the same way GAIA's own write tools are.

Reported via responsible disclosure. Reproduced on main before the fix and confirmed closed after, against a live MCP server.

Builds directly on #2210: that change made consoles deny rather than answer for an absent human, and this one makes MCP tools reach that gate in the first place. The two together mean an injected MCP write is refused on the CLI and prompts in the Agent UI, instead of running silently on both.

A tool is exempt only when its server sets the MCP-spec readOnlyHint to true and its name carries no mutating verb — so a server that ships no annotations gets no benefit of the doubt, and a readOnlyHint: true on a tool named delete_file is ignored. Note this defends against servers that annotate incorrectly, not against a malicious server, which could simply name its write tool something innocuous; vetting the server remains the control for that.

Test plan

  • python -m pytest tests/unit/mcp/client/test_mcp_tool_risk_classification.py tests/unit/agents/test_mcp_tool_confirmation_gate.py -q (65 tests)
  • python -m pytest tests/unit/mcp/ tests/unit/agents/ -q — no new failures vs. the pre-existing baseline
  • Connect an MCP server exposing a write tool, ask an agent to use it in the Agent UI, confirm the permission modal appears and that denying it prevents the write
  • Connect a server declaring readOnlyHint: true on a read tool, confirm it still runs with no prompt
  • Confirm calling the tool by its short name (write_file rather than mcp_<server>_write_file) is also gated
  • Confirm an unattended run (no TTY) refuses an unannotated MCP write tool rather than running it
  • python util/lint.py --all

@github-actions github-actions Bot added documentation Documentation changes mcp MCP integration changes tests Test changes agents labels Aug 5, 2026
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Verdict: Approve

This closes a real confirmation-gate bypass: the gate matched a static set of tool-name strings, but MCP tools register under server-chosen mcp_<server>_<tool> names that can never appear in that set — so every MCP write/destructive tool ran with zero confirmation. Combined with GAIA reading untrusted documents/email/web, that was a direct prompt-injection → unconfirmed-write path. The fix classifies each MCP tool at registration and fails closed: a tool skips confirmation only when the server proves it read-only (readOnlyHint: true) and its name carries no mutating verb.

The design is sound and honest about its own limits — the code and docs both state plainly this defends against untrusted content, not a malicious server (which could just name its write tool innocuously). Vetting the server stays the control for that. Fail-closed defaults, unattended-mode denial, and unprefixed-alias resolution are all covered by tests.

No blocking issues. One nit below (a misleading test docstring). No security concern to escalate — this is the security hardening.

Real-world evidence

evidence-bundle.md is present and thorough — it exercises the fix end-to-end against two live third-party MCP servers (no mocks), and the verdict rests on it:

  • Real Agent._execute_tool against real fs-evidence (filesystem server) tools:
    • mcp_fs_evidence_write_file + deny → denied, file not created (the exact bug pre-e9e8e2f8 would have written unconditionally).
    • mcp_fs_evidence_write_file + approve → success, file written (gate isn't "always deny").
    • mcp_fs_evidence_read_text_file + deny → success, never prompted (read-only exemption doesn't over-gate).
  • Live annotation parsing confirmed against both servers (write_filereadOnlyHint: false, read_filetrue).
  • HTTP /api/chat/confirm-tool exercised (404/422 contract) + /api/chat/cancel spot-regression.
  • New unit tests run: 89 passed.
  • Deferred (legitimately, per rubric): the Agent-UI confirmation-modal screenshot — pending strix-halo lane; the underlying route was exercised.

Evidence supports merge.

🔍 Technical details

Strengths

  • Correct layering: risk classification travels on the registry entry (requires_confirmation in MCPTool.to_gaia_format, src/gaia/mcp/client/mcp_client.py:326) because server-chosen names can't live in a static set; Agent._tool_requires_confirmation (agent.py:1934) unions it with the static path and _execute_tool calls the unified check (agent.py:2014).
  • Fail-closed is defense-in-depth, not just the happy path: agent.py:1940 treats any un-flagged mcp_-prefixed tool as gated, so a future registration path that forgets to classify still can't open the gate (pinned by test_mcp_entry_without_flag_fails_closed).
  • Alias resolution runs before the gate (agent.py:1971-1978_execute_tool), so a bare write_file emitted by a local model can't skip confirmation — verified by TestUnprefixedAliasCannotBypass.
  • Non-dict annotations degrade to {} (gated), never to "trusted" (mcp_client.py:382-386) — good hostile-input handling, tested.
  • Camel/snake tokenisation with the screaming-camel boundary (_CAMEL_BOUNDARY_RE) is a nice touch and tested (WRITEFile, POSTMessage).

🟢 Minor — inaccurate test docstring re: plural verbs (tests/unit/mcp/client/test_mcp_tool_risk_classification.py:784-793)

test_substring_match_does_not_false_positive's docstring states list_updates "contains 'update' as a token and is treated as mutating." It isn't: _name_tokens("list_updates"){"list", "updates"}, and "updates" is not in _MUTATING_NAME_TOKENS (only the singular "update" is), so it's actually exempt. The docstring describes behavior opposite to reality. Worth correcting the comment, and worth a one-line note that plural verb forms (updates, creates, deletes) aren't in the override set — this is an override-only, honest-but-wrong-server best-effort (residual risk already documented in the client guide's <Warning>), so it's not a blocker, but the comment shouldn't claim coverage the tokenizer doesn't provide. Either drop the list_updates sentence or assert it:

    def test_substring_match_does_not_false_positive(self):
        """``forward`` and ``setting`` merely *contain* the letters of verb
        tokens but tokenise to no mutating token, so they stay exempt.
        Matching is by token, not substring. (Note: plural forms like
        ``updates`` are NOT in the override set — the override is a best-effort
        catch for honest-but-wrong servers, not a complete verb lexicon.)"""

MCP tools bypassed the confirmation guardrail entirely. The gate matches
tool names against a fixed set, but MCP tools register under names chosen
by the connected server (mcp_<server>_<tool>), so no MCP tool could ever
match it. Every write- and destructive-capable MCP tool executed with no
prompt, which turned untrusted content the agent ingests into a path to
an unconfirmed write.

The classification now travels with the registry entry instead of being
looked up by name. MCPTool captures the MCP-spec annotations,
to_gaia_format stamps a requires_confirmation flag, and the gate unions
the static name set with that flag. A tool is exempt only when the server
declares readOnlyHint true and the name carries no mutating verb; a
missing, false, or non-boolean hint all require confirmation. Entries
under the mcp_ prefix carrying no flag fail closed, so a registration
path that forgets to classify cannot silently reopen the gate.

Resolving an unprefixed alias to its canonical name already happened
before the gate, so calling write_file instead of mcp_fs_write_file no
longer skips the check either.

Reported via responsible disclosure.
@kovtcharov-amd
kovtcharov-amd force-pushed the security/mcp-confirmation-gate branch from e9e8e2f to eb21f07 Compare August 5, 2026 23:14
@kovtcharov-amd kovtcharov-amd self-assigned this Aug 5, 2026
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

🟡 One spec example block wasn't updated when _mcp_tool_name was removed.

The PR correctly removes _mcp_tool_name from the code and updates the to_gaia_format docstring example in docs/spec/mcp-client.mdx — but a second verbatim registry-format block in that file (the "GAIA tool format" section) was missed. It still shows the deleted field and is missing the newly-required requires_confirmation key. Anyone reading the full spec to understand the registry entry shape gets contradictory examples in the same file.

🔍 Technical details

docs/spec/mcp-client.mdx:1189–1209 — the "GAIA tool format (in _TOOL_REGISTRY)" example block:

# current (stale)
{
    "name": "mcp_filesystem_read_file",
    ...
    "atomic": True,
    "_mcp_server": "filesystem",
    "_mcp_tool_name": "read_file"   # ← removed from code; no longer emitted
}

Should match what MCPTool.to_gaia_format now produces:

# correct
{
    "name": "mcp_filesystem_read_file",
    "display_name": "read_file (filesystem)",
    ...
    "atomic": True,
    "requires_confirmation": True,  # ← always present now
    "_mcp_server": "filesystem"
    # _mcp_tool_name gone
}

The to_gaia_format docstring example updated in this PR (around line 1115) is correct; only this second block at line 1208 needs the same treatment.

The _TOOL_REGISTRY example block still listed _mcp_tool_name, which
to_gaia_format has not emitted for some time, and omitted the new
requires_confirmation key. Two blocks in the same file described the same
structure differently.
@kovtcharov-amd

Copy link
Copy Markdown
Collaborator Author

Good catch — fixed in a4a6a93. The _TOOL_REGISTRY block at the end of the spec now matches what to_gaia_format actually returns: _mcp_tool_name dropped, display_name and requires_confirmation added. Grepped the rest of docs/, src/ and hub/ for the stale key — no other occurrences.

…n note

The tool-annotations link pointed at an unversioned spec path that 404s;
CI's external-link check caught it. Also corrects the note that said the
CLI proceeds automatically — since #2210 it prompts on a TTY and denies
when there is no terminal to ask.
The name-resolution fixtures hand-build mcp_-prefixed registry entries.
Those now fail closed without an explicit verdict, so every call was
denied before resolution was reached. Stub them the way to_gaia_format
stamps a tool the server proved read-only, keeping these tests about
naming.
kovtcharov-amd added a commit that referenced this pull request Aug 7, 2026
#2853)

CodeAgent's orchestrator ran tools by pulling the callable straight out
of `_TOOL_REGISTRY`, skipping `Agent._execute_tool` and with it the
user-confirmation guardrail — so `run_shell_command`, `write_file` and
any MCP tool registered by a co-resident agent in the same process all
executed with no prompt during orchestrated runs. Orchestrated calls now
take the same path as the agent loop's, so the same tools prompt whether
they are invoked directly or through a checklist.

Same vulnerability class as #2846, which fixes it for MCP tools in the
base agent; this is the CodeAgent half. Reported via responsible
disclosure.

A denial is treated as a user decision rather than a transient fault: it
is never retried, it stops the checklist, and the orchestrator stops
replanning instead of queuing another prompt for the same work. Result
parsing now reads the base agent's `status` field alongside the legacy
`success` key, so a denied or errored call can no longer be mistaken for
success by tools that return a bare payload dict.

Also fixes warnings being dropped when a checklist exits early — a
stopped run now still reports what the completed items produced.

## Test plan

- [ ] `python -m pytest
hub/agents/code/python/tests/test_tool_executor_confirmation.py -q` — 16
tests, 19 subtests
- [ ] Confirm a denied gated tool does not execute (asserted via
side-effect, not just the return value)
- [ ] Confirm a denied item stops the checklist and is not retried by
the error handler
- [ ] Confirm the orchestrator halts replanning after a denial rather
than re-prompting
- [ ] Run a normal `gaia-code` generation task through the orchestrator
and confirm non-gated tools are unaffected
- [ ] `python util/lint.py --all`

---------

Co-authored-by: Ovtcharov <kovtchar@amd.com>
@kovtcharov-amd
kovtcharov-amd added this pull request to the merge queue Aug 11, 2026
Merged via the queue into main with commit 878d473 Aug 11, 2026
7 checks passed
@kovtcharov-amd
kovtcharov-amd deleted the security/mcp-confirmation-gate branch August 11, 2026 22:00
pull Bot pushed a commit to bhardwajRahul/gaia that referenced this pull request Aug 12, 2026
…#2851)

Every MCP tool in the C++ SDK ran with no confirmation.
`connectMcpServer()` stamped the registry's default policy — `ALLOW` —
onto each discovered tool, so the user-confirmation guardrail could
never fire for them, and untrusted content the agent ingests could drive
an `mcp_*_delete_file` or `mcp_*_start_process` call straight to
execution. Discovered MCP tools are now classified fail-closed and
registered `CONFIRM` unless the server proves the tool read-only, so the
C++ SDK matches the guardrail the Python SDK enforces.

Same vulnerability class as amd#2846, which fixes it in the Python SDK;
this is the C++ half. Reported via responsible disclosure.

A tool is exempt only when `annotations.readOnlyHint` is the JSON
boolean `true` **and** its name carries no state-changing verb. Missing
annotations, a non-object value, the string `"true"`, or a read-only
claim contradicted by a name like `delete_file` all require
confirmation. The registry default can raise that verdict but never
lower it.

Note for headless hosts: agents in `silentMode` have no confirmation
callback, so gated MCP tools are denied until the host installs one or
pre-approves the tool. That is deliberate — nothing can answer for an
absent human — but it is a behaviour change for unattended C++ hosts
using MCP.

## Test plan

- [ ] Build the C++ SDK and run `tests_mock` — 483 tests pass, including
24 in `MCPConfirmationTest`
- [ ] `tests_mock --gtest_filter="*Confirm*:*Annotation*:*ReadOnly*"`
passes
- [ ] Confirm a denied MCP tool does not execute (asserted via
side-effect, not just return value)
- [ ] Confirm a server declaring `readOnlyHint: true` on a read-shaped
tool still runs unprompted
- [ ] Confirm `readOnlyHint: "true"` (string) and a non-object
`annotations` both still require confirmation

Co-authored-by: Ovtcharov <kovtchar@amd.com>
itomek added a commit that referenced this pull request Aug 12, 2026
…r re-verification

Merging current main brought 41 more commits (now 331 since v0.22.0). Re-verified
every headline against the new pin on real hardware: all six hold. Folded in what
that delta added and verified live — the confirmation gate now covers the terminal,
the local API, and MCP tool calls (#2846/#2854, both proven fail-closed model-free);
the security section gains enforced MCP --auth-token, shell-less MCP launch, the
~/.gaia write guard, and the CWE-89 SQL block (#2844/#2847/#2860); and the skills
section reflects the verified create/import/sign/audit/migrate lifecycle, stated as
opt-in since no agent loads skills by default (#2848). The C++ SDK and a skills-by-
default claim were deliberately excluded — the former ships no user artifact, the
latter is false.
@itomek itomek mentioned this pull request Aug 12, 2026
8 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents documentation Documentation changes mcp MCP integration changes tests Test changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants