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.
Files changed (125) hide show
  1. cycode/__init__.py +1 -1
  2. cycode/cli/app.py +9 -10
  3. cycode/cli/apps/activation_manager.py +6 -6
  4. cycode/cli/apps/ai_guardrails/command_utils.py +1 -2
  5. cycode/cli/apps/ai_guardrails/hooks_manager.py +6 -7
  6. cycode/cli/apps/ai_guardrails/ides/__init__.py +8 -9
  7. cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py +11 -10
  8. cycode/cli/apps/ai_guardrails/ides/_skill_utils.py +1 -2
  9. cycode/cli/apps/ai_guardrails/ides/base.py +19 -21
  10. cycode/cli/apps/ai_guardrails/ides/claude_code.py +54 -16
  11. cycode/cli/apps/ai_guardrails/ides/codex.py +54 -20
  12. cycode/cli/apps/ai_guardrails/ides/copilot.py +23 -25
  13. cycode/cli/apps/ai_guardrails/ides/cursor.py +48 -10
  14. cycode/cli/apps/ai_guardrails/install_command.py +3 -3
  15. cycode/cli/apps/ai_guardrails/scan/guardrail_config.py +44 -22
  16. cycode/cli/apps/ai_guardrails/scan/handlers.py +79 -14
  17. cycode/cli/apps/ai_guardrails/scan/mcp_server_status.py +103 -0
  18. cycode/cli/apps/ai_guardrails/scan/payload.py +13 -14
  19. cycode/cli/apps/ai_guardrails/scan/policy.py +3 -3
  20. cycode/cli/apps/ai_guardrails/scan/scan_command.py +4 -4
  21. cycode/cli/apps/ai_guardrails/scan/types.py +1 -0
  22. cycode/cli/apps/ai_guardrails/session_start_command.py +46 -21
  23. cycode/cli/apps/ai_guardrails/status_command.py +2 -2
  24. cycode/cli/apps/ai_guardrails/uninstall_command.py +2 -2
  25. cycode/cli/apps/api/__init__.py +3 -3
  26. cycode/cli/apps/api/api_command.py +6 -6
  27. cycode/cli/apps/api/openapi_spec.py +4 -5
  28. cycode/cli/apps/auth/auth_common.py +4 -4
  29. cycode/cli/apps/configure/configure_command.py +2 -4
  30. cycode/cli/apps/configure/messages.py +2 -4
  31. cycode/cli/apps/configure/prompts.py +5 -7
  32. cycode/cli/apps/ignore/ignore_command.py +7 -7
  33. cycode/cli/apps/mcp/mcp_command.py +11 -11
  34. cycode/cli/apps/report/sbom/common.py +5 -5
  35. cycode/cli/apps/report/sbom/sbom_command.py +2 -2
  36. cycode/cli/apps/report/sbom/sbom_report_file.py +1 -2
  37. cycode/cli/apps/report_import/sbom/sbom_command.py +3 -3
  38. cycode/cli/apps/sca_options.py +3 -3
  39. cycode/cli/apps/scan/aggregation_report.py +4 -4
  40. cycode/cli/apps/scan/code_scanner.py +8 -7
  41. cycode/cli/apps/scan/commit_range_scanner.py +10 -10
  42. cycode/cli/apps/scan/detection_excluder.py +2 -4
  43. cycode/cli/apps/scan/pre_commit/pre_commit_command.py +3 -3
  44. cycode/cli/apps/scan/pre_push/pre_push_command.py +3 -3
  45. cycode/cli/apps/scan/pre_receive/pre_receive_command.py +2 -2
  46. cycode/cli/apps/scan/remote_url_resolver.py +6 -8
  47. cycode/cli/apps/scan/repository/repository_command.py +2 -2
  48. cycode/cli/apps/scan/scan_command.py +2 -2
  49. cycode/cli/apps/scan/scan_parameters.py +1 -3
  50. cycode/cli/apps/scan/scan_result.py +5 -5
  51. cycode/cli/console.py +2 -2
  52. cycode/cli/exceptions/custom_exceptions.py +1 -1
  53. cycode/cli/exceptions/handle_errors.py +1 -3
  54. cycode/cli/exceptions/handle_scan_errors.py +1 -2
  55. cycode/cli/files_collector/commit_range_documents.py +14 -14
  56. cycode/cli/files_collector/models/in_memory_zip.py +3 -3
  57. cycode/cli/files_collector/repository_documents.py +3 -3
  58. cycode/cli/files_collector/sca/base_restore_dependencies.py +7 -8
  59. cycode/cli/files_collector/sca/go/restore_go_dependencies.py +1 -2
  60. cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py +2 -3
  61. cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py +3 -4
  62. cycode/cli/files_collector/sca/npm/restore_bun_dependencies.py +3 -4
  63. cycode/cli/files_collector/sca/npm/restore_deno_dependencies.py +1 -2
  64. cycode/cli/files_collector/sca/npm/restore_pnpm_dependencies.py +2 -3
  65. cycode/cli/files_collector/sca/npm/restore_yarn_dependencies.py +2 -3
  66. cycode/cli/files_collector/sca/npm/workspace/coverage.py +5 -5
  67. cycode/cli/files_collector/sca/npm/workspace/files.py +2 -3
  68. cycode/cli/files_collector/sca/npm/workspace/globs.py +1 -2
  69. cycode/cli/files_collector/sca/npm/workspace/resolvers.py +14 -16
  70. cycode/cli/files_collector/sca/php/restore_composer_dependencies.py +1 -2
  71. cycode/cli/files_collector/sca/python/restore_pip_dependencies.py +2 -3
  72. cycode/cli/files_collector/sca/python/restore_pipenv_dependencies.py +1 -2
  73. cycode/cli/files_collector/sca/python/restore_poetry_dependencies.py +2 -3
  74. cycode/cli/files_collector/sca/python/restore_uv_dependencies.py +2 -3
  75. cycode/cli/files_collector/sca/sca_file_collector.py +5 -5
  76. cycode/cli/files_collector/zip_documents.py +1 -2
  77. cycode/cli/models.py +8 -8
  78. cycode/cli/printers/console_printer.py +9 -9
  79. cycode/cli/printers/json_printer.py +2 -2
  80. cycode/cli/printers/printer_base.py +3 -3
  81. cycode/cli/printers/rich_printer.py +2 -2
  82. cycode/cli/printers/tables/table.py +4 -4
  83. cycode/cli/printers/tables/table_models.py +2 -2
  84. cycode/cli/printers/tables/table_printer_base.py +2 -2
  85. cycode/cli/printers/text_printer.py +4 -4
  86. cycode/cli/printers/utils/code_snippet_syntax.py +1 -1
  87. cycode/cli/printers/utils/detection_data.py +3 -3
  88. cycode/cli/printers/utils/sca_ossf.py +4 -4
  89. cycode/cli/printers/utils/sca_policy_details.py +2 -1
  90. cycode/cli/user_settings/config_file_manager.py +10 -10
  91. cycode/cli/user_settings/configuration_manager.py +6 -6
  92. cycode/cli/user_settings/credentials_manager.py +5 -6
  93. cycode/cli/utils/get_api_client.py +5 -5
  94. cycode/cli/utils/git_proxy.py +8 -8
  95. cycode/cli/utils/host_info.py +21 -38
  96. cycode/cli/utils/ignore_utils.py +13 -15
  97. cycode/cli/utils/jwt_utils.py +2 -4
  98. cycode/cli/utils/path_utils.py +6 -6
  99. cycode/cli/utils/progress_bar.py +4 -4
  100. cycode/cli/utils/scan_batch.py +2 -1
  101. cycode/cli/utils/scan_utils.py +2 -2
  102. cycode/cli/utils/shell_executor.py +3 -4
  103. cycode/cli/utils/task_timer.py +4 -4
  104. cycode/cli/utils/trust_store.py +0 -17
  105. cycode/cli/utils/url_utils.py +1 -2
  106. cycode/cli/utils/version_checker.py +4 -5
  107. cycode/cli/utils/yaml_utils.py +1 -1
  108. cycode/config.py +1 -2
  109. cycode/cyclient/ai_security_manager_client.py +27 -21
  110. cycode/cyclient/auth_client.py +2 -4
  111. cycode/cyclient/base_token_auth_client.py +2 -2
  112. cycode/cyclient/client_creator.py +4 -6
  113. cycode/cyclient/cycode_client_base.py +10 -9
  114. cycode/cyclient/cycode_dev_based_client.py +1 -3
  115. cycode/cyclient/headers.py +1 -2
  116. cycode/cyclient/import_sbom_client.py +3 -4
  117. cycode/cyclient/models.py +88 -30
  118. cycode/cyclient/report_client.py +1 -2
  119. cycode/cyclient/scan_client.py +8 -7
  120. cycode/logger.py +4 -4
  121. {cycode-3.25.1.dev6.dist-info → cycode-3.25.2.dev2.dist-info}/METADATA +12 -19
  122. {cycode-3.25.1.dev6.dist-info → cycode-3.25.2.dev2.dist-info}/RECORD +125 -124
  123. {cycode-3.25.1.dev6.dist-info → cycode-3.25.2.dev2.dist-info}/WHEEL +1 -1
  124. {cycode-3.25.1.dev6.dist-info → cycode-3.25.2.dev2.dist-info}/entry_points.txt +0 -0
  125. {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, Callable, NamedTuple, Optional
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: Optional[str] = None
51
- scan_id: Optional[str] = None
52
- verdict: Optional[GuardrailsMode] = None
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: Optional[str]) -> Optional[GuardrailsMode]:
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 e
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 e
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 e
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
- """Scan MCP tool arguments for secrets before execution."""
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) -> Optional[HandlerFn]:
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: Optional['GuardrailConfig'],
387
+ config: 'GuardrailConfig | None',
323
388
  policy: dict,
324
389
  event_name: str,
325
- ide_name: Optional[str],
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: Optional[tuple[str, ...]],
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: Optional[str] = None # Canonical event type from AiHookEventType
19
- conversation_id: Optional[str] = None
20
- generation_id: Optional[str] = None
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: Optional[str] = None
29
- model: Optional[str] = None
30
- ide_provider: Optional[str] = None # Matches IDE.name (e.g. 'cursor', 'claude-code')
31
- ide_version: Optional[str] = None
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: Optional[str] = None
32
+ source: str | None = None
34
33
 
35
34
  # Event-specific data
36
- prompt: Optional[str] = None # PROMPT events
37
- file_path: Optional[str] = None # FILE_READ events
38
- mcp_server_name: Optional[str] = None # MCP_EXECUTION events
39
- mcp_tool_name: Optional[str] = None
40
- mcp_arguments: Optional[dict] = None
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, Optional
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) -> Optional[dict]:
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: Optional[str] = None) -> dict:
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, Optional, Union
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: Optional[Union[str, AiHookEventType]],
49
+ event_name: str | AiHookEventType | None,
50
50
  user_message: str,
51
- agent_message: Optional[str] = None,
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: Optional[dict]) -> bool:
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')
@@ -40,6 +40,7 @@ class BlockReason(StrEnum):
40
40
  SECRETS_IN_FILE = 'secrets_in_file'
41
41
  SECRETS_IN_MCP_ARGS = 'secrets_in_mcp_args'
42
42
  SENSITIVE_PATH = 'sensitive_path'
43
+ UNAUTHORIZED_MCP_SERVER = 'unauthorized_mcp_server'
43
44
  SCAN_FAILURE = 'scan_failure'
44
45
 
45
46
 
@@ -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, Optional
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 load_guardrail_config, save_guardrail_config
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 _should_skip_report(digest: str, tenant_id: Optional[str]) -> bool:
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)) < _SESSION_CONTEXT_TTL_SECONDS
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: Optional[str]) -> None:
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: Optional[str],
80
- tenant_id: Optional[str],
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
- config_files_by_ide, enabled_plugins = collect_all_session_contexts()
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(config_files_by_ide.values(), key=lambda f: f['path']),
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
- if ai_client.report_session_context(**report):
111
- _save_report_cache(digest, tenant_id)
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: Optional[str]) -> None:
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
- save_guardrail_config(resolved, tenant_id)
190
- logger.debug('Guardrail config cache updated')
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, Optional
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
- Optional[Path],
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, Optional
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
- Optional[Path],
31
+ Path | None,
32
32
  typer.Option(
33
33
  '--repo-path',
34
34
  help='Repository path for repo-scoped uninstallation (defaults to current directory).',
@@ -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, Optional
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: Optional[click.Context]) -> None:
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) -> Optional[click.Command]:
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, Optional
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[len(common_prefix) :] if path.startswith(common_prefix) else 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: Optional[str] = None,
95
- client_secret: Optional[str] = None,
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: Optional[str] = None,
119
- client_secret: Optional[str] = None,
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