Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions cycode/cli/apps/ai_guardrails/hooks_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,22 @@

_CYCODE_COMMAND_MARKERS = ('cycode ai-guardrails',)

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


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


def _has_cycode_command_field(entry: dict) -> bool:
return any(_is_cycode_command(entry.get(field, '')) for field in _COMMAND_FIELDS)


def is_cycode_hook_entry(entry: dict) -> bool:
"""True if any hook inside ``entry`` is owned by Cycode."""
command = entry.get('command', '')
if _is_cycode_command(command):
if _has_cycode_command_field(entry):
return True

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

# Claude Code / Codex format: nested `hooks` list inside the entry.
nested = entry.get('hooks')
Expand Down
3 changes: 2 additions & 1 deletion cycode/cli/apps/ai_guardrails/ides/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@
from cycode.cli.apps.ai_guardrails.ides.base import IDE
from cycode.cli.apps.ai_guardrails.ides.claude_code import ClaudeCode
from cycode.cli.apps.ai_guardrails.ides.codex import Codex
from cycode.cli.apps.ai_guardrails.ides.copilot import Copilot
from cycode.cli.apps.ai_guardrails.ides.cursor import Cursor

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

# Default IDE used when `--ide` is omitted. Kept here so the value is colocated
# with the registry; no module outside `ides/` needs to know which IDE wins.
Expand Down
19 changes: 19 additions & 0 deletions cycode/cli/apps/ai_guardrails/ides/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
JSON response shape that the IDE expects on stdout.
"""

import platform
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
Expand All @@ -24,6 +25,24 @@
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType


def shell_background_suffix(async_mode: bool) -> str:
"""`' &'` when backgrounding is requested and the platform's shell supports it.

Only valid for hooks whose runner is stdin-safe under backgrounding (zsh keeps
a backgrounded command's stdin; verified for Cursor/Codex). bash/sh reattach it
to /dev/null, silently emptying the payload — hooks that run under bash (e.g.
Copilot's `bash` field) must add an explicit `<&0` redirect instead.

Windows gets no suffix: depending on the IDE, hooks may run under cmd (where a
trailing `&` is a no-op separator) or Windows PowerShell (where it's a parse
error that would fail the hook). Until the CLI can self-detach in report mode,
Windows hooks run synchronously.
"""
if not async_mode or platform.system() == 'Windows':
return ''
return ' &'


class DecisionAction(str, Enum):
"""Canonical decision action returned by event handlers."""

Expand Down
6 changes: 5 additions & 1 deletion cycode/cli/apps/ai_guardrails/ides/claude_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,11 @@ def render_hooks_config(self, async_mode: bool = False) -> dict:
}

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

def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload:
hook_event_name = raw_payload.get('hook_event_name', '')
Expand Down
9 changes: 4 additions & 5 deletions cycode/cli/apps/ai_guardrails/ides/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
resolve_cached_plugin_dir,
walk_enabled_plugins,
)
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision, shell_background_suffix
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
from cycode.cli.utils.jwt_utils import decode_jwt_unverified
Expand Down Expand Up @@ -191,10 +191,9 @@ def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path:

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