Skip to content

MCP tool risk scoring — hook relay (4/4)#224

Open
zeus-12 wants to merge 1 commit into
mainfrom
mcp-security
Open

MCP tool risk scoring — hook relay (4/4)#224
zeus-12 wants to merge 1 commit into
mainfrom
mcp-security

Conversation

@zeus-12

@zeus-12 zeus-12 commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

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 to mcp_server_config as tool_content_hash so the gateway can resolve a pre-computed risk score.

What's here

  • cache_key = sha256 of the non-empty {name, url, command, args} subset — the same one rule as the discovery tool. No fingerprint / extraction logic (device-side, public code).
  • Applied to all 5 hooks (claude-code, codex, copilot, augment, cursor). The embedded section is byte-identical across all 5 (enforced by a drift-guard test) except three per-hook constants (the agent's discovery name + dispatch env value).
  • Fully fail-open: any miss or error attaches nothing, single small file read, 2 MB cap, 64-hex validation.
  • Single-server scan dispatch passes UNBOUND_CODING_TOOL so the on-demand scan caches under the right agent.
  • Comments kept minimal by design (ships to user devices): the section carries only the KEEP-IN-SYNC marker.

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

Medium Risk
Touches the PreToolUse path that forwards MCP metadata to the gateway; behavior is fail-open on cache errors, but incorrect keying or drift across hooks could omit or mis-attach risk hashes.

Overview
Adds MCP tool risk-scoring relay across all five agent hooks (Claude Code, Codex, Copilot, Augment, Cursor). On MCP PreToolUse, each hook hashes the server config with the same compute_mcp_cache_key rule as the discovery tool, reads mcp-tools-cache.json, and when there is a hit attaches tool_content_hash on mcp_server_config for the gateway.

The shared block is duplicated per hook (with per-agent coding-tool constants) and guarded by a cross-hook drift test. Lookup is fail-open (miss, corrupt, oversized file, or bad hash → no field). Background mcp-scan dispatch now sets UNBOUND_CODING_TOOL so cache writes align with lookup keys.

New claude-code/hooks/test_tool_content_hash.py covers cache keying, attach behavior, dispatch env, and section consistency.

Reviewed by Cursor Bugbot for commit ec894d1. 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:

  • Adds shared cache-key and content-hash lookup logic to five hooks.
  • Attaches tool_content_hash to MCP server metadata on cache hits.
  • Passes coding-tool identity to supported single-server scans.
  • Adds keying, cache behavior, dispatch, and drift-guard tests.

Confidence Score: 4/5

The cache fallback path needs a fix before merging.

  • A bad home cache prevents use of a valid fallback cache.
  • Affected requests silently lose the hash required for risk-score resolution.
  • The keying and valid-cache paths have broad test coverage.

The mirrored _read_mcp_tools_cache function in all five hook files.

Security Review

A malformed, oversized, or unreadable preferred cache can shadow a valid fallback cache. Affected MCP requests are sent without the content hash used to resolve their precomputed risk score.

Important Files Changed

Filename Overview
augment/hooks/unbound.py Adds the shared hash relay and scan identity forwarding, including the fallback-cache failure.
claude-code/hooks/unbound.py Adds the shared hash relay and scan identity forwarding; invalid preferred caches suppress valid fallback data.
claude-code/hooks/test_tool_content_hash.py Adds broad lookup and drift coverage, but does not test recovery through the second cache directory.
codex/hooks/unbound.py Adds the shared hash relay and scan identity forwarding, including the fallback-cache failure.
copilot/hooks/unbound.py Adds the shared hash relay, including the fallback-cache failure.
cursor/unbound.py Adds the shared hash relay and scan identity forwarding, including the fallback-cache failure.

Reviews (1): Last reviewed commit: "feat(mcp): relay per-tool content hash f..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

Context used:

  • Rule used - Ensure that the confidence score is always within ... (source)

Learned From
websentry-ai/ai-gateway-data#448

  • Context used - P0 — Critical (must block merge)
    Django / Backend ... (source)

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
@zeus-12
zeus-12 requested a review from a team July 16, 2026 21:33
Comment on lines +1090 to +1095
if len(data) > _MCP_TOOLS_CACHE_MAX_BYTES:
return {}
parsed = json.loads(data.decode('utf-8'))
return parsed if isinstance(parsed, dict) else {}
except Exception:
pass

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Invalid Cache Shadows Fallback

When the home cache is oversized, malformed, or unreadable, this function returns {} instead of checking the valid /var/tmp candidate. MCP requests then omit tool_content_hash, so the gateway cannot resolve their precomputed risk score. Handle each candidate's failure inside the loop and continue to the next path; the same fix is required in all five hook copies.

Comment on lines +1094 to +1095
except Exception:
pass

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Cache Failures Leave No Diagnostics

Every read, decode, and parse error is silently discarded, making a failed risk-cache lookup indistinguishable from a normal miss. Operators cannot determine why tool_content_hash was omitted from a request; log the cache path and exception before trying the next candidate. This silent failure is mirrored in all five hook copies.

Context Used: P0 — Critical (must block merge)
Django / Backend ... (source)

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit ec894d1. Configure here.

return parsed if isinstance(parsed, dict) else {}
except Exception:
pass
return {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bad cache file blocks fallbacks

Medium Severity

The _read_mcp_tools_cache function's error handling prevents it from checking subsequent cache file candidates if the first one is oversized, corrupt, or contains invalid JSON. This means a valid cache in a fallback location is ignored, and tool_content_hash won't be attached.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ec894d1. Configure here.

Comment thread augment/hooks/unbound.py
name=server_name,
command=server_cfg.get('command'),
url=server_cfg.get('url'),
args=server_cfg.get('args'),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sanitized config misses cache keys

High Severity

On Augment and Copilot, tool_content_hash lookup builds the cache key from mcp_server_config after _redact_args / _redact_url (and Augment command splitting). The shared key contract keeps full args such as -y, so these hooks miss discovery cache entries for typical npx MCP servers and never attach the hash. Claude, Codex, and Cursor key from raw config fields and do not have this gap.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ec894d1. Configure here.

Comment thread augment/hooks/unbound.py
metadata['mcp_tool'] = mcp_tool_name
if mcp_cfg:
metadata['mcp_server_config'] = mcp_cfg
_attach_tool_content_hash(metadata)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty config skips hash attach

Medium Severity

Augment and Copilot only call _attach_tool_content_hash when mcp_server_config is truthy. Both store missing normalized fields as {}, which is falsy, so attach never runs. Name-only cache keys are supported and work when a dict is present, so empty-config servers that discovery keyed by name alone never get tool_content_hash.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ec894d1. Configure here.

@vigneshsubbiah16 vigneshsubbiah16 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ 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_hash attached) — accepted by design per PR description.

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head ec894d16 · 2026-07-16T21:44Z

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.

2 participants