cycode 3.25.1.dev6__py3-none-any.whl → 3.25.2.dev2__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 +9 -10
- cycode/cli/apps/activation_manager.py +6 -6
- cycode/cli/apps/ai_guardrails/command_utils.py +1 -2
- cycode/cli/apps/ai_guardrails/hooks_manager.py +6 -7
- cycode/cli/apps/ai_guardrails/ides/__init__.py +8 -9
- cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py +11 -10
- cycode/cli/apps/ai_guardrails/ides/_skill_utils.py +1 -2
- cycode/cli/apps/ai_guardrails/ides/base.py +19 -21
- cycode/cli/apps/ai_guardrails/ides/claude_code.py +54 -16
- cycode/cli/apps/ai_guardrails/ides/codex.py +54 -20
- cycode/cli/apps/ai_guardrails/ides/copilot.py +23 -25
- cycode/cli/apps/ai_guardrails/ides/cursor.py +48 -10
- cycode/cli/apps/ai_guardrails/install_command.py +3 -3
- cycode/cli/apps/ai_guardrails/scan/guardrail_config.py +44 -22
- cycode/cli/apps/ai_guardrails/scan/handlers.py +79 -14
- cycode/cli/apps/ai_guardrails/scan/mcp_server_status.py +103 -0
- cycode/cli/apps/ai_guardrails/scan/payload.py +13 -14
- cycode/cli/apps/ai_guardrails/scan/policy.py +3 -3
- cycode/cli/apps/ai_guardrails/scan/scan_command.py +4 -4
- cycode/cli/apps/ai_guardrails/scan/types.py +1 -0
- cycode/cli/apps/ai_guardrails/session_start_command.py +46 -21
- cycode/cli/apps/ai_guardrails/status_command.py +2 -2
- cycode/cli/apps/ai_guardrails/uninstall_command.py +2 -2
- cycode/cli/apps/api/__init__.py +3 -3
- cycode/cli/apps/api/api_command.py +6 -6
- cycode/cli/apps/api/openapi_spec.py +4 -5
- cycode/cli/apps/auth/auth_common.py +4 -4
- cycode/cli/apps/configure/configure_command.py +2 -4
- cycode/cli/apps/configure/messages.py +2 -4
- cycode/cli/apps/configure/prompts.py +5 -7
- cycode/cli/apps/ignore/ignore_command.py +7 -7
- cycode/cli/apps/mcp/mcp_command.py +11 -11
- cycode/cli/apps/report/sbom/common.py +5 -5
- cycode/cli/apps/report/sbom/sbom_command.py +2 -2
- cycode/cli/apps/report/sbom/sbom_report_file.py +1 -2
- cycode/cli/apps/report_import/sbom/sbom_command.py +3 -3
- cycode/cli/apps/sca_options.py +3 -3
- cycode/cli/apps/scan/aggregation_report.py +4 -4
- cycode/cli/apps/scan/code_scanner.py +8 -7
- cycode/cli/apps/scan/commit_range_scanner.py +10 -10
- cycode/cli/apps/scan/detection_excluder.py +2 -4
- cycode/cli/apps/scan/pre_commit/pre_commit_command.py +3 -3
- cycode/cli/apps/scan/pre_push/pre_push_command.py +3 -3
- cycode/cli/apps/scan/pre_receive/pre_receive_command.py +2 -2
- cycode/cli/apps/scan/remote_url_resolver.py +6 -8
- cycode/cli/apps/scan/repository/repository_command.py +2 -2
- cycode/cli/apps/scan/scan_command.py +2 -2
- cycode/cli/apps/scan/scan_parameters.py +1 -3
- cycode/cli/apps/scan/scan_result.py +5 -5
- cycode/cli/console.py +2 -2
- cycode/cli/exceptions/custom_exceptions.py +1 -1
- cycode/cli/exceptions/handle_errors.py +1 -3
- cycode/cli/exceptions/handle_scan_errors.py +1 -2
- cycode/cli/files_collector/commit_range_documents.py +14 -14
- cycode/cli/files_collector/models/in_memory_zip.py +3 -3
- cycode/cli/files_collector/repository_documents.py +3 -3
- cycode/cli/files_collector/sca/base_restore_dependencies.py +7 -8
- cycode/cli/files_collector/sca/go/restore_go_dependencies.py +1 -2
- cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py +2 -3
- cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py +3 -4
- cycode/cli/files_collector/sca/npm/restore_bun_dependencies.py +3 -4
- cycode/cli/files_collector/sca/npm/restore_deno_dependencies.py +1 -2
- cycode/cli/files_collector/sca/npm/restore_pnpm_dependencies.py +2 -3
- cycode/cli/files_collector/sca/npm/restore_yarn_dependencies.py +2 -3
- cycode/cli/files_collector/sca/npm/workspace/coverage.py +5 -5
- cycode/cli/files_collector/sca/npm/workspace/files.py +2 -3
- cycode/cli/files_collector/sca/npm/workspace/globs.py +1 -2
- cycode/cli/files_collector/sca/npm/workspace/resolvers.py +14 -16
- cycode/cli/files_collector/sca/php/restore_composer_dependencies.py +1 -2
- cycode/cli/files_collector/sca/python/restore_pip_dependencies.py +2 -3
- cycode/cli/files_collector/sca/python/restore_pipenv_dependencies.py +1 -2
- cycode/cli/files_collector/sca/python/restore_poetry_dependencies.py +2 -3
- cycode/cli/files_collector/sca/python/restore_uv_dependencies.py +2 -3
- cycode/cli/files_collector/sca/sca_file_collector.py +5 -5
- cycode/cli/files_collector/zip_documents.py +1 -2
- cycode/cli/models.py +8 -8
- cycode/cli/printers/console_printer.py +9 -9
- cycode/cli/printers/json_printer.py +2 -2
- cycode/cli/printers/printer_base.py +3 -3
- cycode/cli/printers/rich_printer.py +2 -2
- cycode/cli/printers/tables/table.py +4 -4
- cycode/cli/printers/tables/table_models.py +2 -2
- cycode/cli/printers/tables/table_printer_base.py +2 -2
- cycode/cli/printers/text_printer.py +4 -4
- cycode/cli/printers/utils/code_snippet_syntax.py +1 -1
- cycode/cli/printers/utils/detection_data.py +3 -3
- cycode/cli/printers/utils/sca_ossf.py +4 -4
- cycode/cli/printers/utils/sca_policy_details.py +2 -1
- cycode/cli/user_settings/config_file_manager.py +10 -10
- cycode/cli/user_settings/configuration_manager.py +6 -6
- cycode/cli/user_settings/credentials_manager.py +5 -6
- cycode/cli/utils/get_api_client.py +5 -5
- cycode/cli/utils/git_proxy.py +8 -8
- cycode/cli/utils/host_info.py +21 -38
- cycode/cli/utils/ignore_utils.py +13 -15
- cycode/cli/utils/jwt_utils.py +2 -4
- cycode/cli/utils/path_utils.py +6 -6
- cycode/cli/utils/progress_bar.py +4 -4
- cycode/cli/utils/scan_batch.py +2 -1
- cycode/cli/utils/scan_utils.py +2 -2
- cycode/cli/utils/shell_executor.py +3 -4
- cycode/cli/utils/task_timer.py +4 -4
- cycode/cli/utils/trust_store.py +0 -17
- cycode/cli/utils/url_utils.py +1 -2
- cycode/cli/utils/version_checker.py +4 -5
- cycode/cli/utils/yaml_utils.py +1 -1
- cycode/config.py +1 -2
- cycode/cyclient/ai_security_manager_client.py +27 -21
- cycode/cyclient/auth_client.py +2 -4
- cycode/cyclient/base_token_auth_client.py +2 -2
- cycode/cyclient/client_creator.py +4 -6
- cycode/cyclient/cycode_client_base.py +10 -9
- cycode/cyclient/cycode_dev_based_client.py +1 -3
- cycode/cyclient/headers.py +1 -2
- cycode/cyclient/import_sbom_client.py +3 -4
- cycode/cyclient/models.py +88 -30
- cycode/cyclient/report_client.py +1 -2
- cycode/cyclient/scan_client.py +8 -7
- cycode/logger.py +4 -4
- {cycode-3.25.1.dev6.dist-info → cycode-3.25.2.dev2.dist-info}/METADATA +12 -19
- {cycode-3.25.1.dev6.dist-info → cycode-3.25.2.dev2.dist-info}/RECORD +125 -124
- {cycode-3.25.1.dev6.dist-info → cycode-3.25.2.dev2.dist-info}/WHEEL +1 -1
- {cycode-3.25.1.dev6.dist-info → cycode-3.25.2.dev2.dist-info}/entry_points.txt +0 -0
- {cycode-3.25.1.dev6.dist-info → cycode-3.25.2.dev2.dist-info}/licenses/LICENCE +0 -0
|
@@ -11,8 +11,9 @@ touching any handler in this module.
|
|
|
11
11
|
import json
|
|
12
12
|
import os
|
|
13
13
|
import threading
|
|
14
|
+
from collections.abc import Callable
|
|
14
15
|
from dataclasses import dataclass
|
|
15
|
-
from typing import TYPE_CHECKING, Any,
|
|
16
|
+
from typing import TYPE_CHECKING, Any, NamedTuple
|
|
16
17
|
|
|
17
18
|
import typer
|
|
18
19
|
|
|
@@ -21,6 +22,7 @@ if TYPE_CHECKING:
|
|
|
21
22
|
|
|
22
23
|
from cycode.cli.apps.ai_guardrails.consts import GuardrailsMode, PolicyMode
|
|
23
24
|
from cycode.cli.apps.ai_guardrails.ides.base import HookDecision
|
|
25
|
+
from cycode.cli.apps.ai_guardrails.scan.mcp_server_status import is_enforced, load_mcp_server_statuses
|
|
24
26
|
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
|
|
25
27
|
from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value
|
|
26
28
|
from cycode.cli.apps.ai_guardrails.scan.types import (
|
|
@@ -47,15 +49,15 @@ HandlerFn = Callable[[typer.Context, AIHookPayload, dict], HookDecision]
|
|
|
47
49
|
class ScanOutcome(NamedTuple):
|
|
48
50
|
"""What one guardrail scan came back with; the verdict is the server's, which applied the tenant's floors."""
|
|
49
51
|
|
|
50
|
-
violation_summary:
|
|
51
|
-
scan_id:
|
|
52
|
-
verdict:
|
|
52
|
+
violation_summary: str | None = None
|
|
53
|
+
scan_id: str | None = None
|
|
54
|
+
verdict: GuardrailsMode | None = None
|
|
53
55
|
|
|
54
56
|
|
|
55
57
|
NO_SCAN = ScanOutcome()
|
|
56
58
|
|
|
57
59
|
|
|
58
|
-
def _parse_verdict(verdict:
|
|
60
|
+
def _parse_verdict(verdict: str | None) -> GuardrailsMode | None:
|
|
59
61
|
"""The server spells the verdict "Block"/"Report" and omits it when the scan found nothing to decide on."""
|
|
60
62
|
if not verdict:
|
|
61
63
|
return None
|
|
@@ -104,7 +106,7 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli
|
|
|
104
106
|
)
|
|
105
107
|
block_reason = BlockReason.SCAN_FAILURE
|
|
106
108
|
error_message = str(e)
|
|
107
|
-
raise
|
|
109
|
+
raise
|
|
108
110
|
finally:
|
|
109
111
|
ai_client.create_event(
|
|
110
112
|
payload,
|
|
@@ -191,7 +193,7 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
|
|
|
191
193
|
)
|
|
192
194
|
block_reason = BlockReason.SCAN_FAILURE
|
|
193
195
|
error_message = str(e)
|
|
194
|
-
raise
|
|
196
|
+
raise
|
|
195
197
|
finally:
|
|
196
198
|
ai_client.create_event(
|
|
197
199
|
payload,
|
|
@@ -217,6 +219,16 @@ class _ArgScanFeature:
|
|
|
217
219
|
deny_agent_message: str
|
|
218
220
|
ask_message: Callable[[str], str]
|
|
219
221
|
ask_agent_message: str
|
|
222
|
+
scan_enabled: bool = True
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class _PreScanFinding(NamedTuple):
|
|
226
|
+
"""A finding on the call itself (e.g. an unauthorized MCP server), made before the text scan."""
|
|
227
|
+
|
|
228
|
+
block_reason: BlockReason
|
|
229
|
+
mode: GuardrailsMode
|
|
230
|
+
deny_message: str
|
|
231
|
+
deny_agent_message: str
|
|
220
232
|
|
|
221
233
|
|
|
222
234
|
def _handle_arg_scan(
|
|
@@ -225,8 +237,12 @@ def _handle_arg_scan(
|
|
|
225
237
|
policy: dict,
|
|
226
238
|
feature: _ArgScanFeature,
|
|
227
239
|
scan_text: str,
|
|
240
|
+
pre_scan_finding: _PreScanFinding | None = None,
|
|
228
241
|
) -> HookDecision:
|
|
229
|
-
"""Shared scan + decision flow for MCP_EXECUTION and COMMAND_EXEC events.
|
|
242
|
+
"""Shared scan + decision flow for MCP_EXECUTION and COMMAND_EXEC events.
|
|
243
|
+
|
|
244
|
+
A Report-mode pre-scan finding still scans, and a secret found then takes over the response.
|
|
245
|
+
"""
|
|
230
246
|
ai_client = ctx.obj['ai_security_client']
|
|
231
247
|
|
|
232
248
|
max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000)
|
|
@@ -239,6 +255,18 @@ def _handle_arg_scan(
|
|
|
239
255
|
error_message = None
|
|
240
256
|
|
|
241
257
|
try:
|
|
258
|
+
if pre_scan_finding is not None:
|
|
259
|
+
block_reason = pre_scan_finding.block_reason
|
|
260
|
+
if pre_scan_finding.mode == GuardrailsMode.BLOCK:
|
|
261
|
+
outcome = AIHookOutcome.BLOCKED
|
|
262
|
+
return HookDecision.deny(
|
|
263
|
+
feature.event_type, pre_scan_finding.deny_message, pre_scan_finding.deny_agent_message
|
|
264
|
+
)
|
|
265
|
+
outcome = AIHookOutcome.WARNED
|
|
266
|
+
|
|
267
|
+
if not feature.scan_enabled:
|
|
268
|
+
return HookDecision.allow(feature.event_type)
|
|
269
|
+
|
|
242
270
|
scan_outcome = _scan_text_for_secrets(
|
|
243
271
|
ctx,
|
|
244
272
|
clipped,
|
|
@@ -270,7 +298,7 @@ def _handle_arg_scan(
|
|
|
270
298
|
)
|
|
271
299
|
block_reason = BlockReason.SCAN_FAILURE
|
|
272
300
|
error_message = str(e)
|
|
273
|
-
raise
|
|
301
|
+
raise
|
|
274
302
|
finally:
|
|
275
303
|
ai_client.create_event(
|
|
276
304
|
payload,
|
|
@@ -282,8 +310,43 @@ def _handle_arg_scan(
|
|
|
282
310
|
)
|
|
283
311
|
|
|
284
312
|
|
|
313
|
+
def _check_mcp_server_authorization(payload: AIHookPayload, policy: dict) -> _PreScanFinding | None:
|
|
314
|
+
"""Fails open: no server name or no cached statuses lets the call through."""
|
|
315
|
+
mcp_config = get_policy_value(policy, 'mcp', default={})
|
|
316
|
+
if not get_policy_value(mcp_config, 'check_server', default=False):
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
server_name = payload.mcp_server_name
|
|
320
|
+
if not server_name:
|
|
321
|
+
logger.debug('No MCP server name in the payload; skipping the server authorization check')
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
statuses = load_mcp_server_statuses()
|
|
325
|
+
if statuses is None:
|
|
326
|
+
logger.debug('No cached MCP server statuses; skipping the server authorization check')
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
status = statuses.status_of(server_name)
|
|
330
|
+
if not is_enforced(status):
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
logger.debug('MCP server is not authorized, %s', {'mcp_server_name': server_name, 'status': status})
|
|
334
|
+
return _PreScanFinding(
|
|
335
|
+
block_reason=BlockReason.UNAUTHORIZED_MCP_SERVER,
|
|
336
|
+
mode=get_effective_mode(mcp_config, action_key='server_action'),
|
|
337
|
+
deny_message=(
|
|
338
|
+
f"Cycode blocked MCP server '{server_name}': it is not authorized in your organization. "
|
|
339
|
+
'Contact your admin to authorize it.'
|
|
340
|
+
),
|
|
341
|
+
deny_agent_message=(
|
|
342
|
+
f"The MCP server '{server_name}' is not authorized in this organization. "
|
|
343
|
+
'Do not retry its tools or reach it another way.'
|
|
344
|
+
),
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
285
348
|
def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision:
|
|
286
|
-
"""
|
|
349
|
+
"""Check the MCP server is authorized, then scan the tool arguments for secrets."""
|
|
287
350
|
tool = payload.mcp_tool_name or 'unknown'
|
|
288
351
|
args = payload.mcp_arguments or {}
|
|
289
352
|
args_text = args if isinstance(args, str) else json.dumps(args)
|
|
@@ -297,12 +360,14 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
|
|
|
297
360
|
deny_agent_message='Do not pass secrets to tools. Use secret references (name/id) instead.',
|
|
298
361
|
ask_message=lambda v: f'Allow MCP tool call "{tool}"? {v}',
|
|
299
362
|
ask_agent_message='Possible secrets detected in tool arguments; proceed with caution.',
|
|
363
|
+
scan_enabled=get_policy_value(policy, 'mcp', 'scan_args', default=True),
|
|
300
364
|
),
|
|
301
365
|
scan_text=args_text,
|
|
366
|
+
pre_scan_finding=_check_mcp_server_authorization(payload, policy),
|
|
302
367
|
)
|
|
303
368
|
|
|
304
369
|
|
|
305
|
-
def get_handler_for_event(event_type: str) ->
|
|
370
|
+
def get_handler_for_event(event_type: str) -> HandlerFn | None:
|
|
306
371
|
"""Look up the handler for a canonical event type."""
|
|
307
372
|
handlers: dict[str, HandlerFn] = {
|
|
308
373
|
AiHookEventType.PROMPT.value: handle_before_submit_prompt,
|
|
@@ -319,10 +384,10 @@ def get_effective_mode(feature_config: dict, action_key: str = 'action') -> Guar
|
|
|
319
384
|
|
|
320
385
|
|
|
321
386
|
def should_detach_scan(
|
|
322
|
-
config:
|
|
387
|
+
config: 'GuardrailConfig | None',
|
|
323
388
|
policy: dict,
|
|
324
389
|
event_name: str,
|
|
325
|
-
ide_name:
|
|
390
|
+
ide_name: str | None,
|
|
326
391
|
) -> bool:
|
|
327
392
|
"""Whether this event's scan is safe to run detached.
|
|
328
393
|
|
|
@@ -339,7 +404,7 @@ def should_detach_scan(
|
|
|
339
404
|
|
|
340
405
|
def build_ai_guardrails_scan_parameters(
|
|
341
406
|
ctx: typer.Context,
|
|
342
|
-
paths:
|
|
407
|
+
paths: tuple[str, ...] | None,
|
|
343
408
|
payload: AIHookPayload,
|
|
344
409
|
event_type: AiHookEventType,
|
|
345
410
|
) -> dict:
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""MCP server authorization status cache: session-start writes it, the pre-MCP-execution hook reads it.
|
|
2
|
+
|
|
3
|
+
An absent or corrupt cache means no status is known, so the guardrail fails open.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from marshmallow import EXCLUDE, Schema, fields, post_load
|
|
13
|
+
|
|
14
|
+
from cycode.cli.apps.ai_guardrails.scan.guardrail_config import DEFAULT_TTL_SECONDS
|
|
15
|
+
from cycode.cli.consts import CYCODE_CONFIGURATION_DIRECTORY
|
|
16
|
+
from cycode.cli.utils.path_utils import atomic_write_text, quarantine_corrupt_file
|
|
17
|
+
from cycode.cyclient.models import McpServerAuthorizationStatus, McpServerStatus, McpServerStatusSchema
|
|
18
|
+
from cycode.logger import get_logger
|
|
19
|
+
|
|
20
|
+
logger = get_logger('AI Guardrails')
|
|
21
|
+
|
|
22
|
+
MCP_SERVER_STATUSES_FILE_NAME = 'ai-guardrails-mcp-servers.json'
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# An alias shared by several servers (e.g. configured differently per device) gets the most restrictive status.
|
|
26
|
+
_RESTRICTIVENESS = {
|
|
27
|
+
McpServerAuthorizationStatus.AUTHORIZED: 0,
|
|
28
|
+
McpServerAuthorizationStatus.UNREVIEWED: 1,
|
|
29
|
+
McpServerAuthorizationStatus.UNAUTHORIZED: 2,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_mcp_server_statuses_cache_path() -> Path:
|
|
34
|
+
return Path.home() / CYCODE_CONFIGURATION_DIRECTORY / MCP_SERVER_STATUSES_FILE_NAME
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_enforced(status: McpServerAuthorizationStatus | None) -> bool:
|
|
38
|
+
"""``status`` is None when the platform never saw the server."""
|
|
39
|
+
return status == McpServerAuthorizationStatus.UNAUTHORIZED
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class McpServerStatuses:
|
|
44
|
+
servers: list[McpServerStatus]
|
|
45
|
+
fetched_at: float
|
|
46
|
+
tenant_id: str | None = None
|
|
47
|
+
ttl_seconds: float = DEFAULT_TTL_SECONDS
|
|
48
|
+
_by_alias: dict = field(init=False, repr=False)
|
|
49
|
+
|
|
50
|
+
def __post_init__(self) -> None:
|
|
51
|
+
# The platform matches aliases case-insensitively, so the CLI does too.
|
|
52
|
+
self._by_alias = {}
|
|
53
|
+
for server in self.servers:
|
|
54
|
+
if not server.alias:
|
|
55
|
+
continue
|
|
56
|
+
key = server.alias.lower()
|
|
57
|
+
known = self._by_alias.get(key)
|
|
58
|
+
if known is None or _RESTRICTIVENESS[server.status] > _RESTRICTIVENESS[known]:
|
|
59
|
+
self._by_alias[key] = server.status
|
|
60
|
+
|
|
61
|
+
def status_of(self, alias: str) -> McpServerAuthorizationStatus | None:
|
|
62
|
+
return self._by_alias.get(alias.lower())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class McpServerStatusesSchema(Schema):
|
|
66
|
+
class Meta:
|
|
67
|
+
unknown = EXCLUDE
|
|
68
|
+
|
|
69
|
+
servers = fields.List(fields.Nested(McpServerStatusSchema), required=True)
|
|
70
|
+
fetched_at = fields.Float(required=True)
|
|
71
|
+
tenant_id = fields.String(allow_none=True, load_default=None)
|
|
72
|
+
ttl_seconds = fields.Float(allow_none=True, load_default=None)
|
|
73
|
+
|
|
74
|
+
@post_load
|
|
75
|
+
def build_dto(self, data: dict[str, Any], **_) -> McpServerStatuses:
|
|
76
|
+
data['ttl_seconds'] = data['ttl_seconds'] or DEFAULT_TTL_SECONDS
|
|
77
|
+
return McpServerStatuses(**data)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def save_mcp_server_statuses(
|
|
81
|
+
servers: list[McpServerStatus], tenant_id: str | None, ttl_seconds: float = DEFAULT_TTL_SECONDS
|
|
82
|
+
) -> None:
|
|
83
|
+
path = get_mcp_server_statuses_cache_path()
|
|
84
|
+
statuses = McpServerStatuses(servers=servers, fetched_at=time.time(), tenant_id=tenant_id, ttl_seconds=ttl_seconds)
|
|
85
|
+
try:
|
|
86
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
atomic_write_text(str(path), json.dumps(McpServerStatusesSchema().dump(statuses)))
|
|
88
|
+
except Exception as e:
|
|
89
|
+
logger.debug('Failed to save MCP server statuses cache', exc_info=e)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_mcp_server_statuses() -> McpServerStatuses | None:
|
|
93
|
+
path = get_mcp_server_statuses_cache_path()
|
|
94
|
+
if not path.exists():
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
with open(path, encoding='UTF-8') as file:
|
|
99
|
+
return McpServerStatusesSchema().load(json.load(file))
|
|
100
|
+
except Exception as e:
|
|
101
|
+
logger.warning('MCP server statuses cache is corrupt and will be moved aside', exc_info=e)
|
|
102
|
+
quarantine_corrupt_file(str(path))
|
|
103
|
+
return None
|
|
@@ -7,7 +7,6 @@ respective IDE class.
|
|
|
7
7
|
|
|
8
8
|
import uuid
|
|
9
9
|
from dataclasses import dataclass, field
|
|
10
|
-
from typing import Optional
|
|
11
10
|
|
|
12
11
|
|
|
13
12
|
@dataclass
|
|
@@ -15,9 +14,9 @@ class AIHookPayload:
|
|
|
15
14
|
"""Unified payload that normalizes field names across IDEs."""
|
|
16
15
|
|
|
17
16
|
# Event identification
|
|
18
|
-
event_name:
|
|
19
|
-
conversation_id:
|
|
20
|
-
generation_id:
|
|
17
|
+
event_name: str | None = None # Canonical event type from AiHookEventType
|
|
18
|
+
conversation_id: str | None = None
|
|
19
|
+
generation_id: str | None = None
|
|
21
20
|
|
|
22
21
|
# Minted here rather than by the server: the guardrail scan and the hook event are reported in two
|
|
23
22
|
# separate requests, and both have to name the same event. A generation id can't stand in for it - the
|
|
@@ -25,16 +24,16 @@ class AIHookPayload:
|
|
|
25
24
|
hook_event_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
26
25
|
|
|
27
26
|
# User and IDE information
|
|
28
|
-
ide_user_email:
|
|
29
|
-
model:
|
|
30
|
-
ide_provider:
|
|
31
|
-
ide_version:
|
|
27
|
+
ide_user_email: str | None = None
|
|
28
|
+
model: str | None = None
|
|
29
|
+
ide_provider: str | None = None # Matches IDE.name (e.g. 'cursor', 'claude-code')
|
|
30
|
+
ide_version: str | None = None
|
|
32
31
|
|
|
33
|
-
source:
|
|
32
|
+
source: str | None = None
|
|
34
33
|
|
|
35
34
|
# Event-specific data
|
|
36
|
-
prompt:
|
|
37
|
-
file_path:
|
|
38
|
-
mcp_server_name:
|
|
39
|
-
mcp_tool_name:
|
|
40
|
-
mcp_arguments:
|
|
35
|
+
prompt: str | None = None # PROMPT events
|
|
36
|
+
file_path: str | None = None # FILE_READ events
|
|
37
|
+
mcp_server_name: str | None = None # MCP_EXECUTION events
|
|
38
|
+
mcp_tool_name: str | None = None
|
|
39
|
+
mcp_arguments: dict | None = None
|
|
@@ -15,7 +15,7 @@ import json
|
|
|
15
15
|
import os
|
|
16
16
|
import sys
|
|
17
17
|
from pathlib import Path
|
|
18
|
-
from typing import Any
|
|
18
|
+
from typing import Any
|
|
19
19
|
|
|
20
20
|
import yaml
|
|
21
21
|
|
|
@@ -71,7 +71,7 @@ def deep_merge(base: dict, override: dict) -> dict:
|
|
|
71
71
|
return result
|
|
72
72
|
|
|
73
73
|
|
|
74
|
-
def load_yaml_file(path: Path) ->
|
|
74
|
+
def load_yaml_file(path: Path) -> dict | None:
|
|
75
75
|
"""Load a YAML or JSON config file."""
|
|
76
76
|
if not path.exists():
|
|
77
77
|
return None
|
|
@@ -101,7 +101,7 @@ def get_policy_value(policy: dict, *keys: str, default: Any = None) -> Any:
|
|
|
101
101
|
return current
|
|
102
102
|
|
|
103
103
|
|
|
104
|
-
def load_policy(workspace_root:
|
|
104
|
+
def load_policy(workspace_root: str | None = None) -> dict:
|
|
105
105
|
"""
|
|
106
106
|
Load policy by merging configs in order of precedence.
|
|
107
107
|
|
|
@@ -7,7 +7,7 @@ The handlers in ``handlers.py`` are agent-agnostic (they return
|
|
|
7
7
|
``HookDecision``); ``IDE.build_hook_response`` is the per-IDE translation step.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
-
from typing import Annotated
|
|
10
|
+
from typing import Annotated
|
|
11
11
|
from uuid import uuid4
|
|
12
12
|
|
|
13
13
|
import click
|
|
@@ -46,9 +46,9 @@ def _get_auth_error_message(error: Exception) -> str:
|
|
|
46
46
|
|
|
47
47
|
|
|
48
48
|
def _deny_for_event(
|
|
49
|
-
event_name:
|
|
49
|
+
event_name: str | AiHookEventType | None,
|
|
50
50
|
user_message: str,
|
|
51
|
-
agent_message:
|
|
51
|
+
agent_message: str | None = None,
|
|
52
52
|
) -> HookDecision:
|
|
53
53
|
"""Build a deny decision matched to ``event_name``'s response shape.
|
|
54
54
|
|
|
@@ -62,7 +62,7 @@ def _deny_for_event(
|
|
|
62
62
|
return HookDecision.deny(target, user_message, agent_message)
|
|
63
63
|
|
|
64
64
|
|
|
65
|
-
def _should_skip_payload(ide_integration: IDE, payload:
|
|
65
|
+
def _should_skip_payload(ide_integration: IDE, payload: dict | None) -> bool:
|
|
66
66
|
"""Fast exits that never scan: empty/foreign/synthetic payloads all answer a plain allow."""
|
|
67
67
|
if not payload:
|
|
68
68
|
logger.debug('Empty or invalid JSON payload received')
|
|
@@ -5,7 +5,7 @@ import json
|
|
|
5
5
|
import sys
|
|
6
6
|
import time
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from typing import TYPE_CHECKING, Annotated
|
|
8
|
+
from typing import TYPE_CHECKING, Annotated
|
|
9
9
|
|
|
10
10
|
import typer
|
|
11
11
|
|
|
@@ -15,7 +15,14 @@ from cycode.cli.apps.ai_guardrails.ides import (
|
|
|
15
15
|
collect_all_skills,
|
|
16
16
|
get_ide,
|
|
17
17
|
)
|
|
18
|
-
from cycode.cli.apps.ai_guardrails.scan.guardrail_config import
|
|
18
|
+
from cycode.cli.apps.ai_guardrails.scan.guardrail_config import (
|
|
19
|
+
DEFAULT_TTL_SECONDS,
|
|
20
|
+
GuardrailConfig,
|
|
21
|
+
load_guardrail_config,
|
|
22
|
+
save_guardrail_config,
|
|
23
|
+
)
|
|
24
|
+
from cycode.cli.apps.ai_guardrails.scan.mcp_server_status import save_mcp_server_statuses
|
|
25
|
+
from cycode.cli.apps.ai_guardrails.scan.types import BlockReason
|
|
19
26
|
from cycode.cli.apps.ai_guardrails.scan.utils import read_stdin_text, safe_json_parse
|
|
20
27
|
from cycode.cli.apps.auth.auth_common import get_authorization_info
|
|
21
28
|
from cycode.cli.apps.auth.auth_manager import AuthManager
|
|
@@ -49,21 +56,28 @@ def _session_context_digest(report: dict) -> str:
|
|
|
49
56
|
return hashlib.sha256(canonical.encode('utf-8')).hexdigest()
|
|
50
57
|
|
|
51
58
|
|
|
52
|
-
def
|
|
59
|
+
def _report_ttl_seconds(config: GuardrailConfig | None) -> float:
|
|
60
|
+
"""The response carries the MCP server statuses, so the guardrail's TTL applies while it is enabled."""
|
|
61
|
+
if config is None or config.is_off_for_every_agent(BlockReason.UNAUTHORIZED_MCP_SERVER):
|
|
62
|
+
return _SESSION_CONTEXT_TTL_SECONDS
|
|
63
|
+
return config.ttl_seconds
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _should_skip_report(digest: str, tenant_id: str | None, ttl_seconds: float) -> bool:
|
|
53
67
|
"""Skip when the same payload was already sent for this tenant and the TTL hasn't expired."""
|
|
54
68
|
try:
|
|
55
69
|
cache = json.loads(_session_context_cache_path().read_text(encoding='utf-8'))
|
|
56
70
|
return (
|
|
57
71
|
cache.get('hash') == digest
|
|
58
72
|
and cache.get('tenant_id') == tenant_id
|
|
59
|
-
and time.time() - float(cache.get('sent_at', 0)) <
|
|
73
|
+
and time.time() - float(cache.get('sent_at', 0)) < ttl_seconds
|
|
60
74
|
)
|
|
61
75
|
except Exception:
|
|
62
76
|
# Missing/corrupt cache reads as a miss - over-sending is harmless
|
|
63
77
|
return False
|
|
64
78
|
|
|
65
79
|
|
|
66
|
-
def _save_report_cache(digest: str, tenant_id:
|
|
80
|
+
def _save_report_cache(digest: str, tenant_id: str | None) -> None:
|
|
67
81
|
try:
|
|
68
82
|
cache_path = _session_context_cache_path()
|
|
69
83
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -76,8 +90,9 @@ def _save_report_cache(digest: str, tenant_id: Optional[str]) -> None:
|
|
|
76
90
|
|
|
77
91
|
def _report_session_context(
|
|
78
92
|
ai_client: 'AISecurityManagerClient',
|
|
79
|
-
user_email:
|
|
80
|
-
tenant_id:
|
|
93
|
+
user_email: str | None,
|
|
94
|
+
tenant_id: str | None,
|
|
95
|
+
config: GuardrailConfig | None,
|
|
81
96
|
) -> None:
|
|
82
97
|
"""Report the device + cross-IDE session context to the AI security manager. Never raises.
|
|
83
98
|
|
|
@@ -86,7 +101,7 @@ def _report_session_context(
|
|
|
86
101
|
until the TTL expires.
|
|
87
102
|
"""
|
|
88
103
|
try:
|
|
89
|
-
|
|
104
|
+
config_files, enabled_plugins = collect_all_session_contexts()
|
|
90
105
|
report = {
|
|
91
106
|
'hostname': get_hostname(),
|
|
92
107
|
'platform_name': get_platform_name(),
|
|
@@ -94,7 +109,7 @@ def _report_session_context(
|
|
|
94
109
|
'serial_number': get_serial_number(),
|
|
95
110
|
'last_login_user': get_last_login_user(),
|
|
96
111
|
# Sorted by path so the digest is stable regardless of IDE registry order.
|
|
97
|
-
'config_files': sorted(
|
|
112
|
+
'config_files': sorted(config_files, key=lambda f: f['path']),
|
|
98
113
|
'enabled_plugins': enabled_plugins,
|
|
99
114
|
# Already deduplicated and sorted by path, for the same digest-stability reason.
|
|
100
115
|
# Editing a skill body changes the digest and so re-reports the device's inventory.
|
|
@@ -103,12 +118,19 @@ def _report_session_context(
|
|
|
103
118
|
}
|
|
104
119
|
|
|
105
120
|
digest = _session_context_digest(report)
|
|
106
|
-
if _should_skip_report(digest, tenant_id):
|
|
121
|
+
if _should_skip_report(digest, tenant_id, _report_ttl_seconds(config)):
|
|
107
122
|
logger.debug('Session context unchanged; skipping report')
|
|
108
123
|
return
|
|
109
124
|
|
|
110
|
-
|
|
111
|
-
|
|
125
|
+
response = ai_client.report_session_context(**report)
|
|
126
|
+
if response is None:
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
_save_report_cache(digest, tenant_id)
|
|
130
|
+
if response.mcp_servers is not None:
|
|
131
|
+
ttl_seconds = config.ttl_seconds if config is not None else DEFAULT_TTL_SECONDS
|
|
132
|
+
save_mcp_server_statuses(response.mcp_servers, tenant_id, ttl_seconds)
|
|
133
|
+
logger.debug('MCP server statuses cache updated')
|
|
112
134
|
except Exception as e:
|
|
113
135
|
logger.debug('Failed to report session context', exc_info=e)
|
|
114
136
|
|
|
@@ -166,14 +188,14 @@ def session_start_command(
|
|
|
166
188
|
except Exception as e:
|
|
167
189
|
logger.debug('Failed to create conversation during session start', exc_info=e)
|
|
168
190
|
|
|
169
|
-
# Report session context (device + cross-IDE MCP servers and plugins)
|
|
170
|
-
_report_session_context(ai_client, session_payload.ide_user_email, auth_info.tenant_id)
|
|
171
|
-
|
|
172
191
|
# SessionStart precedes the first prompt hook in every IDE, so scans normally find a cache.
|
|
173
|
-
_sync_guardrail_config(ai_client, auth_info.tenant_id)
|
|
192
|
+
config = _sync_guardrail_config(ai_client, auth_info.tenant_id)
|
|
193
|
+
|
|
194
|
+
# Report session context (device + cross-IDE MCP servers and plugins)
|
|
195
|
+
_report_session_context(ai_client, session_payload.ide_user_email, auth_info.tenant_id, config)
|
|
174
196
|
|
|
175
197
|
|
|
176
|
-
def _sync_guardrail_config(ai_client: 'AISecurityManagerClient', tenant_id:
|
|
198
|
+
def _sync_guardrail_config(ai_client: 'AISecurityManagerClient', tenant_id: str | None) -> GuardrailConfig | None:
|
|
177
199
|
"""Refresh the guardrail config cache when it is expired or belongs to another tenant.
|
|
178
200
|
|
|
179
201
|
Every step here swallows its own failures - a broken cache or an unreachable platform
|
|
@@ -182,9 +204,12 @@ def _sync_guardrail_config(ai_client: 'AISecurityManagerClient', tenant_id: Opti
|
|
|
182
204
|
cached = load_guardrail_config()
|
|
183
205
|
if cached is not None and not cached.needs_refresh(tenant_id):
|
|
184
206
|
logger.debug('Guardrail config cache is fresh, skipping fetch')
|
|
185
|
-
return
|
|
207
|
+
return cached
|
|
186
208
|
|
|
187
209
|
resolved = ai_client.get_resolved_guardrails()
|
|
188
|
-
if resolved:
|
|
189
|
-
|
|
190
|
-
|
|
210
|
+
if not resolved:
|
|
211
|
+
return cached
|
|
212
|
+
|
|
213
|
+
save_guardrail_config(resolved, tenant_id)
|
|
214
|
+
logger.debug('Guardrail config cache updated')
|
|
215
|
+
return GuardrailConfig(payload=resolved, fetched_at=time.time(), tenant_id=tenant_id)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
from pathlib import Path
|
|
5
|
-
from typing import Annotated
|
|
5
|
+
from typing import Annotated
|
|
6
6
|
|
|
7
7
|
import typer
|
|
8
8
|
from rich.table import Table
|
|
@@ -30,7 +30,7 @@ def status_command(
|
|
|
30
30
|
),
|
|
31
31
|
] = DEFAULT_IDE_NAME,
|
|
32
32
|
repo_path: Annotated[
|
|
33
|
-
|
|
33
|
+
Path | None,
|
|
34
34
|
typer.Option(
|
|
35
35
|
'--repo-path',
|
|
36
36
|
help='Repository path for repo-scoped status (defaults to current directory).',
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Uninstall command for AI guardrails hooks."""
|
|
2
2
|
|
|
3
3
|
from pathlib import Path
|
|
4
|
-
from typing import Annotated
|
|
4
|
+
from typing import Annotated
|
|
5
5
|
|
|
6
6
|
import typer
|
|
7
7
|
|
|
@@ -28,7 +28,7 @@ def uninstall_command(
|
|
|
28
28
|
),
|
|
29
29
|
] = DEFAULT_IDE_NAME,
|
|
30
30
|
repo_path: Annotated[
|
|
31
|
-
|
|
31
|
+
Path | None,
|
|
32
32
|
typer.Option(
|
|
33
33
|
'--repo-path',
|
|
34
34
|
help='Repository path for repo-scoped uninstallation (defaults to current directory).',
|
cycode/cli/apps/api/__init__.py
CHANGED
|
@@ -5,7 +5,7 @@ The spec is fetched lazily — only when the user invokes `cycode platform ...`
|
|
|
5
5
|
and cached locally for 24 hours.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
from typing import Any
|
|
8
|
+
from typing import Any
|
|
9
9
|
|
|
10
10
|
import click
|
|
11
11
|
|
|
@@ -32,7 +32,7 @@ class PlatformGroup(click.Group):
|
|
|
32
32
|
super().__init__(*args, **kwargs)
|
|
33
33
|
self._loaded: bool = False
|
|
34
34
|
|
|
35
|
-
def _ensure_loaded(self, ctx:
|
|
35
|
+
def _ensure_loaded(self, ctx: click.Context | None) -> None:
|
|
36
36
|
if self._loaded:
|
|
37
37
|
return
|
|
38
38
|
self._loaded = True # set first to avoid re-entrancy on errors
|
|
@@ -59,7 +59,7 @@ class PlatformGroup(click.Group):
|
|
|
59
59
|
self._ensure_loaded(ctx)
|
|
60
60
|
return super().list_commands(ctx)
|
|
61
61
|
|
|
62
|
-
def get_command(self, ctx: click.Context, cmd_name: str) ->
|
|
62
|
+
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
|
|
63
63
|
self._ensure_loaded(ctx)
|
|
64
64
|
return super().get_command(ctx, cmd_name)
|
|
65
65
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import re
|
|
5
|
-
from typing import Any
|
|
5
|
+
from typing import Any
|
|
6
6
|
|
|
7
7
|
import click
|
|
8
8
|
|
|
@@ -60,7 +60,7 @@ def _path_to_command_name(path: str, common_prefix: str, has_path_params: bool)
|
|
|
60
60
|
/v4/violations/count (prefix=/v4/violations) -> count
|
|
61
61
|
"""
|
|
62
62
|
# Strip common prefix
|
|
63
|
-
relative = path
|
|
63
|
+
relative = path.removeprefix(common_prefix)
|
|
64
64
|
relative = relative.strip('/')
|
|
65
65
|
|
|
66
66
|
# Remove path parameter segments and empty parts
|
|
@@ -91,8 +91,8 @@ def _make_api_request(
|
|
|
91
91
|
method: str,
|
|
92
92
|
path_params: dict[str, str],
|
|
93
93
|
query_params: dict[str, Any],
|
|
94
|
-
client_id:
|
|
95
|
-
client_secret:
|
|
94
|
+
client_id: str | None = None,
|
|
95
|
+
client_secret: str | None = None,
|
|
96
96
|
) -> dict:
|
|
97
97
|
"""Execute an API request using the CLI's standard auth client."""
|
|
98
98
|
from urllib.parse import quote
|
|
@@ -115,8 +115,8 @@ def _make_api_request(
|
|
|
115
115
|
|
|
116
116
|
|
|
117
117
|
def build_api_command_groups(
|
|
118
|
-
client_id:
|
|
119
|
-
client_secret:
|
|
118
|
+
client_id: str | None = None,
|
|
119
|
+
client_secret: str | None = None,
|
|
120
120
|
) -> list[tuple[click.Group, str]]:
|
|
121
121
|
"""Build Click command groups from the OpenAPI spec.
|
|
122
122
|
|