Skip to content
Open
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
3 changes: 2 additions & 1 deletion cycode/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from typer.completion import install_callback, show_callback

from cycode import __version__
from cycode.cli.apps import ai_remediation, auth, configure, ignore, report, report_import, scan, status
from cycode.cli.apps import ai_guardrails, ai_remediation, auth, configure, ignore, report, report_import, scan, status

if sys.version_info >= (3, 10):
from cycode.cli.apps import mcp
Expand Down Expand Up @@ -45,6 +45,7 @@
add_completion=False, # we add it manually to control the rich help panel
)

app.add_typer(ai_guardrails.app)
app.add_typer(ai_remediation.app)
app.add_typer(auth.app)
app.add_typer(configure.app)
Expand Down
17 changes: 17 additions & 0 deletions cycode/cli/apps/ai_guardrails/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import typer

from cycode.cli.apps.ai_guardrails.install_command import install_command
from cycode.cli.apps.ai_guardrails.scan.scan_command import scan_command
from cycode.cli.apps.ai_guardrails.status_command import status_command
from cycode.cli.apps.ai_guardrails.uninstall_command import uninstall_command

app = typer.Typer(name='ai-guardrails', no_args_is_help=True)

app.command(name='install', short_help='Install AI guardrails hooks for supported IDEs.')(install_command)
app.command(name='uninstall', short_help='Remove AI guardrails hooks from supported IDEs.')(uninstall_command)
app.command(name='status', short_help='Show AI guardrails hook installation status.')(status_command)
app.command(
hidden=True,
name='scan',
short_help='Scan content from AI IDE hooks for secrets (reads JSON from stdin).',
)(scan_command)
66 changes: 66 additions & 0 deletions cycode/cli/apps/ai_guardrails/command_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Common utilities for AI guardrails commands."""

import os
from pathlib import Path
from typing import Optional

import typer
from rich.console import Console

from cycode.cli.apps.ai_guardrails.consts import AIIDEType

console = Console()


def validate_and_parse_ide(ide: str) -> AIIDEType:
"""Validate IDE parameter and convert to AIIDEType enum.

Args:
ide: IDE name string (e.g., 'cursor')

Returns:
AIIDEType enum value

Raises:
typer.Exit: If IDE is invalid
"""
try:
return AIIDEType(ide.lower())
except ValueError:
valid_ides = ', '.join([ide_type.value for ide_type in AIIDEType])
console.print(
f'[red]Error:[/] Invalid IDE "{ide}". Supported IDEs: {valid_ides}',
style='bold red',
)
raise typer.Exit(1) from None


def validate_scope(scope: str, allowed_scopes: tuple[str, ...] = ('user', 'repo')) -> None:
"""Validate scope parameter.

Args:
scope: Scope string to validate
allowed_scopes: Tuple of allowed scope values

Raises:
typer.Exit: If scope is invalid
"""
if scope not in allowed_scopes:
scopes_list = ', '.join(f'"{s}"' for s in allowed_scopes)
console.print(f'[red]Error:[/] Invalid scope. Use {scopes_list}.', style='bold red')
raise typer.Exit(1)


def resolve_repo_path(scope: str, repo_path: Optional[Path]) -> Optional[Path]:
"""Resolve repository path, defaulting to current directory for repo scope.

Args:
scope: The command scope ('user' or 'repo')
repo_path: Provided repo path or None

Returns:
Resolved Path for repo scope, None for user scope
"""
if scope == 'repo' and repo_path is None:
return Path(os.getcwd())
return repo_path
82 changes: 82 additions & 0 deletions cycode/cli/apps/ai_guardrails/consts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Constants for AI guardrails hooks management.

Currently supports:
- Cursor

To add a new IDE (e.g., Claude Code):
1. Add new value to AIIDEType enum
2. Create _get_<ide>_hooks_dir() function with platform-specific paths
3. Add entry to IDE_CONFIGS dict with IDE-specific hook event names
4. Unhide --ide option in commands (install, uninstall, status)
"""

import platform
from enum import Enum
from pathlib import Path
from typing import NamedTuple


class AIIDEType(str, Enum):
"""Supported AI IDE types."""

CURSOR = 'cursor'


class IDEConfig(NamedTuple):
"""Configuration for an AI IDE."""

name: str
hooks_dir: Path
repo_hooks_subdir: str # Subdirectory in repo for hooks (e.g., '.cursor')
hooks_file_name: str
hook_events: list[str] # List of supported hook event names for this IDE


def _get_cursor_hooks_dir() -> Path:
"""Get Cursor hooks directory based on platform."""
if platform.system() == 'Darwin':
return Path.home() / '.cursor'
if platform.system() == 'Windows':
return Path.home() / 'AppData' / 'Roaming' / 'Cursor'
# Linux
return Path.home() / '.config' / 'Cursor'


# IDE-specific configurations
IDE_CONFIGS: dict[AIIDEType, IDEConfig] = {
AIIDEType.CURSOR: IDEConfig(
name='Cursor',
hooks_dir=_get_cursor_hooks_dir(),
repo_hooks_subdir='.cursor',
hooks_file_name='hooks.json',
hook_events=['beforeSubmitPrompt', 'beforeReadFile', 'beforeMCPExecution'],
),
}

# Default IDE
DEFAULT_IDE = AIIDEType.CURSOR

# Marker to identify Cycode hooks
CYCODE_MARKER = 'cycode_guardrails'

# Command used in hooks
CYCODE_SCAN_PROMPT_COMMAND = 'cycode scan prompt'


def get_hooks_config(ide: AIIDEType) -> dict:
"""Get the hooks configuration for a specific IDE.

Args:
ide: The AI IDE type

Returns:
Dict with hooks configuration for the specified IDE
"""
config = IDE_CONFIGS[ide]
hooks = {event: [{'command': CYCODE_SCAN_PROMPT_COMMAND}] for event in config.hook_events}

return {
'version': 1,
'hooks': hooks,
CYCODE_MARKER: True,
}
206 changes: 206 additions & 0 deletions cycode/cli/apps/ai_guardrails/hooks_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
"""
Hooks manager for AI guardrails.

Handles installation, removal, and status checking of AI IDE hooks.
Supports multiple IDEs: Cursor, Claude Code (future).
"""

import json
from pathlib import Path
from typing import Optional

from cycode.cli.apps.ai_guardrails.consts import (
CYCODE_MARKER,
CYCODE_SCAN_PROMPT_COMMAND,
DEFAULT_IDE,
IDE_CONFIGS,
AIIDEType,
get_hooks_config,
)
from cycode.logger import get_logger

logger = get_logger('AI Guardrails Hooks')


def get_hooks_path(scope: str, repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE) -> Path:
"""Get the hooks.json path for the given scope and IDE.

Args:
scope: 'user' for user-level hooks, 'repo' for repository-level hooks
repo_path: Repository path (required if scope is 'repo')
ide: The AI IDE type (default: Cursor)
"""
config = IDE_CONFIGS[ide]
if scope == 'repo' and repo_path:
return repo_path / config.repo_hooks_subdir / config.hooks_file_name
return config.hooks_dir / config.hooks_file_name


def load_hooks_file(hooks_path: Path) -> Optional[dict]:
"""Load existing hooks.json file."""
if not hooks_path.exists():
return None
try:
content = hooks_path.read_text(encoding='utf-8')
return json.loads(content)
except Exception as e:
logger.debug('Failed to load hooks file', exc_info=e)
return None


def save_hooks_file(hooks_path: Path, hooks_config: dict) -> bool:
"""Save hooks.json file."""
try:
hooks_path.parent.mkdir(parents=True, exist_ok=True)
hooks_path.write_text(json.dumps(hooks_config, indent=2), encoding='utf-8')
return True
except Exception as e:
logger.error('Failed to save hooks file', exc_info=e)
return False


def is_cycode_hook_entry(entry: dict) -> bool:
"""Check if a hook entry is from cycode-cli."""
command = entry.get('command', '')
return CYCODE_SCAN_PROMPT_COMMAND in command


def install_hooks(
scope: str = 'user', repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE
) -> tuple[bool, str]:
"""
Install Cycode AI guardrails hooks.

Args:
scope: 'user' for user-level hooks, 'repo' for repository-level hooks
repo_path: Repository path (required if scope is 'repo')
ide: The AI IDE type (default: Cursor)

Returns:
Tuple of (success, message)
"""
hooks_path = get_hooks_path(scope, repo_path, ide)

# Load existing hooks or create new
existing = load_hooks_file(hooks_path) or {'version': 1, 'hooks': {}}
existing.setdefault('version', 1)
existing.setdefault('hooks', {})

# Get IDE-specific hooks configuration
hooks_config = get_hooks_config(ide)

# Add/update Cycode hooks
for event, entries in hooks_config['hooks'].items():
existing['hooks'].setdefault(event, [])

# Remove any existing Cycode entries for this event
existing['hooks'][event] = [e for e in existing['hooks'][event] if not is_cycode_hook_entry(e)]

# Add new Cycode entries
for entry in entries:
existing['hooks'][event].append(entry)

# Add marker
existing[CYCODE_MARKER] = True

# Save
if save_hooks_file(hooks_path, existing):
return True, f'AI guardrails hooks installed: {hooks_path}'
return False, f'Failed to install hooks to {hooks_path}'


def uninstall_hooks(
scope: str = 'user', repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE
) -> tuple[bool, str]:
"""
Remove Cycode AI guardrails hooks.

Args:
scope: 'user' for user-level hooks, 'repo' for repository-level hooks
repo_path: Repository path (required if scope is 'repo')
ide: The AI IDE type (default: Cursor)

Returns:
Tuple of (success, message)
"""
hooks_path = get_hooks_path(scope, repo_path, ide)

existing = load_hooks_file(hooks_path)
if existing is None:
return True, f'No hooks file found at {hooks_path}'

# Remove Cycode entries from all events
modified = False
for event in list(existing.get('hooks', {}).keys()):
original_count = len(existing['hooks'][event])
existing['hooks'][event] = [e for e in existing['hooks'][event] if not is_cycode_hook_entry(e)]
if len(existing['hooks'][event]) != original_count:
modified = True
# Remove empty event lists
if not existing['hooks'][event]:
del existing['hooks'][event]

# Remove marker
if CYCODE_MARKER in existing:
del existing[CYCODE_MARKER]
modified = True

if not modified:
return True, 'No Cycode hooks found to remove'

# Save or delete if empty
if not existing.get('hooks'):
try:
hooks_path.unlink()
return True, f'Removed hooks file: {hooks_path}'
except Exception as e:
logger.debug('Failed to delete hooks file', exc_info=e)
return False, f'Failed to remove hooks file: {hooks_path}'

if save_hooks_file(hooks_path, existing):
return True, f'Cycode hooks removed from: {hooks_path}'
return False, f'Failed to update hooks file: {hooks_path}'


def get_hooks_status(scope: str = 'user', repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE) -> dict:
"""
Get the status of AI guardrails hooks.

Args:
scope: 'user' for user-level hooks, 'repo' for repository-level hooks
repo_path: Repository path (required if scope is 'repo')
ide: The AI IDE type (default: Cursor)

Returns:
Dict with status information
"""
hooks_path = get_hooks_path(scope, repo_path, ide)

status = {
'scope': scope,
'ide': ide.value,
'ide_name': IDE_CONFIGS[ide].name,
'hooks_path': str(hooks_path),
'file_exists': hooks_path.exists(),
'cycode_installed': False,
'hooks': {},
}

existing = load_hooks_file(hooks_path)
if existing is None:
return status

status['cycode_installed'] = existing.get(CYCODE_MARKER, False)

# Check each hook event for this IDE
ide_config = IDE_CONFIGS[ide]
for event in ide_config.hook_events:
entries = existing.get('hooks', {}).get(event, [])
cycode_entries = [e for e in entries if is_cycode_hook_entry(e)]
status['hooks'][event] = {
'total_entries': len(entries),
'cycode_entries': len(cycode_entries),
'enabled': len(cycode_entries) > 0,
}

return status
Loading