Skip to content

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

Open
zeus-12 wants to merge 6 commits into
stagingfrom
mcp-security
Open

MCP tool risk scoring — hook relay (4/4)#224
zeus-12 wants to merge 6 commits into
stagingfrom
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

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.json and, on a match, adds tool_content_hash to the outgoing mcp_server_config so 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 sha256 over 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_TOOL so 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:

  • Adds cache-key computation and bounded cache reads across five hooks.
  • Attaches validated tool content hashes to MCP server metadata.
  • Passes the coding-tool identity to on-demand discovery scans.
  • Adds cache behavior, dispatch, and cross-hook consistency tests.

Confidence Score: 5/5

No additional blocking issue was found in this follow-up.

  • No new failure requiring a separate code change was identified.
  • The remaining observed behavior is already covered by existing review feedback.

Important Files Changed

Filename Overview
claude-code/hooks/unbound.py Adds MCP cache lookup, hash attachment, and coding-tool scan metadata.
augment/hooks/unbound.py Adds the mirrored MCP hash relay for Auggie CLI.
codex/hooks/unbound.py Adds the mirrored MCP hash relay for Codex.
copilot/hooks/unbound.py Adds the mirrored MCP hash relay for GitHub Copilot CLI.
cursor/unbound.py Adds the mirrored MCP hash relay for Cursor.
claude-code/hooks/test_tool_content_hash.py Adds tests for cache keys, lookup outcomes, scan metadata, and hook consistency.

Reviews (4): Last reviewed commit: "Revert "Revert MCP tool-hash relay from ..." | Re-trigger Greptile

Context used:

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

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

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 thread claude-code/hooks/unbound.py
Comment thread claude-code/hooks/unbound.py
Comment thread claude-code/hooks/unbound.py
Comment thread augment/hooks/unbound.py
Comment thread augment/hooks/unbound.py

@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

@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)

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

@zeus-12
zeus-12 changed the base branch from main to staging August 11, 2026 15:49
# Conflicts:
#	claude-code/hooks/unbound.py

@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 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

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 9f5db47. Configure here.

pass


# ───────────────────────── end MCP tool risk-scoring section ─────────────────

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9f5db47. 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)

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 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)

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

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