1- """GitHub Copilot (VS Code extension) integration for AI guardrails.
1+ """GitHub Copilot integration for AI guardrails.
22
33Hooks are installed in Copilot's native format to ``~/.copilot/hooks/cycode.json``
4- (user scope) or ``<repo>/.github/hooks/cycode.json`` (repo scope). Both locations
5- are also read by Copilot CLI and the Copilot cloud coding agent, but only the
6- VS Code payload dialect is parsed here — CLI payloads (camelCase, no event name)
7- are rejected by ``matches_payload`` and fall through to the allow-and-skip path.
8-
9- VS Code sends Claude-style payloads (``hook_event_name``, ``tool_name``,
10- ``tool_input``), told apart by the one field Claude Code never sends: a top-level
11- ISO ``timestamp``. VS Code also sends a ``transcript_path`` of its own once a
12- workspace has chat history, so that field cannot discriminate. Copilot hooks have no
13- matchers, so ``preToolUse`` fires for every tool; tools we don't scan pass
14- through as raw event names, which match no handler and allow immediately.
4+ (user scope) or ``<repo>/.github/hooks/cycode.json`` (repo scope). One file, but
5+ two runtimes are known to execute it: VS Code's own chat runtime, and the Copilot
6+ agent runtime (Copilot CLI, and VS Code agent sessions). The repo-scope location
7+ is also read by the Copilot cloud coding agent, whose dialect is untested here.
8+
9+ Both deliver Claude-style payloads (``hook_event_name``, ``tool_name``,
10+ ``tool_input``) when the event keys are registered in PascalCase; the agent runtime
11+ answers camelCase keys with its own dialect (``sessionId``, no event name) instead.
12+ Copilot payloads are told apart from Claude Code's by the one field Claude Code
13+ never sends, a top-level ``timestamp``; ``transcript_path`` cannot discriminate,
14+ since VS Code sends one of its own whenever a folder is open.
15+
16+ The tool vocabulary still differs by runtime — VS Code reads files with
17+ ``read_file``/``filePath`` and names MCP tools ``mcp_<server>_<tool>``, the agent
18+ runtime uses ``Read``/``path`` and ``<server>-<tool>`` — so both are accepted.
19+ Copilot hooks have no matchers, so ``PreToolUse`` fires for every tool; tools we
20+ don't scan pass through as raw event names, which match no handler and allow
21+ immediately.
1522"""
1623
1724import json
3744
3845logger = get_logger ('AI Guardrails Copilot' )
3946
40- # Payload dialect (VS Code sends Claude-style PascalCase event names).
47+ # Payload dialect (Claude-style PascalCase event names).
4148_COPILOT_SCAN_EVENT_NAMES = frozenset ({'UserPromptSubmit' , 'PreToolUse' })
42- _READ_FILE_TOOL = 'read_file'
43- # VS Code names MCP tools `mcp_<server>_<tool>` (single underscores).
49+
50+ # Two tool vocabularies reach us through one hooks file: VS Code's own runtime
51+ # names file reads `read_file` with a `filePath` argument, while the Copilot agent
52+ # runtime (Copilot CLI, and VS Code agent sessions) names them `Read` with `path`.
53+ # The names are disjoint, so both are accepted rather than switched between.
54+ _READ_FILE_TOOLS = frozenset ({'read_file' , 'Read' })
55+ _READ_PATH_KEYS = ('path' , 'filePath' )
56+
57+ # VS Code names MCP tools `mcp_<server>_<tool>` (single underscores); the agent
58+ # runtime uses `<server>-<tool>` with no prefix (its SDK documents that wire form),
59+ # leaving a hyphen as the only marker of an MCP call there. Every built-in agent
60+ # tool observed is lower snake_case (`view`, `glob`, `str_replace`, `ask_user`) or
61+ # PascalCase (`Read`), so this holds for them — but SDK- or custom-agent-registered
62+ # tools may be named freely. A hyphenated custom tool would be scanned as an MCP
63+ # call with no resolvable server: an extra scan, never a missed one, which is the
64+ # safe direction to err for a guardrail.
4465_MCP_TOOL_PREFIX = 'mcp_'
66+ _MCP_AGENT_SEPARATOR = '-'
4567
46- # Hooks-file dialect (Copilot-native camelCase event names) .
47- _HOOK_EVENTS = ['userPromptSubmitted ' , 'preToolUse ' ]
68+ # Hooks-file event keys. Their case selects the agent runtime's payload dialect .
69+ _HOOK_EVENTS = ['UserPromptSubmit ' , 'PreToolUse ' ]
4870
4971_COPILOT_HOME_ENV_VAR = 'COPILOT_HOME'
5072_HOOKS_FILE_NAME = 'cycode.json'
5173_REPO_HOOKS_SUBDIR = Path ('.github' ) / 'hooks'
5274_HOOK_TIMEOUT_SEC = 20
5375_MCP_CONFIG_FILENAME = 'mcp.json'
76+ _AGENT_MCP_CONFIG_FILENAME = 'mcp-config.json'
5477
5578# Plugin sources. CLI installs register in ~/.copilot/config.json and auto-surface
5679# in VS Code; VS Code UI installs register in ~/.vscode/agent-plugins/installed.json;
6891 Path ('.claude-plugin' ) / 'plugin.json' ,
6992)
7093
71- # --event is ignored by the VS Code payload parsing (the payload self-describes)
72- # but Copilot CLI payloads carry no event name at all — baking the flag in now
73- # means CLI support won't require customers to re-install hooks. Values use the
74- # payload-dialect spelling so a future CLI path can inject them straight into
75- # hook_event_name and reuse the existing parsing.
76- _SCAN_PROMPT_COMMAND = f'{ CYCODE_SCAN_PROMPT_COMMAND } --ide copilot --event UserPromptSubmit'
77- _SCAN_TOOL_COMMAND = f'{ CYCODE_SCAN_PROMPT_COMMAND } --ide copilot --event PreToolUse'
94+ # One command for both events: every runtime self-describes via hook_event_name once
95+ # the events are registered in PascalCase, so --event is no longer passed.
96+ _SCAN_COMMAND = f'{ CYCODE_SCAN_PROMPT_COMMAND } --ide copilot'
7897_SESSION_START_COMMAND = f'{ CYCODE_SESSION_START_COMMAND } --ide copilot'
7998
8099
@@ -253,14 +272,24 @@ def _collect_installed_plugins() -> dict:
253272
254273
255274def _known_mcp_server_names () -> list [str ]:
256- """Config-declared MCP server names: user-level ``mcp.json`` + plugin configs.
275+ """Config-declared MCP server names, across both runtimes' config files.
276+
277+ VS Code declares them in its user-level ``mcp.json`` under ``servers``; the
278+ agent runtime uses ``~/.copilot/mcp-config.json`` under ``mcpServers``. Both are
279+ read because one hooks file serves both, and plugin configs contribute to either.
257280
258281 Best-effort inventory: servers contributed by extensions, ``chat.mcp.discovery``
259282 imports, dev containers, or non-default profiles are not discoverable from disk.
260283 """
261284 config = _load_vscode_mcp_config ()
262285 servers = (config or {}).get ('servers' )
263286 names = list (servers .keys ()) if isinstance (servers , dict ) else []
287+
288+ agent_config = _load_jsonc (_copilot_home () / _AGENT_MCP_CONFIG_FILENAME ) or {}
289+ agent_servers = agent_config .get ('mcpServers' )
290+ if isinstance (agent_servers , dict ):
291+ names .extend (agent_servers .keys ())
292+
264293 for plugin in _collect_installed_plugins ().values ():
265294 names .extend (plugin .get ('mcp_server_names' ) or [])
266295 return names
@@ -279,22 +308,57 @@ def _server_name_variants(server_name: str) -> set[str]:
279308 return {v for v in (server_name , underscored , collapsed ) if v }
280309
281310
282- def split_mcp_tool_name (tool_name : str , server_names : Iterable [str ]) -> tuple [Optional [str ], Optional [str ]]:
283- """Split ``mcp_<server>_<tool>`` into ``(server, tool)``.
311+ def _read_file_path (tool_name : str , tool_input : object ) -> Optional [str ]:
312+ """Path of a file-read tool call, or None when this isn't one.
313+
314+ The agent runtime reuses its read tool for directory listings, with a payload
315+ identical to a file read, so the path has to be stat-ed to tell them apart —
316+ VS Code has no such ambiguity (`read_file` vs `list_dir`). A path that isn't an
317+ existing file (a directory, or already deleted) has nothing to scan.
318+ """
319+ if tool_name not in _READ_FILE_TOOLS or not isinstance (tool_input , dict ):
320+ return None
321+
322+ raw_path = next ((tool_input [key ] for key in _READ_PATH_KEYS if tool_input .get (key )), None )
323+ if not isinstance (raw_path , str ):
324+ return None
325+
326+ try :
327+ if not Path (raw_path ).is_file ():
328+ return None
329+ except OSError as e :
330+ logger .debug ('Failed to stat read path, %s' , {'path' : raw_path }, exc_info = e )
331+ return None
332+ return raw_path
333+
284334
285- The ``<server>`` part is VS Code's sanitized (and possibly truncated) form of
286- the server's SELF-REPORTED handshake name, not the config key — so matching
287- against known config names (and their normalized variants) is best-effort.
288- When nothing matches, return the unsplit remainder as the tool rather than
289- fabricating a server from a guessed split.
335+ def is_mcp_tool_name (tool_name : str ) -> bool :
336+ """Whether a tool name is an MCP call in either runtime's naming scheme."""
337+ return tool_name .startswith (_MCP_TOOL_PREFIX ) or _MCP_AGENT_SEPARATOR in tool_name
338+
339+
340+ def split_mcp_tool_name (tool_name : str , server_names : Iterable [str ]) -> tuple [Optional [str ], Optional [str ]]:
341+ """Split an MCP tool name into ``(server, tool)``.
342+
343+ Handles both naming schemes: VS Code's ``mcp_<server>_<tool>`` and the agent
344+ runtime's prefix-less ``<server>-<tool>``. In the VS Code form the ``<server>``
345+ part is a sanitized (and possibly truncated) form of the server's SELF-REPORTED
346+ handshake name rather than the config key, so matching against known config
347+ names (and their normalized variants) is best-effort. Server names may
348+ themselves contain the separator, hence the longest-match. When nothing
349+ matches, return the unsplit remainder as the tool rather than fabricating a
350+ server from a guessed split.
290351 """
291- rest = tool_name [len (_MCP_TOOL_PREFIX ) :]
352+ if tool_name .startswith (_MCP_TOOL_PREFIX ):
353+ rest , separator = tool_name [len (_MCP_TOOL_PREFIX ) :], '_'
354+ else :
355+ rest , separator = tool_name , _MCP_AGENT_SEPARATOR
292356
293357 best_server = None
294358 best_variant_len = - 1
295359 for server in server_names :
296360 for variant in _server_name_variants (server ):
297- if (rest == variant or rest .startswith (f'{ variant } _ ' )) and len (variant ) > best_variant_len :
361+ if (rest == variant or rest .startswith (f'{ variant } { separator } ' )) and len (variant ) > best_variant_len :
298362 best_server = server
299363 best_variant_len = len (variant )
300364 if best_server is not None :
@@ -318,13 +382,17 @@ def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path:
318382 def render_hooks_config (self , async_mode : bool = False ) -> dict :
319383 def entry (command : str ) -> dict :
320384 if async_mode :
321- # Copilot has no async hook flag; background via shell on unix. The
322- # explicit <&0 keeps the payload flowing: a bare `cmd &` gets its stdin
323- # reattached to /dev/null by the shell (job control is off in hooks).
324- # Windows PowerShell has no trailing-& operator, so it stays sync.
385+ # Copilot has no async hook flag; background via shell on unix. Both
386+ # redirects are load-bearing. `<&0` keeps the payload flowing: a bare
387+ # `cmd &` gets its stdin reattached to /dev/null by the shell (job
388+ # control is off in hooks), so the scan reads nothing and allows. The
389+ # stdout redirect is what actually makes it async: the backgrounded
390+ # child inherits the hook's stdout and the runner waits on that pipe
391+ # for EOF, so without it the scan blocks the response it was meant to
392+ # run behind. Windows PowerShell has no trailing-&, so it stays sync.
325393 return {
326394 'type' : 'command' ,
327- 'bash' : f'{ command } <&0 &' ,
395+ 'bash' : f'{ command } <&0 >/dev/null 2>&1 &' ,
328396 'powershell' : command ,
329397 'timeoutSec' : _HOOK_TIMEOUT_SEC ,
330398 }
@@ -334,9 +402,9 @@ def entry(command: str) -> dict:
334402 return {
335403 'version' : 1 ,
336404 'hooks' : {
337- 'sessionStart ' : [{'type' : 'command' , 'command' : _SESSION_START_COMMAND }],
338- 'userPromptSubmitted ' : [entry (_SCAN_PROMPT_COMMAND )],
339- 'preToolUse ' : [entry (_SCAN_TOOL_COMMAND )],
405+ 'SessionStart ' : [{'type' : 'command' , 'command' : _SESSION_START_COMMAND }],
406+ 'UserPromptSubmit ' : [entry (_SCAN_COMMAND )],
407+ 'PreToolUse ' : [entry (_SCAN_COMMAND )],
340408 },
341409 }
342410
@@ -350,21 +418,21 @@ def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload:
350418 tool_name = raw_payload .get ('tool_name' , '' )
351419 tool_input = raw_payload .get ('tool_input' )
352420
421+ read_path = _read_file_path (tool_name , tool_input )
422+
353423 if hook_event_name == 'UserPromptSubmit' :
354424 canonical_event : Union [AiHookEventType , str ] = AiHookEventType .PROMPT
355- elif hook_event_name == 'PreToolUse' and tool_name == _READ_FILE_TOOL :
425+ elif hook_event_name == 'PreToolUse' and read_path is not None :
356426 canonical_event = AiHookEventType .FILE_READ
357- elif hook_event_name == 'PreToolUse' and tool_name . startswith ( _MCP_TOOL_PREFIX ):
427+ elif hook_event_name == 'PreToolUse' and is_mcp_tool_name ( tool_name ):
358428 canonical_event = AiHookEventType .MCP_EXECUTION
359429 else :
360- # No matchers in Copilot hooks: preToolUse fires for every tool. Pass
430+ # No matchers in Copilot hooks: PreToolUse fires for every tool. Pass
361431 # the raw tool name through — it matches no handler, so scan_command
362432 # answers with a neutral allow before any policy/network work.
363433 canonical_event = tool_name or hook_event_name
364434
365- file_path = None
366- if canonical_event == AiHookEventType .FILE_READ and isinstance (tool_input , dict ):
367- file_path = tool_input .get ('filePath' )
435+ file_path = read_path if canonical_event == AiHookEventType .FILE_READ else None
368436
369437 mcp_server_name = None
370438 mcp_tool_name = None
0 commit comments