Skip to content

Commit f9dbcac

Browse files
Ilanlidoclaude
andcommitted
CM-60929: Add --mode flag to ai-guardrails install with report mode default
- Add InstallMode enum (report/block) and --mode/-m flag defaulting to report - Report mode: async non-blocking hooks + warn policy file - Block mode: sync blocking hooks + block policy file - Add sessionStart/SessionStart auth check hook for both Cursor and Claude Code - Policy file merges with existing on re-install, only updating mode - Improve hook deduplication to recognize both scan and auth commands - Add 14 tests covering async/sync configs, sessionStart, and policy file creation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 867055d commit f9dbcac

File tree

5 files changed

+268
-27
lines changed

5 files changed

+268
-27
lines changed

cycode/cli/apps/ai_guardrails/consts.py

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ class PolicyMode(str, Enum):
2525
WARN = 'warn'
2626

2727

28+
class InstallMode(str, Enum):
29+
"""Installation mode for ai-guardrails install command."""
30+
31+
REPORT = 'report'
32+
BLOCK = 'block'
33+
34+
2835
class IDEConfig(NamedTuple):
2936
"""Configuration for an AI IDE."""
3037

@@ -76,20 +83,23 @@ def _get_claude_code_hooks_dir() -> Path:
7683

7784
# Command used in hooks
7885
CYCODE_SCAN_PROMPT_COMMAND = 'cycode ai-guardrails scan'
86+
CYCODE_AUTH_CHECK_COMMAND = "if cycode status 2>&1 | grep -q 'Is authenticated: False'; then cycode auth 2>&1; fi"
7987

8088

81-
def _get_cursor_hooks_config() -> dict:
89+
def _get_cursor_hooks_config(async_mode: bool = False) -> dict:
8290
"""Get Cursor-specific hooks configuration."""
8391
config = IDE_CONFIGS[AIIDEType.CURSOR]
84-
hooks = {event: [{'command': CYCODE_SCAN_PROMPT_COMMAND}] for event in config.hook_events}
92+
command = f'{CYCODE_SCAN_PROMPT_COMMAND} &' if async_mode else CYCODE_SCAN_PROMPT_COMMAND
93+
hooks = {event: [{'command': command}] for event in config.hook_events}
94+
hooks['sessionStart'] = [{'command': CYCODE_AUTH_CHECK_COMMAND}]
8595

8696
return {
8797
'version': 1,
8898
'hooks': hooks,
8999
}
90100

91101

92-
def _get_claude_code_hooks_config() -> dict:
102+
def _get_claude_code_hooks_config(async_mode: bool = False) -> dict:
93103
"""Get Claude Code-specific hooks configuration.
94104
95105
Claude Code uses a different hook format with nested structure:
@@ -98,36 +108,48 @@ def _get_claude_code_hooks_config() -> dict:
98108
"""
99109
command = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide claude-code'
100110

111+
hook_entry = {'type': 'command', 'command': command}
112+
if async_mode:
113+
hook_entry['async'] = True
114+
hook_entry['timeout'] = 20
115+
101116
return {
102117
'hooks': {
118+
'SessionStart': [
119+
{
120+
'matcher': '',
121+
'hooks': [{'type': 'command', 'command': CYCODE_AUTH_CHECK_COMMAND}],
122+
}
123+
],
103124
'UserPromptSubmit': [
104125
{
105-
'hooks': [{'type': 'command', 'command': command}],
126+
'hooks': [hook_entry.copy()],
106127
}
107128
],
108129
'PreToolUse': [
109130
{
110131
'matcher': 'Read',
111-
'hooks': [{'type': 'command', 'command': command}],
132+
'hooks': [hook_entry.copy()],
112133
},
113134
{
114135
'matcher': 'mcp__.*',
115-
'hooks': [{'type': 'command', 'command': command}],
136+
'hooks': [hook_entry.copy()],
116137
},
117138
],
118139
},
119140
}
120141

121142

122-
def get_hooks_config(ide: AIIDEType) -> dict:
143+
def get_hooks_config(ide: AIIDEType, async_mode: bool = False) -> dict:
123144
"""Get the hooks configuration for a specific IDE.
124145
125146
Args:
126147
ide: The AI IDE type
148+
async_mode: If True, hooks run asynchronously (non-blocking)
127149
128150
Returns:
129151
Dict with hooks configuration for the specified IDE
130152
"""
131153
if ide == AIIDEType.CLAUDE_CODE:
132-
return _get_claude_code_hooks_config()
133-
return _get_cursor_hooks_config()
154+
return _get_claude_code_hooks_config(async_mode=async_mode)
155+
return _get_cursor_hooks_config(async_mode=async_mode)

cycode/cli/apps/ai_guardrails/hooks_manager.py

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,21 @@
55
Supports multiple IDEs: Cursor, Claude Code (future).
66
"""
77

8+
import copy
89
import json
910
from pathlib import Path
1011
from typing import Optional
1112

13+
import yaml
14+
1215
from cycode.cli.apps.ai_guardrails.consts import (
13-
CYCODE_SCAN_PROMPT_COMMAND,
1416
DEFAULT_IDE,
1517
IDE_CONFIGS,
1618
AIIDEType,
19+
PolicyMode,
1720
get_hooks_config,
1821
)
22+
from cycode.cli.apps.ai_guardrails.scan.consts import DEFAULT_POLICY, POLICY_FILE_NAME
1923
from cycode.logger import get_logger
2024

2125
logger = get_logger('AI Guardrails Hooks')
@@ -58,6 +62,13 @@ def save_hooks_file(hooks_path: Path, hooks_config: dict) -> bool:
5862
return False
5963

6064

65+
_CYCODE_COMMAND_MARKERS = ('cycode ai-guardrails', 'cycode auth')
66+
67+
68+
def _is_cycode_command(command: str) -> bool:
69+
return any(marker in command for marker in _CYCODE_COMMAND_MARKERS)
70+
71+
6172
def is_cycode_hook_entry(entry: dict) -> bool:
6273
"""Check if a hook entry is from cycode-cli.
6374
@@ -68,22 +79,68 @@ def is_cycode_hook_entry(entry: dict) -> bool:
6879
"""
6980
# Check Cursor format (flat command)
7081
command = entry.get('command', '')
71-
if CYCODE_SCAN_PROMPT_COMMAND in command:
82+
if _is_cycode_command(command):
7283
return True
7384

7485
# Check Claude Code format (nested hooks array)
7586
hooks = entry.get('hooks', [])
7687
for hook in hooks:
7788
if isinstance(hook, dict):
7889
hook_command = hook.get('command', '')
79-
if CYCODE_SCAN_PROMPT_COMMAND in hook_command:
90+
if _is_cycode_command(hook_command):
8091
return True
8192

8293
return False
8394

8495

96+
def _load_policy(policy_path: Path) -> dict:
97+
"""Load existing policy file merged with defaults, or return defaults if not found."""
98+
if not policy_path.exists():
99+
return copy.deepcopy(DEFAULT_POLICY)
100+
try:
101+
existing = yaml.safe_load(policy_path.read_text(encoding='utf-8')) or {}
102+
except Exception:
103+
existing = {}
104+
return {**copy.deepcopy(DEFAULT_POLICY), **existing}
105+
106+
107+
def create_policy_file(
108+
scope: str, mode: PolicyMode, repo_path: Optional[Path] = None
109+
) -> tuple[bool, str]:
110+
"""Create or update the ai-guardrails.yaml policy file.
111+
112+
If the file already exists, only the mode field is updated.
113+
If it doesn't exist, a new file is created from the default policy.
114+
115+
Args:
116+
scope: 'user' for user-level, 'repo' for repository-level
117+
mode: The policy mode to set
118+
repo_path: Repository path (required if scope is 'repo')
119+
120+
Returns:
121+
Tuple of (success, message)
122+
"""
123+
config_dir = repo_path / '.cycode' if scope == 'repo' and repo_path else Path.home() / '.cycode'
124+
policy_path = config_dir / POLICY_FILE_NAME
125+
126+
policy = _load_policy(policy_path)
127+
128+
policy['mode'] = mode.value
129+
130+
try:
131+
config_dir.mkdir(parents=True, exist_ok=True)
132+
policy_path.write_text(yaml.dump(policy, default_flow_style=False, sort_keys=False), encoding='utf-8')
133+
return True, f'AI guardrails policy ({mode.value} mode) set: {policy_path}'
134+
except Exception as e:
135+
logger.error('Failed to create policy file', exc_info=e)
136+
return False, f'Failed to create policy file: {policy_path}'
137+
138+
85139
def install_hooks(
86-
scope: str = 'user', repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE
140+
scope: str = 'user',
141+
repo_path: Optional[Path] = None,
142+
ide: AIIDEType = DEFAULT_IDE,
143+
report_mode: bool = False,
87144
) -> tuple[bool, str]:
88145
"""
89146
Install Cycode AI guardrails hooks.
@@ -92,6 +149,7 @@ def install_hooks(
92149
scope: 'user' for user-level hooks, 'repo' for repository-level hooks
93150
repo_path: Repository path (required if scope is 'repo')
94151
ide: The AI IDE type (default: Cursor)
152+
report_mode: If True, install hooks in async mode (non-blocking)
95153
96154
Returns:
97155
Tuple of (success, message)
@@ -104,7 +162,7 @@ def install_hooks(
104162
existing.setdefault('hooks', {})
105163

106164
# Get IDE-specific hooks configuration
107-
hooks_config = get_hooks_config(ide)
165+
hooks_config = get_hooks_config(ide, async_mode=report_mode)
108166

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

cycode/cli/apps/ai_guardrails/install_command.py

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
validate_and_parse_ide,
1212
validate_scope,
1313
)
14-
from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS, AIIDEType
15-
from cycode.cli.apps.ai_guardrails.hooks_manager import install_hooks
14+
from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS, AIIDEType, InstallMode, PolicyMode
15+
from cycode.cli.apps.ai_guardrails.hooks_manager import create_policy_file, install_hooks
1616

1717

1818
def install_command(
@@ -43,14 +43,24 @@ def install_command(
4343
resolve_path=True,
4444
),
4545
] = None,
46+
mode: Annotated[
47+
InstallMode,
48+
typer.Option(
49+
'--mode',
50+
'-m',
51+
help='Installation mode: "report" for async non-blocking hooks with warn policy, '
52+
'"block" for sync blocking hooks.',
53+
),
54+
] = InstallMode.REPORT,
4655
) -> None:
4756
"""Install AI guardrails hooks for supported IDEs.
4857
4958
This command configures the specified IDE to use Cycode for scanning prompts, file reads,
5059
and MCP tool calls for secrets before they are sent to AI models.
5160
5261
Examples:
53-
cycode ai-guardrails install # Install for all projects (user scope)
62+
cycode ai-guardrails install # Install in report mode (default)
63+
cycode ai-guardrails install --mode block # Install in block mode
5464
cycode ai-guardrails install --scope repo # Install for current repo only
5565
cycode ai-guardrails install --ide cursor # Install for Cursor IDE
5666
cycode ai-guardrails install --ide all # Install for all supported IDEs
@@ -66,7 +76,8 @@ def install_command(
6676
results: list[tuple[str, bool, str]] = []
6777
for current_ide in ides_to_install:
6878
ide_name = IDE_CONFIGS[current_ide].name
69-
success, message = install_hooks(scope, repo_path, ide=current_ide)
79+
report_mode = mode == InstallMode.REPORT
80+
success, message = install_hooks(scope, repo_path, ide=current_ide, report_mode=report_mode)
7081
results.append((ide_name, success, message))
7182

7283
# Report results for each IDE
@@ -81,14 +92,31 @@ def install_command(
8192
all_success = False
8293

8394
if any_success:
84-
console.print()
85-
console.print('[bold]Next steps:[/]')
86-
successful_ides = [name for name, success, _ in results if success]
87-
ide_list = ', '.join(successful_ides)
88-
console.print(f'1. Restart {ide_list} to activate the hooks')
89-
console.print('2. (Optional) Customize policy in ~/.cycode/ai-guardrails.yaml')
90-
console.print()
91-
console.print('[dim]The hooks will scan prompts, file reads, and MCP tool calls for secrets.[/]')
95+
policy_mode = PolicyMode.WARN if mode == InstallMode.REPORT else PolicyMode.BLOCK
96+
_install_policy(scope, repo_path, policy_mode)
97+
_print_next_steps(results, mode)
9298

9399
if not all_success:
94100
raise typer.Exit(1)
101+
102+
103+
def _install_policy(scope: str, repo_path: Optional[Path], policy_mode: PolicyMode) -> None:
104+
policy_success, policy_message = create_policy_file(scope, policy_mode, repo_path)
105+
if policy_success:
106+
console.print(f'[green]✓[/] {policy_message}')
107+
else:
108+
console.print(f'[red]✗[/] {policy_message}', style='bold red')
109+
110+
111+
def _print_next_steps(results: list[tuple[str, bool, str]], mode: InstallMode) -> None:
112+
console.print()
113+
console.print('[bold]Next steps:[/]')
114+
successful_ides = [name for name, success, _ in results if success]
115+
ide_list = ', '.join(successful_ides)
116+
console.print(f'1. Restart {ide_list} to activate the hooks')
117+
console.print('2. (Optional) Customize policy in ~/.cycode/ai-guardrails.yaml')
118+
console.print()
119+
if mode == InstallMode.REPORT:
120+
console.print('[dim]Report mode: hooks run async (non-blocking) and policy is set to warn.[/]')
121+
else:
122+
console.print('[dim]The hooks will scan prompts, file reads, and MCP tool calls for secrets.[/]')

tests/cli/commands/ai_guardrails/scan/test_payload.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ def test_from_cursor_payload_prompt_event() -> None:
2929
assert unified.ide_provider == 'cursor'
3030
assert unified.ide_version == '0.42.0'
3131
assert unified.prompt == 'Test prompt'
32+
assert type(unified.ide_provider) is str
3233

3334

3435
def test_from_cursor_payload_file_read_event() -> None:
@@ -153,6 +154,7 @@ def test_from_claude_code_payload_prompt_event() -> None:
153154
assert unified.conversation_id == 'session-123'
154155
assert unified.ide_provider == 'claude-code'
155156
assert unified.prompt == 'Test prompt for Claude Code'
157+
assert type(unified.ide_provider) is str
156158

157159

158160
def test_from_claude_code_payload_file_read_event() -> None:

0 commit comments

Comments
 (0)