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
cycode/__init__.py CHANGED
@@ -5,4 +5,4 @@ import time as _time
5
5
  # end-to-end scan duration from the moment the user actually triggered it.
6
6
  _BOOT_WALL: float = _time.time()
7
7
 
8
- __version__ = '3.25.1.dev6' # DON'T TOUCH. Placeholder. Will be filled automatically on poetry build from Git Tag
8
+ __version__ = '3.25.2.dev2' # DON'T TOUCH. Placeholder. Will be filled automatically on poetry build from Git Tag
cycode/cli/app.py CHANGED
@@ -1,7 +1,7 @@
1
1
  import importlib
2
2
  import logging
3
3
  import sys
4
- from typing import Annotated, Optional
4
+ from typing import Annotated
5
5
 
6
6
  import click
7
7
  import typer
@@ -51,13 +51,12 @@ _SUBAPP_MODULES: dict[str, str] = {
51
51
  'auth': 'cycode.cli.apps.auth',
52
52
  'configure': 'cycode.cli.apps.configure',
53
53
  'ignore': 'cycode.cli.apps.ignore',
54
+ 'mcp': 'cycode.cli.apps.mcp',
54
55
  'report': 'cycode.cli.apps.report',
55
56
  'import': 'cycode.cli.apps.report_import',
56
57
  'scan': 'cycode.cli.apps.scan',
57
58
  'status': 'cycode.cli.apps.status',
58
59
  }
59
- if sys.version_info >= (3, 10):
60
- _SUBAPP_MODULES['mcp'] = 'cycode.cli.apps.mcp'
61
60
 
62
61
  # Aliases: alternate spellings that resolve to a primary subcommand key.
63
62
  _SUBAPP_ALIASES: dict[str, str] = {
@@ -80,7 +79,7 @@ _ROOT_OPTS_WITH_VALUE = frozenset(
80
79
  )
81
80
 
82
81
 
83
- def _detect_invocation() -> tuple[Optional[str], Optional[str]]:
82
+ def _detect_invocation() -> tuple[str | None, str | None]:
84
83
  """Return (top-level-subapp, second-level-subcommand) parsed from sys.argv.
85
84
 
86
85
  Both values may be None: when no positional arg matches a known subapp,
@@ -113,7 +112,7 @@ def _detect_invocation() -> tuple[Optional[str], Optional[str]]:
113
112
  _INVOKED_SUBAPP, _INVOKED_SUBCOMMAND = _detect_invocation()
114
113
 
115
114
 
116
- def _register_subapps(only: Optional[str]) -> None:
115
+ def _register_subapps(only: str | None) -> None:
117
116
  if only is not None:
118
117
  app.add_typer(importlib.import_module(_SUBAPP_MODULES[only]).app)
119
118
  return
@@ -189,32 +188,32 @@ def app_callback(
189
188
  OutputTypeOption, typer.Option('--output', '-o', case_sensitive=False, help='Specify the output type.')
190
189
  ] = OutputTypeOption.RICH,
191
190
  user_agent: Annotated[
192
- Optional[str],
191
+ str | None,
193
192
  typer.Option(hidden=True, help='Characteristic JSON object that lets servers identify the application.'),
194
193
  ] = None,
195
194
  client_secret: Annotated[
196
- Optional[str],
195
+ str | None,
197
196
  typer.Option(
198
197
  help='Specify a Cycode client secret for this specific scan execution.',
199
198
  rich_help_panel=_AUTH_RICH_HELP_PANEL,
200
199
  ),
201
200
  ] = None,
202
201
  client_id: Annotated[
203
- Optional[str],
202
+ str | None,
204
203
  typer.Option(
205
204
  help='Specify a Cycode client ID for this specific scan execution.',
206
205
  rich_help_panel=_AUTH_RICH_HELP_PANEL,
207
206
  ),
208
207
  ] = None,
209
208
  id_token: Annotated[
210
- Optional[str],
209
+ str | None,
211
210
  typer.Option(
212
211
  help='Specify a Cycode OIDC ID token for this specific scan execution.',
213
212
  rich_help_panel=_AUTH_RICH_HELP_PANEL,
214
213
  ),
215
214
  ] = None,
216
215
  _: Annotated[
217
- Optional[bool],
216
+ bool | None,
218
217
  typer.Option(
219
218
  '--install-completion',
220
219
  callback=install_callback,
@@ -1,4 +1,4 @@
1
- from typing import TYPE_CHECKING, Optional
1
+ from typing import TYPE_CHECKING
2
2
 
3
3
  from cycode import __version__
4
4
  from cycode.cli.config import configuration_manager
@@ -13,13 +13,13 @@ logger = get_logger('Activation Manager')
13
13
  _CLI_CLIENT_NAME = 'cli'
14
14
 
15
15
 
16
- def _get_client_and_version(plugin_app_name: Optional[str], plugin_app_version: Optional[str]) -> tuple[str, str]:
16
+ def _get_client_and_version(plugin_app_name: str | None, plugin_app_version: str | None) -> tuple[str, str]:
17
17
  return plugin_app_name or _CLI_CLIENT_NAME, plugin_app_version or __version__
18
18
 
19
19
 
20
20
  def should_report_cli_activation(
21
- plugin_app_name: Optional[str] = None,
22
- plugin_app_version: Optional[str] = None,
21
+ plugin_app_name: str | None = None,
22
+ plugin_app_version: str | None = None,
23
23
  ) -> bool:
24
24
  client, version = _get_client_and_version(plugin_app_name, plugin_app_version)
25
25
  return configuration_manager.get_last_reported_activation_version(client) != version
@@ -27,8 +27,8 @@ def should_report_cli_activation(
27
27
 
28
28
  def report_cli_activation(
29
29
  cycode_client: 'CycodeClientBase',
30
- plugin_app_name: Optional[str] = None,
31
- plugin_app_version: Optional[str] = None,
30
+ plugin_app_name: str | None = None,
31
+ plugin_app_version: str | None = None,
32
32
  ) -> None:
33
33
  """Report CLI/IDE activation to the backend if the (client, version) pair is new.
34
34
 
@@ -2,7 +2,6 @@
2
2
 
3
3
  import os
4
4
  from pathlib import Path
5
- from typing import Optional
6
5
 
7
6
  import typer
8
7
  from rich.console import Console
@@ -18,7 +17,7 @@ def validate_scope(scope: str, allowed_scopes: tuple[str, ...] = ('user', 'repo'
18
17
  raise typer.Exit(1)
19
18
 
20
19
 
21
- def resolve_repo_path(scope: str, repo_path: Optional[Path]) -> Optional[Path]:
20
+ def resolve_repo_path(scope: str, repo_path: Path | None) -> Path | None:
22
21
  """Default repo_path to cwd for 'repo' scope; leave None for 'user' scope."""
23
22
  if scope == 'repo' and repo_path is None:
24
23
  return Path(os.getcwd())
@@ -8,7 +8,6 @@ agent-agnostic.
8
8
  import copy
9
9
  import json
10
10
  from pathlib import Path
11
- from typing import Optional
12
11
 
13
12
  import yaml
14
13
 
@@ -47,7 +46,7 @@ def is_cycode_hook_entry(entry: dict) -> bool:
47
46
  return False
48
47
 
49
48
 
50
- def _strip_cycode_from_entry(entry: dict) -> Optional[dict]:
49
+ def _strip_cycode_from_entry(entry: dict) -> dict | None:
51
50
  """Remove Cycode hooks from ``entry`` and return the remainder.
52
51
 
53
52
  Returns ``None`` when nothing useful remains (Cursor-flat Cycode entry, or
@@ -72,7 +71,7 @@ def _strip_cycode_from_entry(entry: dict) -> Optional[dict]:
72
71
  return entry
73
72
 
74
73
 
75
- def _load_hooks_file(hooks_path: Path) -> Optional[dict]:
74
+ def _load_hooks_file(hooks_path: Path) -> dict | None:
76
75
  if not hooks_path.exists():
77
76
  return None
78
77
  try:
@@ -102,7 +101,7 @@ def _load_policy_dict(policy_path: Path) -> dict:
102
101
  return {**copy.deepcopy(DEFAULT_POLICY), **existing}
103
102
 
104
103
 
105
- def create_policy_file(scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]:
104
+ def create_policy_file(scope: str, repo_path: Path | None = None) -> tuple[bool, str]:
106
105
  """Create or update the ai-guardrails.yaml policy file (operational knobs only).
107
106
 
108
107
  Enforcement mode and sensitive-path globs are platform-managed; those keys are stripped
@@ -125,7 +124,7 @@ def create_policy_file(scope: str, repo_path: Optional[Path] = None) -> tuple[bo
125
124
  def install_hooks(
126
125
  ide: IDE,
127
126
  scope: str = 'user',
128
- repo_path: Optional[Path] = None,
127
+ repo_path: Path | None = None,
129
128
  ) -> tuple[bool, str]:
130
129
  """Install Cycode AI guardrails hooks for ``ide``."""
131
130
  hooks_path = ide.settings_path(scope, repo_path)
@@ -197,7 +196,7 @@ def _persist_uninstall(hooks_path: Path, existing: dict, modified: bool) -> tupl
197
196
  return True, f'Cycode hooks removed from: {hooks_path}'
198
197
 
199
198
 
200
- def uninstall_hooks(ide: IDE, scope: str = 'user', repo_path: Optional[Path] = None) -> tuple[bool, str]:
199
+ def uninstall_hooks(ide: IDE, scope: str = 'user', repo_path: Path | None = None) -> tuple[bool, str]:
201
200
  """Remove Cycode AI guardrails hooks for ``ide``."""
202
201
  hooks_path = ide.settings_path(scope, repo_path)
203
202
 
@@ -218,7 +217,7 @@ def uninstall_hooks(ide: IDE, scope: str = 'user', repo_path: Optional[Path] = N
218
217
  return True, message
219
218
 
220
219
 
221
- def get_hooks_status(ide: IDE, scope: str = 'user', repo_path: Optional[Path] = None) -> dict:
220
+ def get_hooks_status(ide: IDE, scope: str = 'user', repo_path: Path | None = None) -> dict:
222
221
  """Return installation status of Cycode hooks for ``ide``."""
223
222
  hooks_path = ide.settings_path(scope, repo_path)
224
223
 
@@ -35,23 +35,22 @@ def get_ide(name: str) -> IDE:
35
35
  return ide
36
36
 
37
37
 
38
- def collect_all_session_contexts() -> tuple[dict[str, dict], dict]:
38
+ def collect_all_session_contexts() -> tuple[list[dict], dict]:
39
39
  """Sweep every registered IDE's session context, regardless of which IDE triggered the hook.
40
40
 
41
- Returns ``(config_files_by_ide, plugins)``: the global MCP config file of each IDE that has
42
- one (keyed by IDE name), and the enabled plugins merged across IDEs (first registered IDE
43
- wins on a duplicate plugin key - plugins are IDE-agnostic marketplace artifacts).
41
+ Returns ``(config_files, plugins)``: the global MCP config files of every IDE, and the
42
+ enabled plugins merged across IDEs (first registered IDE wins on a duplicate plugin key -
43
+ plugins are IDE-agnostic marketplace artifacts).
44
44
  """
45
- config_files_by_ide: dict[str, dict] = {}
45
+ config_files: list[dict] = []
46
46
  plugins: dict = {}
47
47
  for ide in IDES.values():
48
- global_config_file, enabled_plugins = ide.get_session_context()
49
- if global_config_file:
50
- config_files_by_ide[ide.name] = global_config_file
48
+ global_config_files, enabled_plugins = ide.get_session_context()
49
+ config_files.extend(global_config_files)
51
50
  for plugin_key, plugin in (enabled_plugins or {}).items():
52
51
  plugins.setdefault(plugin_key, plugin)
53
52
 
54
- return config_files_by_ide, plugins
53
+ return config_files, plugins
55
54
 
56
55
 
57
56
  def collect_all_skills() -> list[dict]:
@@ -7,15 +7,16 @@ supplies the two callables that vary (``locate_dir`` + ``read_plugin``).
7
7
  """
8
8
 
9
9
  import json
10
+ from collections.abc import Callable
10
11
  from pathlib import Path
11
- from typing import Any, Callable, Optional
12
+ from typing import Any
12
13
 
13
14
  from cycode.logger import get_logger
14
15
 
15
16
  logger = get_logger('AI Guardrails Plugins')
16
17
 
17
18
 
18
- def resolve_cached_plugin_dir(cache_root: Path, marketplace: str, plugin_name: str) -> Optional[Path]:
19
+ def resolve_cached_plugin_dir(cache_root: Path, marketplace: str, plugin_name: str) -> Path | None:
19
20
  """Find ``<cache_root>/<marketplace>/<plugin>/<version-or-hash>/``.
20
21
 
21
22
  Both Claude Code and Codex cache installed plugin content in this layout (the trailing
@@ -31,7 +32,7 @@ def resolve_cached_plugin_dir(cache_root: Path, marketplace: str, plugin_name: s
31
32
  return max(candidates, key=lambda d: (d.stat().st_mtime, d.name))
32
33
 
33
34
 
34
- def load_plugin_json(path: Path) -> Optional[dict]:
35
+ def load_plugin_json(path: Path) -> dict | None:
35
36
  """Load a JSON file inside a plugin directory; None if missing or invalid."""
36
37
  if not path.exists():
37
38
  return None
@@ -42,23 +43,23 @@ def load_plugin_json(path: Path) -> Optional[dict]:
42
43
  return None
43
44
 
44
45
 
45
- def build_global_config_file(path: Path, mcp_servers: Optional[dict]) -> Optional[dict]:
46
+ def build_global_config_files(path: Path, mcp_servers: dict | None) -> list[dict]:
46
47
  """Wrap a global (non-plugin) MCP config into the session-context file shape.
47
48
 
48
- Returns ``{"path": <full path>, "content": <{"mcpServers": ...} JSON>}`` when
49
- there are servers, else ``None``. ``content`` is normalized to the canonical
49
+ Returns ``[{"path": <full path>, "content": <{"mcpServers": ...} JSON>}]`` when
50
+ there are servers, else ``[]``. ``content`` is normalized to the canonical
50
51
  ``{"mcpServers": {...}}`` shape, dropping everything else in the source file.
51
52
  """
52
- servers = mcp_servers or {}
53
+ servers = mcp_servers if isinstance(mcp_servers, dict) else {}
53
54
  if not servers:
54
- return None
55
- return {'path': str(path), 'content': json.dumps({'mcpServers': servers})}
55
+ return []
56
+ return [{'path': str(path), 'content': json.dumps({'mcpServers': servers})}]
56
57
 
57
58
 
58
59
  def walk_enabled_plugins(
59
60
  plugin_entries: dict[str, Any],
60
61
  is_enabled: Callable[[Any], bool],
61
- locate_dir: Callable[[str, str], Optional[Path]],
62
+ locate_dir: Callable[[str, str], Path | None],
62
63
  read_plugin: Callable[[Path], tuple[dict, dict]],
63
64
  ) -> dict:
64
65
  """Iterate enabled plugins and build their inventory metadata.
@@ -11,7 +11,6 @@ the device its MCP inventory as well.
11
11
  """
12
12
 
13
13
  from pathlib import Path
14
- from typing import Optional
15
14
 
16
15
  from cycode.logger import get_logger
17
16
 
@@ -32,7 +31,7 @@ MAX_SKILL_FILE_BYTES = 256 * 1024
32
31
  MAX_SKILLS_PER_ROOT = 200
33
32
 
34
33
 
35
- def _read_skill_file(skill_file: Path) -> Optional[dict]:
34
+ def _read_skill_file(skill_file: Path) -> dict | None:
36
35
  """Read one ``SKILL.md`` into the session-context file shape, or None if unusable."""
37
36
  try:
38
37
  size = skill_file.stat().st_size
@@ -18,7 +18,7 @@ from abc import ABC, abstractmethod
18
18
  from dataclasses import dataclass
19
19
  from enum import Enum
20
20
  from pathlib import Path
21
- from typing import ClassVar, Optional
21
+ from typing import ClassVar
22
22
 
23
23
  from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
24
24
  from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
@@ -44,17 +44,15 @@ class HookDecision:
44
44
 
45
45
  action: DecisionAction
46
46
  event_type: AiHookEventType
47
- user_message: Optional[str] = None
48
- agent_message: Optional[str] = None
47
+ user_message: str | None = None
48
+ agent_message: str | None = None
49
49
 
50
50
  @classmethod
51
51
  def allow(cls, event_type: AiHookEventType) -> 'HookDecision':
52
52
  return cls(action=DecisionAction.ALLOW, event_type=event_type)
53
53
 
54
54
  @classmethod
55
- def deny(
56
- cls, event_type: AiHookEventType, user_message: str, agent_message: Optional[str] = None
57
- ) -> 'HookDecision':
55
+ def deny(cls, event_type: AiHookEventType, user_message: str, agent_message: str | None = None) -> 'HookDecision':
58
56
  return cls(
59
57
  action=DecisionAction.DENY,
60
58
  event_type=event_type,
@@ -63,7 +61,7 @@ class HookDecision:
63
61
  )
64
62
 
65
63
  @classmethod
66
- def ask(cls, event_type: AiHookEventType, user_message: str, agent_message: Optional[str] = None) -> 'HookDecision':
64
+ def ask(cls, event_type: AiHookEventType, user_message: str, agent_message: str | None = None) -> 'HookDecision':
67
65
  return cls(
68
66
  action=DecisionAction.ASK,
69
67
  event_type=event_type,
@@ -92,7 +90,7 @@ class IDE(ABC):
92
90
  # --- install / status ---
93
91
 
94
92
  @abstractmethod
95
- def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path:
93
+ def settings_path(self, scope: str, repo_path: Path | None = None) -> Path:
96
94
  """Return the hooks/settings file path for the given scope.
97
95
 
98
96
  `scope` is 'user' or 'repo'. `repo_path` is required when scope == 'repo'.
@@ -108,7 +106,7 @@ class IDE(ABC):
108
106
  ``hooks_manager`` can treat them uniformly.
109
107
  """
110
108
 
111
- def post_install(self, scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]:
109
+ def post_install(self, scope: str, repo_path: Path | None = None) -> tuple[bool, str]:
112
110
  """Run IDE-specific actions after the hooks file is written.
113
111
 
114
112
  Default: no-op success. Override to perform extra setup that doesn't
@@ -120,7 +118,7 @@ class IDE(ABC):
120
118
  """
121
119
  return True, ''
122
120
 
123
- def post_uninstall(self, scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]:
121
+ def post_uninstall(self, scope: str, repo_path: Path | None = None) -> tuple[bool, str]:
124
122
  """Run IDE-specific cleanup after the hooks file is removed.
125
123
 
126
124
  Default: no-op success. Override to undo whatever ``post_install``
@@ -169,26 +167,26 @@ class IDE(ABC):
169
167
  """
170
168
  return AIHookPayload(ide_provider=self.name)
171
169
 
172
- def get_user_email(self) -> Optional[str]:
170
+ def get_user_email(self) -> str | None:
173
171
  """Best-effort read of the user's email from IDE-specific config.
174
172
 
175
173
  Default: None. Override if the IDE stores a usable account locally.
176
174
  """
177
175
  return None
178
176
 
179
- def get_session_context(self) -> tuple[Optional[dict], dict]:
180
- """Return ``(global_config_file, enabled_plugins)`` for session-context reporting.
177
+ def get_session_context(self) -> tuple[list[dict], dict]:
178
+ """Return ``(global_config_files, enabled_plugins)`` for session-context reporting.
181
179
 
182
- ``global_config_file`` is the IDE's global (non-plugin) MCP config as
183
- ``{"path": <full path>, "content": <normalized {"mcpServers": ...} JSON>}``,
184
- or ``None`` when there is no global MCP config. ``enabled_plugins`` maps each
185
- enabled plugin key to its metadata (including its own ``mcp_config_file``
186
- content and ``mcp_config_file_path``).
180
+ ``global_config_files`` are the IDE's global (non-plugin) MCP configs, each as
181
+ ``{"path": <full path>, "content": <normalized {"mcpServers": ...} JSON>}``;
182
+ empty when there is none. ``enabled_plugins`` maps each enabled plugin key to
183
+ its metadata (including its own ``mcp_config_file`` content and
184
+ ``mcp_config_file_path``).
187
185
 
188
- Default: ``(None, {})`` (no plugin system, no discoverable MCP config).
186
+ Default: ``([], {})`` (no plugin system, no discoverable MCP config).
189
187
  Override to surface MCP/plugin inventory.
190
188
  """
191
- return None, {}
189
+ return [], {}
192
190
 
193
191
  def get_skills(self) -> list[dict]:
194
192
  """Return the IDE's user-scope skills as ``[{"path", "content"}]``.
@@ -198,7 +196,7 @@ class IDE(ABC):
198
196
  read these files off endpoints can only ever return raw content.
199
197
 
200
198
  Kept separate from ``get_session_context`` rather than folded into its
201
- ``global_config_file`` slot, which is normalized to an MCP server map.
199
+ ``global_config_files``, which are normalized to an MCP server map.
202
200
 
203
201
  Default: ``[]`` (the IDE has no skill system). Override to surface skills.
204
202
  """
@@ -1,14 +1,15 @@
1
1
  """Claude Code IDE integration for AI guardrails."""
2
2
 
3
3
  import json
4
+ import re
4
5
  from collections.abc import Iterator
5
6
  from copy import deepcopy
6
7
  from pathlib import Path
7
- from typing import ClassVar, Optional
8
+ from typing import ClassVar
8
9
 
9
10
  from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND
10
11
  from cycode.cli.apps.ai_guardrails.ides._plugin_utils import (
11
- build_global_config_file,
12
+ build_global_config_files,
12
13
  load_plugin_json,
13
14
  resolve_cached_plugin_dir,
14
15
  walk_enabled_plugins,
@@ -79,12 +80,12 @@ def _reverse_readline(path: Path, buf_size: int = 8192) -> Iterator[str]:
79
80
  yield buffer.decode('utf-8', errors='replace')
80
81
 
81
82
 
82
- def _extract_model(entry: dict) -> Optional[str]:
83
+ def _extract_model(entry: dict) -> str | None:
83
84
  """Extract model from a transcript entry (top level or nested in message)."""
84
85
  return entry.get('model') or (entry.get('message') or {}).get('model')
85
86
 
86
87
 
87
- def _extract_generation_id(entry: dict) -> Optional[str]:
88
+ def _extract_generation_id(entry: dict) -> str | None:
88
89
  """Extract generation ID from a user-type transcript entry."""
89
90
  if entry.get('type') == 'user':
90
91
  return entry.get('uuid')
@@ -93,7 +94,7 @@ def _extract_generation_id(entry: dict) -> Optional[str]:
93
94
 
94
95
  def extract_from_claude_transcript(
95
96
  transcript_path: str,
96
- ) -> tuple[Optional[str], Optional[str], Optional[str]]:
97
+ ) -> tuple[str | None, str | None, str | None]:
97
98
  """Extract ``(ide_version, model, generation_id)`` from a transcript.
98
99
 
99
100
  The transcript is a JSONL file scanned from end → start so the most recent
@@ -134,7 +135,7 @@ def extract_from_claude_transcript(
134
135
  # --- ~/.claude.json + ~/.claude/settings.json parsing -------------------------
135
136
 
136
137
 
137
- def load_claude_config(config_path: Optional[Path] = None) -> Optional[dict]:
138
+ def load_claude_config(config_path: Path | None = None) -> dict | None:
138
139
  """Load and parse `~/.claude.json`. Returns None if missing/invalid."""
139
140
  path = config_path or _CLAUDE_CONFIG_PATH
140
141
  if not path.exists():
@@ -147,17 +148,17 @@ def load_claude_config(config_path: Optional[Path] = None) -> Optional[dict]:
147
148
  return None
148
149
 
149
150
 
150
- def _email_from_config(config: dict) -> Optional[str]:
151
+ def _email_from_config(config: dict) -> str | None:
151
152
  """Read ``oauthAccount.emailAddress`` from a parsed Claude config."""
152
153
  return config.get('oauthAccount', {}).get('emailAddress')
153
154
 
154
155
 
155
- def get_mcp_servers(config: dict) -> Optional[dict]:
156
+ def get_mcp_servers(config: dict) -> dict | None:
156
157
  """Read ``mcpServers`` from a parsed Claude config."""
157
158
  return config.get('mcpServers')
158
159
 
159
160
 
160
- def load_claude_settings(settings_path: Optional[Path] = None) -> Optional[dict]:
161
+ def load_claude_settings(settings_path: Path | None = None) -> dict | None:
161
162
  """Load and parse `~/.claude/settings.json`. Returns None if missing/invalid."""
162
163
  path = settings_path or _CLAUDE_SETTINGS_PATH
163
164
  if not path.exists():
@@ -184,7 +185,7 @@ def _plugins_cache_dir() -> Path:
184
185
  return Path.home() / '.claude' / 'plugins' / 'cache'
185
186
 
186
187
 
187
- def _resolve_marketplace_path(marketplace: dict) -> Optional[Path]:
188
+ def _resolve_marketplace_path(marketplace: dict) -> Path | None:
188
189
  """Resolve filesystem path for a directory-type marketplace."""
189
190
  source = marketplace.get('source', {})
190
191
  if source.get('source') != 'directory':
@@ -235,7 +236,7 @@ def resolve_plugins(settings: dict) -> dict:
235
236
  enabled = settings.get('enabledPlugins') or {}
236
237
  marketplaces = settings.get('extraKnownMarketplaces') or {}
237
238
 
238
- def _locate(plugin_name: str, marketplace_name: str) -> Optional[Path]:
239
+ def _locate(plugin_name: str, marketplace_name: str) -> 'Path | None':
239
240
  # Directory-type marketplaces point straight at the plugin source; every other source
240
241
  # type (git, github, ...) is cloned into the local plugin cache.
241
242
  marketplace = marketplaces.get(marketplace_name)
@@ -253,6 +254,41 @@ def resolve_plugins(settings: dict) -> dict:
253
254
  )
254
255
 
255
256
 
257
+ def _sanitize_mcp_name(name: str) -> str:
258
+ """Mirrors how Claude Code sanitizes a server name inside a tool name."""
259
+ return re.sub(r'[^a-z0-9_-]', '_', name.lower())
260
+
261
+
262
+ def _local_mcp_servers(config: dict | None, cwd: str | None) -> Iterator[tuple[str, str]]:
263
+ """Yield ``(name as Claude Code namespaces it, config key)`` for each locally configured MCP server."""
264
+ sources = []
265
+ if config:
266
+ sources.append(get_mcp_servers(config))
267
+ if cwd:
268
+ sources.append(((config.get('projects') or {}).get(cwd) or {}).get('mcpServers'))
269
+ if cwd:
270
+ sources.append((load_plugin_json(Path(cwd) / '.mcp.json') or {}).get('mcpServers'))
271
+ for servers in sources:
272
+ if isinstance(servers, dict):
273
+ for name in servers:
274
+ yield name, name
275
+
276
+ settings = load_claude_settings()
277
+ for plugin_key, entry in (resolve_plugins(settings) if settings else {}).items():
278
+ plugin_name = plugin_key.split('@', 1)[0]
279
+ for server in entry.get('mcp_server_names') or []:
280
+ yield f'plugin_{plugin_name}_{server}', server
281
+
282
+
283
+ def _resolve_mcp_server_name(server: str, config: dict | None, cwd: str | None) -> str:
284
+ """Map the server segment of ``mcp__<server>__<tool>`` back to its config key; unknown names pass through."""
285
+ wanted = _sanitize_mcp_name(server)
286
+ for namespaced, name in _local_mcp_servers(config, cwd):
287
+ if _sanitize_mcp_name(namespaced) == wanted:
288
+ return name
289
+ return server
290
+
291
+
256
292
  # --- IDE integration ----------------------------------------------------------
257
293
 
258
294
 
@@ -261,7 +297,7 @@ class ClaudeCode(IDE):
261
297
  display_name: ClassVar[str] = 'Claude Code'
262
298
  hook_events: ClassVar[list[str]] = list(_HOOK_EVENTS)
263
299
 
264
- def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path:
300
+ def settings_path(self, scope: str, repo_path: Path | None = None) -> Path:
265
301
  if scope == 'repo' and repo_path:
266
302
  return repo_path / _REPO_SUBDIR / _HOOKS_FILE_NAME
267
303
  return _USER_HOOKS_DIR / _HOOKS_FILE_NAME
@@ -339,6 +375,8 @@ class ClaudeCode(IDE):
339
375
 
340
376
  config = load_claude_config()
341
377
  ide_user_email = _email_from_config(config) if config else None
378
+ if mcp_server_name:
379
+ mcp_server_name = _resolve_mcp_server_name(mcp_server_name, config, raw_payload.get('cwd'))
342
380
 
343
381
  return AIHookPayload(
344
382
  event_name=canonical_event,
@@ -392,18 +430,18 @@ class ClaudeCode(IDE):
392
430
  source=raw_payload.get('source'),
393
431
  )
394
432
 
395
- def get_user_email(self) -> Optional[str]:
433
+ def get_user_email(self) -> str | None:
396
434
  config = load_claude_config()
397
435
  return _email_from_config(config) if config else None
398
436
 
399
- def get_session_context(self) -> tuple[Optional[dict], dict]:
437
+ def get_session_context(self) -> tuple[list[dict], dict]:
400
438
  config = load_claude_config()
401
- global_config_file = build_global_config_file(_CLAUDE_CONFIG_PATH, get_mcp_servers(config)) if config else None
439
+ global_config_files = build_global_config_files(_CLAUDE_CONFIG_PATH, get_mcp_servers(config)) if config else []
402
440
 
403
441
  settings = load_claude_settings()
404
442
  enriched_plugins = resolve_plugins(settings) if settings else {}
405
443
 
406
- return global_config_file, enriched_plugins
444
+ return global_config_files, enriched_plugins
407
445
 
408
446
  def get_skills(self) -> list[dict]:
409
447
  return walk_skill_dirs(_claude_skills_dir())