Skip to content

Commit 279cc84

Browse files
Ilanlidoclaude
andauthored
CM-60929: Add report mode to ai-guardrails install (#410)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 364da74 commit 279cc84

File tree

8 files changed

+295
-32
lines changed

8 files changed

+295
-32
lines changed

cycode/cli/apps/ai_guardrails/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import typer
22

3+
from cycode.cli.apps.ai_guardrails.ensure_auth_command import ensure_auth_command
34
from cycode.cli.apps.ai_guardrails.install_command import install_command
45
from cycode.cli.apps.ai_guardrails.scan.scan_command import scan_command
56
from cycode.cli.apps.ai_guardrails.status_command import status_command
@@ -17,3 +18,6 @@
1718
name='scan',
1819
short_help='Scan content from AI IDE hooks for secrets (reads JSON from stdin).',
1920
)(scan_command)
21+
app.command(hidden=True, name='ensure-auth', short_help='Ensure authentication, triggering auth if needed.')(
22+
ensure_auth_command
23+
)

cycode/cli/apps/ai_guardrails/consts.py

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"""
77

88
import platform
9+
from copy import deepcopy
910
from enum import Enum
1011
from pathlib import Path
1112
from typing import NamedTuple
@@ -25,6 +26,13 @@ class PolicyMode(str, Enum):
2526
WARN = 'warn'
2627

2728

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

@@ -76,20 +84,23 @@ def _get_claude_code_hooks_dir() -> Path:
7684

7785
# Command used in hooks
7886
CYCODE_SCAN_PROMPT_COMMAND = 'cycode ai-guardrails scan'
87+
CYCODE_ENSURE_AUTH_COMMAND = 'cycode ai-guardrails ensure-auth'
7988

8089

81-
def _get_cursor_hooks_config() -> dict:
90+
def _get_cursor_hooks_config(async_mode: bool = False) -> dict:
8291
"""Get Cursor-specific hooks configuration."""
8392
config = IDE_CONFIGS[AIIDEType.CURSOR]
84-
hooks = {event: [{'command': CYCODE_SCAN_PROMPT_COMMAND}] for event in config.hook_events}
93+
command = f'{CYCODE_SCAN_PROMPT_COMMAND} &' if async_mode else CYCODE_SCAN_PROMPT_COMMAND
94+
hooks = {event: [{'command': command}] for event in config.hook_events}
95+
hooks['sessionStart'] = [{'command': CYCODE_ENSURE_AUTH_COMMAND}]
8596

8697
return {
8798
'version': 1,
8899
'hooks': hooks,
89100
}
90101

91102

92-
def _get_claude_code_hooks_config() -> dict:
103+
def _get_claude_code_hooks_config(async_mode: bool = False) -> dict:
93104
"""Get Claude Code-specific hooks configuration.
94105
95106
Claude Code uses a different hook format with nested structure:
@@ -98,36 +109,48 @@ def _get_claude_code_hooks_config() -> dict:
98109
"""
99110
command = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide claude-code'
100111

112+
hook_entry = {'type': 'command', 'command': command}
113+
if async_mode:
114+
hook_entry['async'] = True
115+
hook_entry['timeout'] = 20
116+
101117
return {
102118
'hooks': {
119+
'SessionStart': [
120+
{
121+
'matcher': 'startup',
122+
'hooks': [{'type': 'command', 'command': CYCODE_ENSURE_AUTH_COMMAND}],
123+
}
124+
],
103125
'UserPromptSubmit': [
104126
{
105-
'hooks': [{'type': 'command', 'command': command}],
127+
'hooks': [deepcopy(hook_entry)],
106128
}
107129
],
108130
'PreToolUse': [
109131
{
110132
'matcher': 'Read',
111-
'hooks': [{'type': 'command', 'command': command}],
133+
'hooks': [deepcopy(hook_entry)],
112134
},
113135
{
114136
'matcher': 'mcp__.*',
115-
'hooks': [{'type': 'command', 'command': command}],
137+
'hooks': [deepcopy(hook_entry)],
116138
},
117139
],
118140
},
119141
}
120142

121143

122-
def get_hooks_config(ide: AIIDEType) -> dict:
144+
def get_hooks_config(ide: AIIDEType, async_mode: bool = False) -> dict:
123145
"""Get the hooks configuration for a specific IDE.
124146
125147
Args:
126148
ide: The AI IDE type
149+
async_mode: If True, hooks run asynchronously (non-blocking)
127150
128151
Returns:
129152
Dict with hooks configuration for the specified IDE
130153
"""
131154
if ide == AIIDEType.CLAUDE_CODE:
132-
return _get_claude_code_hooks_config()
133-
return _get_cursor_hooks_config()
155+
return _get_claude_code_hooks_config(async_mode=async_mode)
156+
return _get_cursor_hooks_config(async_mode=async_mode)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import typer
2+
3+
from cycode.cli.apps.auth.auth_common import get_authorization_info
4+
from cycode.cli.apps.auth.auth_manager import AuthManager
5+
from cycode.cli.exceptions.handle_auth_errors import handle_auth_exception
6+
from cycode.cli.logger import logger
7+
8+
9+
def ensure_auth_command(ctx: typer.Context) -> None:
10+
"""Ensure the user is authenticated, triggering authentication if needed."""
11+
auth_info = get_authorization_info(ctx)
12+
if auth_info is not None:
13+
logger.debug('Already authenticated')
14+
return
15+
16+
logger.debug('Not authenticated, starting authentication')
17+
try:
18+
auth_manager = AuthManager()
19+
auth_manager.authenticate()
20+
except Exception as err:
21+
handle_auth_exception(ctx, err)

cycode/cli/apps/ai_guardrails/hooks_manager.py

Lines changed: 61 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',)
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,66 @@ 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(scope: str, mode: PolicyMode, repo_path: Optional[Path] = None) -> tuple[bool, str]:
108+
"""Create or update the ai-guardrails.yaml policy file.
109+
110+
If the file already exists, only the mode field is updated.
111+
If it doesn't exist, a new file is created from the default policy.
112+
113+
Args:
114+
scope: 'user' for user-level, 'repo' for repository-level
115+
mode: The policy mode to set
116+
repo_path: Repository path (required if scope is 'repo')
117+
118+
Returns:
119+
Tuple of (success, message)
120+
"""
121+
config_dir = repo_path / '.cycode' if scope == 'repo' and repo_path else Path.home() / '.cycode'
122+
policy_path = config_dir / POLICY_FILE_NAME
123+
124+
policy = _load_policy(policy_path)
125+
126+
policy['mode'] = mode.value
127+
128+
try:
129+
config_dir.mkdir(parents=True, exist_ok=True)
130+
policy_path.write_text(yaml.dump(policy, default_flow_style=False, sort_keys=False), encoding='utf-8')
131+
return True, f'AI guardrails policy ({mode.value} mode) set: {policy_path}'
132+
except Exception as e:
133+
logger.error('Failed to create policy file', exc_info=e)
134+
return False, f'Failed to create policy file: {policy_path}'
135+
136+
85137
def install_hooks(
86-
scope: str = 'user', repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE
138+
scope: str = 'user',
139+
repo_path: Optional[Path] = None,
140+
ide: AIIDEType = DEFAULT_IDE,
141+
report_mode: bool = False,
87142
) -> tuple[bool, str]:
88143
"""
89144
Install Cycode AI guardrails hooks.
@@ -92,6 +147,7 @@ def install_hooks(
92147
scope: 'user' for user-level hooks, 'repo' for repository-level hooks
93148
repo_path: Repository path (required if scope is 'repo')
94149
ide: The AI IDE type (default: Cursor)
150+
report_mode: If True, install hooks in async mode (non-blocking)
95151
96152
Returns:
97153
Tuple of (success, message)
@@ -104,7 +160,7 @@ def install_hooks(
104160
existing.setdefault('hooks', {})
105161

106162
# Get IDE-specific hooks configuration
107-
hooks_config = get_hooks_config(ide)
163+
hooks_config = get_hooks_config(ide, async_mode=report_mode)
108164

109165
# Add/update Cycode hooks
110166
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.[/]')

cycode/cli/apps/ai_guardrails/scan/handlers.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -345,12 +345,10 @@ def _scan_path_for_secrets(ctx: typer.Context, file_path: str, policy: dict) ->
345345
if not file_path or not os.path.exists(file_path):
346346
return None, None
347347

348-
with open(file_path, encoding='utf-8', errors='replace') as f:
349-
content = f.read()
350-
351-
# Truncate content based on policy max_bytes
352348
max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000)
353-
content = truncate_utf8(content, max_bytes)
349+
350+
with open(file_path, encoding='utf-8', errors='replace') as f:
351+
content = f.read(max_bytes)
354352

355353
# Get timeout from policy
356354
timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000)

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)