Skip to content

fix(mcp): guard buffer reads at one chokepoint instead of per verb - #35

Merged
singhpratech merged 1 commit into
mainfrom
fix/mcp-sixth-credential-door
Aug 8, 2026
Merged

fix(mcp): guard buffer reads at one chokepoint instead of per verb#35
singhpratech merged 1 commit into
mainfrom
fix/mcp-sixth-credential-door

Conversation

@singhpratech

Copy link
Copy Markdown
Owner

Retesting v0.1.126 found a sixth credential door. The interesting part is not the door — it is why there kept being another one.

The leak

select_range accepted any range inside a private key and get_selection returned it as text, so a file that open_file, read_tab, find_in_tab, search_project and resources/read all correctly refused was readable one selection at a time.

open_file    .ssh/id_rsa   → refused
read_tab     tab 6         → refused
find_in_tab  tab 6         → refused
search_project "FAKEKEY…"  → key not among results
resources/read tab/6       → -32002 + deny-list detail

select_range {"tab_index":6,…} → {"ok":true}          ← the sixth door
get_selection {}               → "-----BEGIN OPENSSH PRIVATE KEY-----…"

get_selection was exempted deliberately, on the reasoning that a selection is human-made so reading it back implies consent. select_range makes that false: the client chooses the range.

apply_edit was a quieter form of the same thing — it reads the buffer as a match oracle, so "no match" versus an approval card naming the match reports what a file contains, one probe at a time, without ever returning text. The retest did not flag it; it is the same class.

Why enumerating callers was never going to converge

Release Diagnosis Fix
v0.1.125 deny-list existed in two hand-maintained copies unify the list
v0.1.126 the unified list ran in 1 of 4 read paths add the check to 3 more
v0.1.127 a fifth and sixth path nobody had enumerated

Every one of those fixes was correct and none converged, because what kept going stale was not the list — it was the enumeration. Each new verb had to remember to ask, and a guard that depends on remembering is not a guard.

So the check moved to where buffer text is fetched:

bool McpBridge::tabTextFor(int idx, QString *out, QString *refusal) const;

Every verb that needs buffer contents calls it; none calls m_host.tabText directly. select_range refuses to even stage a selection over a credential file, so no future verb inherits a leak already set up for it.

And a test lints mcp_bridge.cpp and fails the build on a direct m_host.tabText / m_host.selection call. A source-reading test is unusual — it earns its place because the per-verb tests can only cover verbs that exist today, and this defect has twice arrived through one that was not on anyone's list. It carries a vacuity guard, so a rename that breaks the pattern fails rather than passing silently.

Also fixed

  • read_tab's marker said [truncated at 5 MB] whatever cap applied — a constant left over from before max_bytes existed. Now [truncated: showing N of M characters]. The v0.1.126 notes also claimed read_tab "always reports truncated and total_chars"; that holds for the editor↔sidecar wire and not for the tool result, which is plain text. Docs corrected; the shipped v0.1.126 changelog entry left as-is rather than quietly rewritten.
  • select_range advertised tab_id in its inputSchema and rejected it with -32602. The v0.1.126 sweep matched only the single-line reject_extras form and select_range is the one written multi-line — one verb out of thirteen. Rather than fix that one, a test now walks the entire tool list and asserts every advertised tab_id is genuinely accepted. A schema that lies is worse than a missing feature: it makes correct clients fail.
  • list_open_tabs published the field as id while every verb takes tab_id. Both names ship for one release.

Verification

76/76 C++ suites, 66 sidecar protocol tests, both Cargo feature sets, plus the Windows cross-compile check added in v0.1.126.

Every fix red-state verified by restoring the pre-fix code. The two security tests were confirmed to fail independently — the lint flags a raw call site even when nobody has thought to test the verb it appears in, which is exactly the property that makes it worth having.

Known gap: when the host reports a selection it cannot attribute to a tab (tabIndex < 0) there is no path to check, so the text is returned. Refusing every unattributed selection would break get_selection on hosts that do not track the owning tab. Commented at the call site.

🤖 Generated with Claude Code

https://claude.ai/code/session_01W6BGY2dFUSujZeS3vTDikq

Retesting v0.1.126 found a sixth credential door. select_range accepted any
range inside a private key and get_selection returned it as text, so a file
that open_file, read_tab, find_in_tab, search_project and resources/read all
correctly refused was readable one selection at a time.

get_selection had been exempted deliberately, on the reasoning that a
selection is human-made so reading it back implies consent. select_range makes
that false: the CLIENT chooses the range. apply_edit was a quieter form of the
same thing — it reads the buffer as a match oracle, so "no match" versus an
approval card naming the match reports what a file contains, one probe at a
time, without ever returning text.

The important part is not the sixth door. It is why there kept being another:

  v0.1.125  the deny-list existed in two hand-maintained copies -> unify LIST
  v0.1.126  the unified list ran in 1 of 4 read paths -> add 3 checks
  v0.1.127  a fifth and sixth path nobody had enumerated

Every one of those fixes was correct and none converged, because what kept
going stale was the ENUMERATION, not the list. Each new verb had to remember
to ask, and a guard that depends on remembering is not a guard.

So the check moved to where buffer text is FETCHED. McpBridge::tabTextFor is
now the only way it leaves the host; select_range refuses to even stage a
selection over a credential file, so no future verb inherits a leak already
set up for it. A test lints mcp_bridge.cpp and fails the build on a direct
m_host.tabText / m_host.selection call. A source-reading test is unusual and
it earns its place here: the per-verb tests can only cover verbs that exist
today, and this defect has twice arrived through one that was not on a list.
It carries a vacuity guard, so a rename that breaks the pattern fails rather
than passing silently.

Also fixed:

  - read_tab's marker said "[truncated at 5 MB]" whatever cap applied — a
    constant left from before max_bytes existed. Now "[truncated: showing N of
    M characters]". The v0.1.126 notes claimed read_tab "always reports
    truncated and total_chars"; that holds for the editor<->sidecar wire and
    not for the tool result, which is plain text. Docs corrected; the shipped
    v0.1.126 entry left as-is rather than quietly rewritten.
  - select_range advertised tab_id in its inputSchema and rejected it with
    -32602. The v0.1.126 sweep matched only the single-line reject_extras
    form and select_range is the one written multi-line. Rather than fix the
    one, a test now walks the entire tool list and asserts every advertised
    tab_id is genuinely accepted — a schema that lies makes correct clients
    fail.
  - list_open_tabs published the field as `id` while every verb takes
    `tab_id`. Both names ship for one release.

76/76 C++ suites, 66 sidecar protocol tests, both Cargo feature sets. Every
fix red-state verified by restoring the pre-fix code; the two security tests
were confirmed to fail independently, which is the property that makes the
lint worth having.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6BGY2dFUSujZeS3vTDikq
@singhpratech
singhpratech merged commit 750cca0 into main Aug 8, 2026
8 checks passed
@singhpratech
singhpratech deleted the fix/mcp-sixth-credential-door branch August 8, 2026 00:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant