Skip to content

Commit 2d82352

Browse files
Ilanlidoclaude
andauthored
CM-64462: add GitHub Copilot (VS Code) support to AI guardrails (#498)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent a57ba40 commit 2d82352

15 files changed

Lines changed: 1094 additions & 37 deletions

File tree

cycode/cli/apps/ai_guardrails/hooks_manager.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,22 @@
2222

2323
_CYCODE_COMMAND_MARKERS = ('cycode ai-guardrails',)
2424

25+
# Command-carrying fields of a flat hook entry. Copilot entries use per-OS
26+
# `bash`/`powershell` fields instead of `command`.
27+
_COMMAND_FIELDS = ('command', 'bash', 'powershell')
28+
2529

2630
def _is_cycode_command(command: str) -> bool:
2731
return any(marker in command for marker in _CYCODE_COMMAND_MARKERS)
2832

2933

34+
def _has_cycode_command_field(entry: dict) -> bool:
35+
return any(_is_cycode_command(entry.get(field, '')) for field in _COMMAND_FIELDS)
36+
37+
3038
def is_cycode_hook_entry(entry: dict) -> bool:
3139
"""True if any hook inside ``entry`` is owned by Cycode."""
32-
command = entry.get('command', '')
33-
if _is_cycode_command(command):
40+
if _has_cycode_command_field(entry):
3441
return True
3542

3643
for hook in entry.get('hooks', []):
@@ -47,9 +54,9 @@ def _strip_cycode_from_entry(entry: dict) -> Optional[dict]:
4754
every nested hook was Cycode). Non-Cycode hooks co-located in the same
4855
entry are preserved.
4956
"""
50-
# Cursor format: the entry itself IS a single hook command.
51-
if 'command' in entry and 'hooks' not in entry:
52-
return None if _is_cycode_command(entry.get('command', '')) else entry
57+
# Cursor/Copilot format: the entry itself IS a single hook command.
58+
if 'hooks' not in entry and any(field in entry for field in _COMMAND_FIELDS):
59+
return None if _has_cycode_command_field(entry) else entry
5360

5461
# Claude Code / Codex format: nested `hooks` list inside the entry.
5562
nested = entry.get('hooks')

cycode/cli/apps/ai_guardrails/ides/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@
1010
from cycode.cli.apps.ai_guardrails.ides.base import IDE
1111
from cycode.cli.apps.ai_guardrails.ides.claude_code import ClaudeCode
1212
from cycode.cli.apps.ai_guardrails.ides.codex import Codex
13+
from cycode.cli.apps.ai_guardrails.ides.copilot import Copilot
1314
from cycode.cli.apps.ai_guardrails.ides.cursor import Cursor
1415

1516
# Single source of truth: name → singleton instance.
1617
# `--ide` choices and install/uninstall/status iteration both derive from this.
17-
IDES: dict[str, IDE] = {ide.name: ide for ide in (Cursor(), ClaudeCode(), Codex())}
18+
IDES: dict[str, IDE] = {ide.name: ide for ide in (Cursor(), ClaudeCode(), Codex(), Copilot())}
1819

1920
# Default IDE used when `--ide` is omitted. Kept here so the value is colocated
2021
# with the registry; no module outside `ides/` needs to know which IDE wins.

cycode/cli/apps/ai_guardrails/ides/base.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
JSON response shape that the IDE expects on stdout.
1515
"""
1616

17+
import platform
1718
from abc import ABC, abstractmethod
1819
from dataclasses import dataclass
1920
from enum import Enum
@@ -24,6 +25,24 @@
2425
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
2526

2627

28+
def shell_background_suffix(async_mode: bool) -> str:
29+
"""`' &'` when backgrounding is requested and the platform's shell supports it.
30+
31+
Only valid for hooks whose runner is stdin-safe under backgrounding (zsh keeps
32+
a backgrounded command's stdin; verified for Cursor/Codex). bash/sh reattach it
33+
to /dev/null, silently emptying the payload — hooks that run under bash (e.g.
34+
Copilot's `bash` field) must add an explicit `<&0` redirect instead.
35+
36+
Windows gets no suffix: depending on the IDE, hooks may run under cmd (where a
37+
trailing `&` is a no-op separator) or Windows PowerShell (where it's a parse
38+
error that would fail the hook). Until the CLI can self-detach in report mode,
39+
Windows hooks run synchronously.
40+
"""
41+
if not async_mode or platform.system() == 'Windows':
42+
return ''
43+
return ' &'
44+
45+
2746
class DecisionAction(str, Enum):
2847
"""Canonical decision action returned by event handlers."""
2948

cycode/cli/apps/ai_guardrails/ides/claude_code.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,11 @@ def render_hooks_config(self, async_mode: bool = False) -> dict:
278278
}
279279

280280
def matches_payload(self, raw_payload: dict) -> bool:
281-
return raw_payload.get('hook_event_name', '') in _CLAUDE_CODE_EVENT_NAMES
281+
# transcript_path is a documented Claude Code common field, present on every
282+
# hook event. VS Code Copilot emits near-identical payloads (same event names,
283+
# snake_case fields) without it — requiring it keeps those from being
284+
# processed as Claude Code events.
285+
return raw_payload.get('hook_event_name', '') in _CLAUDE_CODE_EVENT_NAMES and 'transcript_path' in raw_payload
282286

283287
def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload:
284288
hook_event_name = raw_payload.get('hook_event_name', '')

cycode/cli/apps/ai_guardrails/ides/codex.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
resolve_cached_plugin_dir,
2121
walk_enabled_plugins,
2222
)
23-
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
23+
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision, shell_background_suffix
2424
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
2525
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
2626
from cycode.cli.utils.jwt_utils import decode_jwt_unverified
@@ -191,10 +191,9 @@ def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path:
191191

192192
def render_hooks_config(self, async_mode: bool = False) -> dict:
193193
# Codex's TOML `async: true` flag is unimplemented; shell-background via
194-
# `&` is the working mechanism. SessionStart stays sync so the
195-
# conversation context is registered before any scan hook fires.
196-
bg = ' &' if async_mode else ''
197-
scan_cmd = f'{_SCAN_COMMAND}{bg}'
194+
# `&` is the working mechanism (unix only). SessionStart stays sync so
195+
# the conversation context is registered before any scan hook fires.
196+
scan_cmd = f'{_SCAN_COMMAND}{shell_background_suffix(async_mode)}'
198197
return {
199198
'hooks': {
200199
'SessionStart': [

0 commit comments

Comments
 (0)