cycode 3.8.10.dev1__py3-none-any.whl → 3.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cycode/__init__.py +1 -1
- cycode/cli/app.py +2 -1
- cycode/cli/apps/ai_guardrails/__init__.py +19 -0
- cycode/cli/apps/ai_guardrails/command_utils.py +66 -0
- cycode/cli/apps/ai_guardrails/consts.py +78 -0
- cycode/cli/apps/ai_guardrails/hooks_manager.py +200 -0
- cycode/cli/apps/ai_guardrails/install_command.py +78 -0
- cycode/cli/apps/ai_guardrails/scan/__init__.py +1 -0
- cycode/cli/apps/ai_guardrails/scan/consts.py +48 -0
- cycode/cli/apps/ai_guardrails/scan/handlers.py +341 -0
- cycode/cli/apps/ai_guardrails/scan/payload.py +72 -0
- cycode/cli/apps/ai_guardrails/scan/policy.py +85 -0
- cycode/cli/apps/ai_guardrails/scan/response_builders.py +86 -0
- cycode/cli/apps/ai_guardrails/scan/scan_command.py +134 -0
- cycode/cli/apps/ai_guardrails/scan/types.py +54 -0
- cycode/cli/apps/ai_guardrails/scan/utils.py +72 -0
- cycode/cli/apps/ai_guardrails/status_command.py +92 -0
- cycode/cli/apps/ai_guardrails/uninstall_command.py +73 -0
- cycode/cli/apps/scan/code_scanner.py +1 -1
- cycode/cli/cli_types.py +13 -0
- cycode/cli/utils/get_api_client.py +15 -2
- cycode/cli/utils/scan_utils.py +24 -0
- cycode/cyclient/ai_security_manager_client.py +86 -0
- cycode/cyclient/ai_security_manager_service_config.py +27 -0
- cycode/cyclient/client_creator.py +20 -0
- {cycode-3.8.10.dev1.dist-info → cycode-3.9.0.dist-info}/METADATA +1 -1
- {cycode-3.8.10.dev1.dist-info → cycode-3.9.0.dist-info}/RECORD +30 -12
- {cycode-3.8.10.dev1.dist-info → cycode-3.9.0.dist-info}/WHEEL +0 -0
- {cycode-3.8.10.dev1.dist-info → cycode-3.9.0.dist-info}/entry_points.txt +0 -0
- {cycode-3.8.10.dev1.dist-info → cycode-3.9.0.dist-info}/licenses/LICENCE +0 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Scan command for AI guardrails.
|
|
3
|
+
|
|
4
|
+
This command handles AI IDE hooks by reading JSON from stdin and outputting
|
|
5
|
+
a JSON response to stdout. It scans prompts, file reads, and MCP tool calls
|
|
6
|
+
for secrets before they are sent to AI models.
|
|
7
|
+
|
|
8
|
+
Supports multiple IDEs with different hook event types. The specific hook events
|
|
9
|
+
supported depend on the IDE being used (e.g., Cursor supports beforeSubmitPrompt,
|
|
10
|
+
beforeReadFile, beforeMCPExecution).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Annotated
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
import typer
|
|
18
|
+
|
|
19
|
+
from cycode.cli.apps.ai_guardrails.scan.handlers import get_handler_for_event
|
|
20
|
+
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
|
|
21
|
+
from cycode.cli.apps.ai_guardrails.scan.policy import load_policy
|
|
22
|
+
from cycode.cli.apps.ai_guardrails.scan.response_builders import get_response_builder
|
|
23
|
+
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
|
|
24
|
+
from cycode.cli.apps.ai_guardrails.scan.utils import output_json, safe_json_parse
|
|
25
|
+
from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError
|
|
26
|
+
from cycode.cli.utils.get_api_client import get_ai_security_manager_client, get_scan_cycode_client
|
|
27
|
+
from cycode.cli.utils.sentry import add_breadcrumb
|
|
28
|
+
from cycode.logger import get_logger
|
|
29
|
+
|
|
30
|
+
logger = get_logger('AI Guardrails')
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _get_auth_error_message(error: Exception) -> str:
|
|
34
|
+
"""Get user-friendly message for authentication errors."""
|
|
35
|
+
if isinstance(error, click.ClickException):
|
|
36
|
+
# Missing credentials
|
|
37
|
+
return f'{error.message} Please run `cycode configure` to set up your credentials.'
|
|
38
|
+
|
|
39
|
+
if isinstance(error, HttpUnauthorizedError):
|
|
40
|
+
# Invalid/expired credentials
|
|
41
|
+
return (
|
|
42
|
+
'Unable to authenticate to Cycode. Your credentials are invalid or have expired. '
|
|
43
|
+
'Please run `cycode configure` to update your credentials.'
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Fallback
|
|
47
|
+
return 'Authentication failed. Please run `cycode configure` to set up your credentials.'
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _initialize_clients(ctx: typer.Context) -> None:
|
|
51
|
+
"""Initialize API clients.
|
|
52
|
+
|
|
53
|
+
May raise click.ClickException if credentials are missing,
|
|
54
|
+
or HttpUnauthorizedError if credentials are invalid.
|
|
55
|
+
"""
|
|
56
|
+
scan_client = get_scan_cycode_client(ctx)
|
|
57
|
+
ctx.obj['client'] = scan_client
|
|
58
|
+
|
|
59
|
+
ai_security_client = get_ai_security_manager_client(ctx)
|
|
60
|
+
ctx.obj['ai_security_client'] = ai_security_client
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def scan_command(
|
|
64
|
+
ctx: typer.Context,
|
|
65
|
+
ide: Annotated[
|
|
66
|
+
str,
|
|
67
|
+
typer.Option(
|
|
68
|
+
'--ide',
|
|
69
|
+
help='IDE that sent the payload (e.g., "cursor"). Defaults to cursor.',
|
|
70
|
+
hidden=True,
|
|
71
|
+
),
|
|
72
|
+
] = 'cursor',
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Scan content from AI IDE hooks for secrets.
|
|
75
|
+
|
|
76
|
+
This command reads a JSON payload from stdin containing hook event data
|
|
77
|
+
and outputs a JSON response to stdout indicating whether to allow or block the action.
|
|
78
|
+
|
|
79
|
+
The hook event type is determined from the event field in the payload (field name
|
|
80
|
+
varies by IDE). Each IDE may support different hook events for scanning prompts,
|
|
81
|
+
file access, and tool executions.
|
|
82
|
+
|
|
83
|
+
Example usage (from IDE hooks configuration):
|
|
84
|
+
{ "command": "cycode ai-guardrails scan" }
|
|
85
|
+
"""
|
|
86
|
+
add_breadcrumb('ai-guardrails-scan')
|
|
87
|
+
|
|
88
|
+
stdin_data = sys.stdin.read().strip()
|
|
89
|
+
payload = safe_json_parse(stdin_data)
|
|
90
|
+
|
|
91
|
+
tool = ide.lower()
|
|
92
|
+
response_builder = get_response_builder(tool)
|
|
93
|
+
|
|
94
|
+
if not payload:
|
|
95
|
+
logger.debug('Empty or invalid JSON payload received')
|
|
96
|
+
output_json(response_builder.allow_prompt())
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
unified_payload = AIHookPayload.from_payload(payload, tool=tool)
|
|
100
|
+
event_name = unified_payload.event_name
|
|
101
|
+
logger.debug('Processing AI guardrails hook', extra={'event_name': event_name, 'tool': tool})
|
|
102
|
+
|
|
103
|
+
workspace_roots = payload.get('workspace_roots', ['.'])
|
|
104
|
+
policy = load_policy(workspace_roots[0])
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
_initialize_clients(ctx)
|
|
108
|
+
|
|
109
|
+
handler = get_handler_for_event(event_name)
|
|
110
|
+
if handler is None:
|
|
111
|
+
logger.debug('Unknown hook event, allowing by default', extra={'event_name': event_name})
|
|
112
|
+
output_json(response_builder.allow_prompt())
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
response = handler(ctx, unified_payload, policy)
|
|
116
|
+
logger.debug('Hook handler completed', extra={'event_name': event_name, 'response': response})
|
|
117
|
+
output_json(response)
|
|
118
|
+
|
|
119
|
+
except (click.ClickException, HttpUnauthorizedError) as e:
|
|
120
|
+
error_message = _get_auth_error_message(e)
|
|
121
|
+
if event_name == AiHookEventType.PROMPT:
|
|
122
|
+
output_json(response_builder.deny_prompt(error_message))
|
|
123
|
+
return
|
|
124
|
+
output_json(response_builder.deny_permission(error_message, 'Authentication required'))
|
|
125
|
+
|
|
126
|
+
except Exception as e:
|
|
127
|
+
logger.error('Hook handler failed', exc_info=e)
|
|
128
|
+
if policy.get('fail_open', True):
|
|
129
|
+
output_json(response_builder.allow_prompt())
|
|
130
|
+
return
|
|
131
|
+
if event_name == AiHookEventType.PROMPT:
|
|
132
|
+
output_json(response_builder.deny_prompt('Cycode guardrails error - blocking due to fail-closed policy'))
|
|
133
|
+
return
|
|
134
|
+
output_json(response_builder.deny_permission('Cycode guardrails error', 'Blocking due to fail-closed policy'))
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Type definitions for AI guardrails."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
if sys.version_info >= (3, 11):
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
else:
|
|
8
|
+
from enum import Enum
|
|
9
|
+
|
|
10
|
+
class StrEnum(str, Enum):
|
|
11
|
+
def __str__(self) -> str:
|
|
12
|
+
return self.value
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AiHookEventType(StrEnum):
|
|
16
|
+
"""Canonical event types for AI guardrails.
|
|
17
|
+
|
|
18
|
+
These are IDE-agnostic event types. Each IDE's specific event names
|
|
19
|
+
are mapped to these canonical types using the mapping dictionaries below.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
PROMPT = 'Prompt'
|
|
23
|
+
FILE_READ = 'FileRead'
|
|
24
|
+
MCP_EXECUTION = 'McpExecution'
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# IDE-specific event name mappings to canonical types
|
|
28
|
+
CURSOR_EVENT_MAPPING = {
|
|
29
|
+
'beforeSubmitPrompt': AiHookEventType.PROMPT,
|
|
30
|
+
'beforeReadFile': AiHookEventType.FILE_READ,
|
|
31
|
+
'beforeMCPExecution': AiHookEventType.MCP_EXECUTION,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AIHookOutcome(StrEnum):
|
|
36
|
+
"""Outcome of an AI hook event evaluation."""
|
|
37
|
+
|
|
38
|
+
ALLOWED = 'allowed'
|
|
39
|
+
BLOCKED = 'blocked'
|
|
40
|
+
WARNED = 'warned'
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BlockReason(StrEnum):
|
|
44
|
+
"""Reason why an AI hook event was blocked.
|
|
45
|
+
|
|
46
|
+
These are categorical reasons sent to the backend for tracking/analytics,
|
|
47
|
+
separate from the detailed user-facing messages.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
SECRETS_IN_PROMPT = 'secrets_in_prompt'
|
|
51
|
+
SECRETS_IN_FILE = 'secrets_in_file'
|
|
52
|
+
SECRETS_IN_MCP_ARGS = 'secrets_in_mcp_args'
|
|
53
|
+
SENSITIVE_PATH = 'sensitive_path'
|
|
54
|
+
SCAN_FAILURE = 'scan_failure'
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utility functions for AI guardrails.
|
|
3
|
+
|
|
4
|
+
Includes JSON parsing, path matching, and text handling utilities.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def safe_json_parse(s: str) -> dict:
|
|
15
|
+
"""Parse JSON string, returning empty dict on failure."""
|
|
16
|
+
try:
|
|
17
|
+
return json.loads(s) if s else {}
|
|
18
|
+
except (json.JSONDecodeError, TypeError):
|
|
19
|
+
return {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def truncate_utf8(text: str, max_bytes: int) -> str:
|
|
23
|
+
"""Truncate text to max bytes while preserving valid UTF-8."""
|
|
24
|
+
if not text:
|
|
25
|
+
return ''
|
|
26
|
+
encoded = text.encode('utf-8')
|
|
27
|
+
if len(encoded) <= max_bytes:
|
|
28
|
+
return text
|
|
29
|
+
return encoded[:max_bytes].decode('utf-8', errors='ignore')
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def normalize_path(file_path: str) -> str:
|
|
33
|
+
"""Normalize path to prevent traversal attacks."""
|
|
34
|
+
if not file_path:
|
|
35
|
+
return ''
|
|
36
|
+
normalized = os.path.normpath(file_path)
|
|
37
|
+
# Reject paths that attempt to escape outside bounds
|
|
38
|
+
if normalized.startswith('..'):
|
|
39
|
+
return ''
|
|
40
|
+
return normalized
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def matches_glob(file_path: str, pattern: str) -> bool:
|
|
44
|
+
"""Check if file path matches a glob pattern.
|
|
45
|
+
|
|
46
|
+
Case-insensitive matching for cross-platform compatibility.
|
|
47
|
+
"""
|
|
48
|
+
normalized = normalize_path(file_path)
|
|
49
|
+
if not normalized or not pattern:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
path = Path(normalized)
|
|
53
|
+
# Try case-sensitive first
|
|
54
|
+
if path.match(pattern):
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
# Then try case-insensitive by lowercasing both path and pattern
|
|
58
|
+
path_lower = Path(normalized.lower())
|
|
59
|
+
return path_lower.match(pattern.lower())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def is_denied_path(file_path: str, policy: dict) -> bool:
|
|
63
|
+
"""Check if file path is in the denylist."""
|
|
64
|
+
if not file_path:
|
|
65
|
+
return False
|
|
66
|
+
globs = get_policy_value(policy, 'file_read', 'deny_globs', default=[])
|
|
67
|
+
return any(matches_glob(file_path, g) for g in globs)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def output_json(obj: dict) -> None:
|
|
71
|
+
"""Write JSON response to stdout (for IDE to read)."""
|
|
72
|
+
print(json.dumps(obj), end='') # noqa: T201
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Status command for AI guardrails hooks."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated, Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from cycode.cli.apps.ai_guardrails.command_utils import console, validate_and_parse_ide, validate_scope
|
|
11
|
+
from cycode.cli.apps.ai_guardrails.hooks_manager import get_hooks_status
|
|
12
|
+
from cycode.cli.utils.sentry import add_breadcrumb
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def status_command(
|
|
16
|
+
ctx: typer.Context,
|
|
17
|
+
scope: Annotated[
|
|
18
|
+
str,
|
|
19
|
+
typer.Option(
|
|
20
|
+
'--scope',
|
|
21
|
+
'-s',
|
|
22
|
+
help='Check scope: "user", "repo", or "all" for both.',
|
|
23
|
+
),
|
|
24
|
+
] = 'all',
|
|
25
|
+
ide: Annotated[
|
|
26
|
+
str,
|
|
27
|
+
typer.Option(
|
|
28
|
+
'--ide',
|
|
29
|
+
help='IDE to check status for (e.g., "cursor"). Defaults to cursor.',
|
|
30
|
+
),
|
|
31
|
+
] = 'cursor',
|
|
32
|
+
repo_path: Annotated[
|
|
33
|
+
Optional[Path],
|
|
34
|
+
typer.Option(
|
|
35
|
+
'--repo-path',
|
|
36
|
+
help='Repository path for repo-scoped status (defaults to current directory).',
|
|
37
|
+
exists=True,
|
|
38
|
+
file_okay=False,
|
|
39
|
+
dir_okay=True,
|
|
40
|
+
resolve_path=True,
|
|
41
|
+
),
|
|
42
|
+
] = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Show AI guardrails hook installation status.
|
|
45
|
+
|
|
46
|
+
Displays the current status of Cycode AI guardrails hooks for the specified IDE.
|
|
47
|
+
|
|
48
|
+
Examples:
|
|
49
|
+
cycode ai-guardrails status # Show both user and repo status
|
|
50
|
+
cycode ai-guardrails status --scope user # Show only user-level status
|
|
51
|
+
cycode ai-guardrails status --scope repo # Show only repo-level status
|
|
52
|
+
cycode ai-guardrails status --ide cursor # Check status for Cursor IDE
|
|
53
|
+
"""
|
|
54
|
+
add_breadcrumb('ai-guardrails-status')
|
|
55
|
+
|
|
56
|
+
# Validate inputs (status allows 'all' scope)
|
|
57
|
+
validate_scope(scope, allowed_scopes=('user', 'repo', 'all'))
|
|
58
|
+
if repo_path is None:
|
|
59
|
+
repo_path = Path(os.getcwd())
|
|
60
|
+
ide_type = validate_and_parse_ide(ide)
|
|
61
|
+
|
|
62
|
+
scopes_to_check = ['user', 'repo'] if scope == 'all' else [scope]
|
|
63
|
+
|
|
64
|
+
for check_scope in scopes_to_check:
|
|
65
|
+
status = get_hooks_status(check_scope, repo_path if check_scope == 'repo' else None, ide=ide_type)
|
|
66
|
+
|
|
67
|
+
console.print()
|
|
68
|
+
console.print(f'[bold]{check_scope.upper()} SCOPE[/]')
|
|
69
|
+
console.print(f'Path: {status["hooks_path"]}')
|
|
70
|
+
|
|
71
|
+
if not status['file_exists']:
|
|
72
|
+
console.print('[dim]No hooks.json file found[/]')
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
if status['cycode_installed']:
|
|
76
|
+
console.print('[green]✓ Cycode AI guardrails: INSTALLED[/]')
|
|
77
|
+
else:
|
|
78
|
+
console.print('[yellow]○ Cycode AI guardrails: NOT INSTALLED[/]')
|
|
79
|
+
|
|
80
|
+
# Show hook details
|
|
81
|
+
table = Table(show_header=True, header_style='bold')
|
|
82
|
+
table.add_column('Hook Event')
|
|
83
|
+
table.add_column('Cycode Enabled')
|
|
84
|
+
table.add_column('Total Hooks')
|
|
85
|
+
|
|
86
|
+
for event, info in status['hooks'].items():
|
|
87
|
+
enabled = '[green]Yes[/]' if info['enabled'] else '[dim]No[/]'
|
|
88
|
+
table.add_row(event, enabled, str(info['total_entries']))
|
|
89
|
+
|
|
90
|
+
console.print(table)
|
|
91
|
+
|
|
92
|
+
console.print()
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Uninstall command for AI guardrails hooks."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated, Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from cycode.cli.apps.ai_guardrails.command_utils import (
|
|
9
|
+
console,
|
|
10
|
+
resolve_repo_path,
|
|
11
|
+
validate_and_parse_ide,
|
|
12
|
+
validate_scope,
|
|
13
|
+
)
|
|
14
|
+
from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS
|
|
15
|
+
from cycode.cli.apps.ai_guardrails.hooks_manager import uninstall_hooks
|
|
16
|
+
from cycode.cli.utils.sentry import add_breadcrumb
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def uninstall_command(
|
|
20
|
+
ctx: typer.Context,
|
|
21
|
+
scope: Annotated[
|
|
22
|
+
str,
|
|
23
|
+
typer.Option(
|
|
24
|
+
'--scope',
|
|
25
|
+
'-s',
|
|
26
|
+
help='Uninstall scope: "user" for user-level hooks, "repo" for repository-level hooks.',
|
|
27
|
+
),
|
|
28
|
+
] = 'user',
|
|
29
|
+
ide: Annotated[
|
|
30
|
+
str,
|
|
31
|
+
typer.Option(
|
|
32
|
+
'--ide',
|
|
33
|
+
help='IDE to uninstall hooks from (e.g., "cursor"). Defaults to cursor.',
|
|
34
|
+
),
|
|
35
|
+
] = 'cursor',
|
|
36
|
+
repo_path: Annotated[
|
|
37
|
+
Optional[Path],
|
|
38
|
+
typer.Option(
|
|
39
|
+
'--repo-path',
|
|
40
|
+
help='Repository path for repo-scoped uninstallation (defaults to current directory).',
|
|
41
|
+
exists=True,
|
|
42
|
+
file_okay=False,
|
|
43
|
+
dir_okay=True,
|
|
44
|
+
resolve_path=True,
|
|
45
|
+
),
|
|
46
|
+
] = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Remove AI guardrails hooks from supported IDEs.
|
|
49
|
+
|
|
50
|
+
This command removes Cycode hooks from the IDE's hooks configuration.
|
|
51
|
+
Other hooks (if any) will be preserved.
|
|
52
|
+
|
|
53
|
+
Examples:
|
|
54
|
+
cycode ai-guardrails uninstall # Remove user-level hooks
|
|
55
|
+
cycode ai-guardrails uninstall --scope repo # Remove repo-level hooks
|
|
56
|
+
cycode ai-guardrails uninstall --ide cursor # Uninstall from Cursor IDE
|
|
57
|
+
"""
|
|
58
|
+
add_breadcrumb('ai-guardrails-uninstall')
|
|
59
|
+
|
|
60
|
+
# Validate inputs
|
|
61
|
+
validate_scope(scope)
|
|
62
|
+
repo_path = resolve_repo_path(scope, repo_path)
|
|
63
|
+
ide_type = validate_and_parse_ide(ide)
|
|
64
|
+
ide_name = IDE_CONFIGS[ide_type].name
|
|
65
|
+
success, message = uninstall_hooks(scope, repo_path, ide=ide_type)
|
|
66
|
+
|
|
67
|
+
if success:
|
|
68
|
+
console.print(f'[green]✓[/] {message}')
|
|
69
|
+
console.print()
|
|
70
|
+
console.print(f'[dim]Restart {ide_name} for changes to take effect.[/]')
|
|
71
|
+
else:
|
|
72
|
+
console.print(f'[red]✗[/] {message}', style='bold red')
|
|
73
|
+
raise typer.Exit(1)
|
|
@@ -91,7 +91,7 @@ def _should_use_sync_flow(command_scan_type: str, scan_type: str, sync_option: b
|
|
|
91
91
|
if not sync_option and scan_type != consts.IAC_SCAN_TYPE:
|
|
92
92
|
return False
|
|
93
93
|
|
|
94
|
-
if command_scan_type not in {'path', 'repository'}:
|
|
94
|
+
if command_scan_type not in {'path', 'repository', 'ai_guardrails'}:
|
|
95
95
|
return False
|
|
96
96
|
|
|
97
97
|
if scan_type == consts.IAC_SCAN_TYPE:
|
cycode/cli/cli_types.py
CHANGED
|
@@ -86,6 +86,10 @@ class SeverityOption(StrEnum):
|
|
|
86
86
|
def get_member_emoji(name: str) -> str:
|
|
87
87
|
return _SEVERITY_EMOJIS.get(name.lower(), _SEVERITY_DEFAULT_EMOJI)
|
|
88
88
|
|
|
89
|
+
@staticmethod
|
|
90
|
+
def get_member_unicode_emoji(name: str) -> str:
|
|
91
|
+
return _SEVERITY_UNICODE_EMOJIS.get(name.lower(), _SEVERITY_DEFAULT_UNICODE_EMOJI)
|
|
92
|
+
|
|
89
93
|
def __rich__(self) -> str:
|
|
90
94
|
color = self.get_member_color(self.value)
|
|
91
95
|
return f'[{color}]{self.value.upper()}[/]'
|
|
@@ -117,3 +121,12 @@ _SEVERITY_EMOJIS = {
|
|
|
117
121
|
SeverityOption.HIGH.value: ':red_circle:',
|
|
118
122
|
SeverityOption.CRITICAL.value: ':exclamation_mark:', # double_exclamation_mark is not red
|
|
119
123
|
}
|
|
124
|
+
|
|
125
|
+
_SEVERITY_DEFAULT_UNICODE_EMOJI = '⚪'
|
|
126
|
+
_SEVERITY_UNICODE_EMOJIS = {
|
|
127
|
+
SeverityOption.INFO.value: '🔵',
|
|
128
|
+
SeverityOption.LOW.value: '🟡',
|
|
129
|
+
SeverityOption.MEDIUM.value: '🟠',
|
|
130
|
+
SeverityOption.HIGH.value: '🔴',
|
|
131
|
+
SeverityOption.CRITICAL.value: '❗',
|
|
132
|
+
}
|
|
@@ -3,11 +3,17 @@ from typing import TYPE_CHECKING, Optional, Union
|
|
|
3
3
|
import click
|
|
4
4
|
|
|
5
5
|
from cycode.cli.user_settings.credentials_manager import CredentialsManager
|
|
6
|
-
from cycode.cyclient.client_creator import
|
|
6
|
+
from cycode.cyclient.client_creator import (
|
|
7
|
+
create_ai_security_manager_client,
|
|
8
|
+
create_import_sbom_client,
|
|
9
|
+
create_report_client,
|
|
10
|
+
create_scan_client,
|
|
11
|
+
)
|
|
7
12
|
|
|
8
13
|
if TYPE_CHECKING:
|
|
9
14
|
import typer
|
|
10
15
|
|
|
16
|
+
from cycode.cyclient.ai_security_manager_client import AISecurityManagerClient
|
|
11
17
|
from cycode.cyclient.import_sbom_client import ImportSbomClient
|
|
12
18
|
from cycode.cyclient.report_client import ReportClient
|
|
13
19
|
from cycode.cyclient.scan_client import ScanClient
|
|
@@ -19,7 +25,7 @@ def _get_cycode_client(
|
|
|
19
25
|
client_secret: Optional[str],
|
|
20
26
|
hide_response_log: bool,
|
|
21
27
|
id_token: Optional[str] = None,
|
|
22
|
-
) -> Union['ScanClient', 'ReportClient']:
|
|
28
|
+
) -> Union['ScanClient', 'ReportClient', 'ImportSbomClient', 'AISecurityManagerClient']:
|
|
23
29
|
if client_id and id_token:
|
|
24
30
|
return create_client_func(client_id, None, hide_response_log, id_token)
|
|
25
31
|
|
|
@@ -62,6 +68,13 @@ def get_import_sbom_cycode_client(ctx: 'typer.Context', hide_response_log: bool
|
|
|
62
68
|
return _get_cycode_client(create_import_sbom_client, client_id, client_secret, hide_response_log, id_token)
|
|
63
69
|
|
|
64
70
|
|
|
71
|
+
def get_ai_security_manager_client(ctx: 'typer.Context', hide_response_log: bool = True) -> 'AISecurityManagerClient':
|
|
72
|
+
client_id = ctx.obj.get('client_id')
|
|
73
|
+
client_secret = ctx.obj.get('client_secret')
|
|
74
|
+
id_token = ctx.obj.get('id_token')
|
|
75
|
+
return _get_cycode_client(create_ai_security_manager_client, client_id, client_secret, hide_response_log, id_token)
|
|
76
|
+
|
|
77
|
+
|
|
65
78
|
def _get_configured_credentials() -> tuple[str, str]:
|
|
66
79
|
credentials_manager = CredentialsManager()
|
|
67
80
|
return credentials_manager.get_credentials()
|
cycode/cli/utils/scan_utils.py
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import os
|
|
2
|
+
from collections import defaultdict
|
|
2
3
|
from typing import TYPE_CHECKING, Optional
|
|
3
4
|
from uuid import UUID, uuid4
|
|
4
5
|
|
|
5
6
|
import typer
|
|
6
7
|
|
|
8
|
+
from cycode.cli.cli_types import SeverityOption
|
|
9
|
+
|
|
7
10
|
if TYPE_CHECKING:
|
|
8
11
|
from cycode.cli.models import LocalScanResult
|
|
9
12
|
from cycode.cyclient.models import ScanConfiguration
|
|
@@ -33,3 +36,24 @@ def generate_unique_scan_id() -> UUID:
|
|
|
33
36
|
return UUID(os.environ['PYTEST_TEST_UNIQUE_ID'])
|
|
34
37
|
|
|
35
38
|
return uuid4()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_violation_summary(local_scan_results: list['LocalScanResult']) -> str:
|
|
42
|
+
"""Build violation summary string with severity breakdown and emojis."""
|
|
43
|
+
detections_count = 0
|
|
44
|
+
severity_counts = defaultdict(int)
|
|
45
|
+
|
|
46
|
+
for local_scan_result in local_scan_results:
|
|
47
|
+
for document_detections in local_scan_result.document_detections:
|
|
48
|
+
for detection in document_detections.detections:
|
|
49
|
+
if detection.severity:
|
|
50
|
+
detections_count += 1
|
|
51
|
+
severity_counts[SeverityOption(detection.severity)] += 1
|
|
52
|
+
|
|
53
|
+
severity_parts = []
|
|
54
|
+
for severity in reversed(SeverityOption):
|
|
55
|
+
emoji = SeverityOption.get_member_unicode_emoji(severity)
|
|
56
|
+
count = severity_counts[severity]
|
|
57
|
+
severity_parts.append(f'{emoji} {severity.upper()} - {count}')
|
|
58
|
+
|
|
59
|
+
return f'Cycode found {detections_count} violations: {" | ".join(severity_parts)}'
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Client for AI Security Manager service."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Optional
|
|
4
|
+
|
|
5
|
+
from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError
|
|
6
|
+
from cycode.cyclient.cycode_client_base import CycodeClientBase
|
|
7
|
+
from cycode.cyclient.logger import logger
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
|
|
11
|
+
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType, AIHookOutcome, BlockReason
|
|
12
|
+
from cycode.cyclient.ai_security_manager_service_config import AISecurityManagerServiceConfigBase
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AISecurityManagerClient:
|
|
16
|
+
"""Client for interacting with AI Security Manager service."""
|
|
17
|
+
|
|
18
|
+
_CONVERSATIONS_PATH = 'v4/ai-security/interactions/conversations'
|
|
19
|
+
_EVENTS_PATH = 'v4/ai-security/interactions/events'
|
|
20
|
+
|
|
21
|
+
def __init__(self, client: CycodeClientBase, service_config: 'AISecurityManagerServiceConfigBase') -> None:
|
|
22
|
+
self.client = client
|
|
23
|
+
self.service_config = service_config
|
|
24
|
+
|
|
25
|
+
def _build_endpoint_path(self, path: str) -> str:
|
|
26
|
+
"""Build the full endpoint path including service name/port."""
|
|
27
|
+
service_name = self.service_config.get_service_name()
|
|
28
|
+
if service_name:
|
|
29
|
+
return f'{service_name}/{path}'
|
|
30
|
+
return path
|
|
31
|
+
|
|
32
|
+
def create_conversation(self, payload: 'AIHookPayload') -> Optional[str]:
|
|
33
|
+
"""Creates an AI conversation from hook payload."""
|
|
34
|
+
conversation_id = payload.conversation_id
|
|
35
|
+
if not conversation_id:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
body = {
|
|
39
|
+
'id': conversation_id,
|
|
40
|
+
'ide_user_email': payload.ide_user_email,
|
|
41
|
+
'model': payload.model,
|
|
42
|
+
'ide_provider': payload.ide_provider,
|
|
43
|
+
'ide_version': payload.ide_version,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
self.client.post(self._build_endpoint_path(self._CONVERSATIONS_PATH), body=body)
|
|
48
|
+
except HttpUnauthorizedError:
|
|
49
|
+
# Authentication error - re-raise so prompt_command can catch it
|
|
50
|
+
raise
|
|
51
|
+
except Exception as e:
|
|
52
|
+
logger.debug('Failed to create conversation', exc_info=e)
|
|
53
|
+
# Don't fail the hook if tracking fails (non-auth errors)
|
|
54
|
+
|
|
55
|
+
return conversation_id
|
|
56
|
+
|
|
57
|
+
def create_event(
|
|
58
|
+
self,
|
|
59
|
+
payload: 'AIHookPayload',
|
|
60
|
+
event_type: 'AiHookEventType',
|
|
61
|
+
outcome: 'AIHookOutcome',
|
|
62
|
+
scan_id: Optional[str] = None,
|
|
63
|
+
block_reason: Optional['BlockReason'] = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Create an AI hook event from hook payload."""
|
|
66
|
+
conversation_id = payload.conversation_id
|
|
67
|
+
if not conversation_id:
|
|
68
|
+
logger.debug('No conversation ID available, skipping event creation')
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
body = {
|
|
72
|
+
'conversation_id': conversation_id,
|
|
73
|
+
'event_type': event_type,
|
|
74
|
+
'outcome': outcome,
|
|
75
|
+
'generation_id': payload.generation_id,
|
|
76
|
+
'block_reason': block_reason,
|
|
77
|
+
'cli_scan_id': scan_id,
|
|
78
|
+
'mcp_server_name': payload.mcp_server_name,
|
|
79
|
+
'mcp_tool_name': payload.mcp_tool_name,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
self.client.post(self._build_endpoint_path(self._EVENTS_PATH), body=body)
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.debug('Failed to create AI hook event', exc_info=e)
|
|
86
|
+
# Don't fail the hook if tracking fails
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Service configuration for AI Security Manager."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AISecurityManagerServiceConfigBase:
|
|
5
|
+
"""Base class for AI Security Manager service configuration."""
|
|
6
|
+
|
|
7
|
+
def get_service_name(self) -> str:
|
|
8
|
+
"""Get the service name or port for URL construction.
|
|
9
|
+
|
|
10
|
+
In dev mode, returns the port number.
|
|
11
|
+
In production, returns the service name.
|
|
12
|
+
"""
|
|
13
|
+
raise NotImplementedError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DevAISecurityManagerServiceConfig(AISecurityManagerServiceConfigBase):
|
|
17
|
+
"""Dev configuration for AI Security Manager."""
|
|
18
|
+
|
|
19
|
+
def get_service_name(self) -> str:
|
|
20
|
+
return '5163/api'
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DefaultAISecurityManagerServiceConfig(AISecurityManagerServiceConfigBase):
|
|
24
|
+
"""Production configuration for AI Security Manager."""
|
|
25
|
+
|
|
26
|
+
def get_service_name(self) -> str:
|
|
27
|
+
return ''
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
from typing import Optional
|
|
2
2
|
|
|
3
|
+
from cycode.cyclient.ai_security_manager_client import AISecurityManagerClient
|
|
4
|
+
from cycode.cyclient.ai_security_manager_service_config import (
|
|
5
|
+
DefaultAISecurityManagerServiceConfig,
|
|
6
|
+
DevAISecurityManagerServiceConfig,
|
|
7
|
+
)
|
|
3
8
|
from cycode.cyclient.config import dev_mode
|
|
4
9
|
from cycode.cyclient.config_dev import DEV_CYCODE_API_URL
|
|
5
10
|
from cycode.cyclient.cycode_dev_based_client import CycodeDevBasedClient
|
|
@@ -49,3 +54,18 @@ def create_import_sbom_client(
|
|
|
49
54
|
else:
|
|
50
55
|
client = CycodeTokenBasedClient(client_id, client_secret)
|
|
51
56
|
return ImportSbomClient(client)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def create_ai_security_manager_client(
|
|
60
|
+
client_id: str, client_secret: Optional[str] = None, _: bool = False, id_token: Optional[str] = None
|
|
61
|
+
) -> AISecurityManagerClient:
|
|
62
|
+
if dev_mode:
|
|
63
|
+
client = CycodeDevBasedClient(DEV_CYCODE_API_URL)
|
|
64
|
+
service_config = DevAISecurityManagerServiceConfig()
|
|
65
|
+
else:
|
|
66
|
+
if id_token:
|
|
67
|
+
client = CycodeOidcBasedClient(client_id, id_token)
|
|
68
|
+
else:
|
|
69
|
+
client = CycodeTokenBasedClient(client_id, client_secret)
|
|
70
|
+
service_config = DefaultAISecurityManagerServiceConfig()
|
|
71
|
+
return AISecurityManagerClient(client, service_config)
|