fix(mcp): guard buffer reads at one chokepoint instead of per verb - #35
Merged
Conversation
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
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.
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_rangeaccepted any range inside a private key andget_selectionreturned it as text, so a file thatopen_file,read_tab,find_in_tab,search_projectandresources/readall correctly refused was readable one selection at a time.get_selectionwas exempted deliberately, on the reasoning that a selection is human-made so reading it back implies consent.select_rangemakes that false: the client chooses the range.apply_editwas 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
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:
Every verb that needs buffer contents calls it; none calls
m_host.tabTextdirectly.select_rangerefuses 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.cppand fails the build on a directm_host.tabText/m_host.selectioncall. 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 beforemax_bytesexisted. Now[truncated: showing N of M characters]. The v0.1.126 notes also claimedread_tab"always reportstruncatedandtotal_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_rangeadvertisedtab_idin itsinputSchemaand rejected it with-32602. The v0.1.126 sweep matched only the single-linereject_extrasform andselect_rangeis 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 advertisedtab_idis genuinely accepted. A schema that lies is worse than a missing feature: it makes correct clients fail.list_open_tabspublished the field asidwhile every verb takestab_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 breakget_selectionon hosts that do not track the owning tab. Commented at the call site.🤖 Generated with Claude Code
https://claude.ai/code/session_01W6BGY2dFUSujZeS3vTDikq