MCP tool risk scoring — hook relay (4/4) - #224
Conversation
On an MCP PreToolUse call, compute the server's cache key from its config, read
~/.unbound/mcp-tools-cache.json, and attach the called tool's content hash to
mcp_server_config as tool_content_hash so the gateway can resolve a pre-computed
risk score. Fully fail-open: any miss or error attaches nothing.
- cache_key = sha256 of the non-empty {name, url, command, args} subset — same
one rule as the discovery tool, no fingerprint / extraction logic (device-side
public code). Embedded byte-identical across all 5 hooks (drift-guard test).
- Single-server scan dispatch passes UNBOUND_CODING_TOOL so the on-demand scan
caches under the right agent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmB1ccF76u6GziqtTwQqSa
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ Automated Security Review (consensus)
5 findings — 3 high-confidence, 2 to triage. Reviewers: Cursor, Claude, Semgrep, Gitleaks.
🔴 World-writable fallback cache enables local risk-score spoofing
claude-code/hooks/unbound.py:1073 (identical in all 5 hooks)
Impact: /var/tmp/unbound-{uid} (and the non-POSIX shared temp dir) can be pre-created by another local user/process; a planted mcp-tools-cache.json can map a malicious MCP server's cache key to a benign tool's content hash, bypassing precomputed risk scores when ~/.unbound is absent.
Fix: Before reading outside $HOME, stat/lstat the directory and require st_uid == os.getuid(), no group/other write bits, and no symlink; or restrict reads to ~/.unbound only.
Flagged by: Claude, Lead
🔴 Sanitized config breaks cache-key parity (Augment / Copilot)
augment/hooks/unbound.py:760, copilot/hooks/unbound.py:744
Impact: Hash lookup keys are derived from redacted mcp_server_config (_redact_args / _redact_url), but discovery keys on full args (e.g. npx + ['-y', …]); typical servers never get tool_content_hash and the gateway cannot resolve risk scores on these agents.
Fix: Compute compute_mcp_cache_key from the same raw config fields the discovery tool uses (pre-redaction), then attach the hash to the redacted config sent upstream.
Flagged by: Cursor
🔴 Invalid preferred cache blocks valid fallback cache
claude-code/hooks/unbound.py:1095 (identical in all 5 hooks)
Impact: An oversized, corrupt, or unreadable ~/.unbound/mcp-tools-cache.json causes _read_mcp_tools_cache to return {} without trying /var/tmp/unbound-{uid}, silently dropping tool_content_hash even when a valid fallback cache exists.
Fix: Handle each candidate's failure inside the loop (continue to next path) instead of returning early or aborting the whole function on first-path error.
Flagged by: Greptile, Cursor, Lead
🟡 Empty normalized config skips hash attach (Augment / Copilot)
augment/hooks/unbound.py:1333, copilot/hooks/unbound.py:1217
Impact: _attach_tool_content_hash runs only when mcp_cfg is truthy; name-only servers normalized to {} never attach a hash despite supported name-only cache keys.
Fix: Call _attach_tool_content_hash whenever mcp_server and mcp_tool are known; pass at least {'name': server} (or equivalent) into the lookup path.
Flagged by: Cursor
🟡 Cache read failures are fully silent
claude-code/hooks/unbound.py:1095 (identical in all 5 hooks)
Impact: All read/decode/parse errors are swallowed, so omitted tool_content_hash is indistinguishable from a normal miss and operators cannot diagnose cache/keying failures.
Fix: Emit a lightweight diagnostic (path + exception) before advancing to the next cache candidate.
Flagged by: Greptile
Previously acknowledged (not re-flagged)
- Fail-open cache lookup (miss, corrupt file, or any error → no
tool_content_hashattached) — accepted by design per PR description.
🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head ec894d16 · 2026-07-16T21:44Z
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ Automated Security Review (consensus)
2 findings — 0 high-confidence, 2 to triage. Reviewers: Cursor, Claude, Semgrep, Gitleaks.
🟡 TRIAGE — Client-writable cache can spoof tool_content_hash
claude-code/hooks/unbound.py:1760 (byte-identical in all 5 hook copies)
Impact: A user who controls ~/.unbound/mcp-tools-cache.json can relay a syntactically valid 64-hex hash unrelated to the invoked tool; if the gateway treats the field as authoritative, a precomputed low-risk score could apply to an unreviewed tool.
Fix: Gateway must bind resolved scores to canonical server identity (not the client-supplied hash alone); hooks already limit injection to ^[a-f0-9]{64}$ via _CONTENT_HASH_RE.
Reviewers: Lead, Claude (design note)
🟡 TRIAGE — Augment/Copilot omit hashes (redacted keys + falsy-config guard)
augment/hooks/unbound.py:1601, copilot/hooks/unbound.py:1544
Impact: Lookup keys are built from redacted mcp_server_config and _attach_tool_content_hash runs only when config is truthy, so typical npx servers and name-only connectors miss cache hits and gateway risk-score resolution is weakened (fail-open).
Fix: Compute cache keys from raw discovery-equivalent fields before redaction; call attach whenever mcp_server and mcp_tool are present.
Reviewers: Cursor
Previously acknowledged (not re-flagged)
- Invalid/oversized/corrupt preferred cache blocks fallback candidate — PR description: fully fail-open; any miss or error attaches nothing.
- Silent cache read/decode/parse failures — PR description: fail-open by design; minimal comments shipped to user devices.
🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 2d2b40e8 · 2026-08-07T07:08Z
# Conflicts: # claude-code/hooks/unbound.py
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 9f5db47. Configure here.
| pass | ||
|
|
||
|
|
||
| # ───────────────────────── end MCP tool risk-scoring section ───────────────── |
There was a problem hiding this comment.
Sync section includes foreign code
Medium Severity
The KEEP-IN-SYNC markers in the Claude Code hook wrap _read_mcp_server_config_worktree_union, which is not part of the risk-scoring block and is absent from the other four hooks. The embedded section is therefore not byte-identical across variants, so the drift-guard cannot enforce the sync invariant the PR claims.
Reviewed by Cursor Bugbot for commit 9f5db47. Configure here.
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ Automated Security Review (consensus)
3 findings — 2 high-confidence, 1 to triage. Reviewers: Cursor, Claude, Semgrep, Gitleaks.
🔴 HIGH — Bad home cache blocks fallback lookup
claude-code/hooks/unbound.py:1725 (same pattern in augment/hooks/unbound.py, codex/hooks/unbound.py, copilot/hooks/unbound.py, cursor/unbound.py)
Impact: If ~/.unbound/mcp-tools-cache.json is oversized, corrupt, or invalid JSON, _read_mcp_tools_cache returns {} and never tries /var/tmp/unbound-{uid}, so tool_content_hash is omitted and the gateway cannot apply pre-computed MCP tool risk scores.
Fix: On per-path read/parse/size failures, continue to the next candidate instead of returning early from the function.
Flagged by: Greptile, Cursor, Lead
🔴 HIGH — Augment/Copilot cache keys may not match discovery
augment/hooks/unbound.py:997, copilot/hooks/unbound.py:1053
Impact: Hash lookup keys off mcp_server_config fields that may already be redacted/normalized (_redact_args, _redact_url, Augment command splitting), diverging from the discovery tool’s raw {name, url, command, args} contract and causing persistent cache misses for common npx MCP servers—risk scores never attach on those agents.
Fix: Compute compute_mcp_cache_key from the same raw server config used at discovery time (before redaction), or store relay key material separately from the redacted outbound config.
Flagged by: Cursor, Lead
🟡 TRIAGE — Empty {} config skips hash attach on Augment/Copilot
augment/hooks/unbound.py:1601, copilot/hooks/unbound.py:1544
Impact: _attach_tool_content_hash runs only inside if mcp_cfg / if mcp_server_config; falsy {} skips attach even when a name-only cache key would match, leaving connector/builtin servers without tool_content_hash.
Fix: Always call _attach_tool_content_hash(metadata) once mcp_server/mcp_tool are set (mirror Claude/Codex/Cursor), passing at least {'name': server} when normalized config is empty.
Flagged by: Cursor, Lead
🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 9f5db47f · 2026-08-11T16:27Z
…teway no longer reads the hash The risk-scoring section in each hook variant read the local tool-hash cache and attached tool_content_hash to PreToolUse metadata for the gateway's score lookup. With scoring running entirely in the control plane, the relay has no consumer. Hooks return to staging content.
…, the gateway no longer reads the hash" This reverts commit a099321.
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ Automated Security Review (consensus)
3 findings — 2 high-confidence, 1 to triage. Reviewers: Lead, Claude, Semgrep, Gitleaks, Cursor.
🔴 Invalid primary cache aborts fallback lookup
claude-code/hooks/unbound.py:1726 (same block in all 5 hook copies)
Impact: If ~/.unbound/mcp-tools-cache.json is oversized, corrupt, or unreadable, _read_mcp_tools_cache() returns {} instead of trying /var/tmp/unbound-{uid}/, so a valid fallback cache is ignored and tool_content_hash is omitted—gateway cannot apply pre-computed MCP risk scores.
Fix: On oversize/parse/read failure for one candidate, continue to the next path; only return {} after all candidates fail.
Reviewers: Greptile, Cursor Bugbot, Lead
🔴 Empty server config skips hash attach (Augment & Copilot)
augment/hooks/unbound.py:1603, copilot/hooks/unbound.py:1546
Impact: _attach_tool_content_hash() runs only inside if mcp_cfg / if mcp_server_config; an empty {} config is falsy, so name-only MCP servers (cache-keyed on name alone) never receive tool_content_hash on these two agents—risk scoring silently bypassed.
Fix: Attach when mcp_server is present regardless of config emptiness (pass {} or a minimal dict into lookup); align with Claude/Codex/Cursor, which call attach unconditionally.
Reviewers: Cursor Bugbot, Lead
🟡 Cache key may use redacted config on Augment & Copilot
augment/hooks/unbound.py:998, copilot/hooks/unbound.py:1049
Impact: Lookup keys from mcp_server_config fields at attach time; if those hooks populate metadata with _redact_args / _redact_url (or command-split) values, keys won't match discovery's full {name, url, command, args} contract and typical npx servers miss cached hashes—pre-computed risk scores not applied.
Fix: Compute the cache key from raw pre-redaction server config (or retain raw keying fields separately for lookup only).
Reviewers: Cursor Bugbot, Lead (triage—redaction path not in this diff; confirm call-site behavior)
Previously acknowledged (not re-flagged)
- Silent cache read/parse failures (no diagnostics) — PR description: intentionally fail-open; comments kept minimal on device-shipped hook code.
- Semgrep file-permission (
0o755/$BITS) and SQLAlchemy raw-query hits — pre-existing code outside this diff; not introduced by the MCP hash-relay change.
🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head ed974f50 · 2026-08-11T19:11Z


MCP tool risk scoring — hook relay (4 of 4)
On an MCP PreToolUse call, each hook computes the server's cache key from its config, reads
~/.unbound/mcp-tools-cache.json, and attaches the called tool's content hash tomcp_server_configastool_content_hashso the gateway can resolve a pre-computed risk score.What's here
cache_key = sha256of the non-empty{name, url, command, args}subset — the same one rule as the discovery tool. No fingerprint / extraction logic (device-side, public code).UNBOUND_CODING_TOOLso the on-demand scan caches under the right agent.Testing
26 tests (keying vectors, hit/miss/corrupt/oversized, dispatch env, cross-hook byte-identity drift guard). Other hook suites green (the two pre-existing machine-state failures are unrelated, verified on the base branch).
Part of a 4-PR set
Reads the cache written by coding-discovery-tool; the hash it relays is scored by ai-gateway-data and resolved by ai-gateway. Merge last.
🤖 Generated with Claude Code
Note
Low Risk
Additive, fail-open client-side cache lookup on the pretool path; no auth or policy logic changes—worst case omits the hash field.
Overview
MCP PreToolUse now looks up pre-scanned tool definitions in local
mcp-tools-cache.jsonand, on a match, addstool_content_hashto the outgoingmcp_server_configso the gateway can apply pre-computed risk scores.The same embedded block is added to all five agent hooks (Claude Code, Codex, Copilot, Augment, Cursor). Cache keys are
sha256over the non-empty{name, url, command, args}subset, aligned with the discovery tool. Lookup is fail-open (miss, corrupt, or oversized cache → no field), scoped to the current OS user and each hook’s coding-tool identity.On-demand MCP scan dispatch now sets
UNBOUND_CODING_TOOLso scanner writes land under the key the hook reads.Adds
claude-code/hooks/test_tool_content_hash.py: cache-key vectors, hit/miss/error paths, dispatch env, and a drift guard that the five hook copies stay byte-identical aside from per-hook constants.Reviewed by Cursor Bugbot for commit ed974f5. Bugbot is set up for automated code reviews on this repo. Configure here.
Greptile Summary
This PR relays cached MCP tool hashes from coding-tool hooks to the gateway. The main changes are:
Confidence Score: 5/5
No additional blocking issue was found in this follow-up.
Important Files Changed
Reviews (4): Last reviewed commit: "Revert "Revert MCP tool-hash relay from ..." | Re-trigger Greptile
Context used:
Learned From
websentry-ai/ai-gateway-data#448