Diagnose unresolved Copilot MCP calls - #307
Conversation
| f"copilot mcp unresolved session={session_id} tool={raw_tool}", | ||
| 'mcp_match', | ||
| ) | ||
| _dispatch_mcp_diagnostic(raw_tool, event.get('cwd'), api_key) |
There was a problem hiding this comment.
This new detached diagnostic flow has no operation metrics. This violates the repository directive that every new flow ship success/failure and workflow-specific metrics. Add a diagnostic outcome counter covering dispatch, build, upload success, and each failure stage, along with report-build and upload latency.
Context Used: P0 — Critical (must block merge) Django / Backend ... (source)
There was a problem hiding this comment.
@/private/tmp/claude-501/-Users-vishnnu-unbound/f2ae6add-6c7f-463b-bdc7-b667ff790199/scratchpad/r_metrics.txt
There was a problem hiding this comment.
I can’t access the referenced scratchpad attachment in the repository review context. The current PR diff still contains no diagnostic metrics, so the finding remains unresolved: add outcome counters for dispatch, build, upload success, and each failure stage, plus report-build and upload latency. Please paste the scratchpad contents or push the metrics implementation to the PR so I can verify the exact instrumentation.
| popen_kwargs['start_new_session'] = True | ||
| subprocess.Popen(cmd, **popen_kwargs) | ||
| # Stamp only after a successful spawn, so a failed dispatch doesn't mute 6h. | ||
| _mcp_diag_mark_dispatched(server_hint, cwd) |
There was a problem hiding this comment.
Failed Uploads Suppress Retries
The cooldown stamp is written immediately after the detached child is spawned, before the report is built or uploaded. If report construction, authentication, curl, or the gateway fails, the child only logs and exits while the stamp remains. Further diagnostics for that server and cwd are then suppressed for six hours, delaying diagnosis after a transient failure. Clear the stamp after failures or defer it until upload succeeds.
There was a problem hiding this comment.
@/private/tmp/claude-501/-Users-vishnnu-unbound/f2ae6add-6c7f-463b-bdc7-b667ff790199/scratchpad/r_retry.txt
| log_error('mcp diagnostic upload: curl exit %s' % proc.returncode, 'mcp_server') | ||
| except Exception as exc: | ||
| if proc is not None and proc.poll() is None: | ||
| try: | ||
| proc.kill() | ||
| proc.communicate() | ||
| except Exception: | ||
| pass | ||
| log_error('mcp diagnostic upload failed: %s' % exc, 'mcp_server') |
There was a problem hiding this comment.
Detached upload failures are logged only as a curl exit code or exception text, without the affected tool or server, a cwd hash, or a diagnostic dispatch identifier. Multiple concurrent diagnostics therefore cannot be correlated with the unresolved call that failed. This violates the repository's contextual-logging directive and must be addressed before merging by attaching a non-sensitive correlation ID and server/cwd identifiers to every build and upload outcome.
Context Used: P0 — Critical (must block merge) Django / Backend ... (source)
There was a problem hiding this comment.
@/private/tmp/claude-501/-Users-vishnnu-unbound/f2ae6add-6c7f-463b-bdc7-b667ff790199/scratchpad/r_logctx.txt
There was a problem hiding this comment.
I can’t access local scratchpad paths such as /private/tmp/.../r_logctx.txt from this review context. Please paste its contents or summarize the proposed logging changes so I can assess them against the correlation/context requirement.
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ Automated Security Review (consensus)
4 findings — 2 high-confidence, 2 to triage. Reviewers: Cursor, Claude, Semgrep, Gitleaks.
🔴 Gateway bearer token exposed in curl argv
copilot/hooks/unbound.py:6279
- Impact: The gateway API key is interpolated into
curl's-H 'Authorization: Bearer …'argument; on Linux/proc/<pid>/cmdlineis world-readable and on macOSpsexposes argv to same-user processes, so a local observer can steal a live gateway credential during the up-to-35s upload window. - Fix: Stop passing the bearer token via argv — use
urllib.request,curl --config -with headers on stdin, or a temp config file with0600perms; drop-Lfrom-fsSLon this POST. - Flagged by: Cursor, Claude, Greptile
🔴 Diagnostic report transmits sensitive host paths and MCP URL paths
copilot/hooks/unbound.py:6173
- Impact: The uploaded report (stored remotely and alerted to Slack) includes full
cwd/home, config file paths, Claude project names,chat.mcp.*values, and verbatim URL paths from_mcp_diag_summarize; remote MCP endpoints often embed auth tokens in the path, and usernames/project layout leak tenant identity. - Fix: Apply
redact_secretsto the final rendered report before upload; hash or home-relativize paths and replace URL paths with segment counts or hashes instead of literals. - Flagged by: Cursor, Claude, Greptile
🟡 Cooldown bypassable via attacker-controlled server token
copilot/hooks/unbound.py:6373
- Impact: Diagnostics fire on unresolved
mcp_<server>_<tool>names and the 6h stamp keys only on(server_hint, cwd)parsed from that name; a prompt-injected agent can vary the server token each call to spawn unbounded detached children (config sweeps,copilot mcp list, curl uploads) within the same cwd. - Fix: Add a per-cwd or global dispatch cap (e.g., max N per hour regardless of server token) and bound concurrent diagnostic children.
- Flagged by: Claude
🟡 API key passed to detached child via environment
copilot/hooks/unbound.py:6360
- Impact:
UNBOUND_DIAG_API_KEYis injected into the child's full inherited environment, widening credential exposure to any subprocess the child spawns (e.g.,copilot mcp listonPATH). - Fix: Omit
UNBOUND_DIAG_API_KEYand let_run_mcp_diagnostic_clicallget_api_key()locally; pass a minimal env to child subprocesses. - Flagged by: Claude
🤖 consensus review · reviewers: Cursor, Claude, Semgrep, Gitleaks · head 552c645b · 2026-09-10T16:43Z
There was a problem hiding this comment.
Stale comment
Agentic security review of PR #307 (
vv/copilot-mcp-diagnostic).One HIGH finding: Copilot MCP diagnostic URL summaries include URL paths and can send those values to the gateway and Slack. The Claude Code diagnostic this ports already redacts to host-only.
Sent by Cursor Security Agent: Security Reviewer
When a VS Code mcp_<server>_<tool> call matches no configured server, the call goes to the gateway without a server or config, so no policy applies and analytics records server 'unknown'. Until now the only trace was one error.log line. The hook now spawns a detached child, at most once per (server, cwd) every 6h, that builds a text report and uploads it to /v1/hooks/mcp-diagnostics, the endpoint the Claude Code diagnostic already uses. The report covers: - the hook's resolution replay and where each known name came from - every source the hook reads, file by file - sources it does not read: Claude Code (~/.claude.json and installed plugins), Claude Desktop, Cursor, Windsurf, .vscode/mcp.json above the cwd - servers VS Code actually started, from its mcpServer.<collection>.<server> logs, and extensions that contribute MCP servers - copilot mcp list --json - exact and near matches for the tool's server token, applying VS Code's 13-char prefix truncation Server configs are summarized as type, URL host and path, and command basename; args, env and headers are never included. The frozen binary already routes mcp-diagnostic <tool> to _run_mcp_diagnostic_cli.
552c645 to
2baf74c
Compare
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ Automated Security Review (consensus)
4 findings — 2 high-confidence, 2 to triage. Reviewers: Cursor, Claude, Semgrep, Gitleaks.
🔴 [HIGH] MCP server URL paths uploaded to gateway storage and Slack
copilot/hooks/unbound.py:5897
- Impact:
_mcp_diag_summarizekeepsscheme://hostplus the full URL path (unparseable values can pass through whole); many hosted MCP endpoints embed credentials in the path, which then land inmcp_fingerprint_diagnosticsand first-sight Slack alerts despite the PR's "no secrets" intent. - Fix: Match the Claude Code diagnostic: keep only
scheme://hostname[:port]; replace unparseable or scheme-less URLs with<unparseable-url>; if path shape matters, send a hash or segment count instead. - Flagged by: Cursor, Claude
🔴 [HIGH] Gateway bearer token exposed in curl argv (and timeout logs)
copilot/hooks/unbound.py:6276
- Impact: The API key is passed as
-H 'Authorization: Bearer <key>'insubprocess.Popenarguments, visible to other local processes viaps//proc; oncommunicatetimeout,str(exc)can include the full command and is written toerror.log, which_mcp_diag_error_log_tailmay re-upload. - Fix: POST with
urllib.requestor curl config on stdin (-K -) so the key never appears in argv; on subprocess errors log onlytype(exc).__name__, neverstr(exc). - Flagged by: Claude
🟡 [TRIAGE] Diagnostic report lacks privacy redaction before remote upload
copilot/hooks/unbound.py:6170
- Impact: The rendered report includes full
cwd/home, Claude Code project paths, config file paths, rawchat.mcp.*JSON fragments, and up to 15error.loglines — usernames, project names, internal hostnames, and prior exception text can leave the machine for vendor storage and Slack with no final scrub pass. - Fix: Normalize paths (
~for home, hash project paths), drop or strictly filter the error-log section, and run a final secret-scrub over the rendered report (bearer/token patterns, URL userinfo/query strings) before_upload_mcp_diagnostic. - Flagged by: Claude
🟡 [TRIAGE] Windows detached child may execute workspace-planted copilot or curl
copilot/hooks/unbound.py:6109
- Impact: The diagnostic child inherits the hook's cwd and calls bare
shutil.which('copilot')and'curl'; on Windows both can resolve to binaries in the workspace root, giving code execution (and access toUNBOUND_DIAG_API_KEYin the inherited env) when an unresolvedmcp_call fires. - Fix: Set
cwdto a trusted user-owned directory in dispatchPopen, invoke curl by absolute path (%SystemRoot%\System32\curl.exe), setNoDefaultCurrentDirectoryInExePath=1beforewhich, and stripUNBOUND_DIAG_API_KEYfrom the child env after reading it. - Flagged by: Claude
🤖 consensus review · reviewers: Cursor, Claude, Semgrep, Gitleaks · head 2baf74ca · 2026-09-11T09:35Z
MCP HTTP/SSE endpoints often carry a token or install id in the path, and the report is stored server-side and pinged to Slack. Summarize a URL as scheme://host[:port] only, and mark an unparseable one, matching the Claude Code diagnostic's _mcp_diag_host_of.
Three review findings on the detached child:
- it kept UNBOUND_DIAG_API_KEY in its environment, so every command it ran
(copilot, curl) inherited the gateway key. Pop it on read instead.
- the child inherits the editor's workspace as its cwd, and Windows searches
that directory before PATH. shutil.which('copilot') there returns a
workspace copilot.cmd, so resolve from PATH and reject a hit in the cwd.
- curl was invoked bare for the same reason. Pin curl.exe on Windows, as the
discovery download path already does.
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ Automated Security Review (consensus)
4 findings: 1 high-confidence, 3 to triage. Reviewers: Claude, Semgrep, Gitleaks.
-
[MEDIUM] 🔴 Gateway API key passed on the curl command line (
copilot/hooks/unbound.py:6283)- What:
_upload_mcp_diagnosticputsAuthorization: Bearer <api_key>straight into curl's argument list. - Why: Command-line arguments are visible to other local processes while the upload runs, for up to 30s. On Linux that is
/proc/<pid>/cmdlineunlesshidepidis set. On Windows it is WMI or Task Manager. A local user or process can read the key and reuse it against the gateway. The change that popsUNBOUND_DIAG_API_KEYfrom the environment does not help here, because the key goes right back into argv. - Fix: Keep the header off the command line. Two options:
- Pass curl a config on stdin (
-K -withheader = "Authorization: Bearer ...") and send the body from a temp file with 0600 permissions. - Better: do the POST with
urllib.requestinside the child process, so no subprocess is involved at all.
- Pass curl a config on stdin (
- Flagged by: Claude (also raised earlier by Greptile, and still present in this revision)
- What:
-
[MEDIUM] 🟡 Uploaded report has no final scrub for sensitive data (
copilot/hooks/unbound.py:6175)- What: The report is stored on the gateway and pinged to Slack. It includes:
- raw
cwdandhome - full config file paths
Claude Code project <full path>labelschat.mcp.*dict values, up to 200 chars each- the last 15 lines of
error.logthat mention "mcp", up to 300 chars each
- raw
- Why: Paths reveal usernames, customer project names and repo names. The
error.loglines hold free-form exception text, and⚠️ other log calls may write URLs or config values into it. That code is not in this diff, so this is unverified. Either way, the text reaches Slack unfiltered. The URL fix is good:_mcp_diag_summarizenow keeps onlyscheme://host[:port]. The log tail and settings dumps get around that fix. - Fix:
- Replace the home prefix in every path with
~. - Hash or drop Claude project paths.
- Run
error_log_tailandvscode_settingsvalues through a scrubber that strips URLs down to host and masks token-like strings. - Or drop both sections from the upload and keep them local only.
- Replace the home prefix in every path with
- Flagged by: Claude (also raised by Greptile, whose latest reply says it is still valid)
- What: The report is stored on the gateway and pinged to Slack. It includes:
-
[LOW] 🟡
copilotbinary from the workspace can still run through relative PATH entries (copilot/hooks/unbound.py:6109)- What:
_mcp_diag_path_binaryrejects a binary only when its parent folder is exactly the cwd. - Why: A relative PATH entry (for example
node_modules\.binorbin) resolves inside the workspace. A cloned repo could then shipbin/copilot.exe, which passes the check and runs as the user whenever an unresolvedmcp_*call happens. The API key is no longer in that binary's environment, but running code from the repo is enough to cause harm. - Fix: Also reject a binary when its resolved path is anywhere under the cwd (
Path(found).resolve().is_relative_to(Path.cwd().resolve())). Or drop non-absolute PATH entries before callingshutil.which. - Flagged by: Claude
- What:
-
[LOW] 🟡 No global limit on diagnostic child processes (
copilot/hooks/unbound.py:3877)- What: The cooldown key is (server token, cwd). Every new
mcp_<token>_...name spawns a fresh detached child. - Why: Each child runs
copilot mcp list(about 9s on Windows), scans the disk, uploads a report, and can trigger a Slack alert "on first sight". If a prompt-injected agent emits many made-upmcp_tool names, it could flood the machine with processes and flood Slack with alerts.⚠️ Not confirmed that Copilot sends PreToolUse for tool names that don't exist. - Fix: Add a global cap, such as at most N diagnostics per hour across all tokens, checked with a single stamp or counter file before spawning.
- Flagged by: Claude
- What: The cooldown key is (server token, cwd). Every new
Pre-existing (not introduced by this PR)
- Semgrep
insecure-file-permissionsatcopilot/hooks/unbound.py:5567(0o700): this line is not in the diff. The finding is also a false positive, because0o700is owner-only and stricter than the suggested0o644. No action needed.
🤖 consensus review · reviewers: Claude, Semgrep, Gitleaks · head ad30b039 · 2026-09-15T06:22Z
The gateway key sat in curl's argv for the length of the upload, readable by any local process. Post it with urllib and the same no-redirect opener that _request_skill_sync uses, so the header never leaves this process. Drops the curl dependency (and its Windows path pinning) from this path.
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ Automated Security Review (consensus)
3 findings — 0 high-confidence, 3 to triage. Reviewers: Claude, Semgrep, Gitleaks.
-
🟡 [MEDIUM] Server summary can leak secrets passed inline in
command—copilot/hooks/unbound.py:5892- What:
_mcp_diag_summarizeuploadsos.path.basename(command)[:60]. It assumescommandis only a path to a program. - Why: Many MCP configs put the whole command line in
command, for example"docker run -e API_TOKEN=abc…"or"node srv.js --key sk-…".basenameonly splits on/, so the token survives and goes to the gateway diagnostics store and Slack. That breaks the PR's "no args, env or headers" promise. On Windows,C:\…\tool.exe --token xalso passes through unchanged. - Fix: Keep only the first word, split on whitespace with
shlex.splitin a try block. Then take the basename of that, split on both/and\. If the string had more than one word, add a marker such ascmd=docker (+inline args). - Flagged by: Claude
- What:
-
🟡 [LOW] Report sends local paths, project names and raw log lines with no final redaction step —
copilot/hooks/unbound.py(_build_mcp_diagnostic,_mcp_diag_unread_sources,_mcp_diag_error_log_tail)- What: The report includes these values unchanged:
- the full
cwd,homeandhookpaths - every config file path
- every
~/.claude.jsonproject key, which is a full project path, insource chat.mcp.*dict values, up to 200 chars each- the last 15 error.log lines that mention "mcp", up to 300 chars each
- the full
- Why: This goes to a remote store and to Slack. It exposes OS usernames, customer project and repo names, and whatever exception text earlier
log_errorcalls wrote. That text can include URLs. - Fix: Replace the home directory with
~everywhere. Hash or shorten project paths that are not the current cwd. Scrub the error.log lines for URL queries, userinfo and token-like strings before rendering. - Flagged by: Claude (also raised by an earlier Greptile review;
⚠️ the maintainer's reply points to a local scratchpad file that can't be read here, so it's unclear whether this was accepted by design)
- What: The report includes these values unchanged:
-
🟡 [LOW] No overall cap on detached diagnostic processes —
copilot/hooks/unbound.py(_dispatch_mcp_diagnostic)- What: The 6h cooldown is keyed on (server token, cwd). Every new unresolved
mcp_<x>_…token starts a new detached child. - Why: A model steered by prompt injection could emit many distinct fake
mcp_tool names. Each one would start a child that runscopilot mcp list(about 9s on Windows), uploads up to 200KB, and can trigger a first-sight Slack ping.⚠️ Not confirmed: it's unclear whether Copilot sends unregistered tool names to PreToolUse at all. - Fix: Add a global limit next to the per-key stamp, such as at most N dispatches per hour. Also skip dispatch while another diagnostic child is still running, using a lock file.
- Flagged by: Claude
- What: The 6h cooldown is keyed on (server token, cwd). Every new unresolved
🤖 consensus review · reviewers: Claude, Semgrep, Gitleaks · head 44eaca6a · 2026-09-15T06:42Z
There was a problem hiding this comment.
Stale comment
Agentic security review of PR #307 (
vv/copilot-mcp-diagnostic).One MEDIUM finding remains on this head: the Copilot unresolved-MCP diagnostic uploads VS Code
chat.mcp*settings dumps, MCP-relatederror.loglines, and raw host paths without the Claude Code diagnostic scrub pass. Prior HIGH issues (URL-path leakage in server summaries, Windows cwd binary hijack with the diagnostic API key) look addressed in the current diff.Sent by Cursor Security Agent: Security Reviewer
The report carried two pieces of free text without the Claude Code diagnostic's boundary: chat.mcp.* setting values and the error.log excerpt. Reuse its _MCP_DIAG_SECRETISH pattern — a matching value becomes <redacted> and a matching log line is dropped with a count of what was suppressed.
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ Automated Security Review (consensus)
3 findings — 0 high-confidence, 3 to triage. Reviewers: Claude (lead), Semgrep, Gitleaks.
-
[MEDIUM] Command-line secrets can leak through the
cmd=summary —copilot/hooks/unbound.py:5894- What:
_mcp_diag_summarizeuploadsos.path.basename(command)[:60]without scrubbing. Some MCP configs put the whole command line, with flags, into thecommandstring. - Why: Take
"command": "npx -y @acme/mcp-server --api-key sk-live-abc123". Its basename ismcp-server --api-key sk-live-abc123, and that text goes into the report. The report is stored remotely and sent to Slack. The PR says args are never included, but here they are. This path runs for every config source, includingcopilot mcp list. - Fix: Keep only the first whitespace-separated token of
command, then take its basename. Also run the result through_mcp_diag_scrub_value. - Flagged by: Claude
- What:
-
[LOW] Error-log filter misses secrets that have no keyword —
copilot/hooks/unbound.py(_mcp_diag_error_log_tail)- What: Lines are dropped only if they match
_MCP_DIAG_SECRETISH, which looks for words like authorization, token or key. Raw key values (sk-…,ghp_…), URLs with query strings, and exception text that has no keyword all pass through. Up to 300 chars per line are kept. - Why:
log_errorcalls elsewhere put{exc}and server details into lines that mention "mcp". A secret there that lacks a keyword gets uploaded in the report. - Fix: Also drop or redact lines that contain URL queries (
?…=) or long high-entropy tokens. Or send only the log's category and timestamp, not the free text. - Flagged by: Claude
- What: Lines are dropped only if they match
-
[LOW]
copilot mcp listsource field is not scrubbed —copilot/hooks/unbound.py(_mcp_diag_copilot_cli_list)- What:
entry.get('source')from the CLI's JSON output is added to the report as-is, with no length cap and no scrub. - Why: That field is outside the hook's control. It may hold a file path or plugin identifier, and a future CLI version could return anything there.
- Fix: Wrap it in
_mcp_diag_scrub_value(...)and cap its length, as the settings values already are. - Flagged by: Claude
- What:
Previously acknowledged (not re-flagged)
- Full paths in the report (cwd, home, hook path, config file paths, Claude Code project keys). Raised by Greptile and Cursor. The maintainer replied on the thread (the reply's text isn't visible here), and the latest commit added value and line filtering but deliberately kept paths. The PR description says per-file paths are needed for the diagnosis, so this is treated as by design.
- Bearer token visible in curl's command line. The maintainer replied, and the code now POSTs from inside the process with
urlliband a handler that refuses redirects. - Credential-bearing URL paths. The maintainer replied, and
_mcp_diag_host_ofnow keeps onlyscheme://host[:port]and returns<unparseable-url>for URLs it can't parse. copilot/curlbinary hijack from the workspace folder. The maintainer replied. curl is gone, and_mcp_diag_path_binaryrefuses a match that sits in the current directory.- Semgrep
insecure-file-permissionsat line 5567. This line was not touched by this PR.0o700(owner-only access) is also stricter than the0o644Semgrep suggests, so it's a false positive.
🤖 consensus review · reviewers: Claude, Semgrep, Gitleaks · head c3ccf885 · 2026-09-15T07:02Z
Two gaps found by reading a stored report end to end:
- the match section compared the tool name with mcp_ stripped, so a server
whose own name starts with mcp_ never matched. A report could list the
server in its inventory and still say no match was found. Compare with and
without the prefix.
- the row and the Slack alert carried the token parsed out of the tool name
('es' for mcp_es_probe-echo). When exactly one on-disk server matches, file
the diagnostic under that real name instead.
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ Automated Security Review (consensus)
4 findings — 0 high-confidence, 4 to triage. Reviewers: Claude (lead), Semgrep, Gitleaks.
-
[LOW] 🟡 Uploaded report still carries local paths and project names —
copilot/hooks/unbound.py:6204- What:
_build_mcp_diagnosticsends rawcwd,homeand thehookpath. The report also lists every config file path, and eachClaude Code project <path>label comes from~/.claude.json. The upload envelope repeatscwdtoo. - Why: This data is stored on the gateway and can reach Slack. It exposes OS usernames, client or project directory names, and repo layout for every project in
~/.claude.json, not just the current one. - Fix: Swap the home prefix for
~in all paths. Hash or drop the project paths from other Claude projects. Drophookandterm_program, or reduce them to a basename and sha. Add a test that checks no raw home path appears in the rendered report. - Flagged by: Claude, Cursor bot (earlier thread), Greptile (earlier thread).
⚠️ The maintainer's replies on this thread point to local scratchpad files that can't be read here. If they accepted this by design, ignore it.
- What:
-
[LOW] 🟡 Redaction only matches keywords, so some secrets get through —
copilot/hooks/unbound.py(_mcp_diag_scrub_value,_mcp_diag_error_log_tail)- What:
chat.mcp*settings values anderror.loglines are dropped only when they contain words liketokenorapikey. Anything else goes out as-is, up to 200 or 300 characters. - Why: Some secrets have no such keyword. Examples: a URL like
https://host/sse?key=abc123, a URL path with a secret in it, oruser:pass@host. These pass the filter and leave the machine in the report. - Fix: Before the keyword check, run any URL-like substring through
_mcp_diag_host_of. Addkey=,sig=,authand://user:pass@patterns to_MCP_DIAG_SECRETISH. Or send only the setting keys and value types, not the values. - Flagged by: Claude
- What:
-
[LOW] 🟡
copilotbinary check can be bypassed through relative PATH entries —copilot/hooks/unbound.py:6109- What:
_mcp_diag_path_binaryrejects a match only when the binary's parent folder is exactly the cwd. A relative PATH entry such asnode_modules/.binorbinresolves under the workspace and passes the check. - Why: The child runs from the editor's workspace. On a machine with relative PATH entries, a cloned repo that ships
node_modules/.bin/copilotruns as the hook user whenever an unresolvedmcp_call happens. The API key is removed from the env before this runs, which limits the damage. It is still code execution triggered by the repo. - Fix: Build the search path only from absolute PATH entries:
os.pathsep.join(p for p in PATH.split(os.pathsep) if os.path.isabs(p)). Also reject any result that resolves underPath.cwd(), not only one whose parent equals it. Better still, setcwd=on the child to a neutral folder such asLOG_DIR. - Flagged by: Claude (improves on the earlier Cursor bot hijack finding, which the
curlremoval and this helper mostly fixed)
- What:
-
[LOW] 🟡 Server names from repo files flow into the Slack alert without escaping —
copilot/hooks/unbound.py(_mcp_diag_reported_server,_mcp_diag_unread_sources)- What: The report now reads
.vscode/mcp.jsonand.cursor/mcp.jsonfrom the workspace and its parent folders. A single exact match there becomes theserverfield in the upload, which the backend uses for its first-sight Slack ping. - Why: A malicious repo controls that name, which is capped only at 255 characters. If the backend doesn't escape it, the name could inject Slack mrkdwn such as
<!channel>or a fake<https://evil|link>into an internal alert channel.⚠️ The backend's escaping isn't in this diff, so this can't be confirmed from here. - Fix: On the hook side, limit
serverto[A-Za-z0-9._-]. Check that the companion ai-gateway-data PR escapesserver,toolandcwdbefore posting to Slack. - Flagged by: Claude
- What: The report now reads
🤖 consensus review · reviewers: Claude, Semgrep, Gitleaks · head ebc399c0 · 2026-09-15T13:45Z
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ Automated Security Review (consensus)
2 findings — 0 high-confidence, 2 to triage. Reviewers: Claude (lead), Semgrep, Gitleaks.
-
[MEDIUM]
copilotbinary can still resolve to a workspace-controlled file —copilot/hooks/unbound.py:6112- What:
_mcp_diag_path_binaryonly rejects a binary that sits directly in the current working directory. Two cases still get through:- A relative PATH entry such as
binor.\toolsresolves to<workspace>/bin/copilot. - On Windows,
shutil.whichmay still search the working directory for other forms of the name.
- A relative PATH entry such as
- Why: The detached child is started without a
cwd=, so it inherits the editor's workspace. If Copilot emits any unresolvedmcp_*tool name, the child runs a repository-shippedcopilotas the user. The API key is popped from the environment first. But the spawned binary runs with the user's full privileges, so it can still read the key from the hook's config on disk. - Fix:
- Pass
cwd=set to a trusted directory (home or temp) in_dispatch_mcp_diagnostic'sPopen. - In
_mcp_diag_path_binary, search only absolute PATH entries. - Reject any result whose resolved path is inside
cwdorUNBOUND_DIAG_CWD. - On Windows, consider the
_windows_system32_path-style pinning already used forcurl.exe.
- Pass
- Flagged by: Claude. Also raised in earlier bot threads (Greptile, Cursor). The maintainer's replies are unreadable local scratchpad links, so this is not treated as acknowledged.
- What:
-
[LOW] Diagnostic report sends local paths and every Claude Code project path to the gateway and Slack —
copilot/hooks/unbound.py:6204(_build_mcp_diagnostic/_mcp_diag_unread_sources)- What: These go into the uploaded report unredacted:
- raw
cwd,home, and the hook path - the full path of every config file
- one
Claude Code project <path>row for every project in~/.claude.jsonthat defines MCP servers, not just the current one
- raw
- Why: The report is stored remotely and posted to Slack on first sight. It exposes usernames, customer or project directory names, and the user's list of other workspaces, well beyond what is needed to diagnose one unresolved server. The secret-keyword filter (
_MCP_DIAG_SECRETISH) only catches keyword-labelled secrets. A bare token in anerror.logline, such asghp_…or?key=…, would pass through. - Fix:
- Replace the home prefix with
~. - Hash or drop project paths other than the current
cwd. - Add a final scrub over the rendered report (home prefix, known token patterns) before upload.
- Replace the home prefix with
- Flagged by: Claude. Also raised by Cursor and Greptile. The maintainer's reply is an unreadable scratchpad link.
- What: These go into the uploaded report unredacted:
Pre-existing (not introduced by this PR)
- The Semgrep
insecure-file-permissionshit atcopilot/hooks/unbound.py:5567(0o700) is on a line this diff does not touch.0o700is owner-only and more restrictive than Semgrep's suggested0o644, so it is a false positive.
🤖 consensus review · reviewers: Claude, Semgrep, Gitleaks · head 624578be · 2026-09-15T14:03Z
The previous guard rejected only a binary sitting directly in the cwd, so a relative PATH entry such as 'bin' still resolved inside the editor workspace the detached child inherits. Resolve from absolute PATH entries only and refuse any result under the cwd tree.
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ Automated Security Review (consensus)
3 findings — 0 high-confidence, 3 to triage. Reviewers: Claude (lead), Semgrep, Gitleaks.
-
[MEDIUM] Diagnostic report still sends local paths and project names off the machine —
copilot/hooks/unbound.py:6208- What: The report is stored remotely and pinged to Slack. It still includes several unredacted values:
- raw
cwdandhome - the full
hookpath - every config
filepath - every
~/.claude.jsonproject path that has MCP servers, asClaude Code project <path>
- raw
- Why: A single unresolved
mcp_call lists every Claude Code project directory with MCP servers on the laptop. That exposes usernames, client or repo names, and tenant identifiers to the gateway store and Slack._mcp_diag_summarizealso keepsos.path.basename(command)[:60]. Ifcommandis a full shell string, such assh -c "FOO=abc …", part of that string can survive. - Fix: Before rendering, swap the home prefix for
~in all paths. Hash or drop the Claude Code project keys, for exampleproject#<sha8>. Removehookandterm_program. Run_mcp_diag_scrub_valueon thecommandbasename too. - Flagged by: Claude. This is what is left of the earlier Greptile and Cursor scrub thread. URL and settings scrubbing are now fixed.
⚠️ The maintainer's replies point to local scratchpad files that aren't visible here. If they acceptedcwd/homeas needed by design, ignore that part.
- What: The report is stored remotely and pinged to Slack. It still includes several unredacted values:
-
[LOW] Error-log redaction misses common credential words —
copilot/hooks/unbound.py:6211- What:
_mcp_diag_error_log_taildrops a line only when it matchesauthorization|bearer|api_key|token|secret|password|passwd|credential. Other lines are forwarded as-is, up to 300 chars each. - Why: Exception text that holds URLs with
?key=,?sig=,?code=,X-Api-Key, oraccess_keydoes not match the pattern. Those lines get uploaded and reach Slack. - Fix: Before the secret check, strip URL query strings and userinfo from each line, for example with
re.sub(r'\?\S*', '?<q>', line). Also addkey|sig|signature|code|session|cookieto_MCP_DIAG_SECRETISH. - Flagged by: Claude
- What:
-
[LOW] Workspace-binary check compares against the process cwd, not the event cwd —
copilot/hooks/unbound.py:6109- What:
_mcp_diag_path_binaryrejectscopilotonly when it resolves underPath.cwd(). The workspace from the event (UNBOUND_DIAG_CWD) is never checked. - Why: The hook process cwd may not be the workspace root, for example in multi-root workspaces or when VS Code launches the hook elsewhere. In that case, a repo-controlled
copilotreached through an absolute PATH entry passes the check. direnv'sPATH_add binandnode_modules/.binare common examples. It then runs during diagnostic collection. - Fix: Also reject binaries under
Path(UNBOUND_DIAG_CWD).resolve(). Better, only allow known install locations, such as the npm global prefix or%LOCALAPPDATA%. - Flagged by: Claude
- What:
🤖 consensus review · reviewers: Claude, Semgrep, Gitleaks · head e63289ce · 2026-09-15T14:27Z


Why
VS Code Copilot names MCP tools
mcp_<server>_<tool>. When the hook can't match that token to a configured server, the call reaches the gateway with no server and no config. No MCP policy runs, and analytics records the server asunknown.This has persisted for weeks on individual machines. The only trace was a local
error.logline, and its Sentry report was usually dropped by the hook's one-report-per-60s limit. From our side we couldn't tell where VS Code was loading those servers from.What
Unresolved-MCP diagnostic. The Claude Code hook already has a null-fingerprint diagnostic; this ports it to Copilot. On an unresolved
mcp_call, the hook spawns a detached child, at most once per (server token, cwd) every 6h. The child uploads a text report to the existing/v1/hooks/mcp-diagnosticsendpoint, which stores it inmcp_fingerprint_diagnosticsand pings Slack on first sight. The report covers:~/.claude.jsonand installed plugins.vscode/mcp.jsonabove the cwdmcpServer.<collection>.<server>.logper server, and the collection records where the definition came from (for example,mcp.config.usrlocalis the usermcp.json).copilot mcp list --jsonSafety
mcp-diagnostic <tool>subcommand already calls_run_mcp_diagnostic_clion any module that defines it.Testing
tests/copilot,test_hook_contract,test_mcp_fingerprint_parity,binary/tests(1372 passed, 38 skipped).copilot mcp listentries/v1/hooks/mcp-diagnosticswith the right auth and envelope, and the 6h stamp was written.copilot.exeand VS Code):copilot mcp list.mcpServer.*.elasticsearchlog, and listed an extension-contributed MCP provider.DETACHED_PROCESSchild ran,curl.exePOSTed to the local stub, and the 6h stamp was written.Companion PR: websentry-ai/ai-gateway-data, the same branch name, which adds the tool name and hook source to the Slack alert.
The PR is not yet safe to merge because the new diagnostic workflow still lacks the required operation metrics.
Findings
Summary
The PR adds detached diagnostics for unresolved Copilot MCP calls, collecting sanitized configuration-source and runtime-resolution information and uploading it to the gateway on a six-hour cooldown.
Diagram
%%{init: {'theme': 'neutral'}}%% flowchart LR A[Unresolved Copilot MCP tool] --> B{Cooldown active?} B -->|Yes| C[Continue policy flow] B -->|No| D[Spawn detached diagnostic child] D --> E[Inventory MCP configuration sources] E --> F[Inspect VS Code logs and extensions] F --> G[Run trusted Copilot CLI when available] G --> H[Render bounded diagnostic report] H --> I[Upload to MCP diagnostics endpoint] D --> J[Write six-hour dispatch stamp]Reviews (8) · Last reviewed commit: "Keep workspace binaries off the diagnost..."