Skip to content

fix(mcp): stop search_project leaking $HOME and credentials; clamp goto_line - #33

Merged
singhpratech merged 3 commits into
mainfrom
fix/mcp-windows-defects
Aug 7, 2026
Merged

fix(mcp): stop search_project leaking $HOME and credentials; clamp goto_line#33
singhpratech merged 3 commits into
mainfrom
fix/mcp-windows-defects

Conversation

@singhpratech

Copy link
Copy Markdown
Owner

Four defects reported from Windows testing, plus everything an adversarial review found in the fixes themselves.

# Defect Root cause
NP-01 goto_line past EOF reported {"line":99999,"ok":true} while the cursor sat at line 1 Scintilla rejects an out-of-range position; the verb echoed the request instead of where it landed. insert_text defaults to the cursor, so an assistant aiming at end-of-file wrote at the beginning, behind an approval card that looked correct.
NP-02 search_project walked the user's entire profile FileExplorer's ctor set m_rootPath = QDir::homePath() as a display placeholder. Four separate guards read that as "the workspace" — including the AI CSV sandbox, a security guard that had therefore never once closed, and the Coding Mode prompt, which was dead code.
NP-03 The sidecar hung on every bad argument --version printed nothing and blocked on stdin; --sokcet silently started the mock against fabricated tabs; a mistyped subcommand fell through the same way. All exit 2 loudly now.
NP-04 Windows PE carried FileVersion 0,1,0,0 for 124 releases The .rc was hand-written and never updated. Now generated from CMake, with a CI step reading VersionInfo back.

Beyond the reported four

MainWindow grew four accessors in place of one, because "what folder is the user working on" has four different right answers: workspaceFolder() (scoping — empty means refuse), firstOpenFileDir(), suggestedDialogFolder() (may fall back broadly; the user sees and edits it), aiWorkspaceRoot() (sticky per session). The first NP-02 fix collapsed them again as "folder, else current file's directory" — which resolves to $HOME the moment one file in $HOME is open, and made the AI root flap on every Ctrl+Tab, swapping chat history and cancelling pending write approvals. Chat history migrates from the old sha1($HOME) key by copy, never move.

The credential deny-list existed twice and had drifted in both directions. ai_tools.cpp had *.tfvars/*.tfstate/.pypirc/dotenv/backslash variants; git_hunk_apply.cpp had *.jks and an unanchored id_rsa. Neither was a superset, and search_project consulted neither — so read_file refused ~/.ssh/id_rsa while a one-word search returned the matching lines out of it. src/path_denylist.h is now the only list (the union), with three callers.

insert_text with col but no line silently dropped col and wrote at the cursor. Silently relocating a write is the one thing an approval gate cannot protect against.

Verification

76/76 C++, 91 Rust, clippy 0, fmt clean.

Every new test was red-state verified by restoring the pre-fix code rather than negating a condition — a hand-written sabotage of search_project still returned the right answer and made a sound test look weak. The red signatures matched the original bug reports: {"line":99999,"clamped":false}, workspace_searched:true with no workspace, and leaked contents of keystore.jks — the extension only the git_hunk_apply copy of the deny-list had.

NP-04 is unverified on any platform. Nothing has run rc.exe against the generated .rc or read the embedded version back. This PR exists to make that gate run for the first time.

🤖 Generated with Claude Code

https://claude.ai/code/session_01W6BGY2dFUSujZeS3vTDikq

…to_line

Four defects reported from Windows testing, plus everything an adversarial
review found in the fixes themselves.

NP-01 goto_line past EOF lied. Scintilla rejects an out-of-range position and
leaves the cursor at the TOP, while the response still read {"line":99999,
"ok":true}. Since insert_text defaults to the cursor, an assistant aiming at
the end of a file wrote at the beginning, behind an approval card that looked
correct. Editor::gotoLine now clamps and RETURNS where it landed; the verb
reports line/requested_line/clamped.

NP-02 search_project walked the user's whole profile. FileExplorer's ctor set
m_rootPath = QDir::homePath() as a display placeholder, and four separate
guards read that as "the workspace" — including the AI CSV sandbox, a security
guard that had therefore never once closed, and the Coding Mode prompt, which
was dead code. Display root and workspace root are now different questions:
workspaceRoot() stays empty until the user opens a folder.

MainWindow grew four accessors in place of one, because "what folder is the
user working on" has four different right answers: workspaceFolder() (scoping,
empty means refuse), firstOpenFileDir(), suggestedDialogFolder() (may fall back
broadly — the user sees and edits it), aiWorkspaceRoot() (sticky per session).
The first fix collapsed them again as "folder, else current file's directory",
which resolves to $HOME the moment one file in $HOME is open, and made the AI
root flap on every Ctrl+Tab — swapping the chat history and cancelling pending
write approvals. Chat history migrates from the old sha1($HOME) key by COPY.

NP-03 the sidecar hung on every bad argument. --version printed nothing and
blocked on stdin; --sokcet silently started the MOCK against fabricated tabs;
a mistyped subcommand fell through the same way. All now exit 2 loudly.

NP-04 the Windows PE carried FileVersion 0,1,0,0 for 124 releases. The .rc is
generated from CMake now, with a CI step reading VersionInfo back. UNVERIFIED
on any platform — the gate has never run.

search_project also honours the credential deny-list. That list lived in TWO
places and had drifted in BOTH directions: ai_tools had *.tfvars/*.tfstate/
.pypirc/dotenv/backslash variants, git_hunk_apply had *.jks and an unanchored
id_rsa, neither was a superset, and search_project consulted neither. So
read_file refused ~/.ssh/id_rsa while a one-word search returned its lines.
src/path_denylist.h is now the only list — the union — with three callers.

insert_text with col but no line silently dropped col and wrote at the cursor
instead. Silently relocating a write is the one thing an approval gate cannot
protect against, so it is an error now.

Tests: 76/76 C++, 91 Rust. Every new test red-state verified by restoring the
pre-fix code rather than negating a condition — a hand-written sabotage of
search_project still returned the right answer and made a sound test look
weak. Signatures matched the original bug reports: {"line":99999,
"clamped":false}, workspace_searched:true with no workspace, and
"leaked contents of keystore.jks" — the extension only the git_hunk_apply
copy of the deny-list had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6BGY2dFUSujZeS3vTDikq
@singhpratech singhpratech reopened this Aug 6, 2026
Prateek Singh and others added 2 commits August 6, 2026 22:23
…remote

The vacuity guard I added in a0ae9ba spawns each real subcommand and waits
for it with .output(). That waits for EOF on the child's pipes, and `serve`
built --features remote binds a port and never returns — so the call never
came back. CI runs the suite twice, `cargo test --release` then
`cargo test --release --features remote`, and the second run wedged the
25-minute step on all four platforms. The C++ suite and the other 91 Rust
tests were green everywhere; this one test burned the jobs.

It passed locally because I only ever ran the default-feature build, where
`serve` exits 2 immediately. Verified the difference directly:

  default          serve -> exit 2
  --features remote serve -> still running at 5s

Nothing in this file waits unbounded any more. run_bounded() spawns with a
null stdin, drains both pipes on their own threads so a full pipe cannot
masquerade as a hang, polls try_wait() to a deadline, and kills the child
when the budget expires. run() wraps it at 30s and asserts the child exited,
so a future regression that makes --help hang fails the test by name instead
of timing out the job.

For the vacuity guard specifically, a child still running at the 3s budget is
now a PASS: the typo guard exits before it can bind anything, so a live
process proves the subcommand was understood. If it does exit, the only clean
exit is the built-without-remote refusal (2).

Red-state verified: moved the dispatch below the guard and dropped its
serve|pair|connect exclusion, reproducing the over-broad guard this test
exists to catch. Exactly one test went red —
the_real_subcommands_are_not_swallowed_by_the_typo_guard, on "unknown
subcommand 'serve'" — in 0.10s, not a hang.

Both feature configs now pass locally: 4/4 cli tests, cli.rs finishing in
0.10s default and 3.07s with remote (the killed `serve`). clippy 0 in both
configs, fmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6BGY2dFUSujZeS3vTDikq
The NP-04 gate ran for the first time on 3e839fc and failed instantly:

  ##[error]notepatra.exe not found under build/

Not a version mismatch — I had inserted it at step 11 of build-windows,
between "Transport test trackers" and "Build C++ with CMake (MSVC)". It
asserted on a binary that did not exist yet, so it could only ever fail, and
it would have kept failing no matter what the .rc contained.

Moved to immediately after "Verify exe, embed icon, bundle Qt + QScintilla
DLLs", and retargeted from the build tree to notepatra-win/ — the copy that
actually ships, which is the file a user right-clicks -> Properties ->
Details on. The bundle step does not touch the resource table (windeployqt
and a manual DLL copy only), so this is the same resource the compiler
emitted, just checked on the artifact rather than an intermediate.

Verified the generation half locally, which is as far as Linux reaches:
reconstructed the real WIN32 block from CMakeLists.txt in a throwaway
project and ran cmake on it.

  FILEVERSION     0,1,124,0
  PRODUCTVERSION  0,1,124,0
  VALUE "FileVersion",    "0.1.124.0"
  VALUE "ProductVersion", "0.1.124"

The gate computes "$cmakeVer.0" = "0.1.124.0" and compares against
VersionInfo.FileVersion, which reads that StringFileInfo value — exact
match, no unsubstituted @var@ left, icon path resolves.

What remains unverified anywhere: rc.exe actually compiling the generated
.rc, and the version surviving into the PE. Only the Windows runner can
answer that, and it has still never gotten far enough to try.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6BGY2dFUSujZeS3vTDikq
@singhpratech
singhpratech merged commit ebcea0c into main Aug 7, 2026
8 checks passed
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