Harden MCP tools for minimal-rights operation - #6
Merged
Conversation
…g, dry-run, order-file audit merge_conflicts now validates relativePaths and orderOverrides keys against Paths.ModsDirectory (proper Path.GetFullPath-based prefix check, not naive StartsWith) before any scan or merge runs, rejecting absolute/UNC/`..`-escaping entries with a clear error. Neither value was actually joined into a filesystem path anywhere in this codebase - this closes no live traversal, it's defense-in-depth against that changing later, and it fixes the "silently matches nothing" gap for a malicious-looking relativePaths entry. Audited orderOverrides for other misuse: values were already whitelisted via ModFile.ContainsMod before reaching Path.Combine, but the validation had two real gaps - a partial/duplicate mod list would silently merge an incomplete or self-paired chain and still report success, and a single-remaining-real-source override (reachable once a file's already-merged output re-enters conflict.Mods as a pseudo-source) would report "merged" having done nothing. FileMerger.ResolveMergeOrder now requires at least two entries, no duplicates, and every real source mod covered (excluding the configured merged-mod name itself, matching scan_conflicts's own documented guidance for re-merging). This validation is shared with the `merge` CLI verb's --order-file, so a pre-existing partial order-file that used to be silently accepted now gets rejected per-file instead. Added merge_conflicts's dryRun mode: previews which conflicts would auto-solve without writing merged output, repacking a bundle, or modifying MergeInventory.xml. Distinct from scan_conflicts's alreadyResolved (which only re-checks existing merge records) since dryRun actually exercises the merge engine for currently-unresolved conflicts. Output is redirected under TempBundleContent instead of the real destination; MergeInventory.Load gained an allowSave flag since it can otherwise write to disk on its own (backfilling an old record's missing hash) before dryRun is ever consulted; a dry run now also predicts the same "output already exists, declined" outcome a real run would hit, so the two don't disagree on a conflict whose output is already on disk. Documented minimal required permissions in a new Mcp/CLAUDE.md and updated the root CLAUDE.md's MCP mode section. AI-assisted: implemented by Claude Code per repo convention (CONTRIBUTING.md). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah
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.
Summary
Unit 7 of the ongoing re-architecture batch: MCP hardening for minimal-rights operation. Covers all four items from the unit's scope.
1. Directory allow-listing.
merge_conflicts'srelativePathsandorderOverrideskeys are now validated (WsmMcpTools.EnsureInScope/IsWithinModsDirectory) to resolve insidePaths.ModsDirectorybefore any scan or merge runs — usingPath.GetFullPathplus a proper prefix check against the fully-qualified root (with a trailing separator, soModsDirectorycan't be spoofed by a sibling directory likeModsDirectoryEvil), not a naiveStartsWith.Path.IsPathRootedrejects absolute paths and UNC paths outright (both would otherwise silently discard the mods-directory prefix viaPath.Combine's own "second arg rooted → first arg ignored" behavior). Out-of-scope entries are rejected with a clearArgumentExceptionbefore any scan/merge, listing every offending entry, rather than silently matching nothing.Important finding while implementing this:
relativePathswas never actually joined into a filesystem path anywhere in this codebase — it's only ever compared for equality against already-scannedModFile.RelativePathvalues. So this closes no live traversal; it's defense-in-depth plus fixing the "silently ignored" half of the requirement (a malicious-looking entry used to just fail to match anything, with no signal to the caller that something suspicious was rejected vs. simply absent).2. Audit of all 4 tools.
orderOverridesvalues reachPath.Combine(viaFileMerger.GetModFile) but only after being validated againstModFile.ContainsMod— a whitelist of real, currently-scanned mod folder names — so that path was already safe.scan_conflicts,get_status, andlist_mergestake no path-shaped parameters at all.Two real (non-path-traversal) bugs found and fixed in
orderOverridesvalidation, both "silently succeeds having done less than the caller intended":["modA"]for a 2-mod conflict, or["modA","modA"]) previously passed every existing check and would merge an incomplete or self-paired chain while still reporting the file as fully merged.conflict.Modsas a pseudo-source (scan_conflicts's own description already warns clients about this). An override naming only the one remaining real source mod after that (["modA"]whenconflict.Modsis[mergedMod, modA]) would satisfy a naive "must cover every real source" check while still merging nothing, since a merge chain needs at least a pair.FileMerger.ResolveMergeOrdernow requires: no unknown mod names, no duplicates, at least two entries, and every one of the file's real source mods (explicitly excluding the configured merged-mod name) present at least once. This method is shared with themergeCLI verb's--order-file(Program.cs→MergeOperations.RunMerge→MergeConflictsHeadless→ResolveMergeOrder), so this is a real behavior change there too: an existing--order-filethat lists a subset of a file's mods — previously silently accepted — now gets rejected for that file (Skipped, CLI exit code flips 0→2 for an unchanged config in that scenario). Documented in rootCLAUDE.md's CLI mode section.3.
dryRunmode added tomerge_conflicts.scan_conflicts'salreadyResolvedonly re-checks existing merge records' hashes — it can't tell you whether a currently unresolved conflict would actually auto-solve, since that requires exercising the merge engine.dryRun: truedoes exactly that, without: writing merged output, repacking a bundle, or modifyingMergeInventory.xml.Two things I found and fixed while making that promise actually hold:
MergeInventory.Load()can callSave()on its own (AddMissingHashesbackfilling an older-schema record's missing hash) beforedryRunis ever otherwise consulted.MergeInventory.Loadgained anallowSaveparameter (defaulttrue, unchanged for every other caller — the CLI verb,scan_conflicts,list_merges, the GUI);merge_conflictspassesallowSave: !dryRun.TempBundleContent\DryRun\...instead of the real destination) initially skipped the existing "output already exists, decline to overwrite" check entirely (HeadlessMergeNotifieralways declines). That made a dry run disagree with what a real run of the identical conflict would actually do whenever the real output already existed. BothMergeFlatConflictHeadlessandMergeBundleConflictHeadlessnow check existence against the real would-be output path regardless ofdryRun, and only redirect the write target for a dry run.mergeis always a throwaway object during a dry run rather than one pulled from_inventory.Merges— a structural guarantee (not just anif (!dryRun)at each mutation site) that a dry-run pass can't mutate a live inventory record even indirectly.4.
WitcherScriptMerger.Core/Mcp/CLAUDE.md(new — no parallel unit had created one) documents minimal required permissions: standard user file I/O only, three roots (configured mods/game dirs, and the app's own install directory forMergeInventory.xml/scratch dirs —Program.RunClipinsEnvironment.CurrentDirectory = AppContext.BaseDirectory, verified empirically that a caller-supplied working directory has no effect), no admin rights, no network beyond the stdio transport itself.Response shape change
merge_conflictsnow returns{merged, skipped, unmatched, dryRun}(was{merged, skipped}).unmatchedlists anyrelativePathsentry that's in-scope but doesn't match a currently-detected conflict (resolved/removed between scan and call) — distinct from an out-of-scope entry, which is a hard error for the whole call, not a per-entryunmatched.Known limitations / deliberate scope boundaries (disclosed, not fixed)
Categories.BundleTextscope-check semantics are unexercised. For that category,conflict.RelativePathis a path internal to a bundle archive (fromQuickBms.GetBundleContentPaths), not one rooted atPaths.ModsDirectory— an ordinary internal path validates fine, but a bundle whose internal listing itself contained a rooted/..-bearing entry would make that conflict unreachable viarelativePaths. Every scratch config used for verification hasCheckBundleContents=false(no real QuickBMS/wcc_lite binaries were available in the verification environment), consistent with the bundle path's pre-existing "code-reviewed but not round-tripped" status inCLAUDE.md.dryRunnever callsPackNewBundle/wcc_lite at all, by design, so it can't detect a repack-time failure a real run might hit.relativePathsentry matching a real, in-scope, non-mergeable-category conflict (Categories.FlatNotMergeable/BundleNotMergeable) currently appears in none ofmerged/skipped/unmatched— it's excluded fromunmatched(it did match a real conflict) but then falls out ofMergeConflictsHeadless's category filter before reachingmerged/skipped. Pre-existing filter behavior;unmatchedis new and implies more completeness than this edge case has.dryRunchain's intermediate merge-source label is cosmetic-only wrong (ModFile.GetModNameFromPath's fallback returns the constant"Merged Bundle Content"for the dry-run scratch path instead of a meaningful name) — affects only progress-text/diagnostic labeling, not correctness. Not exercised by the 2-mod smoke tests below.--order-filepath — the CLI is invoked directly by a trusted local operator with full filesystem access already; there's no untrusted-remote-caller threat model there the way there explicitly is for an MCP client.orderOverridesvalues are safe on both paths regardless, viaModFile.ContainsMod's whitelist.EnsureInScopeaborts the entiremerge_conflictscall on any single out-of-scope entry, rather than degrading per-conflict like other validation failures in the same method (ResolveMergeOrder's errors). Deliberate: this is the one validation that's actually about a caller attempting to escape the intended scope, and fail-closed on attack-shaped input is the more conservative posture — a batch with one poisoned entry processes nothing rather than quietly tolerating the attempt as "just another skip reason."orderOverridesinto a case-insensitive, separator-normalized dictionary (WsmMcpTools.MergeConflicts) usesDictionary.ToDictionary, which throws a plainArgumentExceptionif two original keys collide after normalization (e.g. differing only by casing or/vs\) — surfaces as the same genericisError: truetool response as every other validation failure here, just without saying which keys collided.AI assistance disclosure
Implemented by Claude Code per
CONTRIBUTING.md. Code-review skill invoked against the diff, returned 13 findings; the ones above under "fixed" were addressed, the ones under "known limitations" were deliberately left as documented, disclosed tradeoffs rather than expanding this unit's scope further.Verification
dotnet build WitcherScriptMerger.sln— succeeds, same 5 pre-existingCA1823warnings asmain, no new warnings.dotnet format whitespace WitcherScriptMerger.sln --verify-no-changes— clean.initialize→tools/list→tools/call), against a scratch game/mods tree, dependency paths pointed at real-but-inert stand-ins (where.exeas a launchable KDiff3 stand-in, 0-byte placeholders for QuickBMS/wcc_lite,CheckBundleContents=false) soPaths.ValidateDependencyPaths()passes without needing real KDiff3/QuickBMS/wcc_lite (unavailable in this environment):tools/listschema reflects the newdryRunparameter and updated descriptions.get_status/list_merges/scan_conflictsunchanged and correct against a real synthetic 2-mod conflict.merge_conflictswith a..\..\..\Windows\System32\...entry, an absoluteC:\Windows\...entry, a UNC\\server\share\...entry, anullarray element, and a maliciousorderOverrideskey — all rejected cleanly (isError: true, detailed reason logged server-side to stderr, generic message to the client — itself appropriate hardening, not leaking internal path info back to a potentially untrusted caller) with zero filesystem changes (verified via before/after recursive snapshots of the scratch tree, not just the response text).merge_conflictswith a legitimate but non-existentrelativePathsentry correctly returns it inunmatchedrather than being silently dropped or erroring.WitcherScriptMerger.Coredirectly, perCLAUDE.md's Tests section precedent) with a fakeIMergeEnginethat always reportsAutoSolvedand physically writes its output — since the stand-in KDiff3 above can only ever prove the "would not auto-solve" path, this was necessary to prove the accurate-preview path (a real KDiff3/QuickBMS/wcc_lite install still wasn't available in this environment, so this stub is what makes the following meaningful, and no claim about real KDiff3 behavior rests on it):dryRun: trueon a genuinely auto-solvable 2-mod conflict: reportsmerged, but the real output file,tempbundlecontent, andMergeInventory.xmlall remain absent throughout.dryRun: falseimmediately after: reportsmerged, and now the real output file exists with the expected content,MergeInventory.xmlexists with 1 correctly-hashed record.orderOverridesvalidation: partial list, duplicate entry, and unknown mod name each rejected with the expected error message andskipped; a valid full permutation (reversed order) succeeds.conflict.Mods=[mergedMod, modA, modB], override =[modA, modB]) succeeds — this is exactly the case a naive "must cover every one ofconflict.Mods" check would have wrongly rejected.conflict.Mods=[mergedMod, modA], override =[modA]) is correctly rejected ("must name at least two mods to merge"), and the real output file is confirmed byte-for-byte unchanged — not silently "merged" with no work done.dryRun: trueon the same conflict now correctly reportsskipped(predicting the "already exists, declined" outcome) instead of falsely reportingmerged.C:\Users\...,AppData\Local\Temp\...) before committing — clean.