Copilot MCP sanctioning: reliable server identification + telemetry#167
Copilot MCP sanctioning: reliable server identification + telemetry#167anonpran wants to merge 2 commits into
Conversation
…ted fail-secure + telemetry)
Copilot emits sanitized `<server>-<tool>` tool names with no `mcp__` prefix and no
structured server field, so the PreToolUse hook reverse-engineers the server from the
MCP config — and when it couldn't resolve it returned {} (ALLOW), silently bypassing
sanctioning (which works on Claude Code & Cursor through the same gateway path).
All changes in copilot/hooks/unbound.py:
- Identification: honor COPILOT_HOME for the CLI config dir; parse the flat top-level
server form; fall back to the SessionStart-logged cwd when the PreToolUse event omits
it (and log SessionStart so the fallback works); defensive structured-field read.
- Fail-secure (gated, DEFAULT-OFF via UNBOUND_COPILOT_MCP_DENY_UNRESOLVED): an unresolved
tool on an MCP-configured machine that isn't a known built-in is DENIED instead of
allowed. Off by default => inert; distinct from the infra fail-open path.
- Telemetry: per-surface resolution categories over a SEPARATE rate-limit budget so
success telemetry can't suppress real error/bypass reports.
Gateway/backend unchanged (already enforce given a resolved server). New test_pretool_mcp.py (38 tests).
SOC 2: touches the policy-enforcement hook path — needs named-approver review.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
🛡️ Automated Security Review (consensus)4 findings — 1 high-confidence, 3 to triage. Reviewers: Cursor, Claude, Semgrep, Gitleaks. 🔴 Residual MCP policy bypass until kill-switch enabled
Impact: Unidentified MCP tools on MCP-configured hosts still return 🟡 Overly permissive file permissions (
|
Per review (Greptile P1 + Vignesh security consensus): the gated DENY (component 2)
was the source of the P1 (denied-telemetry throttled by the shared budget) and an
over-block risk in orgs with no MCP allow-list configured (the hook can't tell
sanctioning is even active). Removing it makes this PR purely progressive:
- Identification hardening (COPILOT_HOME, flat top-level form, cwd-via-SessionStart
fallback, structured-field read) → more Copilot MCP calls resolve and reach the
gateway, where they're sanctioned/allowed exactly like Claude Code & Cursor.
- Telemetry (resolved / unresolved_with_config / unresolved_no_config) to measure
the residual unresolved rate before any deny is built.
No enforcement-direction change: an unresolved tool still returns {} (allow) as before.
Also fixes the COPILOT_HOME expandvars nit (expanduser on the literal only).
The fail-secure DENY moves to a follow-up that ALSO adds a gateway
`mcp_sanctioning_active` signal, so it denies only when an allow-list is actually
configured (no over-block in non-sanctioning orgs).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Updated in @greptile-apps P1 — @vigneshsubbiah16 🔴 Residual MCP policy bypass until kill-switch enabled: Acknowledged, deliberately deferred (not closed here). Removing the default-OFF deny also removed an over-block risk we found: the hook's deny couldn't tell whether the org even has an MCP allow-list configured, so flag-ON would have over-blocked unresolved calls in non-sanctioning orgs (the gateway allows them — 🟡 COPILOT_HOME expandvars: ✅ Fixed — 🟡 file modes (~1526 / ~1745): Pre-existing — |
|
Thanks for the detailed breakdown — reviewed P1 (budget self-throttle) ✅ — structurally gone. With the DENY component removed, exactly one
Residual bypass / deferred deny ✅ — the rationale holds: the hook has no way to distinguish "unresolved on a sanctioning org" from "unresolved on an org where File modes — pre-existing / out of scope, accepted. One minor observation (non-blocking): Code is clean and the 33-test suite covers the right surface. LGTM. 🟢 Tip: You can customize Greptile's behavior for this repo with |
🛡️ Automated Security Review (consensus)2 findings — 1 high-confidence, 1 to triage. Reviewers: Cursor, Claude, Semgrep, Gitleaks. 1. Policy identity minted from unvalidated client event fields
Impact: The forward-compat Fix: Only trust structured server fields when they resolve to a known entry in Flagged by: Claude, Cursor 2. Overly permissive file permissions on hook-local files
Impact: Wide permission bits ( Fix: Tighten to least privilege (e.g. Flagged by: Semgrep (full-file scan; lines appear outside this PR’s diff hunks — confirm whether introduced here or pre-existing) Clean scans: Gitleaks — no secrets detected. Telemetry paths redact args (names-only, JSON-encoded) and use a separate throttle budget so routine MCP resolution telemetry cannot suppress error/bypass reports. 🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head |
| @@ -861,6 +1052,7 @@ def process_pre_tool_use(event, api_key): | |||
| f"config={'yes' if mcp_server_config else 'no'}", | |||
| 'mcp_match', | |||
| ) | |||
| _report_mcp_resolution('copilot_mcp_resolved', raw_tool, event, api_key) | |||
There was a problem hiding this comment.
Fail-secure flag described in PR but not implemented
The PR description, title, and rollout plan all claim UNBOUND_COPILOT_MCP_DENY_UNRESOLVED is implemented (defaulted OFF) so the team can "flip it ON" in step 3. The code here unconditionally returns {} for unresolved tools with the comment "no deny is issued here" — the env-var gate, the copilot_mcp_denied_unresolved telemetry category, and the deny path referenced in the rollout plan do not exist anywhere in this file.
This is a direct conflict: a named SOC 2 approver reading the PR description would believe a reversible deny gate is available, but step 3 of the rollout ("Flip UNBOUND_COPILOT_MCP_DENY_UNRESOLVED on") would silently do nothing. Either the description needs to be updated to reflect that the deny gate was deferred (and the rollout plan rewritten), or the flag implementation needs to be added back before merge.
Summary
Makes Copilot MCP-server identification reliable so Copilot MCP tool calls resolve to their server and reach the gateway's sanction check the same way Claude Code & Cursor already do — plus telemetry to measure the residual. Purely progressive: no enforcement-direction change — an unidentifiable tool still returns
{}(allow) exactly as before.Copilot emits sanitized
<server>-<tool>tool names (nomcp__prefix, no structured server field), so the hook must reverse-engineer the server from local MCP config. When it resolves, the gateway makes the identical allow/deny decision it makes for CC/Cursor. This PR maximizes how often it resolves.Changes (all in
copilot/hooks/unbound.py+ tests)COPILOT_HOMEfor the CLI config dir (expanduseronly); parse the flat top-level server form; fall back to the SessionStart-loggedcwdwhen PreToolUse omits it (and log SessionStart so the fallback works — also fixes a latentget_session_start_modelmiss); defensive structured-field read (future-proof, dead today).vscode/cli) resolution categories —resolved/unresolved_with_config/unresolved_no_config— names only, over a separate rate-limit budget so it can't suppress real error/bypass reports.Deliberately NOT in this PR (deferred)
The fail-secure DENY for unidentifiable-but-MCP-configured calls is intentionally not here. It needs (a) a gateway
mcp_sanctioning_activesignal so it never over-blocks orgs with no allow-list configured, and (b) a telemetry soak first. It lands in a follow-up (hook deny + gateway signal) onceunresolved_with_configis measured ~0.Known residual (tracked, not closed here)
Until that follow-up, an unidentifiable MCP call on Copilot still allows (the pre-existing bypass). This PR shrinks it (better identification) and measures it; it does not close it.
Test plan
process_pre_tool_use/main()layer: both surfaces, cwd via event + SessionStart fallback,COPILOT_HOME, flat-form (+inputsexclusion), infra-vs-identity, never-crash, telemetry redaction + budget isolation, SessionStart-preservation cleanup.Touches the policy-enforcement hook + adds control-plane telemetry (names-only) — worth a SOC-2-aware glance, though no deny decisions are added.
🤖 Generated with Claude Code
Greptile Summary
This PR hardens Copilot MCP-server tool identification in
copilot/hooks/unbound.pyto fix a silent bypass where unresolved tools were allowed without any observable signal. It addscwdfallback viaSessionStartaudit-log records,COPILOT_HOMEsupport, flat top-level server-form parsing, a separate rate-limit budget for resolution telemetry (so routine resolved/unresolved events can never starve genuine error reports), andcleanup_old_logspreservation of theSessionStartrecord so the fallback survives log rotation.detect_mcp_callnow receives acwdrecovered from the session's loggedSessionStartwhen thePreToolUseevent omits it;COPILOT_HOMEredirects the CLI config directory; flat top-level JSON form is parsed with a_FLAT_FORM_NON_SERVER_KEYSexclusion list to prevent VS Code metadata blocks from being misread as servers.vscode/cli) resolution categories (resolved,unresolved_no_config,unresolved_with_config) use a dedicated throttle file (LAST_RESOLUTION_REPORT_FILE) that is entirely separate from the error/bypass budget.{}(allow) — the PR description and rollout plan reference aUNBOUND_COPILOT_MCP_DENY_UNRESOLVEDfail-secure gate, but that flag and deny path are not present in the committed code.Confidence Score: 3/5
Safe to merge in terms of immediate runtime behavior (all unresolved tools continue to be allowed, no new denies), but the PR description and rollout plan describe a deny gate that does not exist in the code — a SOC 2 approver would be acting on an inaccurate description of what enforcement capability is present.
The identification and telemetry changes are well-engineered and tested. However, the PR title, description, and rollout plan all promise a UNBOUND_COPILOT_MCP_DENY_UNRESOLVED fail-secure gate that is entirely absent from the committed code. For a change explicitly tagged SOC 2 with a named-approver requirement, the disconnect between the stated security posture and the actual implementation is a meaningful gap — the team would believe they can flip a flag to enable deny behavior in step 3 of the rollout when no such flag exists.
copilot/hooks/unbound.py — specifically process_pre_tool_use around the unresolved-tool return path, where the described deny gate is absent
Security Review
copilot/hooks/unbound.py): The PR description and rollout plan state thatUNBOUND_COPILOT_MCP_DENY_UNRESOLVEDis implemented (OFF by default) and can be flipped ON. The flag does not exist in the code; all unresolved MCP tool calls continue to pass through unconditionally. A SOC 2 approver relying on the description to understand what deny capability is being deployed would have an inaccurate picture of the current enforcement posture.Important Files Changed
Reviews (2): Last reviewed commit: "Reduce scope to identification + telemet..." | Re-trigger Greptile