[WEB-5059] fix(claude-code): resolve mcpServers array form in plugin hook#225
Conversation
Per the plugin spec `mcpServers` is `string | array | object`. The plugin hook handled the string (single path), object (inline), and unwrapped-root forms but not the array-of-paths form — a list fell through both branches, so those servers were never read → the gateway fingerprinted the bare name to null → false-block (sanction fail-open, same class as the Playwright/Forter bug). Extract the string-path resolver (with its traversal-containment check) into `_load_plugin_mcp_map` and reuse it for both the string branch and a new list branch. String / object / unwrapped-root behavior is unchanged. Closes WEB-5059. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ Automated Security Review (consensus)
2 findings — 1 high-confidence, 1 to triage. Reviewers: Lead, Claude, Semgrep, Gitleaks.
First-wins array merge may fingerprint wrong MCP server
claude-code/hooks/unbound.py:745
Impact: When multiple array-listed config files define the same server key, setdefault keeps the first entry and silently drops later ones; if the Claude Code client uses last-wins merge semantics, the hook can report a different URL/command than the client actually connects to, causing sanction checks against the wrong server (fail-open class of hook-vs-client divergence).
Fix: Confirm the client's array merge order and match it; at minimum log_error on duplicate keys so collisions are observable.
Reviewers: Claude, Greptile
Silent containment rejections leave no audit trail
claude-code/hooks/unbound.py:697
Impact: _load_plugin_mcp_map returns {} with no logging when a path is absolute, escapes via ../, or resolves outside the version dir via symlink—legitimate misconfigs degrade to null-fingerprint false-blocks with nothing in logs to diagnose, and malicious plugin probing leaves no trace.
Fix: Add log_error(f"mcp plugin path rejected (containment): {rel_path}", 'mcp_plugin') on the ValueError path.
Reviewers: Claude
Note: Semgrep flagged pre-existing insecure-file-permissions at unbound.py:1929 and :2162; those lines are outside this PR's diff and were not re-raised. Gitleaks reported no secrets. Path containment logic (resolve() + relative_to()) is sound and correctly extended to array elements.
🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 35377e28 · 2026-07-18T13:31Z
Address PR #225 review (observability): - Log a real array-key collision in the plugin `mcpServers` array merge, so a wrong-server fingerprint from a first-wins/last-wins divergence is diagnosable instead of silent. Kept first-wins (consistent with the cross-source merge). - Log a path-containment rejection in `_load_plugin_mcp_map` so an absolute/../ /symlink-escape path leaves an audit trail (aids false-block diagnosis and surfaces malicious plugin probing). No change to resolution outcomes; observability only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Both review findings addressed in
57/57 tests pass. |
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
✅ Security consensus: no issues found. (reviewers: Cursor, Claude [unavailable], Semgrep, Gitleaks)
🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 6b84ab8c · 2026-07-19T17:00Z
Closes WEB-5059.
Problem
Per the Claude Code plugin spec,
plugin.json/.mcp.jsonmcpServersis typedstring | array | object("MCP config paths or inline config"). The plugin pretool hook (_plugin_mcp_server_map) handled the string (single path), object (inline), and unwrapped-root forms — but not the array-of-paths form:{ "mcpServers": ["./mcp/a.json", "./mcp/b.json"] }A
listmatched neither thestrnordictbranch → silently fell through → those servers were never read → the gateway fingerprinted the bare name to null → false-block (sanction fail-open, same class as the Playwright/Forter prod bug).Fix
Extracted the string-path resolver (with its
resolve()+relative_to()traversal-containment check) into_load_plugin_mcp_map, and reuse it for both the string branch and a newlistbranch (each element resolved + merged viasetdefault). String / object / unwrapped-root behavior is unchanged.Tests (
claude-code/hooks/test_pretool_mcp.py)../array element → rejected (containment)57/57 pass. Client-hook only; no gateway/backend changes.
Validation note
The plugin spec declares the array type but ships no literal array example, and no public plugin was found using it — so this is spec-completeness / defensive (validated by unit tests). Follow-up to setup#203 (unwrapped
.mcp.json).🤖 Generated with Claude Code
Note
Low Risk
Client-side hook change only; reuses existing path containment checks and is covered by new unit tests with no gateway changes.
Overview
Fixes plugin MCP resolution when
mcpServersis an array of config paths — a spec-supported form that was previously ignored, so those servers never reached the gateway fingerprint and could be falsely blocked.Refactors single-path loading into
_load_plugin_mcp_map(sameresolve()+ containment rules as the string case) and adds a list branch that loads each path, merges server entries (first key wins; logs conflicting duplicates). Inline object and unwrapped.mcp.jsonbehavior is unchanged.New unit tests cover multi-file resolution,
../rejection in arrays, and keeping valid paths when another array element escapes.Reviewed by Cursor Bugbot for commit 6b84ab8. Bugbot is set up for automated code reviews on this repo. Configure here.
Greptile Summary
This PR fixes a gap in the Claude Code plugin pretool hook where
mcpServersspecified as an array of config paths (e.g.,["./a.json", "./b.json"]) was silently ignored — causing those servers to never load and the gateway to fingerprint them asnull, producing false-blocks. The string-path resolver is refactored into_load_plugin_mcp_map(containment/symlink checks preserved) and reused for both the existing string branch and a new list branch._load_plugin_mcp_map(new helper): resolves each path relative toversion_dir, rejects absolute paths and../traversal viaresolve()+relative_to(), and reads the target file'smcpServersdict._plugin_mcp_server_map: iterates array elements through the new helper, merges with first-wins (setdefault), and logs duplicate-key collisions for diagnosability.Confidence Score: 5/5
Safe to merge — client-side hook change only, no gateway or auth impact, containment guards preserved.
The refactor is mechanically sound: path containment is checked per-element, the existing string and inline-object paths are unchanged, and three new tests cover the boundary conditions. The only open items are minor debuggability gaps that do not affect correctness or security.
No files require special attention.
Important Files Changed
_load_plugin_mcp_mapand adds anisinstance(mcp_servers, list)branch; containment/symlink guards are preserved and applied per-element, with collision logging.../traversal rejection, and mixed valid/escaping elements; tests are isolated and correctly exercise the containment boundary.Reviews (2): Last reviewed commit: "fix(claude-code): log array-key collisio..." | Re-trigger Greptile