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
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.
|
|
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
|
|
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[
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
22
|
-
plugin_app_version:
|
|
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:
|
|
31
|
-
plugin_app_version:
|
|
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:
|
|
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) ->
|
|
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) ->
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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[
|
|
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 ``(
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
45
|
+
config_files: list[dict] = []
|
|
46
46
|
plugins: dict = {}
|
|
47
47
|
for ide in IDES.values():
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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
|
|
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) ->
|
|
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) ->
|
|
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
|
|
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 ``
|
|
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
|
|
53
|
+
servers = mcp_servers if isinstance(mcp_servers, dict) else {}
|
|
53
54
|
if not servers:
|
|
54
|
-
return
|
|
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],
|
|
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) ->
|
|
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
|
|
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:
|
|
48
|
-
agent_message:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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) ->
|
|
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[
|
|
180
|
-
"""Return ``(
|
|
177
|
+
def get_session_context(self) -> tuple[list[dict], dict]:
|
|
178
|
+
"""Return ``(global_config_files, enabled_plugins)`` for session-context reporting.
|
|
181
179
|
|
|
182
|
-
``
|
|
183
|
-
``{"path": <full path>, "content": <normalized {"mcpServers": ...} JSON>}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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: ``(
|
|
186
|
+
Default: ``([], {})`` (no plugin system, no discoverable MCP config).
|
|
189
187
|
Override to surface MCP/plugin inventory.
|
|
190
188
|
"""
|
|
191
|
-
return
|
|
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
|
-
``
|
|
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
|
|
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
|
-
|
|
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) ->
|
|
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) ->
|
|
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[
|
|
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:
|
|
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) ->
|
|
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) ->
|
|
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:
|
|
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) ->
|
|
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) ->
|
|
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:
|
|
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) ->
|
|
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[
|
|
437
|
+
def get_session_context(self) -> tuple[list[dict], dict]:
|
|
400
438
|
config = load_claude_config()
|
|
401
|
-
|
|
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
|
|
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())
|