From 46cdd9e9ca9c74d951d9044379525611786e8fde Mon Sep 17 00:00:00 2001 From: Mateusz Sterczewski Date: Wed, 21 Jan 2026 14:54:51 +0100 Subject: [PATCH 001/123] CM-57848-Fix UTF encoding for Windows characters (#374) --- .../files_collector/models/in_memory_zip.py | 6 +++- .../cli/files_collector/test_in_memory_zip.py | 29 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 tests/cli/files_collector/test_in_memory_zip.py diff --git a/cycode/cli/files_collector/models/in_memory_zip.py b/cycode/cli/files_collector/models/in_memory_zip.py index 93ac4ac7..8bb9bf9e 100644 --- a/cycode/cli/files_collector/models/in_memory_zip.py +++ b/cycode/cli/files_collector/models/in_memory_zip.py @@ -26,7 +26,11 @@ def append(self, filename: str, unique_id: Optional[str], content: str) -> None: if unique_id: filename = concat_unique_id(filename, unique_id) - self.zip.writestr(filename, content) + # Encode content to bytes with error handling to handle surrogate characters + # that cannot be encoded to UTF-8. Use 'replace' to replace invalid characters + # with the Unicode replacement character (U+FFFD). + content_bytes = content.encode('utf-8', errors='replace') + self.zip.writestr(filename, content_bytes) def close(self) -> None: self.zip.close() diff --git a/tests/cli/files_collector/test_in_memory_zip.py b/tests/cli/files_collector/test_in_memory_zip.py new file mode 100644 index 00000000..d1790c7c --- /dev/null +++ b/tests/cli/files_collector/test_in_memory_zip.py @@ -0,0 +1,29 @@ +"""Tests for InMemoryZip class, specifically for handling surrogate characters and encoding issues.""" + +import zipfile +from io import BytesIO + +from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip + + +def test_append_with_surrogate_characters() -> None: + """Test that surrogate characters are handled gracefully without raising encoding errors.""" + # Surrogate characters (U+D800 to U+DFFF) cannot be encoded to UTF-8 directly + zip_file = InMemoryZip() + content = 'Normal text \udc96 more text' + + # Should not raise UnicodeEncodeError + zip_file.append('test.txt', None, content) + zip_file.close() + + # Verify the ZIP was created successfully + zip_data = zip_file.read() + assert len(zip_data) > 0 + + # Verify we can read it back and the surrogate was replaced + with zipfile.ZipFile(BytesIO(zip_data), 'r') as zf: + extracted = zf.read('test.txt').decode('utf-8') + assert 'Normal text' in extracted + assert 'more text' in extracted + # The surrogate should have been replaced with the replacement character + assert '\udc96' not in extracted From 3c73e3ded9d3a452a59ba5dae633baf2d1fc58c5 Mon Sep 17 00:00:00 2001 From: Mateusz Sterczewski Date: Thu, 22 Jan 2026 09:07:10 +0100 Subject: [PATCH 002/123] CM-57660-Remove PAT token from repository URL (#375) --- .../repository_url/repository_url_command.py | 11 ++- cycode/cli/apps/scan/remote_url_resolver.py | 11 ++- cycode/cli/utils/url_utils.py | 64 +++++++++++++++ cycode/cyclient/report_client.py | 10 ++- tests/utils/test_url_utils.py | 80 +++++++++++++++++++ 5 files changed, 172 insertions(+), 4 deletions(-) create mode 100644 cycode/cli/utils/url_utils.py create mode 100644 tests/utils/test_url_utils.py diff --git a/cycode/cli/apps/report/sbom/repository_url/repository_url_command.py b/cycode/cli/apps/report/sbom/repository_url/repository_url_command.py index 9e2f4885..e0955871 100644 --- a/cycode/cli/apps/report/sbom/repository_url/repository_url_command.py +++ b/cycode/cli/apps/report/sbom/repository_url/repository_url_command.py @@ -8,6 +8,10 @@ from cycode.cli.utils.get_api_client import get_report_cycode_client from cycode.cli.utils.progress_bar import SbomReportProgressBarSection from cycode.cli.utils.sentry import add_breadcrumb +from cycode.cli.utils.url_utils import sanitize_repository_url +from cycode.logger import get_logger + +logger = get_logger('Repository URL Command') def repository_url_command( @@ -28,8 +32,13 @@ def repository_url_command( start_scan_time = time.time() report_execution_id = -1 + # Sanitize repository URL to remove any embedded credentials/tokens before sending to API + sanitized_uri = sanitize_repository_url(uri) + if sanitized_uri != uri: + logger.debug('Sanitized repository URL to remove credentials') + try: - report_execution = client.request_sbom_report_execution(report_parameters, repository_url=uri) + report_execution = client.request_sbom_report_execution(report_parameters, repository_url=sanitized_uri) report_execution_id = report_execution.id create_sbom_report(progress_bar, client, report_execution_id, output_file, output_format) diff --git a/cycode/cli/apps/scan/remote_url_resolver.py b/cycode/cli/apps/scan/remote_url_resolver.py index 967e6ea0..870115e2 100644 --- a/cycode/cli/apps/scan/remote_url_resolver.py +++ b/cycode/cli/apps/scan/remote_url_resolver.py @@ -3,6 +3,7 @@ from cycode.cli import consts from cycode.cli.utils.git_proxy import git_proxy from cycode.cli.utils.shell_executor import shell +from cycode.cli.utils.url_utils import sanitize_repository_url from cycode.logger import get_logger logger = get_logger('Remote URL Resolver') @@ -102,7 +103,11 @@ def _try_get_git_remote_url(path: str) -> Optional[str]: repo = git_proxy.get_repo(path, search_parent_directories=True) remote_url = repo.remotes[0].config_reader.get('url') logger.debug('Found Git remote URL, %s', {'remote_url': remote_url, 'repo_path': repo.working_dir}) - return remote_url + # Sanitize URL to remove any embedded credentials/tokens before returning + sanitized_url = sanitize_repository_url(remote_url) + if sanitized_url != remote_url: + logger.debug('Sanitized repository URL to remove credentials') + return sanitized_url except Exception as e: logger.debug('Failed to get Git remote URL. Probably not a Git repository', exc_info=e) return None @@ -124,7 +129,9 @@ def get_remote_url_scan_parameter(paths: tuple[str, ...]) -> Optional[str]: # - len(paths)*2 Plastic SCM subprocess calls remote_url = _try_get_any_remote_url(path) if remote_url: - remote_urls.add(remote_url) + # URLs are already sanitized in _try_get_git_remote_url, but sanitize again as safety measure + sanitized_url = sanitize_repository_url(remote_url) + remote_urls.add(sanitized_url) if len(remote_urls) == 1: # we are resolving remote_url only if all paths belong to the same repo (identical remote URLs), diff --git a/cycode/cli/utils/url_utils.py b/cycode/cli/utils/url_utils.py new file mode 100644 index 00000000..91e50f77 --- /dev/null +++ b/cycode/cli/utils/url_utils.py @@ -0,0 +1,64 @@ +from typing import Optional +from urllib.parse import urlparse, urlunparse + +from cycode.logger import get_logger + +logger = get_logger('URL Utils') + + +def sanitize_repository_url(url: Optional[str]) -> Optional[str]: + """Remove credentials (username, password, tokens) from repository URL. + + This function sanitizes repository URLs to prevent sending PAT tokens or other + credentials to the API. It handles both HTTP/HTTPS URLs with embedded credentials + and SSH URLs (which are returned as-is since they don't contain credentials in the URL). + + Args: + url: Repository URL that may contain credentials (e.g., https://token@github.com/user/repo.git) + + Returns: + Sanitized URL without credentials (e.g., https://github.com/user/repo.git), or None if input is None + + Examples: + >>> sanitize_repository_url('https://token@github.com/user/repo.git') + 'https://github.com/user/repo.git' + >>> sanitize_repository_url('https://user:token@github.com/user/repo.git') + 'https://github.com/user/repo.git' + >>> sanitize_repository_url('git@github.com:user/repo.git') + 'git@github.com:user/repo.git' + >>> sanitize_repository_url(None) + None + """ + if not url: + return url + + # Handle SSH URLs - no credentials to remove + # ssh:// URLs have the format ssh://git@host/path + if url.startswith('ssh://'): + return url + # git@host:path format (scp-style) + if '@' in url and '://' not in url and url.startswith('git@'): + return url + + try: + parsed = urlparse(url) + # Remove username and password from netloc + # Reconstruct URL without credentials + sanitized_netloc = parsed.hostname + if parsed.port: + sanitized_netloc = f'{sanitized_netloc}:{parsed.port}' + + return urlunparse( + ( + parsed.scheme, + sanitized_netloc, + parsed.path, + parsed.params, + parsed.query, + parsed.fragment, + ) + ) + except Exception as e: + logger.debug('Failed to sanitize repository URL, returning original, %s', {'url': url, 'error': str(e)}) + # If parsing fails, return original URL to avoid breaking functionality + return url diff --git a/cycode/cyclient/report_client.py b/cycode/cyclient/report_client.py index e8107827..a55b5c40 100644 --- a/cycode/cyclient/report_client.py +++ b/cycode/cyclient/report_client.py @@ -6,8 +6,12 @@ from cycode.cli.exceptions.custom_exceptions import CycodeError from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip +from cycode.cli.utils.url_utils import sanitize_repository_url from cycode.cyclient import models from cycode.cyclient.cycode_client_base import CycodeClientBase +from cycode.logger import get_logger + +logger = get_logger('Report Client') @dataclasses.dataclass @@ -49,7 +53,11 @@ def request_sbom_report_execution( # entity type required only for zipped-file request_data = {'report_parameters': params.to_json(without_entity_type=zip_file is None)} if repository_url: - request_data['repository_url'] = repository_url + # Sanitize repository URL to remove any embedded credentials/tokens before sending to API + sanitized_url = sanitize_repository_url(repository_url) + if sanitized_url != repository_url: + logger.debug('Sanitized repository URL to remove credentials') + request_data['repository_url'] = sanitized_url request_args = { 'url_path': url_path, diff --git a/tests/utils/test_url_utils.py b/tests/utils/test_url_utils.py new file mode 100644 index 00000000..f7f6b6b0 --- /dev/null +++ b/tests/utils/test_url_utils.py @@ -0,0 +1,80 @@ +from cycode.cli.utils.url_utils import sanitize_repository_url + + +def test_sanitize_repository_url_with_token() -> None: + """Test that PAT tokens are removed from HTTPS URLs.""" + url = 'https://token@github.com/user/repo.git' + expected = 'https://github.com/user/repo.git' + assert sanitize_repository_url(url) == expected + + +def test_sanitize_repository_url_with_username_and_token() -> None: + """Test that username and token are removed from HTTPS URLs.""" + url = 'https://user:token@github.com/user/repo.git' + expected = 'https://github.com/user/repo.git' + assert sanitize_repository_url(url) == expected + + +def test_sanitize_repository_url_with_port() -> None: + """Test that URLs with ports are handled correctly.""" + url = 'https://token@github.com:443/user/repo.git' + expected = 'https://github.com:443/user/repo.git' + assert sanitize_repository_url(url) == expected + + +def test_sanitize_repository_url_ssh_format() -> None: + """Test that SSH URLs are returned as-is (no credentials in URL format).""" + url = 'git@github.com:user/repo.git' + assert sanitize_repository_url(url) == url + + +def test_sanitize_repository_url_ssh_protocol() -> None: + """Test that ssh:// URLs are returned as-is.""" + url = 'ssh://git@github.com/user/repo.git' + assert sanitize_repository_url(url) == url + + +def test_sanitize_repository_url_no_credentials() -> None: + """Test that URLs without credentials are returned unchanged.""" + url = 'https://github.com/user/repo.git' + assert sanitize_repository_url(url) == url + + +def test_sanitize_repository_url_none() -> None: + """Test that None input returns None.""" + assert sanitize_repository_url(None) is None + + +def test_sanitize_repository_url_empty_string() -> None: + """Test that empty string is returned as-is.""" + assert sanitize_repository_url('') == '' + + +def test_sanitize_repository_url_gitlab() -> None: + """Test that GitLab URLs are sanitized correctly.""" + url = 'https://oauth2:token@gitlab.com/user/repo.git' + expected = 'https://gitlab.com/user/repo.git' + assert sanitize_repository_url(url) == expected + + +def test_sanitize_repository_url_bitbucket() -> None: + """Test that Bitbucket URLs are sanitized correctly.""" + url = 'https://x-token-auth:token@bitbucket.org/user/repo.git' + expected = 'https://bitbucket.org/user/repo.git' + assert sanitize_repository_url(url) == expected + + +def test_sanitize_repository_url_with_path_and_query() -> None: + """Test that URLs with paths, query params, and fragments are preserved.""" + url = 'https://token@github.com/user/repo.git?ref=main#section' + expected = 'https://github.com/user/repo.git?ref=main#section' + assert sanitize_repository_url(url) == expected + + +def test_sanitize_repository_url_invalid_url() -> None: + """Test that invalid URLs are returned as-is (graceful degradation).""" + # This should not raise an exception, but return the original + url = 'not-a-valid-url' + result = sanitize_repository_url(url) + # Should return original since parsing fails + assert result == url From 26d13d2d2aaf4323b7e3d6a847a14a530af6f328 Mon Sep 17 00:00:00 2001 From: Mateusz Sterczewski Date: Mon, 26 Jan 2026 10:10:44 +0100 Subject: [PATCH 003/123] CM-57848-Fix UTF encoding when displaying code snippet (#376) --- cycode/cli/printers/tables/table_printer.py | 4 +- .../cli/printers/utils/code_snippet_syntax.py | 4 +- cycode/cli/printers/utils/rich_helpers.py | 4 +- cycode/cli/utils/string_utils.py | 9 ++ tests/cli/printers/__init__.py | 0 tests/cli/printers/utils/__init__.py | 0 .../printers/utils/test_rich_encoding_fix.py | 86 +++++++++++++++++++ 7 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 tests/cli/printers/__init__.py create mode 100644 tests/cli/printers/utils/__init__.py create mode 100644 tests/cli/printers/utils/test_rich_encoding_fix.py diff --git a/cycode/cli/printers/tables/table_printer.py b/cycode/cli/printers/tables/table_printer.py index 6a5dd198..4468ef9f 100644 --- a/cycode/cli/printers/tables/table_printer.py +++ b/cycode/cli/printers/tables/table_printer.py @@ -8,7 +8,7 @@ from cycode.cli.printers.tables.table_printer_base import TablePrinterBase from cycode.cli.printers.utils import is_git_diff_based_scan from cycode.cli.printers.utils.detection_ordering.common_ordering import sort_and_group_detections_from_scan_result -from cycode.cli.utils.string_utils import get_position_in_line, obfuscate_text +from cycode.cli.utils.string_utils import get_position_in_line, obfuscate_text, sanitize_text_for_encoding if TYPE_CHECKING: from cycode.cli.models import LocalScanResult @@ -96,6 +96,8 @@ def _enrich_table_with_detection_code_segment_values( if not self.show_secret: violation = obfuscate_text(violation) + violation = sanitize_text_for_encoding(violation) + table.add_cell(LINE_NUMBER_COLUMN, str(detection_line)) table.add_cell(COLUMN_NUMBER_COLUMN, str(detection_column)) table.add_cell(VIOLATION_LENGTH_COLUMN, f'{violation_length} chars') diff --git a/cycode/cli/printers/utils/code_snippet_syntax.py b/cycode/cli/printers/utils/code_snippet_syntax.py index 20f94d4e..57bc084e 100644 --- a/cycode/cli/printers/utils/code_snippet_syntax.py +++ b/cycode/cli/printers/utils/code_snippet_syntax.py @@ -5,7 +5,7 @@ from cycode.cli import consts from cycode.cli.console import _SYNTAX_HIGHLIGHT_THEME from cycode.cli.printers.utils import is_git_diff_based_scan -from cycode.cli.utils.string_utils import get_position_in_line, obfuscate_text +from cycode.cli.utils.string_utils import get_position_in_line, obfuscate_text, sanitize_text_for_encoding if TYPE_CHECKING: from cycode.cli.models import Document @@ -72,6 +72,7 @@ def _get_code_snippet_syntax_from_file( code_lines_to_render.append(line_content) code_to_render = '\n'.join(code_lines_to_render) + code_to_render = sanitize_text_for_encoding(code_to_render) return _get_syntax_highlighted_code( code=code_to_render, lexer=Syntax.guess_lexer(document.path, code=code_to_render), @@ -94,6 +95,7 @@ def _get_code_snippet_syntax_from_git_diff( violation = line_content[detection_position_in_line : detection_position_in_line + violation_length] line_content = line_content.replace(violation, obfuscate_text(violation)) + line_content = sanitize_text_for_encoding(line_content) return _get_syntax_highlighted_code( code=line_content, lexer='diff', diff --git a/cycode/cli/printers/utils/rich_helpers.py b/cycode/cli/printers/utils/rich_helpers.py index 52d2a0f2..6049b211 100644 --- a/cycode/cli/printers/utils/rich_helpers.py +++ b/cycode/cli/printers/utils/rich_helpers.py @@ -5,6 +5,7 @@ from rich.panel import Panel from cycode.cli.console import console +from cycode.cli.utils.string_utils import sanitize_text_for_encoding if TYPE_CHECKING: from rich.console import RenderableType @@ -20,8 +21,9 @@ def get_panel(renderable: 'RenderableType', title: str) -> Panel: def get_markdown_panel(markdown_text: str, title: str) -> Panel: + sanitized_text = sanitize_text_for_encoding(markdown_text.strip()) return get_panel( - Markdown(markdown_text.strip()), + Markdown(sanitized_text), title=title, ) diff --git a/cycode/cli/utils/string_utils.py b/cycode/cli/utils/string_utils.py index c3c0c6c6..06d3a51c 100644 --- a/cycode/cli/utils/string_utils.py +++ b/cycode/cli/utils/string_utils.py @@ -65,3 +65,12 @@ def shortcut_dependency_paths(dependency_paths_list: str) -> str: result += '\n' return result.rstrip().rstrip(',') + + +def sanitize_text_for_encoding(text: str) -> str: + """Sanitize text by replacing surrogate characters and invalid UTF-8 sequences. + + This prevents encoding errors when Rich tries to display the content, especially on Windows. + Surrogate characters (U+D800 to U+DFFF) cannot be encoded to UTF-8 and will cause errors. + """ + return text.encode('utf-8', errors='replace').decode('utf-8') diff --git a/tests/cli/printers/__init__.py b/tests/cli/printers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/printers/utils/__init__.py b/tests/cli/printers/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/printers/utils/test_rich_encoding_fix.py b/tests/cli/printers/utils/test_rich_encoding_fix.py new file mode 100644 index 00000000..721f1c6a --- /dev/null +++ b/tests/cli/printers/utils/test_rich_encoding_fix.py @@ -0,0 +1,86 @@ +"""Tests for Rich encoding fix to handle surrogate characters.""" + +from io import StringIO +from typing import Any +from unittest.mock import MagicMock + +from rich.console import Console + +from cycode.cli import consts +from cycode.cli.models import Document +from cycode.cli.printers.rich_printer import RichPrinter +from cycode.cyclient.models import Detection + + +def create_strict_encoding_console() -> tuple[Console, StringIO]: + """Create a Console that enforces strict UTF-8 encoding, simulating Windows console behavior. + + When Rich writes to the console, the file object needs to encode strings to bytes. + With errors='strict' (default for TextIOWrapper), this raises UnicodeEncodeError on surrogates. + This function simulates that behavior to test the encoding fix. + """ + buffer = StringIO() + + class StrictEncodingWrapper: + def __init__(self, file_obj: StringIO) -> None: + self._file = file_obj + + def write(self, text: str) -> int: + """Validate encoding before writing to simulate strict encoding behavior.""" + text.encode('utf-8') + return self._file.write(text) + + def flush(self) -> None: + self._file.flush() + + def isatty(self) -> bool: + return False + + def __getattr__(self, name: str) -> Any: + # Delegate all other attributes to the underlying file + return getattr(self._file, name) + + strict_file = StrictEncodingWrapper(buffer) + console = Console(file=strict_file, width=80, force_terminal=False) + return console, buffer + + +def test_rich_printer_handles_surrogate_characters_in_violation_card() -> None: + """Test that RichPrinter._print_violation_card() handles surrogate characters without errors. + + The error occurs in Rich's console._write_buffer() -> write() when console.print() is called. + On Windows with strict encoding, this raises UnicodeEncodeError on surrogates. + """ + surrogate_char = chr(0xDC96) + document_content = 'A' * 1236 + surrogate_char + 'B' * 100 + document = Document( + path='test.py', + content=document_content, + is_git_diff_format=False, + ) + + detection = Detection( + detection_type_id='test-id', + type='test-type', + message='Test message', + detection_details={ + 'description': 'Summary with ' + surrogate_char + ' surrogate character', + 'policy_display_name': 'Test Policy', + 'start_position': 1236, + 'length': 1, + 'line': 0, + }, + detection_rule_id='test-rule-id', + severity='Medium', + ) + + mock_ctx = MagicMock() + mock_ctx.obj = { + 'scan_type': consts.SAST_SCAN_TYPE, + 'show_secret': False, + } + mock_ctx.info_name = consts.SAST_SCAN_TYPE + + console, _ = create_strict_encoding_console() + printer = RichPrinter(mock_ctx, console, console) + printer._print_violation_card(document, detection, 1, 1) From 043ab3b8a1e57537bf74b85dc86009d8d9a3ebff Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Thu, 29 Jan 2026 17:45:29 +0200 Subject: [PATCH 004/123] CM-58022: cycode guardrails support cursor scan via hooks (#377) --- cycode/cli/app.py | 3 +- cycode/cli/apps/ai_guardrails/__init__.py | 19 + .../cli/apps/ai_guardrails/command_utils.py | 66 ++++ cycode/cli/apps/ai_guardrails/consts.py | 78 ++++ .../cli/apps/ai_guardrails/hooks_manager.py | 200 ++++++++++ .../cli/apps/ai_guardrails/install_command.py | 78 ++++ .../cli/apps/ai_guardrails/scan/__init__.py | 1 + cycode/cli/apps/ai_guardrails/scan/consts.py | 48 +++ .../cli/apps/ai_guardrails/scan/handlers.py | 341 +++++++++++++++++ cycode/cli/apps/ai_guardrails/scan/payload.py | 72 ++++ cycode/cli/apps/ai_guardrails/scan/policy.py | 85 +++++ .../ai_guardrails/scan/response_builders.py | 86 +++++ .../apps/ai_guardrails/scan/scan_command.py | 134 +++++++ cycode/cli/apps/ai_guardrails/scan/types.py | 54 +++ cycode/cli/apps/ai_guardrails/scan/utils.py | 72 ++++ .../cli/apps/ai_guardrails/status_command.py | 92 +++++ .../apps/ai_guardrails/uninstall_command.py | 73 ++++ cycode/cli/apps/scan/code_scanner.py | 2 +- cycode/cli/cli_types.py | 13 + cycode/cli/utils/get_api_client.py | 17 +- cycode/cli/utils/scan_utils.py | 24 ++ cycode/cyclient/ai_security_manager_client.py | 86 +++++ .../ai_security_manager_service_config.py | 27 ++ cycode/cyclient/client_creator.py | 20 + tests/cli/commands/ai_guardrails/__init__.py | 0 .../commands/ai_guardrails/scan/__init__.py | 0 .../ai_guardrails/scan/test_handlers.py | 361 ++++++++++++++++++ .../ai_guardrails/scan/test_payload.py | 135 +++++++ .../ai_guardrails/scan/test_policy.py | 199 ++++++++++ .../scan/test_response_builders.py | 79 ++++ .../commands/ai_guardrails/scan/test_utils.py | 113 ++++++ .../ai_guardrails/test_command_utils.py | 57 +++ 32 files changed, 2631 insertions(+), 4 deletions(-) create mode 100644 cycode/cli/apps/ai_guardrails/__init__.py create mode 100644 cycode/cli/apps/ai_guardrails/command_utils.py create mode 100644 cycode/cli/apps/ai_guardrails/consts.py create mode 100644 cycode/cli/apps/ai_guardrails/hooks_manager.py create mode 100644 cycode/cli/apps/ai_guardrails/install_command.py create mode 100644 cycode/cli/apps/ai_guardrails/scan/__init__.py create mode 100644 cycode/cli/apps/ai_guardrails/scan/consts.py create mode 100644 cycode/cli/apps/ai_guardrails/scan/handlers.py create mode 100644 cycode/cli/apps/ai_guardrails/scan/payload.py create mode 100644 cycode/cli/apps/ai_guardrails/scan/policy.py create mode 100644 cycode/cli/apps/ai_guardrails/scan/response_builders.py create mode 100644 cycode/cli/apps/ai_guardrails/scan/scan_command.py create mode 100644 cycode/cli/apps/ai_guardrails/scan/types.py create mode 100644 cycode/cli/apps/ai_guardrails/scan/utils.py create mode 100644 cycode/cli/apps/ai_guardrails/status_command.py create mode 100644 cycode/cli/apps/ai_guardrails/uninstall_command.py create mode 100644 cycode/cyclient/ai_security_manager_client.py create mode 100644 cycode/cyclient/ai_security_manager_service_config.py create mode 100644 tests/cli/commands/ai_guardrails/__init__.py create mode 100644 tests/cli/commands/ai_guardrails/scan/__init__.py create mode 100644 tests/cli/commands/ai_guardrails/scan/test_handlers.py create mode 100644 tests/cli/commands/ai_guardrails/scan/test_payload.py create mode 100644 tests/cli/commands/ai_guardrails/scan/test_policy.py create mode 100644 tests/cli/commands/ai_guardrails/scan/test_response_builders.py create mode 100644 tests/cli/commands/ai_guardrails/scan/test_utils.py create mode 100644 tests/cli/commands/ai_guardrails/test_command_utils.py diff --git a/cycode/cli/app.py b/cycode/cli/app.py index 3ef0b322..e838519e 100644 --- a/cycode/cli/app.py +++ b/cycode/cli/app.py @@ -9,7 +9,7 @@ from typer.completion import install_callback, show_callback from cycode import __version__ -from cycode.cli.apps import ai_remediation, auth, configure, ignore, report, report_import, scan, status +from cycode.cli.apps import ai_guardrails, ai_remediation, auth, configure, ignore, report, report_import, scan, status if sys.version_info >= (3, 10): from cycode.cli.apps import mcp @@ -45,6 +45,7 @@ add_completion=False, # we add it manually to control the rich help panel ) +app.add_typer(ai_guardrails.app) app.add_typer(ai_remediation.app) app.add_typer(auth.app) app.add_typer(configure.app) diff --git a/cycode/cli/apps/ai_guardrails/__init__.py b/cycode/cli/apps/ai_guardrails/__init__.py new file mode 100644 index 00000000..f8486ed4 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/__init__.py @@ -0,0 +1,19 @@ +import typer + +from cycode.cli.apps.ai_guardrails.install_command import install_command +from cycode.cli.apps.ai_guardrails.scan.scan_command import scan_command +from cycode.cli.apps.ai_guardrails.status_command import status_command +from cycode.cli.apps.ai_guardrails.uninstall_command import uninstall_command + +app = typer.Typer(name='ai-guardrails', no_args_is_help=True, hidden=True) + +app.command(hidden=True, name='install', short_help='Install AI guardrails hooks for supported IDEs.')(install_command) +app.command(hidden=True, name='uninstall', short_help='Remove AI guardrails hooks from supported IDEs.')( + uninstall_command +) +app.command(hidden=True, name='status', short_help='Show AI guardrails hook installation status.')(status_command) +app.command( + hidden=True, + name='scan', + short_help='Scan content from AI IDE hooks for secrets (reads JSON from stdin).', +)(scan_command) diff --git a/cycode/cli/apps/ai_guardrails/command_utils.py b/cycode/cli/apps/ai_guardrails/command_utils.py new file mode 100644 index 00000000..e010f0a2 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/command_utils.py @@ -0,0 +1,66 @@ +"""Common utilities for AI guardrails commands.""" + +import os +from pathlib import Path +from typing import Optional + +import typer +from rich.console import Console + +from cycode.cli.apps.ai_guardrails.consts import AIIDEType + +console = Console() + + +def validate_and_parse_ide(ide: str) -> AIIDEType: + """Validate IDE parameter and convert to AIIDEType enum. + + Args: + ide: IDE name string (e.g., 'cursor') + + Returns: + AIIDEType enum value + + Raises: + typer.Exit: If IDE is invalid + """ + try: + return AIIDEType(ide.lower()) + except ValueError: + valid_ides = ', '.join([ide_type.value for ide_type in AIIDEType]) + console.print( + f'[red]Error:[/] Invalid IDE "{ide}". Supported IDEs: {valid_ides}', + style='bold red', + ) + raise typer.Exit(1) from None + + +def validate_scope(scope: str, allowed_scopes: tuple[str, ...] = ('user', 'repo')) -> None: + """Validate scope parameter. + + Args: + scope: Scope string to validate + allowed_scopes: Tuple of allowed scope values + + Raises: + typer.Exit: If scope is invalid + """ + if scope not in allowed_scopes: + scopes_list = ', '.join(f'"{s}"' for s in allowed_scopes) + console.print(f'[red]Error:[/] Invalid scope. Use {scopes_list}.', style='bold red') + raise typer.Exit(1) + + +def resolve_repo_path(scope: str, repo_path: Optional[Path]) -> Optional[Path]: + """Resolve repository path, defaulting to current directory for repo scope. + + Args: + scope: The command scope ('user' or 'repo') + repo_path: Provided repo path or None + + Returns: + Resolved Path for repo scope, None for user scope + """ + if scope == 'repo' and repo_path is None: + return Path(os.getcwd()) + return repo_path diff --git a/cycode/cli/apps/ai_guardrails/consts.py b/cycode/cli/apps/ai_guardrails/consts.py new file mode 100644 index 00000000..21d89a3f --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/consts.py @@ -0,0 +1,78 @@ +"""Constants for AI guardrails hooks management. + +Currently supports: +- Cursor + +To add a new IDE (e.g., Claude Code): +1. Add new value to AIIDEType enum +2. Create _get__hooks_dir() function with platform-specific paths +3. Add entry to IDE_CONFIGS dict with IDE-specific hook event names +4. Unhide --ide option in commands (install, uninstall, status) +""" + +import platform +from enum import Enum +from pathlib import Path +from typing import NamedTuple + + +class AIIDEType(str, Enum): + """Supported AI IDE types.""" + + CURSOR = 'cursor' + + +class IDEConfig(NamedTuple): + """Configuration for an AI IDE.""" + + name: str + hooks_dir: Path + repo_hooks_subdir: str # Subdirectory in repo for hooks (e.g., '.cursor') + hooks_file_name: str + hook_events: list[str] # List of supported hook event names for this IDE + + +def _get_cursor_hooks_dir() -> Path: + """Get Cursor hooks directory based on platform.""" + if platform.system() == 'Darwin': + return Path.home() / '.cursor' + if platform.system() == 'Windows': + return Path.home() / 'AppData' / 'Roaming' / 'Cursor' + # Linux + return Path.home() / '.config' / 'Cursor' + + +# IDE-specific configurations +IDE_CONFIGS: dict[AIIDEType, IDEConfig] = { + AIIDEType.CURSOR: IDEConfig( + name='Cursor', + hooks_dir=_get_cursor_hooks_dir(), + repo_hooks_subdir='.cursor', + hooks_file_name='hooks.json', + hook_events=['beforeSubmitPrompt', 'beforeReadFile', 'beforeMCPExecution'], + ), +} + +# Default IDE +DEFAULT_IDE = AIIDEType.CURSOR + +# Command used in hooks +CYCODE_SCAN_PROMPT_COMMAND = 'cycode ai-guardrails scan' + + +def get_hooks_config(ide: AIIDEType) -> dict: + """Get the hooks configuration for a specific IDE. + + Args: + ide: The AI IDE type + + Returns: + Dict with hooks configuration for the specified IDE + """ + config = IDE_CONFIGS[ide] + hooks = {event: [{'command': CYCODE_SCAN_PROMPT_COMMAND}] for event in config.hook_events} + + return { + 'version': 1, + 'hooks': hooks, + } diff --git a/cycode/cli/apps/ai_guardrails/hooks_manager.py b/cycode/cli/apps/ai_guardrails/hooks_manager.py new file mode 100644 index 00000000..42f879f6 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/hooks_manager.py @@ -0,0 +1,200 @@ +""" +Hooks manager for AI guardrails. + +Handles installation, removal, and status checking of AI IDE hooks. +Supports multiple IDEs: Cursor, Claude Code (future). +""" + +import json +from pathlib import Path +from typing import Optional + +from cycode.cli.apps.ai_guardrails.consts import ( + CYCODE_SCAN_PROMPT_COMMAND, + DEFAULT_IDE, + IDE_CONFIGS, + AIIDEType, + get_hooks_config, +) +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails Hooks') + + +def get_hooks_path(scope: str, repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE) -> Path: + """Get the hooks.json path for the given scope and IDE. + + Args: + scope: 'user' for user-level hooks, 'repo' for repository-level hooks + repo_path: Repository path (required if scope is 'repo') + ide: The AI IDE type (default: Cursor) + """ + config = IDE_CONFIGS[ide] + if scope == 'repo' and repo_path: + return repo_path / config.repo_hooks_subdir / config.hooks_file_name + return config.hooks_dir / config.hooks_file_name + + +def load_hooks_file(hooks_path: Path) -> Optional[dict]: + """Load existing hooks.json file.""" + if not hooks_path.exists(): + return None + try: + content = hooks_path.read_text(encoding='utf-8') + return json.loads(content) + except Exception as e: + logger.debug('Failed to load hooks file', exc_info=e) + return None + + +def save_hooks_file(hooks_path: Path, hooks_config: dict) -> bool: + """Save hooks.json file.""" + try: + hooks_path.parent.mkdir(parents=True, exist_ok=True) + hooks_path.write_text(json.dumps(hooks_config, indent=2), encoding='utf-8') + return True + except Exception as e: + logger.error('Failed to save hooks file', exc_info=e) + return False + + +def is_cycode_hook_entry(entry: dict) -> bool: + """Check if a hook entry is from cycode-cli.""" + command = entry.get('command', '') + return CYCODE_SCAN_PROMPT_COMMAND in command + + +def install_hooks( + scope: str = 'user', repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE +) -> tuple[bool, str]: + """ + Install Cycode AI guardrails hooks. + + Args: + scope: 'user' for user-level hooks, 'repo' for repository-level hooks + repo_path: Repository path (required if scope is 'repo') + ide: The AI IDE type (default: Cursor) + + Returns: + Tuple of (success, message) + """ + hooks_path = get_hooks_path(scope, repo_path, ide) + + # Load existing hooks or create new + existing = load_hooks_file(hooks_path) or {'version': 1, 'hooks': {}} + existing.setdefault('version', 1) + existing.setdefault('hooks', {}) + + # Get IDE-specific hooks configuration + hooks_config = get_hooks_config(ide) + + # Add/update Cycode hooks + for event, entries in hooks_config['hooks'].items(): + existing['hooks'].setdefault(event, []) + + # Remove any existing Cycode entries for this event + existing['hooks'][event] = [e for e in existing['hooks'][event] if not is_cycode_hook_entry(e)] + + # Add new Cycode entries + for entry in entries: + existing['hooks'][event].append(entry) + + # Save + if save_hooks_file(hooks_path, existing): + return True, f'AI guardrails hooks installed: {hooks_path}' + return False, f'Failed to install hooks to {hooks_path}' + + +def uninstall_hooks( + scope: str = 'user', repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE +) -> tuple[bool, str]: + """ + Remove Cycode AI guardrails hooks. + + Args: + scope: 'user' for user-level hooks, 'repo' for repository-level hooks + repo_path: Repository path (required if scope is 'repo') + ide: The AI IDE type (default: Cursor) + + Returns: + Tuple of (success, message) + """ + hooks_path = get_hooks_path(scope, repo_path, ide) + + existing = load_hooks_file(hooks_path) + if existing is None: + return True, f'No hooks file found at {hooks_path}' + + # Remove Cycode entries from all events + modified = False + for event in list(existing.get('hooks', {}).keys()): + original_count = len(existing['hooks'][event]) + existing['hooks'][event] = [e for e in existing['hooks'][event] if not is_cycode_hook_entry(e)] + if len(existing['hooks'][event]) != original_count: + modified = True + # Remove empty event lists + if not existing['hooks'][event]: + del existing['hooks'][event] + + if not modified: + return True, 'No Cycode hooks found to remove' + + # Save or delete if empty + if not existing.get('hooks'): + try: + hooks_path.unlink() + return True, f'Removed hooks file: {hooks_path}' + except Exception as e: + logger.debug('Failed to delete hooks file', exc_info=e) + return False, f'Failed to remove hooks file: {hooks_path}' + + if save_hooks_file(hooks_path, existing): + return True, f'Cycode hooks removed from: {hooks_path}' + return False, f'Failed to update hooks file: {hooks_path}' + + +def get_hooks_status(scope: str = 'user', repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE) -> dict: + """ + Get the status of AI guardrails hooks. + + Args: + scope: 'user' for user-level hooks, 'repo' for repository-level hooks + repo_path: Repository path (required if scope is 'repo') + ide: The AI IDE type (default: Cursor) + + Returns: + Dict with status information + """ + hooks_path = get_hooks_path(scope, repo_path, ide) + + status = { + 'scope': scope, + 'ide': ide.value, + 'ide_name': IDE_CONFIGS[ide].name, + 'hooks_path': str(hooks_path), + 'file_exists': hooks_path.exists(), + 'cycode_installed': False, + 'hooks': {}, + } + + existing = load_hooks_file(hooks_path) + if existing is None: + return status + + # Check each hook event for this IDE + ide_config = IDE_CONFIGS[ide] + has_cycode_hooks = False + for event in ide_config.hook_events: + entries = existing.get('hooks', {}).get(event, []) + cycode_entries = [e for e in entries if is_cycode_hook_entry(e)] + if cycode_entries: + has_cycode_hooks = True + status['hooks'][event] = { + 'total_entries': len(entries), + 'cycode_entries': len(cycode_entries), + 'enabled': len(cycode_entries) > 0, + } + + status['cycode_installed'] = has_cycode_hooks + + return status diff --git a/cycode/cli/apps/ai_guardrails/install_command.py b/cycode/cli/apps/ai_guardrails/install_command.py new file mode 100644 index 00000000..6186752d --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/install_command.py @@ -0,0 +1,78 @@ +"""Install command for AI guardrails hooks.""" + +from pathlib import Path +from typing import Annotated, Optional + +import typer + +from cycode.cli.apps.ai_guardrails.command_utils import ( + console, + resolve_repo_path, + validate_and_parse_ide, + validate_scope, +) +from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS +from cycode.cli.apps.ai_guardrails.hooks_manager import install_hooks +from cycode.cli.utils.sentry import add_breadcrumb + + +def install_command( + ctx: typer.Context, + scope: Annotated[ + str, + typer.Option( + '--scope', + '-s', + help='Installation scope: "user" for all projects, "repo" for current repository only.', + ), + ] = 'user', + ide: Annotated[ + str, + typer.Option( + '--ide', + help='IDE to install hooks for (e.g., "cursor"). Defaults to cursor.', + ), + ] = 'cursor', + repo_path: Annotated[ + Optional[Path], + typer.Option( + '--repo-path', + help='Repository path for repo-scoped installation (defaults to current directory).', + exists=True, + file_okay=False, + dir_okay=True, + resolve_path=True, + ), + ] = None, +) -> None: + """Install AI guardrails hooks for supported IDEs. + + This command configures the specified IDE to use Cycode for scanning prompts, file reads, + and MCP tool calls for secrets before they are sent to AI models. + + Examples: + cycode ai-guardrails install # Install for all projects (user scope) + cycode ai-guardrails install --scope repo # Install for current repo only + cycode ai-guardrails install --ide cursor # Install for Cursor IDE + cycode ai-guardrails install --scope repo --repo-path /path/to/repo + """ + add_breadcrumb('ai-guardrails-install') + + # Validate inputs + validate_scope(scope) + repo_path = resolve_repo_path(scope, repo_path) + ide_type = validate_and_parse_ide(ide) + ide_name = IDE_CONFIGS[ide_type].name + success, message = install_hooks(scope, repo_path, ide=ide_type) + + if success: + console.print(f'[green]✓[/] {message}') + console.print() + console.print('[bold]Next steps:[/]') + console.print(f'1. Restart {ide_name} to activate the hooks') + console.print('2. (Optional) Customize policy in ~/.cycode/ai-guardrails.yaml') + console.print() + console.print('[dim]The hooks will scan prompts, file reads, and MCP tool calls for secrets.[/]') + else: + console.print(f'[red]✗[/] {message}', style='bold red') + raise typer.Exit(1) diff --git a/cycode/cli/apps/ai_guardrails/scan/__init__.py b/cycode/cli/apps/ai_guardrails/scan/__init__.py new file mode 100644 index 00000000..47349e78 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/__init__.py @@ -0,0 +1 @@ +# Prompt scan command for AI guardrails (hooks) diff --git a/cycode/cli/apps/ai_guardrails/scan/consts.py b/cycode/cli/apps/ai_guardrails/scan/consts.py new file mode 100644 index 00000000..007892a8 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/consts.py @@ -0,0 +1,48 @@ +""" +Constants and default configuration for AI guardrails. + +These defaults can be overridden by: +1. User-level config: ~/.cycode/ai-guardrails.yaml +2. Repo-level config: /.cycode/ai-guardrails.yaml +""" + +# Policy file name +POLICY_FILE_NAME = 'ai-guardrails.yaml' + +# Default policy configuration +DEFAULT_POLICY = { + 'version': 1, + 'mode': 'block', # block | warn + 'fail_open': True, # allow if scan fails/timeouts + 'secrets': { + 'scan_type': 'secret', + 'timeout_ms': 30000, + 'max_bytes': 200000, + }, + 'prompt': { + 'enabled': True, + 'action': 'block', + }, + 'file_read': { + 'enabled': True, + 'action': 'block', + 'deny_globs': [ + '.env', + '.env.*', + '*.pem', + '*.p12', + '*.key', + '.aws/**', + '.ssh/**', + '*kubeconfig*', + '.npmrc', + '.netrc', + ], + 'scan_content': True, + }, + 'mcp': { + 'enabled': True, + 'action': 'block', + 'scan_arguments': True, + }, +} diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py new file mode 100644 index 00000000..95e9d606 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -0,0 +1,341 @@ +""" +Hook handlers for AI IDE events. + +Each handler receives a unified payload from an IDE, applies policy rules, +and returns a response that either allows or blocks the action. +""" + +import json +import os +from multiprocessing.pool import ThreadPool +from multiprocessing.pool import TimeoutError as PoolTimeoutError +from typing import Callable, Optional + +import typer + +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value +from cycode.cli.apps.ai_guardrails.scan.response_builders import get_response_builder +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType, AIHookOutcome, BlockReason +from cycode.cli.apps.ai_guardrails.scan.utils import is_denied_path, truncate_utf8 +from cycode.cli.apps.scan.code_scanner import _get_scan_documents_thread_func +from cycode.cli.apps.scan.scan_parameters import get_scan_parameters +from cycode.cli.cli_types import ScanTypeOption, SeverityOption +from cycode.cli.models import Document +from cycode.cli.utils.progress_bar import DummyProgressBar, ScanProgressBarSection +from cycode.cli.utils.scan_utils import build_violation_summary +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails') + + +def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> dict: + """ + Handle beforeSubmitPrompt hook. + + Scans prompt text for secrets before it's sent to the AI model. + Returns {"continue": False} to block, {"continue": True} to allow. + """ + ai_client = ctx.obj['ai_security_client'] + ide = payload.ide_provider + response_builder = get_response_builder(ide) + + prompt_config = get_policy_value(policy, 'prompt', default={}) + ai_client.create_conversation(payload) + if not get_policy_value(prompt_config, 'enabled', default=True): + ai_client.create_event(payload, AiHookEventType.PROMPT, AIHookOutcome.ALLOWED) + return response_builder.allow_prompt() + + mode = get_policy_value(policy, 'mode', default='block') + prompt = payload.prompt or '' + max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000) + timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000) + clipped = truncate_utf8(prompt, max_bytes) + + scan_id = None + block_reason = None + outcome = AIHookOutcome.ALLOWED + + try: + violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms) + + if ( + violation_summary + and get_policy_value(prompt_config, 'action', default='block') == 'block' + and mode == 'block' + ): + outcome = AIHookOutcome.BLOCKED + block_reason = BlockReason.SECRETS_IN_PROMPT + user_message = f'{violation_summary}. Remove secrets before sending.' + response = response_builder.deny_prompt(user_message) + else: + if violation_summary: + outcome = AIHookOutcome.WARNED + response = response_builder.allow_prompt() + return response + except Exception as e: + outcome = ( + AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED + ) + block_reason = BlockReason.SCAN_FAILURE if outcome == AIHookOutcome.BLOCKED else None + raise e + finally: + ai_client.create_event( + payload, + AiHookEventType.PROMPT, + outcome, + scan_id=scan_id, + block_reason=block_reason, + ) + + +def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> dict: + """ + Handle beforeReadFile hook. + + Blocks sensitive files (via deny_globs) and scans file content for secrets. + Returns {"permission": "deny"} to block, {"permission": "allow"} to allow. + """ + ai_client = ctx.obj['ai_security_client'] + ide = payload.ide_provider + response_builder = get_response_builder(ide) + + file_read_config = get_policy_value(policy, 'file_read', default={}) + ai_client.create_conversation(payload) + if not get_policy_value(file_read_config, 'enabled', default=True): + ai_client.create_event(payload, AiHookEventType.FILE_READ, AIHookOutcome.ALLOWED) + return response_builder.allow_permission() + + mode = get_policy_value(policy, 'mode', default='block') + file_path = payload.file_path or '' + action = get_policy_value(file_read_config, 'action', default='block') + + scan_id = None + block_reason = None + outcome = AIHookOutcome.ALLOWED + + try: + # Check path-based denylist first + if is_denied_path(file_path, policy) and action == 'block': + outcome = AIHookOutcome.BLOCKED + block_reason = BlockReason.SENSITIVE_PATH + user_message = f'Cycode blocked sending {file_path} to the AI (sensitive path policy).' + return response_builder.deny_permission( + user_message, + 'This file path is classified as sensitive; do not read/send it to the model.', + ) + + # Scan file content if enabled + if get_policy_value(file_read_config, 'scan_content', default=True): + violation_summary, scan_id = _scan_path_for_secrets(ctx, file_path, policy) + if violation_summary and action == 'block' and mode == 'block': + outcome = AIHookOutcome.BLOCKED + block_reason = BlockReason.SECRETS_IN_FILE + user_message = f'Cycode blocked reading {file_path}. {violation_summary}' + return response_builder.deny_permission( + user_message, + 'Secrets detected; do not send this file to the model.', + ) + if violation_summary: + outcome = AIHookOutcome.WARNED + return response_builder.allow_permission() + + return response_builder.allow_permission() + except Exception as e: + outcome = ( + AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED + ) + block_reason = BlockReason.SCAN_FAILURE if outcome == AIHookOutcome.BLOCKED else None + raise e + finally: + ai_client.create_event( + payload, + AiHookEventType.FILE_READ, + outcome, + scan_id=scan_id, + block_reason=block_reason, + ) + + +def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> dict: + """ + Handle beforeMCPExecution hook. + + Scans tool arguments for secrets before MCP tool execution. + Returns {"permission": "deny"} to block, {"permission": "ask"} to warn, + {"permission": "allow"} to allow. + """ + ai_client = ctx.obj['ai_security_client'] + ide = payload.ide_provider + response_builder = get_response_builder(ide) + + mcp_config = get_policy_value(policy, 'mcp', default={}) + ai_client.create_conversation(payload) + if not get_policy_value(mcp_config, 'enabled', default=True): + ai_client.create_event(payload, AiHookEventType.MCP_EXECUTION, AIHookOutcome.ALLOWED) + return response_builder.allow_permission() + + mode = get_policy_value(policy, 'mode', default='block') + tool = payload.mcp_tool_name or 'unknown' + args = payload.mcp_arguments or {} + args_text = args if isinstance(args, str) else json.dumps(args) + max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000) + timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000) + clipped = truncate_utf8(args_text, max_bytes) + action = get_policy_value(mcp_config, 'action', default='block') + + scan_id = None + block_reason = None + outcome = AIHookOutcome.ALLOWED + + try: + if get_policy_value(mcp_config, 'scan_arguments', default=True): + violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms) + if violation_summary: + if mode == 'block' and action == 'block': + outcome = AIHookOutcome.BLOCKED + block_reason = BlockReason.SECRETS_IN_MCP_ARGS + user_message = f'Cycode blocked MCP tool call "{tool}". {violation_summary}' + return response_builder.deny_permission( + user_message, + 'Do not pass secrets to tools. Use secret references (name/id) instead.', + ) + outcome = AIHookOutcome.WARNED + return response_builder.ask_permission( + f'{violation_summary} in MCP tool call "{tool}". Allow execution?', + 'Possible secrets detected in tool arguments; proceed with caution.', + ) + + return response_builder.allow_permission() + except Exception as e: + outcome = ( + AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED + ) + block_reason = BlockReason.SCAN_FAILURE if outcome == AIHookOutcome.BLOCKED else None + raise e + finally: + ai_client.create_event( + payload, + AiHookEventType.MCP_EXECUTION, + outcome, + scan_id=scan_id, + block_reason=block_reason, + ) + + +def get_handler_for_event(event_type: str) -> Optional[Callable[[typer.Context, AIHookPayload, dict], dict]]: + """Get the appropriate handler function for a canonical event type. + + Args: + event_type: Canonical event type string (from AiHookEventType enum) + + Returns: + Handler function or None if event type is not recognized + """ + handlers = { + AiHookEventType.PROMPT.value: handle_before_submit_prompt, + AiHookEventType.FILE_READ.value: handle_before_read_file, + AiHookEventType.MCP_EXECUTION.value: handle_before_mcp_execution, + } + return handlers.get(event_type) + + +def _setup_scan_context(ctx: typer.Context) -> typer.Context: + """Set up minimal context for scan_documents without progress bars or printing.""" + + # Set up minimal required context + ctx.obj['progress_bar'] = DummyProgressBar([ScanProgressBarSection]) + ctx.obj['sync'] = True # Synchronous scan + ctx.obj['scan_type'] = ScanTypeOption.SECRET # AI guardrails always scans for secrets + ctx.obj['severity_threshold'] = SeverityOption.INFO # Report all severities + + # Set command name for scan logic + ctx.info_name = 'ai_guardrails' + + return ctx + + +def _perform_scan( + ctx: typer.Context, documents: list[Document], scan_parameters: dict, timeout_seconds: float +) -> tuple[Optional[str], Optional[str]]: + """ + Perform a scan on documents and extract results. + + Returns tuple of (violation_summary, scan_id) if secrets found, (None, scan_id) if clean. + Raises exception if scan fails or times out (triggers fail_open policy). + """ + if not documents: + return None, None + + # Get the thread function for scanning + scan_batch_thread_func = _get_scan_documents_thread_func( + ctx, is_git_diff=False, is_commit_range=False, scan_parameters=scan_parameters + ) + + # Use ThreadPool.apply_async with timeout to abort if scan takes too long + # This uses the same ThreadPool mechanism as run_parallel_batched_scan but with timeout support + with ThreadPool(processes=1) as pool: + result = pool.apply_async(scan_batch_thread_func, (documents,)) + try: + scan_id, error, local_scan_result = result.get(timeout=timeout_seconds) + except PoolTimeoutError: + logger.debug('Scan timed out after %s seconds', timeout_seconds) + raise RuntimeError(f'Scan timed out after {timeout_seconds} seconds') from None + + # Check if scan failed - raise exception to trigger fail_open policy + if error: + raise RuntimeError(error.message) + + if not local_scan_result: + return None, None + + scan_id = local_scan_result.scan_id + + # Check if there are any detections + if local_scan_result.detections_count > 0: + violation_summary = build_violation_summary([local_scan_result]) + return violation_summary, scan_id + + return None, scan_id + + +def _scan_text_for_secrets(ctx: typer.Context, text: str, timeout_ms: int) -> tuple[Optional[str], Optional[str]]: + """ + Scan text content for secrets using Cycode CLI. + + Returns tuple of (violation_summary, scan_id) if secrets found, (None, scan_id) if clean. + Raises exception on error or timeout. + """ + if not text: + return None, None + + document = Document(path='prompt-content.txt', content=text, is_git_diff_format=False) + scan_ctx = _setup_scan_context(ctx) + timeout_seconds = timeout_ms / 1000.0 + return _perform_scan(scan_ctx, [document], get_scan_parameters(scan_ctx, None), timeout_seconds) + + +def _scan_path_for_secrets(ctx: typer.Context, file_path: str, policy: dict) -> tuple[Optional[str], Optional[str]]: + """ + Scan a file path for secrets. + + Returns tuple of (violation_summary, scan_id) if secrets found, (None, scan_id) if clean. + Raises exception on error or timeout. + """ + if not file_path or not os.path.exists(file_path): + return None, None + + with open(file_path, encoding='utf-8', errors='replace') as f: + content = f.read() + + # Truncate content based on policy max_bytes + max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000) + content = truncate_utf8(content, max_bytes) + + # Get timeout from policy + timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000) + timeout_seconds = timeout_ms / 1000.0 + + document = Document(path=os.path.basename(file_path), content=content, is_git_diff_format=False) + scan_ctx = _setup_scan_context(ctx) + return _perform_scan(scan_ctx, [document], get_scan_parameters(scan_ctx, (file_path,)), timeout_seconds) diff --git a/cycode/cli/apps/ai_guardrails/scan/payload.py b/cycode/cli/apps/ai_guardrails/scan/payload.py new file mode 100644 index 00000000..83787348 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/payload.py @@ -0,0 +1,72 @@ +"""Unified payload object for AI hook events from different tools.""" + +from dataclasses import dataclass +from typing import Optional + +from cycode.cli.apps.ai_guardrails.scan.types import CURSOR_EVENT_MAPPING + + +@dataclass +class AIHookPayload: + """Unified payload object that normalizes field names from different AI tools.""" + + # Event identification + event_name: str # Canonical event type (e.g., 'prompt', 'file_read', 'mcp_execution') + conversation_id: Optional[str] = None + generation_id: Optional[str] = None + + # User and IDE information + ide_user_email: Optional[str] = None + model: Optional[str] = None + ide_provider: str = None # e.g., 'cursor', 'claude-code' + ide_version: Optional[str] = None + + # Event-specific data + prompt: Optional[str] = None # For prompt events + file_path: Optional[str] = None # For file_read events + mcp_server_name: Optional[str] = None # For mcp_execution events + mcp_tool_name: Optional[str] = None # For mcp_execution events + mcp_arguments: Optional[dict] = None # For mcp_execution events + + @classmethod + def from_cursor_payload(cls, payload: dict) -> 'AIHookPayload': + """Create AIHookPayload from Cursor IDE payload. + + Maps Cursor-specific event names to canonical event types. + """ + cursor_event_name = payload.get('hook_event_name', '') + # Map Cursor event name to canonical type, fallback to original if not found + canonical_event = CURSOR_EVENT_MAPPING.get(cursor_event_name, cursor_event_name) + + return cls( + event_name=canonical_event, + conversation_id=payload.get('conversation_id'), + generation_id=payload.get('generation_id'), + ide_user_email=payload.get('user_email'), + model=payload.get('model'), + ide_provider='cursor', + ide_version=payload.get('cursor_version'), + prompt=payload.get('prompt', ''), + file_path=payload.get('file_path') or payload.get('path'), + mcp_server_name=payload.get('command'), # MCP server name + mcp_tool_name=payload.get('tool_name') or payload.get('tool'), + mcp_arguments=payload.get('arguments') or payload.get('tool_input') or payload.get('input'), + ) + + @classmethod + def from_payload(cls, payload: dict, tool: str = 'cursor') -> 'AIHookPayload': + """Create AIHookPayload from any tool's payload. + + Args: + payload: The raw payload from the IDE + tool: The IDE/tool name (e.g., 'cursor') + + Returns: + AIHookPayload instance + + Raises: + ValueError: If the tool is not supported + """ + if tool == 'cursor': + return cls.from_cursor_payload(payload) + raise ValueError(f'Unsupported IDE/tool: {tool}.') diff --git a/cycode/cli/apps/ai_guardrails/scan/policy.py b/cycode/cli/apps/ai_guardrails/scan/policy.py new file mode 100644 index 00000000..f40d77c0 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/policy.py @@ -0,0 +1,85 @@ +""" +Policy loading and configuration management for AI guardrails. + +Policies are loaded and merged in order (later overrides earlier): +1. Built-in defaults (consts.DEFAULT_POLICY) +2. User-level config (~/.cycode/ai-guardrails.yaml) +3. Repo-level config (/.cycode/ai-guardrails.yaml) +""" + +import json +from pathlib import Path +from typing import Any, Optional + +import yaml + +from cycode.cli.apps.ai_guardrails.scan.consts import DEFAULT_POLICY, POLICY_FILE_NAME + + +def deep_merge(base: dict, override: dict) -> dict: + """Deep merge two dictionaries, with override taking precedence.""" + result = base.copy() + for key, value in override.items(): + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + result[key] = deep_merge(result[key], value) + else: + result[key] = value + return result + + +def load_yaml_file(path: Path) -> Optional[dict]: + """Load a YAML or JSON config file.""" + if not path.exists(): + return None + try: + content = path.read_text(encoding='utf-8') + if path.suffix in ('.yaml', '.yml'): + return yaml.safe_load(content) + return json.loads(content) + except Exception: + return None + + +def load_defaults() -> dict: + """Load built-in defaults.""" + return DEFAULT_POLICY.copy() + + +def get_policy_value(policy: dict, *keys: str, default: Any = None) -> Any: + """Get a nested value from the policy dict.""" + current = policy + for key in keys: + if not isinstance(current, dict): + return default + current = current.get(key) + if current is None: + return default + return current + + +def load_policy(workspace_root: Optional[str] = None) -> dict: + """ + Load policy by merging configs in order of precedence. + + Merge order: defaults <- user config <- repo config + + Args: + workspace_root: Workspace root path for repo-level config lookup. + """ + # Start with defaults + policy = load_defaults() + + # Merge user-level config (if exists) + user_policy_path = Path.home() / '.cycode' / POLICY_FILE_NAME + user_config = load_yaml_file(user_policy_path) + if user_config: + policy = deep_merge(policy, user_config) + + # Merge repo-level config (if exists) - highest precedence + if workspace_root: + repo_policy_path = Path(workspace_root) / '.cycode' / POLICY_FILE_NAME + repo_config = load_yaml_file(repo_policy_path) + if repo_config: + policy = deep_merge(policy, repo_config) + + return policy diff --git a/cycode/cli/apps/ai_guardrails/scan/response_builders.py b/cycode/cli/apps/ai_guardrails/scan/response_builders.py new file mode 100644 index 00000000..867965c3 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/response_builders.py @@ -0,0 +1,86 @@ +""" +Response builders for different AI IDE hooks. + +Each IDE has its own response format for hooks. This module provides +an abstract interface and concrete implementations for each supported IDE. +""" + +from abc import ABC, abstractmethod + + +class IDEResponseBuilder(ABC): + """Abstract base class for IDE-specific response builders.""" + + @abstractmethod + def allow_permission(self) -> dict: + """Build response to allow file read or MCP execution.""" + + @abstractmethod + def deny_permission(self, user_message: str, agent_message: str) -> dict: + """Build response to deny file read or MCP execution.""" + + @abstractmethod + def ask_permission(self, user_message: str, agent_message: str) -> dict: + """Build response to ask user for permission (warn mode).""" + + @abstractmethod + def allow_prompt(self) -> dict: + """Build response to allow prompt submission.""" + + @abstractmethod + def deny_prompt(self, user_message: str) -> dict: + """Build response to deny prompt submission.""" + + +class CursorResponseBuilder(IDEResponseBuilder): + """Response builder for Cursor IDE hooks. + + Cursor hook response formats: + - beforeSubmitPrompt: {"continue": bool, "user_message": str} + - beforeReadFile: {"permission": str, "user_message": str, "agent_message": str} + - beforeMCPExecution: {"permission": str, "user_message": str, "agent_message": str} + """ + + def allow_permission(self) -> dict: + """Allow file read or MCP execution.""" + return {'permission': 'allow'} + + def deny_permission(self, user_message: str, agent_message: str) -> dict: + """Deny file read or MCP execution.""" + return {'permission': 'deny', 'user_message': user_message, 'agent_message': agent_message} + + def ask_permission(self, user_message: str, agent_message: str) -> dict: + """Ask user for permission (warn mode).""" + return {'permission': 'ask', 'user_message': user_message, 'agent_message': agent_message} + + def allow_prompt(self) -> dict: + """Allow prompt submission.""" + return {'continue': True} + + def deny_prompt(self, user_message: str) -> dict: + """Deny prompt submission.""" + return {'continue': False, 'user_message': user_message} + + +# Registry of response builders by IDE name +_RESPONSE_BUILDERS: dict[str, IDEResponseBuilder] = { + 'cursor': CursorResponseBuilder(), +} + + +def get_response_builder(ide: str = 'cursor') -> IDEResponseBuilder: + """Get the response builder for a specific IDE. + + Args: + ide: The IDE name (e.g., 'cursor', 'claude-code') + + Returns: + IDEResponseBuilder instance for the specified IDE + + Raises: + ValueError: If the IDE is not supported + """ + builder = _RESPONSE_BUILDERS.get(ide.lower()) + if not builder: + raise ValueError(f'Unsupported IDE: {ide}. Supported IDEs: {list(_RESPONSE_BUILDERS.keys())}') + return builder diff --git a/cycode/cli/apps/ai_guardrails/scan/scan_command.py b/cycode/cli/apps/ai_guardrails/scan/scan_command.py new file mode 100644 index 00000000..e08bb4de --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -0,0 +1,134 @@ +""" +Scan command for AI guardrails. + +This command handles AI IDE hooks by reading JSON from stdin and outputting +a JSON response to stdout. It scans prompts, file reads, and MCP tool calls +for secrets before they are sent to AI models. + +Supports multiple IDEs with different hook event types. The specific hook events +supported depend on the IDE being used (e.g., Cursor supports beforeSubmitPrompt, +beforeReadFile, beforeMCPExecution). +""" + +import sys +from typing import Annotated + +import click +import typer + +from cycode.cli.apps.ai_guardrails.scan.handlers import get_handler_for_event +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.policy import load_policy +from cycode.cli.apps.ai_guardrails.scan.response_builders import get_response_builder +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType +from cycode.cli.apps.ai_guardrails.scan.utils import output_json, safe_json_parse +from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError +from cycode.cli.utils.get_api_client import get_ai_security_manager_client, get_scan_cycode_client +from cycode.cli.utils.sentry import add_breadcrumb +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails') + + +def _get_auth_error_message(error: Exception) -> str: + """Get user-friendly message for authentication errors.""" + if isinstance(error, click.ClickException): + # Missing credentials + return f'{error.message} Please run `cycode configure` to set up your credentials.' + + if isinstance(error, HttpUnauthorizedError): + # Invalid/expired credentials + return ( + 'Unable to authenticate to Cycode. Your credentials are invalid or have expired. ' + 'Please run `cycode configure` to update your credentials.' + ) + + # Fallback + return 'Authentication failed. Please run `cycode configure` to set up your credentials.' + + +def _initialize_clients(ctx: typer.Context) -> None: + """Initialize API clients. + + May raise click.ClickException if credentials are missing, + or HttpUnauthorizedError if credentials are invalid. + """ + scan_client = get_scan_cycode_client(ctx) + ctx.obj['client'] = scan_client + + ai_security_client = get_ai_security_manager_client(ctx) + ctx.obj['ai_security_client'] = ai_security_client + + +def scan_command( + ctx: typer.Context, + ide: Annotated[ + str, + typer.Option( + '--ide', + help='IDE that sent the payload (e.g., "cursor"). Defaults to cursor.', + hidden=True, + ), + ] = 'cursor', +) -> None: + """Scan content from AI IDE hooks for secrets. + + This command reads a JSON payload from stdin containing hook event data + and outputs a JSON response to stdout indicating whether to allow or block the action. + + The hook event type is determined from the event field in the payload (field name + varies by IDE). Each IDE may support different hook events for scanning prompts, + file access, and tool executions. + + Example usage (from IDE hooks configuration): + { "command": "cycode ai-guardrails scan" } + """ + add_breadcrumb('ai-guardrails-scan') + + stdin_data = sys.stdin.read().strip() + payload = safe_json_parse(stdin_data) + + tool = ide.lower() + response_builder = get_response_builder(tool) + + if not payload: + logger.debug('Empty or invalid JSON payload received') + output_json(response_builder.allow_prompt()) + return + + unified_payload = AIHookPayload.from_payload(payload, tool=tool) + event_name = unified_payload.event_name + logger.debug('Processing AI guardrails hook', extra={'event_name': event_name, 'tool': tool}) + + workspace_roots = payload.get('workspace_roots', ['.']) + policy = load_policy(workspace_roots[0]) + + try: + _initialize_clients(ctx) + + handler = get_handler_for_event(event_name) + if handler is None: + logger.debug('Unknown hook event, allowing by default', extra={'event_name': event_name}) + output_json(response_builder.allow_prompt()) + return + + response = handler(ctx, unified_payload, policy) + logger.debug('Hook handler completed', extra={'event_name': event_name, 'response': response}) + output_json(response) + + except (click.ClickException, HttpUnauthorizedError) as e: + error_message = _get_auth_error_message(e) + if event_name == AiHookEventType.PROMPT: + output_json(response_builder.deny_prompt(error_message)) + return + output_json(response_builder.deny_permission(error_message, 'Authentication required')) + + except Exception as e: + logger.error('Hook handler failed', exc_info=e) + if policy.get('fail_open', True): + output_json(response_builder.allow_prompt()) + return + if event_name == AiHookEventType.PROMPT: + output_json(response_builder.deny_prompt('Cycode guardrails error - blocking due to fail-closed policy')) + return + output_json(response_builder.deny_permission('Cycode guardrails error', 'Blocking due to fail-closed policy')) diff --git a/cycode/cli/apps/ai_guardrails/scan/types.py b/cycode/cli/apps/ai_guardrails/scan/types.py new file mode 100644 index 00000000..095ca61b --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/types.py @@ -0,0 +1,54 @@ +"""Type definitions for AI guardrails.""" + +import sys + +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + from enum import Enum + + class StrEnum(str, Enum): + def __str__(self) -> str: + return self.value + + +class AiHookEventType(StrEnum): + """Canonical event types for AI guardrails. + + These are IDE-agnostic event types. Each IDE's specific event names + are mapped to these canonical types using the mapping dictionaries below. + """ + + PROMPT = 'Prompt' + FILE_READ = 'FileRead' + MCP_EXECUTION = 'McpExecution' + + +# IDE-specific event name mappings to canonical types +CURSOR_EVENT_MAPPING = { + 'beforeSubmitPrompt': AiHookEventType.PROMPT, + 'beforeReadFile': AiHookEventType.FILE_READ, + 'beforeMCPExecution': AiHookEventType.MCP_EXECUTION, +} + + +class AIHookOutcome(StrEnum): + """Outcome of an AI hook event evaluation.""" + + ALLOWED = 'allowed' + BLOCKED = 'blocked' + WARNED = 'warned' + + +class BlockReason(StrEnum): + """Reason why an AI hook event was blocked. + + These are categorical reasons sent to the backend for tracking/analytics, + separate from the detailed user-facing messages. + """ + + SECRETS_IN_PROMPT = 'secrets_in_prompt' + SECRETS_IN_FILE = 'secrets_in_file' + SECRETS_IN_MCP_ARGS = 'secrets_in_mcp_args' + SENSITIVE_PATH = 'sensitive_path' + SCAN_FAILURE = 'scan_failure' diff --git a/cycode/cli/apps/ai_guardrails/scan/utils.py b/cycode/cli/apps/ai_guardrails/scan/utils.py new file mode 100644 index 00000000..e14c1c02 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/utils.py @@ -0,0 +1,72 @@ +""" +Utility functions for AI guardrails. + +Includes JSON parsing, path matching, and text handling utilities. +""" + +import json +import os +from pathlib import Path + +from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value + + +def safe_json_parse(s: str) -> dict: + """Parse JSON string, returning empty dict on failure.""" + try: + return json.loads(s) if s else {} + except (json.JSONDecodeError, TypeError): + return {} + + +def truncate_utf8(text: str, max_bytes: int) -> str: + """Truncate text to max bytes while preserving valid UTF-8.""" + if not text: + return '' + encoded = text.encode('utf-8') + if len(encoded) <= max_bytes: + return text + return encoded[:max_bytes].decode('utf-8', errors='ignore') + + +def normalize_path(file_path: str) -> str: + """Normalize path to prevent traversal attacks.""" + if not file_path: + return '' + normalized = os.path.normpath(file_path) + # Reject paths that attempt to escape outside bounds + if normalized.startswith('..'): + return '' + return normalized + + +def matches_glob(file_path: str, pattern: str) -> bool: + """Check if file path matches a glob pattern. + + Case-insensitive matching for cross-platform compatibility. + """ + normalized = normalize_path(file_path) + if not normalized or not pattern: + return False + + path = Path(normalized) + # Try case-sensitive first + if path.match(pattern): + return True + + # Then try case-insensitive by lowercasing both path and pattern + path_lower = Path(normalized.lower()) + return path_lower.match(pattern.lower()) + + +def is_denied_path(file_path: str, policy: dict) -> bool: + """Check if file path is in the denylist.""" + if not file_path: + return False + globs = get_policy_value(policy, 'file_read', 'deny_globs', default=[]) + return any(matches_glob(file_path, g) for g in globs) + + +def output_json(obj: dict) -> None: + """Write JSON response to stdout (for IDE to read).""" + print(json.dumps(obj), end='') # noqa: T201 diff --git a/cycode/cli/apps/ai_guardrails/status_command.py b/cycode/cli/apps/ai_guardrails/status_command.py new file mode 100644 index 00000000..0a9801b5 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/status_command.py @@ -0,0 +1,92 @@ +"""Status command for AI guardrails hooks.""" + +import os +from pathlib import Path +from typing import Annotated, Optional + +import typer +from rich.table import Table + +from cycode.cli.apps.ai_guardrails.command_utils import console, validate_and_parse_ide, validate_scope +from cycode.cli.apps.ai_guardrails.hooks_manager import get_hooks_status +from cycode.cli.utils.sentry import add_breadcrumb + + +def status_command( + ctx: typer.Context, + scope: Annotated[ + str, + typer.Option( + '--scope', + '-s', + help='Check scope: "user", "repo", or "all" for both.', + ), + ] = 'all', + ide: Annotated[ + str, + typer.Option( + '--ide', + help='IDE to check status for (e.g., "cursor"). Defaults to cursor.', + ), + ] = 'cursor', + repo_path: Annotated[ + Optional[Path], + typer.Option( + '--repo-path', + help='Repository path for repo-scoped status (defaults to current directory).', + exists=True, + file_okay=False, + dir_okay=True, + resolve_path=True, + ), + ] = None, +) -> None: + """Show AI guardrails hook installation status. + + Displays the current status of Cycode AI guardrails hooks for the specified IDE. + + Examples: + cycode ai-guardrails status # Show both user and repo status + cycode ai-guardrails status --scope user # Show only user-level status + cycode ai-guardrails status --scope repo # Show only repo-level status + cycode ai-guardrails status --ide cursor # Check status for Cursor IDE + """ + add_breadcrumb('ai-guardrails-status') + + # Validate inputs (status allows 'all' scope) + validate_scope(scope, allowed_scopes=('user', 'repo', 'all')) + if repo_path is None: + repo_path = Path(os.getcwd()) + ide_type = validate_and_parse_ide(ide) + + scopes_to_check = ['user', 'repo'] if scope == 'all' else [scope] + + for check_scope in scopes_to_check: + status = get_hooks_status(check_scope, repo_path if check_scope == 'repo' else None, ide=ide_type) + + console.print() + console.print(f'[bold]{check_scope.upper()} SCOPE[/]') + console.print(f'Path: {status["hooks_path"]}') + + if not status['file_exists']: + console.print('[dim]No hooks.json file found[/]') + continue + + if status['cycode_installed']: + console.print('[green]✓ Cycode AI guardrails: INSTALLED[/]') + else: + console.print('[yellow]○ Cycode AI guardrails: NOT INSTALLED[/]') + + # Show hook details + table = Table(show_header=True, header_style='bold') + table.add_column('Hook Event') + table.add_column('Cycode Enabled') + table.add_column('Total Hooks') + + for event, info in status['hooks'].items(): + enabled = '[green]Yes[/]' if info['enabled'] else '[dim]No[/]' + table.add_row(event, enabled, str(info['total_entries'])) + + console.print(table) + + console.print() diff --git a/cycode/cli/apps/ai_guardrails/uninstall_command.py b/cycode/cli/apps/ai_guardrails/uninstall_command.py new file mode 100644 index 00000000..23315693 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/uninstall_command.py @@ -0,0 +1,73 @@ +"""Uninstall command for AI guardrails hooks.""" + +from pathlib import Path +from typing import Annotated, Optional + +import typer + +from cycode.cli.apps.ai_guardrails.command_utils import ( + console, + resolve_repo_path, + validate_and_parse_ide, + validate_scope, +) +from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS +from cycode.cli.apps.ai_guardrails.hooks_manager import uninstall_hooks +from cycode.cli.utils.sentry import add_breadcrumb + + +def uninstall_command( + ctx: typer.Context, + scope: Annotated[ + str, + typer.Option( + '--scope', + '-s', + help='Uninstall scope: "user" for user-level hooks, "repo" for repository-level hooks.', + ), + ] = 'user', + ide: Annotated[ + str, + typer.Option( + '--ide', + help='IDE to uninstall hooks from (e.g., "cursor"). Defaults to cursor.', + ), + ] = 'cursor', + repo_path: Annotated[ + Optional[Path], + typer.Option( + '--repo-path', + help='Repository path for repo-scoped uninstallation (defaults to current directory).', + exists=True, + file_okay=False, + dir_okay=True, + resolve_path=True, + ), + ] = None, +) -> None: + """Remove AI guardrails hooks from supported IDEs. + + This command removes Cycode hooks from the IDE's hooks configuration. + Other hooks (if any) will be preserved. + + Examples: + cycode ai-guardrails uninstall # Remove user-level hooks + cycode ai-guardrails uninstall --scope repo # Remove repo-level hooks + cycode ai-guardrails uninstall --ide cursor # Uninstall from Cursor IDE + """ + add_breadcrumb('ai-guardrails-uninstall') + + # Validate inputs + validate_scope(scope) + repo_path = resolve_repo_path(scope, repo_path) + ide_type = validate_and_parse_ide(ide) + ide_name = IDE_CONFIGS[ide_type].name + success, message = uninstall_hooks(scope, repo_path, ide=ide_type) + + if success: + console.print(f'[green]✓[/] {message}') + console.print() + console.print(f'[dim]Restart {ide_name} for changes to take effect.[/]') + else: + console.print(f'[red]✗[/] {message}', style='bold red') + raise typer.Exit(1) diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index d3e325f3..3ffefd0f 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -91,7 +91,7 @@ def _should_use_sync_flow(command_scan_type: str, scan_type: str, sync_option: b if not sync_option and scan_type != consts.IAC_SCAN_TYPE: return False - if command_scan_type not in {'path', 'repository'}: + if command_scan_type not in {'path', 'repository', 'ai_guardrails'}: return False if scan_type == consts.IAC_SCAN_TYPE: diff --git a/cycode/cli/cli_types.py b/cycode/cli/cli_types.py index 63a1cb36..bd88faea 100644 --- a/cycode/cli/cli_types.py +++ b/cycode/cli/cli_types.py @@ -86,6 +86,10 @@ def get_member_color(name: str) -> str: def get_member_emoji(name: str) -> str: return _SEVERITY_EMOJIS.get(name.lower(), _SEVERITY_DEFAULT_EMOJI) + @staticmethod + def get_member_unicode_emoji(name: str) -> str: + return _SEVERITY_UNICODE_EMOJIS.get(name.lower(), _SEVERITY_DEFAULT_UNICODE_EMOJI) + def __rich__(self) -> str: color = self.get_member_color(self.value) return f'[{color}]{self.value.upper()}[/]' @@ -117,3 +121,12 @@ def __rich__(self) -> str: SeverityOption.HIGH.value: ':red_circle:', SeverityOption.CRITICAL.value: ':exclamation_mark:', # double_exclamation_mark is not red } + +_SEVERITY_DEFAULT_UNICODE_EMOJI = '⚪' +_SEVERITY_UNICODE_EMOJIS = { + SeverityOption.INFO.value: '🔵', + SeverityOption.LOW.value: '🟡', + SeverityOption.MEDIUM.value: '🟠', + SeverityOption.HIGH.value: '🔴', + SeverityOption.CRITICAL.value: '❗', +} diff --git a/cycode/cli/utils/get_api_client.py b/cycode/cli/utils/get_api_client.py index 5c712288..b69666d3 100644 --- a/cycode/cli/utils/get_api_client.py +++ b/cycode/cli/utils/get_api_client.py @@ -3,11 +3,17 @@ import click from cycode.cli.user_settings.credentials_manager import CredentialsManager -from cycode.cyclient.client_creator import create_import_sbom_client, create_report_client, create_scan_client +from cycode.cyclient.client_creator import ( + create_ai_security_manager_client, + create_import_sbom_client, + create_report_client, + create_scan_client, +) if TYPE_CHECKING: import typer + from cycode.cyclient.ai_security_manager_client import AISecurityManagerClient from cycode.cyclient.import_sbom_client import ImportSbomClient from cycode.cyclient.report_client import ReportClient from cycode.cyclient.scan_client import ScanClient @@ -19,7 +25,7 @@ def _get_cycode_client( client_secret: Optional[str], hide_response_log: bool, id_token: Optional[str] = None, -) -> Union['ScanClient', 'ReportClient']: +) -> Union['ScanClient', 'ReportClient', 'ImportSbomClient', 'AISecurityManagerClient']: if client_id and id_token: return create_client_func(client_id, None, hide_response_log, id_token) @@ -62,6 +68,13 @@ def get_import_sbom_cycode_client(ctx: 'typer.Context', hide_response_log: bool return _get_cycode_client(create_import_sbom_client, client_id, client_secret, hide_response_log, id_token) +def get_ai_security_manager_client(ctx: 'typer.Context', hide_response_log: bool = True) -> 'AISecurityManagerClient': + client_id = ctx.obj.get('client_id') + client_secret = ctx.obj.get('client_secret') + id_token = ctx.obj.get('id_token') + return _get_cycode_client(create_ai_security_manager_client, client_id, client_secret, hide_response_log, id_token) + + def _get_configured_credentials() -> tuple[str, str]: credentials_manager = CredentialsManager() return credentials_manager.get_credentials() diff --git a/cycode/cli/utils/scan_utils.py b/cycode/cli/utils/scan_utils.py index 1332a7cf..be86716b 100644 --- a/cycode/cli/utils/scan_utils.py +++ b/cycode/cli/utils/scan_utils.py @@ -1,9 +1,12 @@ import os +from collections import defaultdict from typing import TYPE_CHECKING, Optional from uuid import UUID, uuid4 import typer +from cycode.cli.cli_types import SeverityOption + if TYPE_CHECKING: from cycode.cli.models import LocalScanResult from cycode.cyclient.models import ScanConfiguration @@ -33,3 +36,24 @@ def generate_unique_scan_id() -> UUID: return UUID(os.environ['PYTEST_TEST_UNIQUE_ID']) return uuid4() + + +def build_violation_summary(local_scan_results: list['LocalScanResult']) -> str: + """Build violation summary string with severity breakdown and emojis.""" + detections_count = 0 + severity_counts = defaultdict(int) + + for local_scan_result in local_scan_results: + for document_detections in local_scan_result.document_detections: + for detection in document_detections.detections: + if detection.severity: + detections_count += 1 + severity_counts[SeverityOption(detection.severity)] += 1 + + severity_parts = [] + for severity in reversed(SeverityOption): + emoji = SeverityOption.get_member_unicode_emoji(severity) + count = severity_counts[severity] + severity_parts.append(f'{emoji} {severity.upper()} - {count}') + + return f'Cycode found {detections_count} violations: {" | ".join(severity_parts)}' diff --git a/cycode/cyclient/ai_security_manager_client.py b/cycode/cyclient/ai_security_manager_client.py new file mode 100644 index 00000000..627e2b33 --- /dev/null +++ b/cycode/cyclient/ai_security_manager_client.py @@ -0,0 +1,86 @@ +"""Client for AI Security Manager service.""" + +from typing import TYPE_CHECKING, Optional + +from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError +from cycode.cyclient.cycode_client_base import CycodeClientBase +from cycode.cyclient.logger import logger + +if TYPE_CHECKING: + from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload + from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType, AIHookOutcome, BlockReason + from cycode.cyclient.ai_security_manager_service_config import AISecurityManagerServiceConfigBase + + +class AISecurityManagerClient: + """Client for interacting with AI Security Manager service.""" + + _CONVERSATIONS_PATH = 'v4/ai-security/interactions/conversations' + _EVENTS_PATH = 'v4/ai-security/interactions/events' + + def __init__(self, client: CycodeClientBase, service_config: 'AISecurityManagerServiceConfigBase') -> None: + self.client = client + self.service_config = service_config + + def _build_endpoint_path(self, path: str) -> str: + """Build the full endpoint path including service name/port.""" + service_name = self.service_config.get_service_name() + if service_name: + return f'{service_name}/{path}' + return path + + def create_conversation(self, payload: 'AIHookPayload') -> Optional[str]: + """Creates an AI conversation from hook payload.""" + conversation_id = payload.conversation_id + if not conversation_id: + return None + + body = { + 'id': conversation_id, + 'ide_user_email': payload.ide_user_email, + 'model': payload.model, + 'ide_provider': payload.ide_provider, + 'ide_version': payload.ide_version, + } + + try: + self.client.post(self._build_endpoint_path(self._CONVERSATIONS_PATH), body=body) + except HttpUnauthorizedError: + # Authentication error - re-raise so prompt_command can catch it + raise + except Exception as e: + logger.debug('Failed to create conversation', exc_info=e) + # Don't fail the hook if tracking fails (non-auth errors) + + return conversation_id + + def create_event( + self, + payload: 'AIHookPayload', + event_type: 'AiHookEventType', + outcome: 'AIHookOutcome', + scan_id: Optional[str] = None, + block_reason: Optional['BlockReason'] = None, + ) -> None: + """Create an AI hook event from hook payload.""" + conversation_id = payload.conversation_id + if not conversation_id: + logger.debug('No conversation ID available, skipping event creation') + return + + body = { + 'conversation_id': conversation_id, + 'event_type': event_type, + 'outcome': outcome, + 'generation_id': payload.generation_id, + 'block_reason': block_reason, + 'cli_scan_id': scan_id, + 'mcp_server_name': payload.mcp_server_name, + 'mcp_tool_name': payload.mcp_tool_name, + } + + try: + self.client.post(self._build_endpoint_path(self._EVENTS_PATH), body=body) + except Exception as e: + logger.debug('Failed to create AI hook event', exc_info=e) + # Don't fail the hook if tracking fails diff --git a/cycode/cyclient/ai_security_manager_service_config.py b/cycode/cyclient/ai_security_manager_service_config.py new file mode 100644 index 00000000..60d7f2dd --- /dev/null +++ b/cycode/cyclient/ai_security_manager_service_config.py @@ -0,0 +1,27 @@ +"""Service configuration for AI Security Manager.""" + + +class AISecurityManagerServiceConfigBase: + """Base class for AI Security Manager service configuration.""" + + def get_service_name(self) -> str: + """Get the service name or port for URL construction. + + In dev mode, returns the port number. + In production, returns the service name. + """ + raise NotImplementedError + + +class DevAISecurityManagerServiceConfig(AISecurityManagerServiceConfigBase): + """Dev configuration for AI Security Manager.""" + + def get_service_name(self) -> str: + return '5163/api' + + +class DefaultAISecurityManagerServiceConfig(AISecurityManagerServiceConfigBase): + """Production configuration for AI Security Manager.""" + + def get_service_name(self) -> str: + return '' diff --git a/cycode/cyclient/client_creator.py b/cycode/cyclient/client_creator.py index 01ab6b59..c26795c7 100644 --- a/cycode/cyclient/client_creator.py +++ b/cycode/cyclient/client_creator.py @@ -1,5 +1,10 @@ from typing import Optional +from cycode.cyclient.ai_security_manager_client import AISecurityManagerClient +from cycode.cyclient.ai_security_manager_service_config import ( + DefaultAISecurityManagerServiceConfig, + DevAISecurityManagerServiceConfig, +) from cycode.cyclient.config import dev_mode from cycode.cyclient.config_dev import DEV_CYCODE_API_URL from cycode.cyclient.cycode_dev_based_client import CycodeDevBasedClient @@ -49,3 +54,18 @@ def create_import_sbom_client( else: client = CycodeTokenBasedClient(client_id, client_secret) return ImportSbomClient(client) + + +def create_ai_security_manager_client( + client_id: str, client_secret: Optional[str] = None, _: bool = False, id_token: Optional[str] = None +) -> AISecurityManagerClient: + if dev_mode: + client = CycodeDevBasedClient(DEV_CYCODE_API_URL) + service_config = DevAISecurityManagerServiceConfig() + else: + if id_token: + client = CycodeOidcBasedClient(client_id, id_token) + else: + client = CycodeTokenBasedClient(client_id, client_secret) + service_config = DefaultAISecurityManagerServiceConfig() + return AISecurityManagerClient(client, service_config) diff --git a/tests/cli/commands/ai_guardrails/__init__.py b/tests/cli/commands/ai_guardrails/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/commands/ai_guardrails/scan/__init__.py b/tests/cli/commands/ai_guardrails/scan/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/commands/ai_guardrails/scan/test_handlers.py b/tests/cli/commands/ai_guardrails/scan/test_handlers.py new file mode 100644 index 00000000..58dfe195 --- /dev/null +++ b/tests/cli/commands/ai_guardrails/scan/test_handlers.py @@ -0,0 +1,361 @@ +"""Tests for AI guardrails handlers.""" + +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.apps.ai_guardrails.scan.handlers import ( + handle_before_mcp_execution, + handle_before_read_file, + handle_before_submit_prompt, +) +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.types import AIHookOutcome, BlockReason + + +@pytest.fixture +def mock_ctx() -> MagicMock: + """Create a mock Typer context.""" + ctx = MagicMock(spec=typer.Context) + ctx.obj = { + 'ai_security_client': MagicMock(), + 'scan_type': 'secret', + } + return ctx + + +@pytest.fixture +def mock_payload() -> AIHookPayload: + """Create a mock AIHookPayload.""" + return AIHookPayload( + event_name='prompt', + conversation_id='test-conv-id', + generation_id='test-gen-id', + ide_user_email='test@example.com', + model='gpt-4', + ide_provider='cursor', + ide_version='1.0.0', + prompt='Test prompt', + ) + + +@pytest.fixture +def default_policy() -> dict[str, Any]: + """Create a default policy dict.""" + return { + 'mode': 'block', + 'fail_open': True, + 'secrets': {'max_bytes': 200000}, + 'prompt': {'enabled': True, 'action': 'block'}, + 'file_read': {'enabled': True, 'action': 'block', 'scan_content': True, 'deny_globs': []}, + 'mcp': {'enabled': True, 'action': 'block', 'scan_arguments': True}, + } + + +# Tests for handle_before_submit_prompt + + +def test_handle_before_submit_prompt_disabled( + mock_ctx: MagicMock, mock_payload: AIHookPayload, default_policy: dict[str, Any] +) -> None: + """Test that disabled prompt scanning allows the prompt.""" + default_policy['prompt']['enabled'] = False + + result = handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) + + assert result == {'continue': True} + mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_submit_prompt_no_secrets( + mock_scan: MagicMock, mock_ctx: MagicMock, mock_payload: AIHookPayload, default_policy: dict[str, Any] +) -> None: + """Test that prompt with no secrets is allowed.""" + mock_scan.return_value = (None, 'scan-id-123') + + result = handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) + + assert result == {'continue': True} + mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + # outcome is arg[2], scan_id and block_reason are kwargs + assert call_args.args[2] == AIHookOutcome.ALLOWED + assert call_args.kwargs['scan_id'] == 'scan-id-123' + assert call_args.kwargs['block_reason'] is None + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_submit_prompt_with_secrets_blocked( + mock_scan: MagicMock, mock_ctx: MagicMock, mock_payload: AIHookPayload, default_policy: dict[str, Any] +) -> None: + """Test that prompt with secrets is blocked.""" + mock_scan.return_value = ('Found 1 secret: API key', 'scan-id-456') + + result = handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) + + assert result['continue'] is False + assert 'Found 1 secret: API key' in result['user_message'] + mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.BLOCKED + assert call_args.kwargs['block_reason'] == BlockReason.SECRETS_IN_PROMPT + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_submit_prompt_with_secrets_warned( + mock_scan: MagicMock, mock_ctx: MagicMock, mock_payload: AIHookPayload, default_policy: dict[str, Any] +) -> None: + """Test that prompt with secrets in warn mode is allowed.""" + default_policy['prompt']['action'] = 'warn' + mock_scan.return_value = ('Found 1 secret: API key', 'scan-id-789') + + result = handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) + + assert result == {'continue': True} + mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.WARNED + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_submit_prompt_scan_failure_fail_open( + mock_scan: MagicMock, mock_ctx: MagicMock, mock_payload: AIHookPayload, default_policy: dict[str, Any] +) -> None: + """Test that scan failure with fail_open=True allows the prompt.""" + mock_scan.side_effect = RuntimeError('Scan failed') + default_policy['fail_open'] = True + + with pytest.raises(RuntimeError): + handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) + + # Event should be tracked even on exception + mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.ALLOWED + # When fail_open=True, no block_reason since action is allowed + assert call_args.kwargs['block_reason'] is None + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_submit_prompt_scan_failure_fail_closed( + mock_scan: MagicMock, mock_ctx: MagicMock, mock_payload: AIHookPayload, default_policy: dict[str, Any] +) -> None: + """Test that scan failure with fail_open=False blocks the prompt.""" + mock_scan.side_effect = RuntimeError('Scan failed') + default_policy['fail_open'] = False + + with pytest.raises(RuntimeError): + handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) + + # Event should be tracked even on exception + mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.BLOCKED + assert call_args.kwargs['block_reason'] == BlockReason.SCAN_FAILURE + + +# Tests for handle_before_read_file + + +def test_handle_before_read_file_disabled(mock_ctx: MagicMock, default_policy: dict[str, Any]) -> None: + """Test that disabled file read scanning allows the file.""" + default_policy['file_read']['enabled'] = False + payload = AIHookPayload( + event_name='file_read', + ide_provider='cursor', + file_path='/path/to/file.txt', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + assert result == {'permission': 'allow'} + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') +def test_handle_before_read_file_sensitive_path( + mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that sensitive path is blocked.""" + mock_is_denied.return_value = True + payload = AIHookPayload( + event_name='file_read', + ide_provider='cursor', + file_path='/path/to/.env', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + assert result['permission'] == 'deny' + assert '.env' in result['user_message'] + mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.BLOCKED + assert call_args.kwargs['block_reason'] == BlockReason.SENSITIVE_PATH + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_path_for_secrets') +def test_handle_before_read_file_no_secrets( + mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that file with no secrets is allowed.""" + mock_is_denied.return_value = False + mock_scan.return_value = (None, 'scan-id-123') + payload = AIHookPayload( + event_name='file_read', + ide_provider='cursor', + file_path='/path/to/file.txt', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + assert result == {'permission': 'allow'} + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.ALLOWED + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_path_for_secrets') +def test_handle_before_read_file_with_secrets( + mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that file with secrets is blocked.""" + mock_is_denied.return_value = False + mock_scan.return_value = ('Found 1 secret: password', 'scan-id-456') + payload = AIHookPayload( + event_name='file_read', + ide_provider='cursor', + file_path='/path/to/file.txt', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + assert result['permission'] == 'deny' + assert 'Found 1 secret: password' in result['user_message'] + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.BLOCKED + assert call_args.kwargs['block_reason'] == BlockReason.SECRETS_IN_FILE + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_path_for_secrets') +def test_handle_before_read_file_scan_disabled( + mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that file is allowed when content scanning is disabled.""" + mock_is_denied.return_value = False + default_policy['file_read']['scan_content'] = False + payload = AIHookPayload( + event_name='file_read', + ide_provider='cursor', + file_path='/path/to/file.txt', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + assert result == {'permission': 'allow'} + mock_scan.assert_not_called() + + +# Tests for handle_before_mcp_execution + + +def test_handle_before_mcp_execution_disabled(mock_ctx: MagicMock, default_policy: dict[str, Any]) -> None: + """Test that disabled MCP scanning allows the execution.""" + default_policy['mcp']['enabled'] = False + payload = AIHookPayload( + event_name='mcp_execution', + ide_provider='cursor', + mcp_tool_name='test_tool', + mcp_arguments={'arg1': 'value1'}, + ) + + result = handle_before_mcp_execution(mock_ctx, payload, default_policy) + + assert result == {'permission': 'allow'} + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_mcp_execution_no_secrets( + mock_scan: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that MCP execution with no secrets is allowed.""" + mock_scan.return_value = (None, 'scan-id-123') + payload = AIHookPayload( + event_name='mcp_execution', + ide_provider='cursor', + mcp_tool_name='test_tool', + mcp_arguments={'arg1': 'value1'}, + ) + + result = handle_before_mcp_execution(mock_ctx, payload, default_policy) + + assert result == {'permission': 'allow'} + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.ALLOWED + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_mcp_execution_with_secrets_blocked( + mock_scan: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that MCP execution with secrets is blocked.""" + mock_scan.return_value = ('Found 1 secret: token', 'scan-id-456') + payload = AIHookPayload( + event_name='mcp_execution', + ide_provider='cursor', + mcp_tool_name='test_tool', + mcp_arguments={'arg1': 'secret_token_12345'}, + ) + + result = handle_before_mcp_execution(mock_ctx, payload, default_policy) + + assert result['permission'] == 'deny' + assert 'Found 1 secret: token' in result['user_message'] + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.BLOCKED + assert call_args.kwargs['block_reason'] == BlockReason.SECRETS_IN_MCP_ARGS + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_mcp_execution_with_secrets_warned( + mock_scan: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that MCP execution with secrets in warn mode asks permission.""" + mock_scan.return_value = ('Found 1 secret: token', 'scan-id-789') + default_policy['mcp']['action'] = 'warn' + payload = AIHookPayload( + event_name='mcp_execution', + ide_provider='cursor', + mcp_tool_name='test_tool', + mcp_arguments={'arg1': 'secret_token_12345'}, + ) + + result = handle_before_mcp_execution(mock_ctx, payload, default_policy) + + assert result['permission'] == 'ask' + assert 'Found 1 secret: token' in result['user_message'] + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.WARNED + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_mcp_execution_scan_disabled( + mock_scan: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that MCP execution is allowed when argument scanning is disabled.""" + default_policy['mcp']['scan_arguments'] = False + payload = AIHookPayload( + event_name='mcp_execution', + ide_provider='cursor', + mcp_tool_name='test_tool', + mcp_arguments={'arg1': 'value1'}, + ) + + result = handle_before_mcp_execution(mock_ctx, payload, default_policy) + + assert result == {'permission': 'allow'} + mock_scan.assert_not_called() diff --git a/tests/cli/commands/ai_guardrails/scan/test_payload.py b/tests/cli/commands/ai_guardrails/scan/test_payload.py new file mode 100644 index 00000000..9d14dda3 --- /dev/null +++ b/tests/cli/commands/ai_guardrails/scan/test_payload.py @@ -0,0 +1,135 @@ +"""Tests for AI hook payload normalization.""" + +import pytest + +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType + + +def test_from_cursor_payload_prompt_event() -> None: + """Test conversion of Cursor beforeSubmitPrompt payload.""" + cursor_payload = { + 'hook_event_name': 'beforeSubmitPrompt', + 'conversation_id': 'conv-123', + 'generation_id': 'gen-456', + 'user_email': 'user@example.com', + 'model': 'gpt-4', + 'cursor_version': '0.42.0', + 'prompt': 'Test prompt', + } + + unified = AIHookPayload.from_cursor_payload(cursor_payload) + + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id == 'conv-123' + assert unified.generation_id == 'gen-456' + assert unified.ide_user_email == 'user@example.com' + assert unified.model == 'gpt-4' + assert unified.ide_provider == 'cursor' + assert unified.ide_version == '0.42.0' + assert unified.prompt == 'Test prompt' + + +def test_from_cursor_payload_file_read_event() -> None: + """Test conversion of Cursor beforeReadFile payload.""" + cursor_payload = { + 'hook_event_name': 'beforeReadFile', + 'conversation_id': 'conv-123', + 'file_path': '/path/to/secret.env', + } + + unified = AIHookPayload.from_cursor_payload(cursor_payload) + + assert unified.event_name == AiHookEventType.FILE_READ + assert unified.file_path == '/path/to/secret.env' + assert unified.ide_provider == 'cursor' + + +def test_from_cursor_payload_mcp_execution_event() -> None: + """Test conversion of Cursor beforeMCPExecution payload.""" + cursor_payload = { + 'hook_event_name': 'beforeMCPExecution', + 'conversation_id': 'conv-123', + 'command': 'GitLab', + 'tool_name': 'discussion_list', + 'arguments': {'resource_type': 'merge_request', 'parent_id': 'organization/repo', 'resource_id': '4'}, + } + + unified = AIHookPayload.from_cursor_payload(cursor_payload) + + assert unified.event_name == AiHookEventType.MCP_EXECUTION + assert unified.mcp_server_name == 'GitLab' + assert unified.mcp_tool_name == 'discussion_list' + assert unified.mcp_arguments == { + 'resource_type': 'merge_request', + 'parent_id': 'organization/repo', + 'resource_id': '4', + } + + +def test_from_cursor_payload_with_alternative_field_names() -> None: + """Test that alternative field names are handled (path vs file_path, etc.).""" + cursor_payload = { + 'hook_event_name': 'beforeReadFile', + 'path': '/alternative/path.txt', # Alternative to file_path + } + + unified = AIHookPayload.from_cursor_payload(cursor_payload) + assert unified.file_path == '/alternative/path.txt' + + cursor_payload = { + 'hook_event_name': 'beforeMCPExecution', + 'tool': 'my_tool', # Alternative to tool_name + 'tool_input': {'key': 'value'}, # Alternative to arguments + } + + unified = AIHookPayload.from_cursor_payload(cursor_payload) + assert unified.mcp_tool_name == 'my_tool' + assert unified.mcp_arguments == {'key': 'value'} + + +def test_from_cursor_payload_unknown_event() -> None: + """Test that unknown event names are passed through as-is.""" + cursor_payload = { + 'hook_event_name': 'unknownEvent', + 'conversation_id': 'conv-123', + } + + unified = AIHookPayload.from_cursor_payload(cursor_payload) + # Unknown events fall back to original name + assert unified.event_name == 'unknownEvent' + + +def test_from_payload_cursor() -> None: + """Test from_payload dispatcher with Cursor tool.""" + cursor_payload = { + 'hook_event_name': 'beforeSubmitPrompt', + 'prompt': 'test', + } + + unified = AIHookPayload.from_payload(cursor_payload, tool='cursor') + assert unified.event_name == AiHookEventType.PROMPT + assert unified.ide_provider == 'cursor' + + +def test_from_payload_unsupported_tool() -> None: + """Test from_payload raises ValueError for unsupported tools.""" + payload = {'hook_event_name': 'someEvent'} + + with pytest.raises(ValueError, match='Unsupported IDE/tool: unsupported'): + AIHookPayload.from_payload(payload, tool='unsupported') + + +def test_from_cursor_payload_empty_fields() -> None: + """Test handling of empty/missing fields.""" + cursor_payload = { + 'hook_event_name': 'beforeSubmitPrompt', + # Most fields missing + } + + unified = AIHookPayload.from_cursor_payload(cursor_payload) + + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id is None + assert unified.prompt == '' # Default to empty string + assert unified.ide_provider == 'cursor' diff --git a/tests/cli/commands/ai_guardrails/scan/test_policy.py b/tests/cli/commands/ai_guardrails/scan/test_policy.py new file mode 100644 index 00000000..bbe884b0 --- /dev/null +++ b/tests/cli/commands/ai_guardrails/scan/test_policy.py @@ -0,0 +1,199 @@ +"""Tests for AI guardrails policy loading and management.""" + +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +from pyfakefs.fake_filesystem import FakeFilesystem + +from cycode.cli.apps.ai_guardrails.scan.policy import ( + deep_merge, + get_policy_value, + load_defaults, + load_policy, + load_yaml_file, +) + + +def test_deep_merge_simple() -> None: + """Test deep merging two simple dictionaries.""" + base = {'a': 1, 'b': 2} + override = {'b': 3, 'c': 4} + result = deep_merge(base, override) + + assert result == {'a': 1, 'b': 3, 'c': 4} + + +def test_deep_merge_nested() -> None: + """Test deep merging nested dictionaries.""" + base = {'level1': {'level2': {'key1': 'value1', 'key2': 'value2'}}} + override = {'level1': {'level2': {'key2': 'override2', 'key3': 'value3'}}} + result = deep_merge(base, override) + + assert result == {'level1': {'level2': {'key1': 'value1', 'key2': 'override2', 'key3': 'value3'}}} + + +def test_deep_merge_override_with_non_dict() -> None: + """Test that non-dict overrides replace the base value entirely.""" + base = {'key': {'nested': 'value'}} + override = {'key': 'simple_value'} + result = deep_merge(base, override) + + assert result == {'key': 'simple_value'} + + +def test_load_yaml_file_nonexistent(fs: FakeFilesystem) -> None: + """Test loading a non-existent file returns None.""" + result = load_yaml_file(Path('/fake/nonexistent.yaml')) + assert result is None + + +def test_load_yaml_file_valid_yaml(fs: FakeFilesystem) -> None: + """Test loading a valid YAML file.""" + fs.create_file('/fake/config.yaml', contents='mode: block\nfail_open: true\n') + + result = load_yaml_file(Path('/fake/config.yaml')) + assert result == {'mode': 'block', 'fail_open': True} + + +def test_load_yaml_file_valid_json(fs: FakeFilesystem) -> None: + """Test loading a valid JSON file.""" + fs.create_file('/fake/config.json', contents='{"mode": "block", "fail_open": true}') + + result = load_yaml_file(Path('/fake/config.json')) + assert result == {'mode': 'block', 'fail_open': True} + + +def test_load_yaml_file_invalid_yaml(fs: FakeFilesystem) -> None: + """Test loading an invalid YAML file returns None.""" + fs.create_file('/fake/invalid.yaml', contents='{ invalid yaml content [') + + result = load_yaml_file(Path('/fake/invalid.yaml')) + assert result is None + + +def test_load_defaults() -> None: + """Test that load_defaults returns a dict with expected keys.""" + defaults = load_defaults() + + assert isinstance(defaults, dict) + assert 'mode' in defaults + assert 'fail_open' in defaults + assert 'prompt' in defaults + assert 'file_read' in defaults + assert 'mcp' in defaults + + +def test_get_policy_value_single_key() -> None: + """Test getting a single-level value.""" + policy = {'mode': 'block', 'fail_open': True} + + assert get_policy_value(policy, 'mode') == 'block' + assert get_policy_value(policy, 'fail_open') is True + + +def test_get_policy_value_nested_keys() -> None: + """Test getting a nested value.""" + policy = {'prompt': {'enabled': True, 'action': 'block'}} + + assert get_policy_value(policy, 'prompt', 'enabled') is True + assert get_policy_value(policy, 'prompt', 'action') == 'block' + + +def test_get_policy_value_missing_key() -> None: + """Test that missing keys return the default value.""" + policy = {'mode': 'block'} + + assert get_policy_value(policy, 'nonexistent', default='default_value') == 'default_value' + + +def test_get_policy_value_deeply_nested() -> None: + """Test getting deeply nested values.""" + policy = {'level1': {'level2': {'level3': 'value'}}} + + assert get_policy_value(policy, 'level1', 'level2', 'level3') == 'value' + assert get_policy_value(policy, 'level1', 'level2', 'missing', default='def') == 'def' + + +def test_get_policy_value_non_dict_in_path() -> None: + """Test that non-dict values in path return default.""" + policy = {'key': 'string_value'} + + # Trying to access nested key on non-dict should return default + assert get_policy_value(policy, 'key', 'nested', default='default') == 'default' + + +@patch('cycode.cli.apps.ai_guardrails.scan.policy.load_yaml_file') +def test_load_policy_defaults_only(mock_load: MagicMock) -> None: + """Test loading policy with only defaults (no user or repo config).""" + mock_load.return_value = None # No user or repo config + + policy = load_policy() + + assert 'mode' in policy + assert 'fail_open' in policy + + +@patch('pathlib.Path.home') +def test_load_policy_with_user_config(mock_home: MagicMock, fs: FakeFilesystem) -> None: + """Test loading policy with user config override.""" + mock_home.return_value = Path('/home/testuser') + + # Create user config in fake filesystem + fs.create_file('/home/testuser/.cycode/ai-guardrails.yaml', contents='mode: warn\nfail_open: false\n') + + policy = load_policy() + + # User config should override defaults + assert policy['mode'] == 'warn' + assert policy['fail_open'] is False + + +@patch('cycode.cli.apps.ai_guardrails.scan.policy.load_yaml_file') +def test_load_policy_with_repo_config(mock_load: MagicMock) -> None: + """Test loading policy with repo config (highest precedence).""" + repo_path = Path('/fake/repo') + repo_config = repo_path / '.cycode' / 'ai-guardrails.yaml' + + def side_effect(path: Path) -> Optional[dict]: + if path == repo_config: + return {'mode': 'block', 'prompt': {'enabled': False}} + return None + + mock_load.side_effect = side_effect + + policy = load_policy(str(repo_path)) + + # Repo config should have highest precedence + assert policy['mode'] == 'block' + assert policy['prompt']['enabled'] is False + + +@patch('pathlib.Path.home') +def test_load_policy_precedence(mock_home: MagicMock, fs: FakeFilesystem) -> None: + """Test that policy precedence is: defaults < user < repo.""" + mock_home.return_value = Path('/home/testuser') + + # Create user config + fs.create_file('/home/testuser/.cycode/ai-guardrails.yaml', contents='mode: warn\nfail_open: false\n') + + # Create repo config + fs.create_file('/fake/repo/.cycode/ai-guardrails.yaml', contents='mode: block\n') + + policy = load_policy('/fake/repo') + + # mode should come from repo (highest precedence) + assert policy['mode'] == 'block' + # fail_open should come from user config (repo doesn't override it) + assert policy['fail_open'] is False + + +@patch('cycode.cli.apps.ai_guardrails.scan.policy.load_yaml_file') +def test_load_policy_none_workspace_root(mock_load: MagicMock) -> None: + """Test that None workspace_root is handled correctly.""" + mock_load.return_value = None + + policy = load_policy(None) + + # Should only load defaults (no repo config) + assert 'mode' in policy diff --git a/tests/cli/commands/ai_guardrails/scan/test_response_builders.py b/tests/cli/commands/ai_guardrails/scan/test_response_builders.py new file mode 100644 index 00000000..86e87ca7 --- /dev/null +++ b/tests/cli/commands/ai_guardrails/scan/test_response_builders.py @@ -0,0 +1,79 @@ +"""Tests for IDE response builders.""" + +import pytest + +from cycode.cli.apps.ai_guardrails.scan.response_builders import ( + CursorResponseBuilder, + IDEResponseBuilder, + get_response_builder, +) + + +def test_cursor_response_builder_allow_permission() -> None: + """Test Cursor allow permission response.""" + builder = CursorResponseBuilder() + response = builder.allow_permission() + + assert response == {'permission': 'allow'} + + +def test_cursor_response_builder_deny_permission() -> None: + """Test Cursor deny permission response with messages.""" + builder = CursorResponseBuilder() + response = builder.deny_permission('User message', 'Agent message') + + assert response == { + 'permission': 'deny', + 'user_message': 'User message', + 'agent_message': 'Agent message', + } + + +def test_cursor_response_builder_ask_permission() -> None: + """Test Cursor ask permission response for warnings.""" + builder = CursorResponseBuilder() + response = builder.ask_permission('Warning message', 'Agent warning') + + assert response == { + 'permission': 'ask', + 'user_message': 'Warning message', + 'agent_message': 'Agent warning', + } + + +def test_cursor_response_builder_allow_prompt() -> None: + """Test Cursor allow prompt response.""" + builder = CursorResponseBuilder() + response = builder.allow_prompt() + + assert response == {'continue': True} + + +def test_cursor_response_builder_deny_prompt() -> None: + """Test Cursor deny prompt response with message.""" + builder = CursorResponseBuilder() + response = builder.deny_prompt('Secrets detected') + + assert response == {'continue': False, 'user_message': 'Secrets detected'} + + +def test_get_response_builder_cursor() -> None: + """Test getting Cursor response builder.""" + builder = get_response_builder('cursor') + + assert isinstance(builder, CursorResponseBuilder) + assert isinstance(builder, IDEResponseBuilder) + + +def test_get_response_builder_unsupported() -> None: + """Test that unsupported IDE raises ValueError.""" + with pytest.raises(ValueError, match='Unsupported IDE: unknown'): + get_response_builder('unknown') + + +def test_cursor_response_builder_is_singleton() -> None: + """Test that getting the same builder returns the same instance.""" + builder1 = get_response_builder('cursor') + builder2 = get_response_builder('cursor') + + assert builder1 is builder2 diff --git a/tests/cli/commands/ai_guardrails/scan/test_utils.py b/tests/cli/commands/ai_guardrails/scan/test_utils.py new file mode 100644 index 00000000..ce84c609 --- /dev/null +++ b/tests/cli/commands/ai_guardrails/scan/test_utils.py @@ -0,0 +1,113 @@ +"""Tests for AI guardrails utility functions.""" + +from cycode.cli.apps.ai_guardrails.scan.utils import ( + is_denied_path, + matches_glob, + normalize_path, +) + + +def test_normalize_path_rejects_escape() -> None: + """Test that paths attempting to escape are rejected.""" + path = '../../../etc/passwd' + result = normalize_path(path) + + assert result == '' + + +def test_normalize_path_empty() -> None: + """Test normalizing empty path.""" + result = normalize_path('') + + assert result == '' + + +def test_matches_glob_simple() -> None: + """Test simple glob pattern matching.""" + assert matches_glob('secret.env', '*.env') is True + assert matches_glob('secret.txt', '*.env') is False + + +def test_matches_glob_recursive() -> None: + """Test recursive glob pattern with **.""" + assert matches_glob('path/to/secret.env', '**/*.env') is True + # Note: '**/*.env' requires at least one path separator, so 'secret.env' won't match + assert matches_glob('secret.env', '*.env') is True # Use non-recursive pattern instead + assert matches_glob('path/to/file.txt', '**/*.env') is False + + +def test_matches_glob_directory() -> None: + """Test matching files in specific directories.""" + assert matches_glob('.env', '.env') is True + assert matches_glob('config/.env', '**/.env') is True + assert matches_glob('other/file', '**/.env') is False + + +def test_matches_glob_case_insensitive() -> None: + """Test that glob matching handles case variations.""" + # Case-insensitive matching for cross-platform compatibility + assert matches_glob('secret.env', '*.env') is True + assert matches_glob('SECRET.ENV', '*.env') is True # Uppercase path matches lowercase pattern + assert matches_glob('Secret.Env', '*.env') is True # Mixed case matches + assert matches_glob('secret.env', '*.ENV') is True # Lowercase path matches uppercase pattern + assert matches_glob('SECRET.ENV', '*.ENV') is True # Both uppercase match + + +def test_matches_glob_empty_inputs() -> None: + """Test glob matching with empty inputs.""" + assert matches_glob('', '*.env') is False + assert matches_glob('file.env', '') is False + assert matches_glob('', '') is False + + +def test_matches_glob_with_traversal_attempt() -> None: + """Test that path traversal is normalized before matching.""" + # Path traversal attempts should be normalized + assert matches_glob('../secret.env', '*.env') is False + + +def test_is_denied_path_with_deny_globs() -> None: + """Test path denial with deny_globs policy.""" + policy = {'file_read': {'deny_globs': ['*.env', '.git/*', '**/secrets/*']}} + + assert is_denied_path('.env', policy) is True + # Note: Path.match('*.env') matches paths ending with .env, including nested paths + assert is_denied_path('config/.env', policy) is True # Matches *.env + assert is_denied_path('.git/config', policy) is True # Matches .git/* + assert is_denied_path('app/secrets/api_keys.txt', policy) is True # Matches **/secrets/* + assert is_denied_path('app/config.yaml', policy) is False + + +def test_is_denied_path_nested_patterns() -> None: + """Test denial with various nesting patterns.""" + policy = {'file_read': {'deny_globs': ['*.key', '**/*.key', 'config/*.env']}} + + # *.key matches .key files at root level, **/*.key for nested + assert is_denied_path('private.key', policy) is True + assert is_denied_path('app/private.key', policy) is True + # config/*.env only matches .env files directly in config/ + assert is_denied_path('config/app.env', policy) is True + assert is_denied_path('config/sub/app.env', policy) is False # Not direct child + assert is_denied_path('app/config.yaml', policy) is False + + +def test_is_denied_path_empty_globs() -> None: + """Test that empty deny_globs list denies nothing.""" + policy = {'file_read': {'deny_globs': []}} + + assert is_denied_path('.env', policy) is False + assert is_denied_path('any/path', policy) is False + + +def test_is_denied_path_no_policy() -> None: + """Test denial with missing policy configuration.""" + policy = {} + + assert is_denied_path('.env', policy) is False + + +def test_is_denied_path_empty_path() -> None: + """Test denial check with empty path.""" + policy = {'file_read': {'deny_globs': ['*.env']}} + + assert is_denied_path('', policy) is False diff --git a/tests/cli/commands/ai_guardrails/test_command_utils.py b/tests/cli/commands/ai_guardrails/test_command_utils.py new file mode 100644 index 00000000..4f0ef55e --- /dev/null +++ b/tests/cli/commands/ai_guardrails/test_command_utils.py @@ -0,0 +1,57 @@ +"""Tests for AI guardrails command utilities.""" + +import pytest +import typer + +from cycode.cli.apps.ai_guardrails.command_utils import ( + validate_and_parse_ide, + validate_scope, +) +from cycode.cli.apps.ai_guardrails.consts import AIIDEType + + +def test_validate_and_parse_ide_valid() -> None: + """Test parsing valid IDE names.""" + assert validate_and_parse_ide('cursor') == AIIDEType.CURSOR + assert validate_and_parse_ide('CURSOR') == AIIDEType.CURSOR + assert validate_and_parse_ide('CuRsOr') == AIIDEType.CURSOR + + +def test_validate_and_parse_ide_invalid() -> None: + """Test that invalid IDE raises typer.Exit.""" + with pytest.raises(typer.Exit) as exc_info: + validate_and_parse_ide('invalid_ide') + assert exc_info.value.exit_code == 1 + + +def test_validate_scope_valid_default() -> None: + """Test validating valid scope with default allowed scopes.""" + # Should not raise any exception + validate_scope('user') + validate_scope('repo') + + +def test_validate_scope_invalid_default() -> None: + """Test that invalid scope raises typer.Exit with default allowed scopes.""" + with pytest.raises(typer.Exit) as exc_info: + validate_scope('invalid') + assert exc_info.value.exit_code == 1 + + with pytest.raises(typer.Exit) as exc_info: + validate_scope('all') # 'all' not in default allowed scopes + assert exc_info.value.exit_code == 1 + + +def test_validate_scope_valid_custom() -> None: + """Test validating scope with custom allowed scopes.""" + # Should not raise any exception + validate_scope('user', allowed_scopes=('user', 'repo', 'all')) + validate_scope('repo', allowed_scopes=('user', 'repo', 'all')) + validate_scope('all', allowed_scopes=('user', 'repo', 'all')) + + +def test_validate_scope_invalid_custom() -> None: + """Test that invalid scope raises typer.Exit with custom allowed scopes.""" + with pytest.raises(typer.Exit) as exc_info: + validate_scope('invalid', allowed_scopes=('user', 'repo', 'all')) + assert exc_info.value.exit_code == 1 From dcee451a4337b7207254bbd203670228658cab2e Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Wed, 4 Feb 2026 11:25:25 +0200 Subject: [PATCH 005/123] CM-58331 support claude code (#379) Co-authored-by: Claude Opus 4.5 --- .../cli/apps/ai_guardrails/command_utils.py | 12 +- cycode/cli/apps/ai_guardrails/consts.py | 81 +++++- .../cli/apps/ai_guardrails/hooks_manager.py | 32 ++- .../cli/apps/ai_guardrails/install_command.py | 39 ++- .../cli/apps/ai_guardrails/scan/handlers.py | 89 ++++--- cycode/cli/apps/ai_guardrails/scan/payload.py | 210 ++++++++++++++- .../ai_guardrails/scan/response_builders.py | 62 ++++- .../apps/ai_guardrails/scan/scan_command.py | 13 +- cycode/cli/apps/ai_guardrails/scan/types.py | 11 + .../cli/apps/ai_guardrails/status_command.py | 55 ++-- .../apps/ai_guardrails/uninstall_command.py | 39 ++- cycode/cyclient/ai_security_manager_client.py | 2 + .../ai_guardrails/scan/test_handlers.py | 4 +- .../ai_guardrails/scan/test_payload.py | 241 ++++++++++++++++++ .../scan/test_response_builders.py | 69 +++++ .../ai_guardrails/scan/test_scan_command.py | 138 ++++++++++ .../ai_guardrails/test_command_utils.py | 3 + .../ai_guardrails/test_hooks_manager.py | 53 ++++ 18 files changed, 1039 insertions(+), 114 deletions(-) create mode 100644 tests/cli/commands/ai_guardrails/scan/test_scan_command.py create mode 100644 tests/cli/commands/ai_guardrails/test_hooks_manager.py diff --git a/cycode/cli/apps/ai_guardrails/command_utils.py b/cycode/cli/apps/ai_guardrails/command_utils.py index e010f0a2..edc3104a 100644 --- a/cycode/cli/apps/ai_guardrails/command_utils.py +++ b/cycode/cli/apps/ai_guardrails/command_utils.py @@ -12,24 +12,26 @@ console = Console() -def validate_and_parse_ide(ide: str) -> AIIDEType: - """Validate IDE parameter and convert to AIIDEType enum. +def validate_and_parse_ide(ide: str) -> Optional[AIIDEType]: + """Validate IDE parameter, returning None for 'all'. Args: - ide: IDE name string (e.g., 'cursor') + ide: IDE name string (e.g., 'cursor', 'claude-code', 'all') Returns: - AIIDEType enum value + AIIDEType enum value, or None if 'all' was specified Raises: typer.Exit: If IDE is invalid """ + if ide.lower() == 'all': + return None try: return AIIDEType(ide.lower()) except ValueError: valid_ides = ', '.join([ide_type.value for ide_type in AIIDEType]) console.print( - f'[red]Error:[/] Invalid IDE "{ide}". Supported IDEs: {valid_ides}', + f'[red]Error:[/] Invalid IDE "{ide}". Supported IDEs: {valid_ides}, all', style='bold red', ) raise typer.Exit(1) from None diff --git a/cycode/cli/apps/ai_guardrails/consts.py b/cycode/cli/apps/ai_guardrails/consts.py index 21d89a3f..8714ec10 100644 --- a/cycode/cli/apps/ai_guardrails/consts.py +++ b/cycode/cli/apps/ai_guardrails/consts.py @@ -2,12 +2,7 @@ Currently supports: - Cursor - -To add a new IDE (e.g., Claude Code): -1. Add new value to AIIDEType enum -2. Create _get__hooks_dir() function with platform-specific paths -3. Add entry to IDE_CONFIGS dict with IDE-specific hook event names -4. Unhide --ide option in commands (install, uninstall, status) +- Claude Code """ import platform @@ -20,6 +15,14 @@ class AIIDEType(str, Enum): """Supported AI IDE types.""" CURSOR = 'cursor' + CLAUDE_CODE = 'claude-code' + + +class PolicyMode(str, Enum): + """Policy enforcement mode for global mode and per-feature actions.""" + + BLOCK = 'block' + WARN = 'warn' class IDEConfig(NamedTuple): @@ -42,6 +45,14 @@ def _get_cursor_hooks_dir() -> Path: return Path.home() / '.config' / 'Cursor' +def _get_claude_code_hooks_dir() -> Path: + """Get Claude Code hooks directory. + + Claude Code uses ~/.claude on all platforms. + """ + return Path.home() / '.claude' + + # IDE-specific configurations IDE_CONFIGS: dict[AIIDEType, IDEConfig] = { AIIDEType.CURSOR: IDEConfig( @@ -51,6 +62,13 @@ def _get_cursor_hooks_dir() -> Path: hooks_file_name='hooks.json', hook_events=['beforeSubmitPrompt', 'beforeReadFile', 'beforeMCPExecution'], ), + AIIDEType.CLAUDE_CODE: IDEConfig( + name='Claude Code', + hooks_dir=_get_claude_code_hooks_dir(), + repo_hooks_subdir='.claude', + hooks_file_name='settings.json', + hook_events=['UserPromptSubmit', 'PreToolUse:Read', 'PreToolUse:mcp'], + ), } # Default IDE @@ -60,6 +78,47 @@ def _get_cursor_hooks_dir() -> Path: CYCODE_SCAN_PROMPT_COMMAND = 'cycode ai-guardrails scan' +def _get_cursor_hooks_config() -> dict: + """Get Cursor-specific hooks configuration.""" + config = IDE_CONFIGS[AIIDEType.CURSOR] + hooks = {event: [{'command': CYCODE_SCAN_PROMPT_COMMAND}] for event in config.hook_events} + + return { + 'version': 1, + 'hooks': hooks, + } + + +def _get_claude_code_hooks_config() -> dict: + """Get Claude Code-specific hooks configuration. + + Claude Code uses a different hook format with nested structure: + - hooks are arrays of objects with 'hooks' containing command arrays + - PreToolUse uses 'matcher' field to specify which tools to intercept + """ + command = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide claude-code' + + return { + 'hooks': { + 'UserPromptSubmit': [ + { + 'hooks': [{'type': 'command', 'command': command}], + } + ], + 'PreToolUse': [ + { + 'matcher': 'Read', + 'hooks': [{'type': 'command', 'command': command}], + }, + { + 'matcher': 'mcp__.*', + 'hooks': [{'type': 'command', 'command': command}], + }, + ], + }, + } + + def get_hooks_config(ide: AIIDEType) -> dict: """Get the hooks configuration for a specific IDE. @@ -69,10 +128,6 @@ def get_hooks_config(ide: AIIDEType) -> dict: Returns: Dict with hooks configuration for the specified IDE """ - config = IDE_CONFIGS[ide] - hooks = {event: [{'command': CYCODE_SCAN_PROMPT_COMMAND}] for event in config.hook_events} - - return { - 'version': 1, - 'hooks': hooks, - } + if ide == AIIDEType.CLAUDE_CODE: + return _get_claude_code_hooks_config() + return _get_cursor_hooks_config() diff --git a/cycode/cli/apps/ai_guardrails/hooks_manager.py b/cycode/cli/apps/ai_guardrails/hooks_manager.py index 42f879f6..b8d43c43 100644 --- a/cycode/cli/apps/ai_guardrails/hooks_manager.py +++ b/cycode/cli/apps/ai_guardrails/hooks_manager.py @@ -59,9 +59,27 @@ def save_hooks_file(hooks_path: Path, hooks_config: dict) -> bool: def is_cycode_hook_entry(entry: dict) -> bool: - """Check if a hook entry is from cycode-cli.""" + """Check if a hook entry is from cycode-cli. + + Handles both Cursor format (flat) and Claude Code format (nested). + + Cursor format: {"command": "cycode ai-guardrails scan"} + Claude Code format: {"hooks": [{"type": "command", "command": "cycode ai-guardrails scan --ide claude-code"}]} + """ + # Check Cursor format (flat command) command = entry.get('command', '') - return CYCODE_SCAN_PROMPT_COMMAND in command + if CYCODE_SCAN_PROMPT_COMMAND in command: + return True + + # Check Claude Code format (nested hooks array) + hooks = entry.get('hooks', []) + for hook in hooks: + if isinstance(hook, dict): + hook_command = hook.get('command', '') + if CYCODE_SCAN_PROMPT_COMMAND in hook_command: + return True + + return False def install_hooks( @@ -185,7 +203,15 @@ def get_hooks_status(scope: str = 'user', repo_path: Optional[Path] = None, ide: ide_config = IDE_CONFIGS[ide] has_cycode_hooks = False for event in ide_config.hook_events: - entries = existing.get('hooks', {}).get(event, []) + # Handle event:matcher format + if ':' in event: + actual_event, matcher_prefix = event.split(':', 1) + all_entries = existing.get('hooks', {}).get(actual_event, []) + # Filter entries by matcher + entries = [e for e in all_entries if e.get('matcher', '').startswith(matcher_prefix)] + else: + entries = existing.get('hooks', {}).get(event, []) + cycode_entries = [e for e in entries if is_cycode_hook_entry(e)] if cycode_entries: has_cycode_hooks = True diff --git a/cycode/cli/apps/ai_guardrails/install_command.py b/cycode/cli/apps/ai_guardrails/install_command.py index 6186752d..4b1095ab 100644 --- a/cycode/cli/apps/ai_guardrails/install_command.py +++ b/cycode/cli/apps/ai_guardrails/install_command.py @@ -11,7 +11,7 @@ validate_and_parse_ide, validate_scope, ) -from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS +from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS, AIIDEType from cycode.cli.apps.ai_guardrails.hooks_manager import install_hooks from cycode.cli.utils.sentry import add_breadcrumb @@ -30,9 +30,9 @@ def install_command( str, typer.Option( '--ide', - help='IDE to install hooks for (e.g., "cursor"). Defaults to cursor.', + help='IDE to install hooks for (e.g., "cursor", "claude-code", or "all" for all IDEs). Defaults to cursor.', ), - ] = 'cursor', + ] = AIIDEType.CURSOR, repo_path: Annotated[ Optional[Path], typer.Option( @@ -54,6 +54,7 @@ def install_command( cycode ai-guardrails install # Install for all projects (user scope) cycode ai-guardrails install --scope repo # Install for current repo only cycode ai-guardrails install --ide cursor # Install for Cursor IDE + cycode ai-guardrails install --ide all # Install for all supported IDEs cycode ai-guardrails install --scope repo --repo-path /path/to/repo """ add_breadcrumb('ai-guardrails-install') @@ -62,17 +63,35 @@ def install_command( validate_scope(scope) repo_path = resolve_repo_path(scope, repo_path) ide_type = validate_and_parse_ide(ide) - ide_name = IDE_CONFIGS[ide_type].name - success, message = install_hooks(scope, repo_path, ide=ide_type) - if success: - console.print(f'[green]✓[/] {message}') + ides_to_install: list[AIIDEType] = list(AIIDEType) if ide_type is None else [ide_type] + + results: list[tuple[str, bool, str]] = [] + for current_ide in ides_to_install: + ide_name = IDE_CONFIGS[current_ide].name + success, message = install_hooks(scope, repo_path, ide=current_ide) + results.append((ide_name, success, message)) + + # Report results for each IDE + any_success = False + all_success = True + for _ide_name, success, message in results: + if success: + console.print(f'[green]✓[/] {message}') + any_success = True + else: + console.print(f'[red]✗[/] {message}', style='bold red') + all_success = False + + if any_success: console.print() console.print('[bold]Next steps:[/]') - console.print(f'1. Restart {ide_name} to activate the hooks') + successful_ides = [name for name, success, _ in results if success] + ide_list = ', '.join(successful_ides) + console.print(f'1. Restart {ide_list} to activate the hooks') console.print('2. (Optional) Customize policy in ~/.cycode/ai-guardrails.yaml') console.print() console.print('[dim]The hooks will scan prompts, file reads, and MCP tool calls for secrets.[/]') - else: - console.print(f'[red]✗[/] {message}', style='bold red') + + if not all_success: raise typer.Exit(1) diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index 95e9d606..32be1241 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -13,6 +13,7 @@ import typer +from cycode.cli.apps.ai_guardrails.consts import PolicyMode from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value from cycode.cli.apps.ai_guardrails.scan.response_builders import get_response_builder @@ -46,7 +47,7 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli ai_client.create_event(payload, AiHookEventType.PROMPT, AIHookOutcome.ALLOWED) return response_builder.allow_prompt() - mode = get_policy_value(policy, 'mode', default='block') + mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK) prompt = payload.prompt or '' max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000) timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000) @@ -55,29 +56,26 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli scan_id = None block_reason = None outcome = AIHookOutcome.ALLOWED + error_message = None try: violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms) - if ( - violation_summary - and get_policy_value(prompt_config, 'action', default='block') == 'block' - and mode == 'block' - ): - outcome = AIHookOutcome.BLOCKED + if violation_summary: block_reason = BlockReason.SECRETS_IN_PROMPT - user_message = f'{violation_summary}. Remove secrets before sending.' - response = response_builder.deny_prompt(user_message) - else: - if violation_summary: - outcome = AIHookOutcome.WARNED - response = response_builder.allow_prompt() - return response + action = get_policy_value(prompt_config, 'action', default=PolicyMode.BLOCK) + if action == PolicyMode.BLOCK and mode == PolicyMode.BLOCK: + outcome = AIHookOutcome.BLOCKED + user_message = f'{violation_summary}. Remove secrets before sending.' + return response_builder.deny_prompt(user_message) + outcome = AIHookOutcome.WARNED + return response_builder.allow_prompt() except Exception as e: outcome = ( AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED ) - block_reason = BlockReason.SCAN_FAILURE if outcome == AIHookOutcome.BLOCKED else None + block_reason = BlockReason.SCAN_FAILURE + error_message = str(e) raise e finally: ai_client.create_event( @@ -86,6 +84,7 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli outcome, scan_id=scan_id, block_reason=block_reason, + error_message=error_message, ) @@ -106,38 +105,53 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: ai_client.create_event(payload, AiHookEventType.FILE_READ, AIHookOutcome.ALLOWED) return response_builder.allow_permission() - mode = get_policy_value(policy, 'mode', default='block') + mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK) file_path = payload.file_path or '' - action = get_policy_value(file_read_config, 'action', default='block') + action = get_policy_value(file_read_config, 'action', default=PolicyMode.BLOCK) scan_id = None block_reason = None outcome = AIHookOutcome.ALLOWED + error_message = None try: # Check path-based denylist first - if is_denied_path(file_path, policy) and action == 'block': - outcome = AIHookOutcome.BLOCKED + if is_denied_path(file_path, policy): block_reason = BlockReason.SENSITIVE_PATH - user_message = f'Cycode blocked sending {file_path} to the AI (sensitive path policy).' - return response_builder.deny_permission( + if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK: + outcome = AIHookOutcome.BLOCKED + user_message = f'Cycode blocked sending {file_path} to the AI (sensitive path policy).' + return response_builder.deny_permission( + user_message, + 'This file path is classified as sensitive; do not read/send it to the model.', + ) + # Warn mode - ask user for permission + outcome = AIHookOutcome.WARNED + user_message = f'Cycode flagged {file_path} as sensitive. Allow reading?' + return response_builder.ask_permission( user_message, - 'This file path is classified as sensitive; do not read/send it to the model.', + 'This file path is classified as sensitive; proceed with caution.', ) # Scan file content if enabled if get_policy_value(file_read_config, 'scan_content', default=True): violation_summary, scan_id = _scan_path_for_secrets(ctx, file_path, policy) - if violation_summary and action == 'block' and mode == 'block': - outcome = AIHookOutcome.BLOCKED + if violation_summary: block_reason = BlockReason.SECRETS_IN_FILE - user_message = f'Cycode blocked reading {file_path}. {violation_summary}' - return response_builder.deny_permission( + if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK: + outcome = AIHookOutcome.BLOCKED + user_message = f'Cycode blocked reading {file_path}. {violation_summary}' + return response_builder.deny_permission( + user_message, + 'Secrets detected; do not send this file to the model.', + ) + # Warn mode - ask user for permission + outcome = AIHookOutcome.WARNED + user_message = f'Cycode detected secrets in {file_path}. {violation_summary}' + return response_builder.ask_permission( user_message, - 'Secrets detected; do not send this file to the model.', + 'Possible secrets detected; proceed with caution.', ) - if violation_summary: - outcome = AIHookOutcome.WARNED return response_builder.allow_permission() return response_builder.allow_permission() @@ -145,7 +159,8 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: outcome = ( AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED ) - block_reason = BlockReason.SCAN_FAILURE if outcome == AIHookOutcome.BLOCKED else None + block_reason = BlockReason.SCAN_FAILURE + error_message = str(e) raise e finally: ai_client.create_event( @@ -154,6 +169,7 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: outcome, scan_id=scan_id, block_reason=block_reason, + error_message=error_message, ) @@ -175,26 +191,27 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli ai_client.create_event(payload, AiHookEventType.MCP_EXECUTION, AIHookOutcome.ALLOWED) return response_builder.allow_permission() - mode = get_policy_value(policy, 'mode', default='block') + mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK) tool = payload.mcp_tool_name or 'unknown' args = payload.mcp_arguments or {} args_text = args if isinstance(args, str) else json.dumps(args) max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000) timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000) clipped = truncate_utf8(args_text, max_bytes) - action = get_policy_value(mcp_config, 'action', default='block') + action = get_policy_value(mcp_config, 'action', default=PolicyMode.BLOCK) scan_id = None block_reason = None outcome = AIHookOutcome.ALLOWED + error_message = None try: if get_policy_value(mcp_config, 'scan_arguments', default=True): violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms) if violation_summary: - if mode == 'block' and action == 'block': + block_reason = BlockReason.SECRETS_IN_MCP_ARGS + if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK: outcome = AIHookOutcome.BLOCKED - block_reason = BlockReason.SECRETS_IN_MCP_ARGS user_message = f'Cycode blocked MCP tool call "{tool}". {violation_summary}' return response_builder.deny_permission( user_message, @@ -211,7 +228,8 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli outcome = ( AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED ) - block_reason = BlockReason.SCAN_FAILURE if outcome == AIHookOutcome.BLOCKED else None + block_reason = BlockReason.SCAN_FAILURE + error_message = str(e) raise e finally: ai_client.create_event( @@ -220,6 +238,7 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli outcome, scan_id=scan_id, block_reason=block_reason, + error_message=error_message, ) diff --git a/cycode/cli/apps/ai_guardrails/scan/payload.py b/cycode/cli/apps/ai_guardrails/scan/payload.py index 83787348..ce72a574 100644 --- a/cycode/cli/apps/ai_guardrails/scan/payload.py +++ b/cycode/cli/apps/ai_guardrails/scan/payload.py @@ -1,9 +1,120 @@ """Unified payload object for AI hook events from different tools.""" +import json +from collections.abc import Iterator from dataclasses import dataclass +from pathlib import Path from typing import Optional -from cycode.cli.apps.ai_guardrails.scan.types import CURSOR_EVENT_MAPPING +from cycode.cli.apps.ai_guardrails.consts import AIIDEType +from cycode.cli.apps.ai_guardrails.scan.types import ( + CLAUDE_CODE_EVENT_MAPPING, + CLAUDE_CODE_EVENT_NAMES, + CURSOR_EVENT_MAPPING, + CURSOR_EVENT_NAMES, + AiHookEventType, +) + + +def _reverse_readline(path: Path, buf_size: int = 8192) -> Iterator[str]: + """Read a file line by line from the end without loading entire file into memory. + + Yields lines in reverse order (last line first). + """ + with path.open('rb') as f: + f.seek(0, 2) # Seek to end + file_size = f.tell() + if file_size == 0: + return + + remaining = file_size + buffer = b'' + + while remaining > 0: + # Read a chunk from the end + read_size = min(buf_size, remaining) + remaining -= read_size + f.seek(remaining) + chunk = f.read(read_size) + buffer = chunk + buffer + + # Yield complete lines from buffer + while b'\n' in buffer: + # Find the last newline + newline_pos = buffer.rfind(b'\n') + if newline_pos == len(buffer) - 1: + # Trailing newline, look for previous one + newline_pos = buffer.rfind(b'\n', 0, newline_pos) + if newline_pos == -1: + break + # Yield the line after this newline + line = buffer[newline_pos + 1 :] + buffer = buffer[: newline_pos + 1] + if line.strip(): + yield line.decode('utf-8', errors='replace') + + # Yield any remaining content as the first line of the file + if buffer.strip(): + yield buffer.decode('utf-8', errors='replace') + + +def _extract_model(entry: dict) -> Optional[str]: + """Extract model from a transcript entry (top level or nested in message).""" + return entry.get('model') or (entry.get('message') or {}).get('model') + + +def _extract_generation_id(entry: dict) -> Optional[str]: + """Extract generation ID from a user-type transcript entry.""" + if entry.get('type') == 'user': + return entry.get('uuid') + return None + + +def _extract_from_claude_transcript( + transcript_path: str, +) -> tuple[Optional[str], Optional[str], Optional[str]]: + """Extract IDE version, model, and latest generation ID from Claude Code transcript file. + + The transcript is a JSONL file where each line is a JSON object. + We look for 'version' (IDE version), 'model', and 'uuid' (generation ID) fields. + The generation_id is the UUID of the latest 'user' type message. + + Scans from end to start since latest entries are at the end. + Uses reverse reading to avoid loading entire file into memory. + + Returns: + Tuple of (ide_version, model, generation_id), any may be None if not found. + """ + if not transcript_path: + return None, None, None + + path = Path(transcript_path) + if not path.exists(): + return None, None, None + + ide_version = None + model = None + generation_id = None + + try: + for line in _reverse_readline(path): + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + ide_version = ide_version or entry.get('version') + model = model or _extract_model(entry) + generation_id = generation_id or _extract_generation_id(entry) + + if ide_version and model and generation_id: + break + except json.JSONDecodeError: + continue + except OSError: + pass + + return ide_version, model, generation_id @dataclass @@ -18,7 +129,7 @@ class AIHookPayload: # User and IDE information ide_user_email: Optional[str] = None model: Optional[str] = None - ide_provider: str = None # e.g., 'cursor', 'claude-code' + ide_provider: str = None # AIIDEType value (e.g., 'cursor', 'claude-code') ide_version: Optional[str] = None # Event-specific data @@ -44,7 +155,7 @@ def from_cursor_payload(cls, payload: dict) -> 'AIHookPayload': generation_id=payload.get('generation_id'), ide_user_email=payload.get('user_email'), model=payload.get('model'), - ide_provider='cursor', + ide_provider=AIIDEType.CURSOR, ide_version=payload.get('cursor_version'), prompt=payload.get('prompt', ''), file_path=payload.get('file_path') or payload.get('path'), @@ -54,12 +165,95 @@ def from_cursor_payload(cls, payload: dict) -> 'AIHookPayload': ) @classmethod - def from_payload(cls, payload: dict, tool: str = 'cursor') -> 'AIHookPayload': + def from_claude_code_payload(cls, payload: dict) -> 'AIHookPayload': + """Create AIHookPayload from Claude Code IDE payload. + + Claude Code has a different structure: + - hook_event_name: 'UserPromptSubmit' or 'PreToolUse' + - For PreToolUse: tool_name determines if it's file read ('Read') or MCP ('mcp__*') + - tool_input contains tool arguments (e.g., file_path for Read tool) + - transcript_path points to JSONL file with version and model info + """ + hook_event_name = payload.get('hook_event_name', '') + tool_name = payload.get('tool_name', '') + tool_input = payload.get('tool_input') + + if hook_event_name == 'UserPromptSubmit': + canonical_event = AiHookEventType.PROMPT + elif hook_event_name == 'PreToolUse': + canonical_event = AiHookEventType.FILE_READ if tool_name == 'Read' else AiHookEventType.MCP_EXECUTION + else: + # Unknown event, use the raw event name + canonical_event = CLAUDE_CODE_EVENT_MAPPING.get(hook_event_name, hook_event_name) + + # Extract file_path from tool_input for Read tool + file_path = None + if tool_name == 'Read' and isinstance(tool_input, dict): + file_path = tool_input.get('file_path') + + # For MCP tools, the entire tool_input is the arguments + mcp_arguments = tool_input if tool_name.startswith('mcp__') else None + + # Extract MCP server and tool name from tool_name (format: mcp____) + mcp_server_name = None + mcp_tool_name = None + if tool_name.startswith('mcp__'): + parts = tool_name.split('__') + if len(parts) >= 2: + mcp_server_name = parts[1] + if len(parts) >= 3: + mcp_tool_name = parts[2] + + # Extract IDE version, model, and generation ID from transcript file + ide_version, model, generation_id = _extract_from_claude_transcript(payload.get('transcript_path')) + + return cls( + event_name=canonical_event, + conversation_id=payload.get('session_id'), + generation_id=generation_id, + ide_user_email=None, # Claude Code doesn't provide this in hook payload + model=model, + ide_provider=AIIDEType.CLAUDE_CODE, + ide_version=ide_version, + prompt=payload.get('prompt', ''), + file_path=file_path, + mcp_server_name=mcp_server_name, + mcp_tool_name=mcp_tool_name, + mcp_arguments=mcp_arguments, + ) + + @staticmethod + def is_payload_for_ide(payload: dict, ide: str) -> bool: + """Check if the payload's event name matches the expected IDE. + + This prevents double-processing when Cursor reads Claude Code hooks + or vice versa. If the payload's hook_event_name doesn't match the + expected IDE's event names, we should skip processing. + + Args: + payload: The raw payload from the IDE + ide: The IDE name or AIIDEType enum value + + Returns: + True if the payload matches the IDE, False otherwise. + """ + hook_event_name = payload.get('hook_event_name', '') + + if ide == AIIDEType.CLAUDE_CODE: + return hook_event_name in CLAUDE_CODE_EVENT_NAMES + if ide == AIIDEType.CURSOR: + return hook_event_name in CURSOR_EVENT_NAMES + + # Unknown IDE, allow processing + return True + + @classmethod + def from_payload(cls, payload: dict, tool: str = AIIDEType.CURSOR) -> 'AIHookPayload': """Create AIHookPayload from any tool's payload. Args: payload: The raw payload from the IDE - tool: The IDE/tool name (e.g., 'cursor') + tool: The IDE/tool name or AIIDEType enum value Returns: AIHookPayload instance @@ -67,6 +261,8 @@ def from_payload(cls, payload: dict, tool: str = 'cursor') -> 'AIHookPayload': Raises: ValueError: If the tool is not supported """ - if tool == 'cursor': + if tool == AIIDEType.CURSOR: return cls.from_cursor_payload(payload) - raise ValueError(f'Unsupported IDE/tool: {tool}.') + if tool == AIIDEType.CLAUDE_CODE: + return cls.from_claude_code_payload(payload) + raise ValueError(f'Unsupported IDE/tool: {tool}') diff --git a/cycode/cli/apps/ai_guardrails/scan/response_builders.py b/cycode/cli/apps/ai_guardrails/scan/response_builders.py index 867965c3..f0da71b7 100644 --- a/cycode/cli/apps/ai_guardrails/scan/response_builders.py +++ b/cycode/cli/apps/ai_guardrails/scan/response_builders.py @@ -7,6 +7,8 @@ from abc import ABC, abstractmethod +from cycode.cli.apps.ai_guardrails.consts import AIIDEType + class IDEResponseBuilder(ABC): """Abstract base class for IDE-specific response builders.""" @@ -62,17 +64,64 @@ def deny_prompt(self, user_message: str) -> dict: return {'continue': False, 'user_message': user_message} -# Registry of response builders by IDE name +class ClaudeCodeResponseBuilder(IDEResponseBuilder): + """Response builder for Claude Code IDE hooks. + + Claude Code hook response formats: + - UserPromptSubmit: {} for allow, {"decision": "block", "reason": str} for deny + - PreToolUse: hookSpecificOutput with permissionDecision (allow/deny/ask) + """ + + def allow_permission(self) -> dict: + """Allow file read or MCP execution.""" + return { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'allow', + } + } + + def deny_permission(self, user_message: str, agent_message: str) -> dict: + """Deny file read or MCP execution.""" + return { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'deny', + 'permissionDecisionReason': user_message, + } + } + + def ask_permission(self, user_message: str, agent_message: str) -> dict: + """Ask user for permission (warn mode).""" + return { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'ask', + 'permissionDecisionReason': user_message, + } + } + + def allow_prompt(self) -> dict: + """Allow prompt submission (empty response means allow).""" + return {} + + def deny_prompt(self, user_message: str) -> dict: + """Deny prompt submission.""" + return {'decision': 'block', 'reason': user_message} + + +# Registry of response builders by IDE type _RESPONSE_BUILDERS: dict[str, IDEResponseBuilder] = { - 'cursor': CursorResponseBuilder(), + AIIDEType.CURSOR: CursorResponseBuilder(), + AIIDEType.CLAUDE_CODE: ClaudeCodeResponseBuilder(), } -def get_response_builder(ide: str = 'cursor') -> IDEResponseBuilder: +def get_response_builder(ide: str = AIIDEType.CURSOR) -> IDEResponseBuilder: """Get the response builder for a specific IDE. Args: - ide: The IDE name (e.g., 'cursor', 'claude-code') + ide: The IDE name (e.g., 'cursor', 'claude-code') or AIIDEType enum Returns: IDEResponseBuilder instance for the specified IDE @@ -80,7 +129,10 @@ def get_response_builder(ide: str = 'cursor') -> IDEResponseBuilder: Raises: ValueError: If the IDE is not supported """ - builder = _RESPONSE_BUILDERS.get(ide.lower()) + # Normalize to AIIDEType if string passed + if isinstance(ide, str): + ide = ide.lower() + builder = _RESPONSE_BUILDERS.get(ide) if not builder: raise ValueError(f'Unsupported IDE: {ide}. Supported IDEs: {list(_RESPONSE_BUILDERS.keys())}') return builder diff --git a/cycode/cli/apps/ai_guardrails/scan/scan_command.py b/cycode/cli/apps/ai_guardrails/scan/scan_command.py index e08bb4de..73981831 100644 --- a/cycode/cli/apps/ai_guardrails/scan/scan_command.py +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -16,6 +16,7 @@ import click import typer +from cycode.cli.apps.ai_guardrails.consts import AIIDEType from cycode.cli.apps.ai_guardrails.scan.handlers import get_handler_for_event from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.policy import load_policy @@ -69,7 +70,7 @@ def scan_command( help='IDE that sent the payload (e.g., "cursor"). Defaults to cursor.', hidden=True, ), - ] = 'cursor', + ] = AIIDEType.CURSOR, ) -> None: """Scan content from AI IDE hooks for secrets. @@ -96,6 +97,16 @@ def scan_command( output_json(response_builder.allow_prompt()) return + # Check if the payload matches the expected IDE - prevents double-processing + # when Cursor reads Claude Code hooks from ~/.claude/settings.json + if not AIHookPayload.is_payload_for_ide(payload, tool): + logger.debug( + 'Payload event does not match expected IDE, skipping', + extra={'hook_event_name': payload.get('hook_event_name'), 'expected_ide': tool}, + ) + output_json(response_builder.allow_prompt()) + return + unified_payload = AIHookPayload.from_payload(payload, tool=tool) event_name = unified_payload.event_name logger.debug('Processing AI guardrails hook', extra={'event_name': event_name, 'tool': tool}) diff --git a/cycode/cli/apps/ai_guardrails/scan/types.py b/cycode/cli/apps/ai_guardrails/scan/types.py index 095ca61b..585c7820 100644 --- a/cycode/cli/apps/ai_guardrails/scan/types.py +++ b/cycode/cli/apps/ai_guardrails/scan/types.py @@ -31,6 +31,17 @@ class AiHookEventType(StrEnum): 'beforeMCPExecution': AiHookEventType.MCP_EXECUTION, } +# Claude Code event mapping - note that PreToolUse requires tool_name inspection +# to determine the actual event type (file read vs MCP execution) +CLAUDE_CODE_EVENT_MAPPING = { + 'UserPromptSubmit': AiHookEventType.PROMPT, + 'PreToolUse': None, # Requires tool_name inspection to determine actual type +} + +# Set of known event names per IDE (for IDE detection) +CURSOR_EVENT_NAMES = set(CURSOR_EVENT_MAPPING.keys()) +CLAUDE_CODE_EVENT_NAMES = set(CLAUDE_CODE_EVENT_MAPPING.keys()) + class AIHookOutcome(StrEnum): """Outcome of an AI hook event evaluation.""" diff --git a/cycode/cli/apps/ai_guardrails/status_command.py b/cycode/cli/apps/ai_guardrails/status_command.py index 0a9801b5..14a31e7f 100644 --- a/cycode/cli/apps/ai_guardrails/status_command.py +++ b/cycode/cli/apps/ai_guardrails/status_command.py @@ -8,6 +8,7 @@ from rich.table import Table from cycode.cli.apps.ai_guardrails.command_utils import console, validate_and_parse_ide, validate_scope +from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS, AIIDEType from cycode.cli.apps.ai_guardrails.hooks_manager import get_hooks_status from cycode.cli.utils.sentry import add_breadcrumb @@ -26,9 +27,9 @@ def status_command( str, typer.Option( '--ide', - help='IDE to check status for (e.g., "cursor"). Defaults to cursor.', + help='IDE to check status for (e.g., "cursor", "claude-code", or "all" for all IDEs). Defaults to cursor.', ), - ] = 'cursor', + ] = AIIDEType.CURSOR, repo_path: Annotated[ Optional[Path], typer.Option( @@ -50,6 +51,7 @@ def status_command( cycode ai-guardrails status --scope user # Show only user-level status cycode ai-guardrails status --scope repo # Show only repo-level status cycode ai-guardrails status --ide cursor # Check status for Cursor IDE + cycode ai-guardrails status --ide all # Check status for all supported IDEs """ add_breadcrumb('ai-guardrails-status') @@ -59,34 +61,41 @@ def status_command( repo_path = Path(os.getcwd()) ide_type = validate_and_parse_ide(ide) - scopes_to_check = ['user', 'repo'] if scope == 'all' else [scope] + ides_to_check: list[AIIDEType] = list(AIIDEType) if ide_type is None else [ide_type] - for check_scope in scopes_to_check: - status = get_hooks_status(check_scope, repo_path if check_scope == 'repo' else None, ide=ide_type) + scopes_to_check = ['user', 'repo'] if scope == 'all' else [scope] + for current_ide in ides_to_check: + ide_name = IDE_CONFIGS[current_ide].name console.print() - console.print(f'[bold]{check_scope.upper()} SCOPE[/]') - console.print(f'Path: {status["hooks_path"]}') + console.print(f'[bold cyan]═══ {ide_name} ═══[/]') + + for check_scope in scopes_to_check: + status = get_hooks_status(check_scope, repo_path if check_scope == 'repo' else None, ide=current_ide) + + console.print() + console.print(f'[bold]{check_scope.upper()} SCOPE[/]') + console.print(f'Path: {status["hooks_path"]}') - if not status['file_exists']: - console.print('[dim]No hooks.json file found[/]') - continue + if not status['file_exists']: + console.print('[dim]No hooks file found[/]') + continue - if status['cycode_installed']: - console.print('[green]✓ Cycode AI guardrails: INSTALLED[/]') - else: - console.print('[yellow]○ Cycode AI guardrails: NOT INSTALLED[/]') + if status['cycode_installed']: + console.print('[green]✓ Cycode AI guardrails: INSTALLED[/]') + else: + console.print('[yellow]○ Cycode AI guardrails: NOT INSTALLED[/]') - # Show hook details - table = Table(show_header=True, header_style='bold') - table.add_column('Hook Event') - table.add_column('Cycode Enabled') - table.add_column('Total Hooks') + # Show hook details + table = Table(show_header=True, header_style='bold') + table.add_column('Hook Event') + table.add_column('Cycode Enabled') + table.add_column('Total Hooks') - for event, info in status['hooks'].items(): - enabled = '[green]Yes[/]' if info['enabled'] else '[dim]No[/]' - table.add_row(event, enabled, str(info['total_entries'])) + for event, info in status['hooks'].items(): + enabled = '[green]Yes[/]' if info['enabled'] else '[dim]No[/]' + table.add_row(event, enabled, str(info['total_entries'])) - console.print(table) + console.print(table) console.print() diff --git a/cycode/cli/apps/ai_guardrails/uninstall_command.py b/cycode/cli/apps/ai_guardrails/uninstall_command.py index 23315693..acf3d0c7 100644 --- a/cycode/cli/apps/ai_guardrails/uninstall_command.py +++ b/cycode/cli/apps/ai_guardrails/uninstall_command.py @@ -11,7 +11,7 @@ validate_and_parse_ide, validate_scope, ) -from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS +from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS, AIIDEType from cycode.cli.apps.ai_guardrails.hooks_manager import uninstall_hooks from cycode.cli.utils.sentry import add_breadcrumb @@ -30,9 +30,9 @@ def uninstall_command( str, typer.Option( '--ide', - help='IDE to uninstall hooks from (e.g., "cursor"). Defaults to cursor.', + help='IDE to uninstall hooks from (e.g., "cursor", "claude-code", "all"). Defaults to cursor.', ), - ] = 'cursor', + ] = AIIDEType.CURSOR, repo_path: Annotated[ Optional[Path], typer.Option( @@ -54,6 +54,7 @@ def uninstall_command( cycode ai-guardrails uninstall # Remove user-level hooks cycode ai-guardrails uninstall --scope repo # Remove repo-level hooks cycode ai-guardrails uninstall --ide cursor # Uninstall from Cursor IDE + cycode ai-guardrails uninstall --ide all # Uninstall from all supported IDEs """ add_breadcrumb('ai-guardrails-uninstall') @@ -61,13 +62,31 @@ def uninstall_command( validate_scope(scope) repo_path = resolve_repo_path(scope, repo_path) ide_type = validate_and_parse_ide(ide) - ide_name = IDE_CONFIGS[ide_type].name - success, message = uninstall_hooks(scope, repo_path, ide=ide_type) - if success: - console.print(f'[green]✓[/] {message}') + ides_to_uninstall: list[AIIDEType] = list(AIIDEType) if ide_type is None else [ide_type] + + results: list[tuple[str, bool, str]] = [] + for current_ide in ides_to_uninstall: + ide_name = IDE_CONFIGS[current_ide].name + success, message = uninstall_hooks(scope, repo_path, ide=current_ide) + results.append((ide_name, success, message)) + + # Report results for each IDE + any_success = False + all_success = True + for _ide_name, success, message in results: + if success: + console.print(f'[green]✓[/] {message}') + any_success = True + else: + console.print(f'[red]✗[/] {message}', style='bold red') + all_success = False + + if any_success: console.print() - console.print(f'[dim]Restart {ide_name} for changes to take effect.[/]') - else: - console.print(f'[red]✗[/] {message}', style='bold red') + successful_ides = [name for name, success, _ in results if success] + ide_list = ', '.join(successful_ides) + console.print(f'[dim]Restart {ide_list} for changes to take effect.[/]') + + if not all_success: raise typer.Exit(1) diff --git a/cycode/cyclient/ai_security_manager_client.py b/cycode/cyclient/ai_security_manager_client.py index 627e2b33..1090ad8d 100644 --- a/cycode/cyclient/ai_security_manager_client.py +++ b/cycode/cyclient/ai_security_manager_client.py @@ -61,6 +61,7 @@ def create_event( outcome: 'AIHookOutcome', scan_id: Optional[str] = None, block_reason: Optional['BlockReason'] = None, + error_message: Optional[str] = None, ) -> None: """Create an AI hook event from hook payload.""" conversation_id = payload.conversation_id @@ -77,6 +78,7 @@ def create_event( 'cli_scan_id': scan_id, 'mcp_server_name': payload.mcp_server_name, 'mcp_tool_name': payload.mcp_tool_name, + 'error_message': error_message, } try: diff --git a/tests/cli/commands/ai_guardrails/scan/test_handlers.py b/tests/cli/commands/ai_guardrails/scan/test_handlers.py index 58dfe195..634469b7 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_handlers.py +++ b/tests/cli/commands/ai_guardrails/scan/test_handlers.py @@ -135,8 +135,8 @@ def test_handle_before_submit_prompt_scan_failure_fail_open( mock_ctx.obj['ai_security_client'].create_event.assert_called_once() call_args = mock_ctx.obj['ai_security_client'].create_event.call_args assert call_args.args[2] == AIHookOutcome.ALLOWED - # When fail_open=True, no block_reason since action is allowed - assert call_args.kwargs['block_reason'] is None + # block_reason is set for tracking even when fail_open allows the action + assert call_args.kwargs['block_reason'] == BlockReason.SCAN_FAILURE @patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') diff --git a/tests/cli/commands/ai_guardrails/scan/test_payload.py b/tests/cli/commands/ai_guardrails/scan/test_payload.py index 9d14dda3..27c3010f 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_payload.py +++ b/tests/cli/commands/ai_guardrails/scan/test_payload.py @@ -1,6 +1,7 @@ """Tests for AI hook payload normalization.""" import pytest +from pytest_mock import MockerFixture from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType @@ -133,3 +134,243 @@ def test_from_cursor_payload_empty_fields() -> None: assert unified.conversation_id is None assert unified.prompt == '' # Default to empty string assert unified.ide_provider == 'cursor' + + +# Claude Code payload tests + + +def test_from_claude_code_payload_prompt_event() -> None: + """Test conversion of Claude Code UserPromptSubmit payload.""" + claude_payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'Test prompt for Claude Code', + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id == 'session-123' + assert unified.ide_provider == 'claude-code' + assert unified.prompt == 'Test prompt for Claude Code' + + +def test_from_claude_code_payload_file_read_event() -> None: + """Test conversion of Claude Code PreToolUse with Read tool.""" + claude_payload = { + 'hook_event_name': 'PreToolUse', + 'session_id': 'session-456', + 'tool_name': 'Read', + 'tool_input': {'file_path': '/path/to/secret.env'}, + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + + assert unified.event_name == AiHookEventType.FILE_READ + assert unified.file_path == '/path/to/secret.env' + assert unified.ide_provider == 'claude-code' + assert unified.mcp_tool_name is None + + +def test_from_claude_code_payload_mcp_execution_event() -> None: + """Test conversion of Claude Code PreToolUse with MCP tool.""" + claude_payload = { + 'hook_event_name': 'PreToolUse', + 'session_id': 'session-789', + 'tool_name': 'mcp__gitlab__discussion_list', + 'tool_input': {'resource_type': 'merge_request', 'parent_id': 'org/repo', 'resource_id': '4'}, + } + + unified = AIHookPayload.from_payload(claude_payload, tool='claude-code') + + assert unified.event_name == AiHookEventType.MCP_EXECUTION + assert unified.mcp_server_name == 'gitlab' + assert unified.mcp_tool_name == 'discussion_list' + assert unified.mcp_arguments == {'resource_type': 'merge_request', 'parent_id': 'org/repo', 'resource_id': '4'} + assert unified.ide_provider == 'claude-code' + + +def test_from_claude_code_payload_empty_fields() -> None: + """Test handling of empty/missing fields for Claude Code.""" + claude_payload = { + 'hook_event_name': 'UserPromptSubmit', + # Most fields missing + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id is None + assert unified.prompt == '' # Default to empty string + assert unified.ide_provider == 'claude-code' + + +# Claude Code transcript extraction tests + + +def test_from_claude_code_payload_extracts_from_transcript(mocker: MockerFixture) -> None: + """Test that version, model, and generation_id are extracted from transcript file.""" + transcript_content = ( + b'{"type":"user","version":"2.1.20","uuid":"user-uuid-1","message":{"role":"user","content":"hello"}}\n' + b'{"type":"assistant","message":{"model":"claude-opus-4-5-20251101","role":"assistant",' + b'"content":[{"type":"text","text":"Hi!"}]},"uuid":"assistant-uuid-1"}\n' + b'{"type":"user","version":"2.1.20","uuid":"user-uuid-2","message":{"role":"user","content":"test prompt"}}\n' + ) + mock_path = mocker.patch('cycode.cli.apps.ai_guardrails.scan.payload.Path') + mock_path.return_value.exists.return_value = True + mock_path.return_value.open.return_value.__enter__.return_value.seek = mocker.Mock() + mock_path.return_value.open.return_value.__enter__.return_value.tell.return_value = len(transcript_content) + mock_path.return_value.open.return_value.__enter__.return_value.read.return_value = transcript_content + + claude_payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test prompt', + 'transcript_path': '/mock/transcript.jsonl', + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + + assert unified.ide_version == '2.1.20' + assert unified.model == 'claude-opus-4-5-20251101' + assert unified.generation_id == 'user-uuid-2' + + +def test_from_claude_code_payload_handles_missing_transcript(mocker: MockerFixture) -> None: + """Test that missing transcript file doesn't break payload parsing.""" + mock_path = mocker.patch('cycode.cli.apps.ai_guardrails.scan.payload.Path') + mock_path.return_value.exists.return_value = False + + claude_payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test', + 'transcript_path': '/nonexistent/path/transcript.jsonl', + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + + assert unified.ide_version is None + assert unified.model is None + assert unified.generation_id is None + assert unified.conversation_id == 'session-123' + assert unified.prompt == 'test' + + +def test_from_claude_code_payload_handles_no_transcript_path() -> None: + """Test that absent transcript_path doesn't break payload parsing.""" + claude_payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test', + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + + assert unified.ide_version is None + assert unified.model is None + assert unified.generation_id is None + + +def test_from_claude_code_payload_extracts_model_from_nested_message(mocker: MockerFixture) -> None: + """Test that model is extracted from nested message.model field.""" + transcript_content = ( + b'{"type":"assistant","message":{"model":"claude-sonnet-4-20250514",' + b'"role":"assistant","content":[]},"uuid":"uuid-1"}\n' + ) + + mock_path = mocker.patch('cycode.cli.apps.ai_guardrails.scan.payload.Path') + mock_path.return_value.exists.return_value = True + mock_path.return_value.open.return_value.__enter__.return_value.seek = mocker.Mock() + mock_path.return_value.open.return_value.__enter__.return_value.tell.return_value = len(transcript_content) + mock_path.return_value.open.return_value.__enter__.return_value.read.return_value = transcript_content + + claude_payload = { + 'hook_event_name': 'UserPromptSubmit', + 'prompt': 'test', + 'transcript_path': '/mock/transcript.jsonl', + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + + assert unified.model == 'claude-sonnet-4-20250514' + + +def test_from_claude_code_payload_gets_latest_user_uuid(mocker: MockerFixture) -> None: + """Test that generation_id is the UUID of the latest user message.""" + transcript_content = b"""{"type":"user","uuid":"old-user-uuid","message":{"role":"user","content":"first"}} +{"type":"assistant","uuid":"assistant-uuid","message":{"role":"assistant","content":[]}} +{"type":"user","uuid":"latest-user-uuid","message":{"role":"user","content":"second"}} +{"type":"assistant","uuid":"last-assistant-uuid","message":{"role":"assistant","content":[]}} +""" + mock_path = mocker.patch('cycode.cli.apps.ai_guardrails.scan.payload.Path') + mock_path.return_value.exists.return_value = True + mock_path.return_value.open.return_value.__enter__.return_value.seek = mocker.Mock() + mock_path.return_value.open.return_value.__enter__.return_value.tell.return_value = len(transcript_content) + mock_path.return_value.open.return_value.__enter__.return_value.read.return_value = transcript_content + + claude_payload = { + 'hook_event_name': 'UserPromptSubmit', + 'prompt': 'test', + 'transcript_path': '/mock/transcript.jsonl', + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + + assert unified.generation_id == 'latest-user-uuid' + + +# IDE detection tests + + +def test_is_payload_for_ide_claude_code_matches_claude_code() -> None: + """Test that Claude Code events match when expected IDE is claude-code.""" + payload = {'hook_event_name': 'UserPromptSubmit'} + assert AIHookPayload.is_payload_for_ide(payload, 'claude-code') is True + + payload = {'hook_event_name': 'PreToolUse'} + assert AIHookPayload.is_payload_for_ide(payload, 'claude-code') is True + + +def test_is_payload_for_ide_cursor_matches_cursor() -> None: + """Test that Cursor events match when expected IDE is cursor.""" + payload = {'hook_event_name': 'beforeSubmitPrompt'} + assert AIHookPayload.is_payload_for_ide(payload, 'cursor') is True + + payload = {'hook_event_name': 'beforeReadFile'} + assert AIHookPayload.is_payload_for_ide(payload, 'cursor') is True + + payload = {'hook_event_name': 'beforeMCPExecution'} + assert AIHookPayload.is_payload_for_ide(payload, 'cursor') is True + + +def test_is_payload_for_ide_claude_code_does_not_match_cursor() -> None: + """Test that Claude Code events don't match when expected IDE is cursor. + + This prevents double-processing when Cursor reads Claude Code hooks. + """ + payload = {'hook_event_name': 'UserPromptSubmit'} + assert AIHookPayload.is_payload_for_ide(payload, 'cursor') is False + + payload = {'hook_event_name': 'PreToolUse'} + assert AIHookPayload.is_payload_for_ide(payload, 'cursor') is False + + +def test_is_payload_for_ide_cursor_does_not_match_claude_code() -> None: + """Test that Cursor events don't match when expected IDE is claude-code.""" + payload = {'hook_event_name': 'beforeSubmitPrompt'} + assert AIHookPayload.is_payload_for_ide(payload, 'claude-code') is False + + payload = {'hook_event_name': 'beforeReadFile'} + assert AIHookPayload.is_payload_for_ide(payload, 'claude-code') is False + + +def test_is_payload_for_ide_empty_event_name() -> None: + """Test handling of empty or missing hook_event_name.""" + payload = {'hook_event_name': ''} + assert AIHookPayload.is_payload_for_ide(payload, 'cursor') is False + assert AIHookPayload.is_payload_for_ide(payload, 'claude-code') is False + + payload = {} + assert AIHookPayload.is_payload_for_ide(payload, 'cursor') is False + assert AIHookPayload.is_payload_for_ide(payload, 'claude-code') is False diff --git a/tests/cli/commands/ai_guardrails/scan/test_response_builders.py b/tests/cli/commands/ai_guardrails/scan/test_response_builders.py index 86e87ca7..45f80829 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_response_builders.py +++ b/tests/cli/commands/ai_guardrails/scan/test_response_builders.py @@ -3,6 +3,7 @@ import pytest from cycode.cli.apps.ai_guardrails.scan.response_builders import ( + ClaudeCodeResponseBuilder, CursorResponseBuilder, IDEResponseBuilder, get_response_builder, @@ -77,3 +78,71 @@ def test_cursor_response_builder_is_singleton() -> None: builder2 = get_response_builder('cursor') assert builder1 is builder2 + + +# Claude Code response builder tests + + +def test_claude_code_response_builder_allow_permission() -> None: + """Test Claude Code allow permission response.""" + builder = ClaudeCodeResponseBuilder() + response = builder.allow_permission() + + assert response == { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'allow', + } + } + + +def test_claude_code_response_builder_deny_permission() -> None: + """Test Claude Code deny permission response with messages.""" + builder = ClaudeCodeResponseBuilder() + response = builder.deny_permission('User message', 'Agent message') + + assert response == { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'deny', + 'permissionDecisionReason': 'User message', + } + } + + +def test_claude_code_response_builder_ask_permission() -> None: + """Test Claude Code ask permission response for warnings.""" + builder = ClaudeCodeResponseBuilder() + response = builder.ask_permission('Warning message', 'Agent warning') + + assert response == { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'ask', + 'permissionDecisionReason': 'Warning message', + } + } + + +def test_claude_code_response_builder_allow_prompt() -> None: + """Test Claude Code allow prompt response (empty dict).""" + builder = ClaudeCodeResponseBuilder() + response = builder.allow_prompt() + + assert response == {} + + +def test_claude_code_response_builder_deny_prompt() -> None: + """Test Claude Code deny prompt response with message.""" + builder = ClaudeCodeResponseBuilder() + response = builder.deny_prompt('Secrets detected') + + assert response == {'decision': 'block', 'reason': 'Secrets detected'} + + +def test_get_response_builder_claude_code() -> None: + """Test getting Claude Code response builder.""" + builder = get_response_builder('claude-code') + + assert isinstance(builder, ClaudeCodeResponseBuilder) + assert isinstance(builder, IDEResponseBuilder) diff --git a/tests/cli/commands/ai_guardrails/scan/test_scan_command.py b/tests/cli/commands/ai_guardrails/scan/test_scan_command.py new file mode 100644 index 00000000..d1473c69 --- /dev/null +++ b/tests/cli/commands/ai_guardrails/scan/test_scan_command.py @@ -0,0 +1,138 @@ +"""Tests for AI guardrails scan command.""" + +import json +from io import StringIO +from unittest.mock import MagicMock + +import pytest +from pytest_mock import MockerFixture + +from cycode.cli.apps.ai_guardrails.scan.scan_command import scan_command + + +@pytest.fixture +def mock_ctx() -> MagicMock: + """Create a mock typer context.""" + ctx = MagicMock() + ctx.obj = {} + return ctx + + +@pytest.fixture +def mock_scan_command_deps(mocker: MockerFixture) -> dict[str, MagicMock]: + """Mock scan_command dependencies that should not be called on early exit.""" + return { + 'initialize_clients': mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command._initialize_clients'), + 'load_policy': mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command.load_policy'), + 'get_handler': mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command.get_handler_for_event'), + } + + +def _assert_no_api_calls(mocks: dict[str, MagicMock]) -> None: + """Assert that no API-related functions were called.""" + mocks['initialize_clients'].assert_not_called() + mocks['load_policy'].assert_not_called() + mocks['get_handler'].assert_not_called() + + +class TestIdeMismatchSkipsProcessing: + """Tests that verify IDE mismatch causes early exit without API calls.""" + + def test_claude_code_payload_with_cursor_ide( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Test Claude Code payload is skipped when --ide cursor is specified. + + When Cursor reads Claude Code hooks from ~/.claude/settings.json, it will invoke + the hook with Claude Code event names. The scan command should skip processing. + """ + payload = {'hook_event_name': 'UserPromptSubmit', 'session_id': 'session-123', 'prompt': 'test'} + mocker.patch('sys.stdin', StringIO(json.dumps(payload))) + + scan_command(mock_ctx, ide='cursor') + + _assert_no_api_calls(mock_scan_command_deps) + response = json.loads(capsys.readouterr().out) + assert response.get('continue') is True + + def test_cursor_payload_with_claude_code_ide( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Test Cursor payload is skipped when --ide claude-code is specified.""" + payload = {'hook_event_name': 'beforeSubmitPrompt', 'conversation_id': 'conv-123', 'prompt': 'test'} + mocker.patch('sys.stdin', StringIO(json.dumps(payload))) + + scan_command(mock_ctx, ide='claude-code') + + _assert_no_api_calls(mock_scan_command_deps) + response = json.loads(capsys.readouterr().out) + assert response == {} # Claude Code allow_prompt returns empty dict + + +class TestInvalidPayloadSkipsProcessing: + """Tests that verify invalid payloads cause early exit without API calls.""" + + def test_empty_payload( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Test empty payload skips processing.""" + mocker.patch('sys.stdin', StringIO('')) + + scan_command(mock_ctx, ide='cursor') + + mock_scan_command_deps['initialize_clients'].assert_not_called() + response = json.loads(capsys.readouterr().out) + assert response.get('continue') is True + + def test_invalid_json_payload( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Test invalid JSON skips processing.""" + mocker.patch('sys.stdin', StringIO('not valid json {')) + + scan_command(mock_ctx, ide='cursor') + + mock_scan_command_deps['initialize_clients'].assert_not_called() + response = json.loads(capsys.readouterr().out) + assert response.get('continue') is True + + +class TestMatchingIdeProcessesPayload: + """Tests that verify matching IDE processes the payload normally.""" + + def test_claude_code_payload_with_claude_code_ide( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Test Claude Code payload is processed when --ide claude-code is specified.""" + payload = {'hook_event_name': 'UserPromptSubmit', 'session_id': 'session-123', 'prompt': 'test'} + mocker.patch('sys.stdin', StringIO(json.dumps(payload))) + + mock_scan_command_deps['load_policy'].return_value = {'fail_open': True} + mock_handler = MagicMock(return_value={'decision': 'allow'}) + mock_scan_command_deps['get_handler'].return_value = mock_handler + + scan_command(mock_ctx, ide='claude-code') + + mock_scan_command_deps['initialize_clients'].assert_called_once() + mock_scan_command_deps['load_policy'].assert_called_once() + mock_scan_command_deps['get_handler'].assert_called_once() + mock_handler.assert_called_once() diff --git a/tests/cli/commands/ai_guardrails/test_command_utils.py b/tests/cli/commands/ai_guardrails/test_command_utils.py index 4f0ef55e..5d8d224b 100644 --- a/tests/cli/commands/ai_guardrails/test_command_utils.py +++ b/tests/cli/commands/ai_guardrails/test_command_utils.py @@ -15,6 +15,9 @@ def test_validate_and_parse_ide_valid() -> None: assert validate_and_parse_ide('cursor') == AIIDEType.CURSOR assert validate_and_parse_ide('CURSOR') == AIIDEType.CURSOR assert validate_and_parse_ide('CuRsOr') == AIIDEType.CURSOR + assert validate_and_parse_ide('claude-code') == AIIDEType.CLAUDE_CODE + assert validate_and_parse_ide('Claude-Code') == AIIDEType.CLAUDE_CODE + assert validate_and_parse_ide('all') is None def test_validate_and_parse_ide_invalid() -> None: diff --git a/tests/cli/commands/ai_guardrails/test_hooks_manager.py b/tests/cli/commands/ai_guardrails/test_hooks_manager.py new file mode 100644 index 00000000..f0dec6f7 --- /dev/null +++ b/tests/cli/commands/ai_guardrails/test_hooks_manager.py @@ -0,0 +1,53 @@ +"""Tests for AI guardrails hooks manager.""" + +from cycode.cli.apps.ai_guardrails.hooks_manager import is_cycode_hook_entry + + +def test_is_cycode_hook_entry_cursor_format() -> None: + """Test detecting Cycode hook in Cursor format (flat command).""" + entry = {'command': 'cycode ai-guardrails scan'} + assert is_cycode_hook_entry(entry) is True + + entry = {'command': 'cycode ai-guardrails scan --some-flag'} + assert is_cycode_hook_entry(entry) is True + + +def test_is_cycode_hook_entry_claude_code_format() -> None: + """Test detecting Cycode hook in Claude Code format (nested).""" + entry = { + 'hooks': [{'type': 'command', 'command': 'cycode ai-guardrails scan --ide claude-code'}], + } + assert is_cycode_hook_entry(entry) is True + + entry = { + 'matcher': 'Read', + 'hooks': [{'type': 'command', 'command': 'cycode ai-guardrails scan --ide claude-code'}], + } + assert is_cycode_hook_entry(entry) is True + + +def test_is_cycode_hook_entry_non_cycode() -> None: + """Test that non-Cycode hooks are not detected.""" + # Cursor format + entry = {'command': 'some-other-command'} + assert is_cycode_hook_entry(entry) is False + + # Claude Code format + entry = { + 'hooks': [{'type': 'command', 'command': 'some-other-command'}], + } + assert is_cycode_hook_entry(entry) is False + + # Empty entry + entry = {} + assert is_cycode_hook_entry(entry) is False + + +def test_is_cycode_hook_entry_partial_match() -> None: + """Test partial command match.""" + # Should match if command contains 'cycode ai-guardrails scan' + entry = {'command': '/usr/local/bin/cycode ai-guardrails scan'} + assert is_cycode_hook_entry(entry) is True + + entry = {'command': 'cycode ai-guardrails scan --verbose'} + assert is_cycode_hook_entry(entry) is True From 71d08c86cb7e4bcf36142252fc07e1557ef679f8 Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Wed, 4 Feb 2026 15:46:24 +0000 Subject: [PATCH 006/123] CM-58972: Remove sentry (#380) --- cycode/cli/app.py | 4 - .../cli/apps/ai_guardrails/install_command.py | 3 - .../apps/ai_guardrails/scan/scan_command.py | 3 - .../cli/apps/ai_guardrails/status_command.py | 3 - .../apps/ai_guardrails/uninstall_command.py | 3 - cycode/cli/apps/auth/auth_command.py | 2 - .../cli/apps/configure/configure_command.py | 3 - cycode/cli/apps/ignore/ignore_command.py | 3 - cycode/cli/apps/mcp/mcp_command.py | 3 - cycode/cli/apps/report/report_command.py | 2 - .../cli/apps/report/sbom/path/path_command.py | 3 - .../repository_url/repository_url_command.py | 3 - cycode/cli/apps/report/sbom/sbom_command.py | 3 - .../report_import/report_import_command.py | 3 - .../apps/report_import/sbom/sbom_command.py | 3 - .../commit_history/commit_history_command.py | 3 - cycode/cli/apps/scan/path/path_command.py | 3 - .../scan/pre_commit/pre_commit_command.py | 3 - .../apps/scan/pre_push/pre_push_command.py | 3 - .../scan/pre_receive/pre_receive_command.py | 3 - .../scan/repository/repository_command.py | 3 - .../cli/apps/scan/scan_ci/scan_ci_command.py | 2 - cycode/cli/apps/scan/scan_command.py | 4 - cycode/cli/consts.py | 8 -- cycode/cli/exceptions/handle_errors.py | 3 - .../cli/user_settings/credentials_manager.py | 5 - cycode/cli/utils/sentry.py | 112 ------------------ cycode/cyclient/headers.py | 3 - poetry.lock | 64 +--------- pyproject.toml | 1 - 30 files changed, 2 insertions(+), 262 deletions(-) delete mode 100644 cycode/cli/utils/sentry.py diff --git a/cycode/cli/app.py b/cycode/cli/app.py index e838519e..41391f99 100644 --- a/cycode/cli/app.py +++ b/cycode/cli/app.py @@ -19,7 +19,6 @@ from cycode.cli.printers import ConsolePrinter from cycode.cli.user_settings.configuration_manager import ConfigurationManager from cycode.cli.utils.progress_bar import SCAN_PROGRESS_BAR_SECTIONS, get_progress_bar -from cycode.cli.utils.sentry import add_breadcrumb, init_sentry from cycode.cli.utils.version_checker import version_checker from cycode.cyclient.cycode_client_base import CycodeClientBase from cycode.cyclient.models import UserAgentOptionScheme @@ -143,9 +142,6 @@ def app_callback( ] = None, ) -> None: """[bold cyan]Cycode CLI - Command Line Interface for Cycode.[/]""" - init_sentry() - add_breadcrumb('cycode') - ctx.ensure_object(dict) configuration_manager = ConfigurationManager() diff --git a/cycode/cli/apps/ai_guardrails/install_command.py b/cycode/cli/apps/ai_guardrails/install_command.py index 4b1095ab..882ebad4 100644 --- a/cycode/cli/apps/ai_guardrails/install_command.py +++ b/cycode/cli/apps/ai_guardrails/install_command.py @@ -13,7 +13,6 @@ ) from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS, AIIDEType from cycode.cli.apps.ai_guardrails.hooks_manager import install_hooks -from cycode.cli.utils.sentry import add_breadcrumb def install_command( @@ -57,8 +56,6 @@ def install_command( cycode ai-guardrails install --ide all # Install for all supported IDEs cycode ai-guardrails install --scope repo --repo-path /path/to/repo """ - add_breadcrumb('ai-guardrails-install') - # Validate inputs validate_scope(scope) repo_path = resolve_repo_path(scope, repo_path) diff --git a/cycode/cli/apps/ai_guardrails/scan/scan_command.py b/cycode/cli/apps/ai_guardrails/scan/scan_command.py index 73981831..288b0025 100644 --- a/cycode/cli/apps/ai_guardrails/scan/scan_command.py +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -25,7 +25,6 @@ from cycode.cli.apps.ai_guardrails.scan.utils import output_json, safe_json_parse from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError from cycode.cli.utils.get_api_client import get_ai_security_manager_client, get_scan_cycode_client -from cycode.cli.utils.sentry import add_breadcrumb from cycode.logger import get_logger logger = get_logger('AI Guardrails') @@ -84,8 +83,6 @@ def scan_command( Example usage (from IDE hooks configuration): { "command": "cycode ai-guardrails scan" } """ - add_breadcrumb('ai-guardrails-scan') - stdin_data = sys.stdin.read().strip() payload = safe_json_parse(stdin_data) diff --git a/cycode/cli/apps/ai_guardrails/status_command.py b/cycode/cli/apps/ai_guardrails/status_command.py index 14a31e7f..0808d806 100644 --- a/cycode/cli/apps/ai_guardrails/status_command.py +++ b/cycode/cli/apps/ai_guardrails/status_command.py @@ -10,7 +10,6 @@ from cycode.cli.apps.ai_guardrails.command_utils import console, validate_and_parse_ide, validate_scope from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS, AIIDEType from cycode.cli.apps.ai_guardrails.hooks_manager import get_hooks_status -from cycode.cli.utils.sentry import add_breadcrumb def status_command( @@ -53,8 +52,6 @@ def status_command( cycode ai-guardrails status --ide cursor # Check status for Cursor IDE cycode ai-guardrails status --ide all # Check status for all supported IDEs """ - add_breadcrumb('ai-guardrails-status') - # Validate inputs (status allows 'all' scope) validate_scope(scope, allowed_scopes=('user', 'repo', 'all')) if repo_path is None: diff --git a/cycode/cli/apps/ai_guardrails/uninstall_command.py b/cycode/cli/apps/ai_guardrails/uninstall_command.py index acf3d0c7..be4288e3 100644 --- a/cycode/cli/apps/ai_guardrails/uninstall_command.py +++ b/cycode/cli/apps/ai_guardrails/uninstall_command.py @@ -13,7 +13,6 @@ ) from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS, AIIDEType from cycode.cli.apps.ai_guardrails.hooks_manager import uninstall_hooks -from cycode.cli.utils.sentry import add_breadcrumb def uninstall_command( @@ -56,8 +55,6 @@ def uninstall_command( cycode ai-guardrails uninstall --ide cursor # Uninstall from Cursor IDE cycode ai-guardrails uninstall --ide all # Uninstall from all supported IDEs """ - add_breadcrumb('ai-guardrails-uninstall') - # Validate inputs validate_scope(scope) repo_path = resolve_repo_path(scope, repo_path) diff --git a/cycode/cli/apps/auth/auth_command.py b/cycode/cli/apps/auth/auth_command.py index 817e0213..1184a916 100644 --- a/cycode/cli/apps/auth/auth_command.py +++ b/cycode/cli/apps/auth/auth_command.py @@ -4,7 +4,6 @@ from cycode.cli.exceptions.handle_auth_errors import handle_auth_exception from cycode.cli.logger import logger from cycode.cli.models import CliResult -from cycode.cli.utils.sentry import add_breadcrumb def auth_command(ctx: typer.Context) -> None: @@ -16,7 +15,6 @@ def auth_command(ctx: typer.Context) -> None: * `cycode auth`: Start interactive authentication * `cycode auth --help`: View authentication options """ - add_breadcrumb('auth') printer = ctx.obj.get('console_printer') try: diff --git a/cycode/cli/apps/configure/configure_command.py b/cycode/cli/apps/configure/configure_command.py index a8759459..1811271c 100644 --- a/cycode/cli/apps/configure/configure_command.py +++ b/cycode/cli/apps/configure/configure_command.py @@ -10,7 +10,6 @@ get_id_token_input, ) from cycode.cli.console import console -from cycode.cli.utils.sentry import add_breadcrumb def _should_update_value( @@ -39,8 +38,6 @@ def configure_command() -> None: * `cycode configure`: Start interactive configuration * `cycode configure --help`: View configuration options """ - add_breadcrumb('configure') - global_config_manager = CONFIGURATION_MANAGER.global_config_file_manager current_api_url = global_config_manager.get_api_url() diff --git a/cycode/cli/apps/ignore/ignore_command.py b/cycode/cli/apps/ignore/ignore_command.py index 1183114a..c65197c3 100644 --- a/cycode/cli/apps/ignore/ignore_command.py +++ b/cycode/cli/apps/ignore/ignore_command.py @@ -9,7 +9,6 @@ from cycode.cli.config import configuration_manager from cycode.cli.logger import logger from cycode.cli.utils.path_utils import get_absolute_path, is_path_exists -from cycode.cli.utils.sentry import add_breadcrumb from cycode.cli.utils.string_utils import hash_string_to_sha256 _FILTER_BY_RICH_HELP_PANEL = 'Filter options' @@ -97,8 +96,6 @@ def ignore_command( # noqa: C901 * `cycode ignore --by-rule GUID`: Ignore rule with the specified GUID * `cycode ignore --by-package lodash@4.17.21`: Ignore lodash version 4.17.21 """ - add_breadcrumb('ignore') - all_by_values = [by_value, by_sha, by_path, by_rule, by_package, by_cve] if all(by is None for by in all_by_values): raise click.ClickException('Ignore by type is missing') diff --git a/cycode/cli/apps/mcp/mcp_command.py b/cycode/cli/apps/mcp/mcp_command.py index b9989ce2..39bcce40 100644 --- a/cycode/cli/apps/mcp/mcp_command.py +++ b/cycode/cli/apps/mcp/mcp_command.py @@ -13,7 +13,6 @@ from pydantic import Field from cycode.cli.cli_types import McpTransportOption, ScanTypeOption -from cycode.cli.utils.sentry import add_breadcrumb from cycode.logger import LoggersManager, get_logger try: @@ -381,8 +380,6 @@ def mcp_command( cycode mcp # Start with default transport (stdio) cycode mcp -t sse -p 8080 # Start with Server-Sent Events (SSE) transport on port 8080 """ - add_breadcrumb('mcp') - try: _run_mcp_server(transport, host, port) except Exception as e: diff --git a/cycode/cli/apps/report/report_command.py b/cycode/cli/apps/report/report_command.py index 75debb33..ba19be1c 100644 --- a/cycode/cli/apps/report/report_command.py +++ b/cycode/cli/apps/report/report_command.py @@ -1,7 +1,6 @@ import typer from cycode.cli.utils.progress_bar import SBOM_REPORT_PROGRESS_BAR_SECTIONS, get_progress_bar -from cycode.cli.utils.sentry import add_breadcrumb def report_command(ctx: typer.Context) -> int: @@ -10,6 +9,5 @@ def report_command(ctx: typer.Context) -> int: Example usage: * `cycode report sbom`: Generate SBOM report """ - add_breadcrumb('report') ctx.obj['progress_bar'] = get_progress_bar(hidden=False, sections=SBOM_REPORT_PROGRESS_BAR_SECTIONS) return 1 diff --git a/cycode/cli/apps/report/sbom/path/path_command.py b/cycode/cli/apps/report/sbom/path/path_command.py index 61c9ddb7..93be3d3c 100644 --- a/cycode/cli/apps/report/sbom/path/path_command.py +++ b/cycode/cli/apps/report/sbom/path/path_command.py @@ -13,7 +13,6 @@ from cycode.cli.utils.get_api_client import get_report_cycode_client from cycode.cli.utils.progress_bar import SbomReportProgressBarSection from cycode.cli.utils.scan_utils import is_cycodeignore_allowed_by_scan_config -from cycode.cli.utils.sentry import add_breadcrumb def path_command( @@ -23,8 +22,6 @@ def path_command( typer.Argument(exists=True, resolve_path=True, help='Path to generate SBOM report for.', show_default=False), ], ) -> None: - add_breadcrumb('path') - client = get_report_cycode_client(ctx) report_parameters = ctx.obj['report_parameters'] output_format = report_parameters.output_format diff --git a/cycode/cli/apps/report/sbom/repository_url/repository_url_command.py b/cycode/cli/apps/report/sbom/repository_url/repository_url_command.py index e0955871..2b208ea2 100644 --- a/cycode/cli/apps/report/sbom/repository_url/repository_url_command.py +++ b/cycode/cli/apps/report/sbom/repository_url/repository_url_command.py @@ -7,7 +7,6 @@ from cycode.cli.exceptions.handle_report_sbom_errors import handle_report_exception from cycode.cli.utils.get_api_client import get_report_cycode_client from cycode.cli.utils.progress_bar import SbomReportProgressBarSection -from cycode.cli.utils.sentry import add_breadcrumb from cycode.cli.utils.url_utils import sanitize_repository_url from cycode.logger import get_logger @@ -18,8 +17,6 @@ def repository_url_command( ctx: typer.Context, uri: Annotated[str, typer.Argument(help='Repository URL to generate SBOM report for.', show_default=False)], ) -> None: - add_breadcrumb('repository_url') - progress_bar = ctx.obj['progress_bar'] progress_bar.start() progress_bar.set_section_length(SbomReportProgressBarSection.PREPARE_LOCAL_FILES) diff --git a/cycode/cli/apps/report/sbom/sbom_command.py b/cycode/cli/apps/report/sbom/sbom_command.py index 06126dd0..4454a966 100644 --- a/cycode/cli/apps/report/sbom/sbom_command.py +++ b/cycode/cli/apps/report/sbom/sbom_command.py @@ -5,7 +5,6 @@ import typer from cycode.cli.cli_types import SbomFormatOption, SbomOutputFormatOption -from cycode.cli.utils.sentry import add_breadcrumb from cycode.cyclient.report_client import ReportParameters _OUTPUT_RICH_HELP_PANEL = 'Output options' @@ -50,8 +49,6 @@ def sbom_command( ] = False, ) -> int: """Generate SBOM report.""" - add_breadcrumb('sbom') - sbom_format_parts = sbom_format.split('-') if len(sbom_format_parts) != 2: raise click.ClickException('Invalid SBOM format.') diff --git a/cycode/cli/apps/report_import/report_import_command.py b/cycode/cli/apps/report_import/report_import_command.py index 7f4e8844..3e346bbe 100644 --- a/cycode/cli/apps/report_import/report_import_command.py +++ b/cycode/cli/apps/report_import/report_import_command.py @@ -1,7 +1,5 @@ import typer -from cycode.cli.utils.sentry import add_breadcrumb - def report_import_command(ctx: typer.Context) -> int: """:bar_chart: [bold cyan]Import security reports.[/] @@ -9,5 +7,4 @@ def report_import_command(ctx: typer.Context) -> int: Example usage: * `cycode import sbom`: Import SBOM report """ - add_breadcrumb('import') return 1 diff --git a/cycode/cli/apps/report_import/sbom/sbom_command.py b/cycode/cli/apps/report_import/sbom/sbom_command.py index de9e85d4..b6b5dfeb 100644 --- a/cycode/cli/apps/report_import/sbom/sbom_command.py +++ b/cycode/cli/apps/report_import/sbom/sbom_command.py @@ -6,7 +6,6 @@ from cycode.cli.cli_types import BusinessImpactOption from cycode.cli.exceptions.handle_report_sbom_errors import handle_report_exception from cycode.cli.utils.get_api_client import get_import_sbom_cycode_client -from cycode.cli.utils.sentry import add_breadcrumb from cycode.cyclient.import_sbom_client import ImportSbomParameters @@ -52,8 +51,6 @@ def sbom_command( ] = BusinessImpactOption.MEDIUM, ) -> None: """Import SBOM.""" - add_breadcrumb('sbom') - client = get_import_sbom_cycode_client(ctx) import_parameters = ImportSbomParameters( diff --git a/cycode/cli/apps/scan/commit_history/commit_history_command.py b/cycode/cli/apps/scan/commit_history/commit_history_command.py index 5935cf59..46d911e8 100644 --- a/cycode/cli/apps/scan/commit_history/commit_history_command.py +++ b/cycode/cli/apps/scan/commit_history/commit_history_command.py @@ -6,7 +6,6 @@ from cycode.cli.apps.scan.commit_range_scanner import scan_commit_range from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception from cycode.cli.logger import logger -from cycode.cli.utils.sentry import add_breadcrumb def commit_history_command( @@ -25,8 +24,6 @@ def commit_history_command( ] = '--all', ) -> None: try: - add_breadcrumb('commit_history') - logger.debug('Starting commit history scan process, %s', {'path': path, 'commit_range': commit_range}) scan_commit_range(ctx, repo_path=str(path), commit_range=commit_range) except Exception as e: diff --git a/cycode/cli/apps/scan/path/path_command.py b/cycode/cli/apps/scan/path/path_command.py index 3ee87350..6b2beab5 100644 --- a/cycode/cli/apps/scan/path/path_command.py +++ b/cycode/cli/apps/scan/path/path_command.py @@ -5,7 +5,6 @@ from cycode.cli.apps.scan.code_scanner import scan_disk_files from cycode.cli.logger import logger -from cycode.cli.utils.sentry import add_breadcrumb def path_command( @@ -14,8 +13,6 @@ def path_command( list[Path], typer.Argument(exists=True, resolve_path=True, help='Paths to scan', show_default=False) ], ) -> None: - add_breadcrumb('path') - progress_bar = ctx.obj['progress_bar'] progress_bar.start() diff --git a/cycode/cli/apps/scan/pre_commit/pre_commit_command.py b/cycode/cli/apps/scan/pre_commit/pre_commit_command.py index 5693412f..e0cbc7a8 100644 --- a/cycode/cli/apps/scan/pre_commit/pre_commit_command.py +++ b/cycode/cli/apps/scan/pre_commit/pre_commit_command.py @@ -4,15 +4,12 @@ import typer from cycode.cli.apps.scan.commit_range_scanner import scan_pre_commit -from cycode.cli.utils.sentry import add_breadcrumb def pre_commit_command( ctx: typer.Context, _: Annotated[Optional[list[str]], typer.Argument(help='Ignored arguments', hidden=True)] = None, ) -> None: - add_breadcrumb('pre_commit') - repo_path = os.getcwd() # change locally for easy testing progress_bar = ctx.obj['progress_bar'] diff --git a/cycode/cli/apps/scan/pre_push/pre_push_command.py b/cycode/cli/apps/scan/pre_push/pre_push_command.py index 868ab62e..d3339ea9 100644 --- a/cycode/cli/apps/scan/pre_push/pre_push_command.py +++ b/cycode/cli/apps/scan/pre_push/pre_push_command.py @@ -19,7 +19,6 @@ ) from cycode.cli.logger import logger from cycode.cli.utils import scan_utils -from cycode.cli.utils.sentry import add_breadcrumb from cycode.cli.utils.task_timer import TimeoutAfter from cycode.logger import set_logging_level @@ -29,8 +28,6 @@ def pre_push_command( _: Annotated[Optional[list[str]], typer.Argument(help='Ignored arguments', hidden=True)] = None, ) -> None: try: - add_breadcrumb('pre_push') - if should_skip_pre_receive_scan(): logger.info( 'A scan has been skipped as per your request. ' diff --git a/cycode/cli/apps/scan/pre_receive/pre_receive_command.py b/cycode/cli/apps/scan/pre_receive/pre_receive_command.py index f6265fd2..70abd4aa 100644 --- a/cycode/cli/apps/scan/pre_receive/pre_receive_command.py +++ b/cycode/cli/apps/scan/pre_receive/pre_receive_command.py @@ -19,7 +19,6 @@ ) from cycode.cli.logger import logger from cycode.cli.utils import scan_utils -from cycode.cli.utils.sentry import add_breadcrumb from cycode.cli.utils.task_timer import TimeoutAfter from cycode.logger import set_logging_level @@ -29,8 +28,6 @@ def pre_receive_command( _: Annotated[Optional[list[str]], typer.Argument(help='Ignored arguments', hidden=True)] = None, ) -> None: try: - add_breadcrumb('pre_receive') - if should_skip_pre_receive_scan(): logger.info( 'A scan has been skipped as per your request. ' diff --git a/cycode/cli/apps/scan/repository/repository_command.py b/cycode/cli/apps/scan/repository/repository_command.py index f36c07e6..e32fec0d 100644 --- a/cycode/cli/apps/scan/repository/repository_command.py +++ b/cycode/cli/apps/scan/repository/repository_command.py @@ -17,7 +17,6 @@ from cycode.cli.utils.path_utils import get_path_by_os from cycode.cli.utils.progress_bar import ScanProgressBarSection from cycode.cli.utils.scan_utils import is_cycodeignore_allowed_by_scan_config -from cycode.cli.utils.sentry import add_breadcrumb def repository_command( @@ -30,8 +29,6 @@ def repository_command( ] = None, ) -> None: try: - add_breadcrumb('repository') - logger.debug('Starting repository scan process, %s', {'path': path, 'branch': branch}) scan_type = ctx.obj['scan_type'] diff --git a/cycode/cli/apps/scan/scan_ci/scan_ci_command.py b/cycode/cli/apps/scan/scan_ci/scan_ci_command.py index 4303cda2..7874a054 100644 --- a/cycode/cli/apps/scan/scan_ci/scan_ci_command.py +++ b/cycode/cli/apps/scan/scan_ci/scan_ci_command.py @@ -5,7 +5,6 @@ from cycode.cli.apps.scan.commit_range_scanner import scan_commit_range from cycode.cli.apps.scan.scan_ci.ci_integrations import get_commit_range -from cycode.cli.utils.sentry import add_breadcrumb # This command is not finished yet. It is not used in the codebase. @@ -16,5 +15,4 @@ ) @click.pass_context def scan_ci_command(ctx: typer.Context) -> None: - add_breadcrumb('ci') scan_commit_range(ctx, repo_path=os.getcwd(), commit_range=get_commit_range()) diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 2eb51f12..9892f1b6 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -14,7 +14,6 @@ from cycode.cli.files_collector.file_excluder import excluder from cycode.cli.utils import scan_utils from cycode.cli.utils.get_api_client import get_scan_cycode_client -from cycode.cli.utils.sentry import add_breadcrumb _EXPORT_RICH_HELP_PANEL = 'Export options' _SCA_RICH_HELP_PANEL = 'SCA options' @@ -136,8 +135,6 @@ def scan_command( * `cycode scan commit-history `: Scan the commit history of a local Git repository. """ - add_breadcrumb('scan') - if export_file and export_type is None: raise typer.BadParameter( 'Export type must be specified when --export-file is provided.', @@ -186,7 +183,6 @@ def _sca_scan_to_context(ctx: typer.Context, sca_scan_user_selected: list[str]) @click.pass_context def scan_command_result_callback(ctx: click.Context, *_, **__) -> None: - add_breadcrumb('scan_finalized') ctx.obj['scan_finalized'] = True progress_bar = ctx.obj.get('progress_bar') diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 0acd887e..8f051edd 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -210,14 +210,6 @@ SCAN_BATCH_MAX_PARALLEL_SCANS = 5 SCAN_BATCH_SCANS_PER_CPU = 1 -# sentry -SENTRY_DSN = 'https://5e26b304b30ced3a34394b6f81f1076d@o1026942.ingest.us.sentry.io/4507543840096256' -SENTRY_DEBUG = False -SENTRY_SAMPLE_RATE = 1.0 -SENTRY_SEND_DEFAULT_PII = False -SENTRY_INCLUDE_LOCAL_VARIABLES = False -SENTRY_MAX_REQUEST_BODY_SIZE = 'never' - # sync scans SYNC_SCAN_TIMEOUT_IN_SECONDS_ENV_VAR_NAME = 'SYNC_SCAN_TIMEOUT_IN_SECONDS' DEFAULT_SYNC_SCAN_TIMEOUT_IN_SECONDS = 180 diff --git a/cycode/cli/exceptions/handle_errors.py b/cycode/cli/exceptions/handle_errors.py index 8d230902..ded1d88c 100644 --- a/cycode/cli/exceptions/handle_errors.py +++ b/cycode/cli/exceptions/handle_errors.py @@ -4,7 +4,6 @@ import typer from cycode.cli.models import CliError, CliErrors -from cycode.cli.utils.sentry import capture_exception def handle_errors( @@ -28,8 +27,6 @@ def handle_errors( if isinstance(err, click.ClickException): raise err - capture_exception(err) - unknown_error = CliError(code='unknown_error', message=str(err)) if return_exception: return unknown_error diff --git a/cycode/cli/user_settings/credentials_manager.py b/cycode/cli/user_settings/credentials_manager.py index 32564b0e..9522981b 100644 --- a/cycode/cli/user_settings/credentials_manager.py +++ b/cycode/cli/user_settings/credentials_manager.py @@ -9,7 +9,6 @@ ) from cycode.cli.user_settings.base_file_manager import BaseFileManager from cycode.cli.user_settings.jwt_creator import JwtCreator -from cycode.cli.utils.sentry import setup_scope_from_access_token class CredentialsManager(BaseFileManager): @@ -77,8 +76,6 @@ def get_access_token(self) -> tuple[Optional[str], Optional[float], Optional[Jwt if hashed_creator: creator = JwtCreator(hashed_creator) - setup_scope_from_access_token(access_token) - return access_token, expires_in, creator def update_access_token( @@ -91,7 +88,5 @@ def update_access_token( } self.write_content_to_file(file_content_to_update) - setup_scope_from_access_token(access_token) - def get_filename(self) -> str: return os.path.join(self.HOME_PATH, self.CYCODE_HIDDEN_DIRECTORY, self.FILE_NAME) diff --git a/cycode/cli/utils/sentry.py b/cycode/cli/utils/sentry.py deleted file mode 100644 index 16b2a982..00000000 --- a/cycode/cli/utils/sentry.py +++ /dev/null @@ -1,112 +0,0 @@ -import logging -from dataclasses import dataclass -from typing import Optional - -import sentry_sdk -from sentry_sdk.integrations.atexit import AtexitIntegration -from sentry_sdk.integrations.dedupe import DedupeIntegration -from sentry_sdk.integrations.excepthook import ExcepthookIntegration -from sentry_sdk.integrations.logging import LoggingIntegration -from sentry_sdk.scrubber import DEFAULT_DENYLIST, EventScrubber - -from cycode import __version__ -from cycode.cli import consts -from cycode.cli.logger import logger -from cycode.cli.utils.jwt_utils import get_user_and_tenant_ids_from_access_token -from cycode.cyclient.config import on_premise_installation - -# when Sentry is blocked on the machine, we want to keep clean output without retries warnings -logging.getLogger('urllib3.connectionpool').setLevel(logging.ERROR) -logging.getLogger('sentry_sdk').setLevel(logging.ERROR) - - -@dataclass -class _SentrySession: - user_id: Optional[str] = None - tenant_id: Optional[str] = None - correlation_id: Optional[str] = None - - -_SENTRY_SESSION = _SentrySession() -_DENY_LIST = [*DEFAULT_DENYLIST, 'access_token'] - - -def _get_sentry_release() -> str: - return f'{consts.APP_NAME}@{__version__}' - - -def _get_sentry_local_release() -> str: - return f'{consts.APP_NAME}@0.0.0' - - -_SENTRY_LOCAL_RELEASE = _get_sentry_local_release() -_SENTRY_DISABLED = on_premise_installation - - -def _before_sentry_event_send(event: dict, _: dict) -> Optional[dict]: - if _SENTRY_DISABLED: - # drop all events when Sentry is disabled - return None - - if event.get('release') == _SENTRY_LOCAL_RELEASE: - logger.debug('Dropping Sentry event due to local development setup') - return None - - return event - - -def init_sentry() -> None: - sentry_sdk.init( - dsn=consts.SENTRY_DSN, - debug=consts.SENTRY_DEBUG, - release=_get_sentry_release(), - server_name='', - before_send=_before_sentry_event_send, - sample_rate=consts.SENTRY_SAMPLE_RATE, - send_default_pii=consts.SENTRY_SEND_DEFAULT_PII, - include_local_variables=consts.SENTRY_INCLUDE_LOCAL_VARIABLES, - max_request_body_size=consts.SENTRY_MAX_REQUEST_BODY_SIZE, - event_scrubber=EventScrubber(denylist=_DENY_LIST, recursive=True), - default_integrations=False, - integrations=[ - AtexitIntegration(lambda _, __: None), # disable output to stderr about pending events - ExcepthookIntegration(), - DedupeIntegration(), - LoggingIntegration(), - ], - ) - - -def setup_scope_from_access_token(access_token: Optional[str]) -> None: - if not access_token: - return - - user_id, tenant_id = get_user_and_tenant_ids_from_access_token(access_token) - - _SENTRY_SESSION.user_id = user_id - _SENTRY_SESSION.tenant_id = tenant_id - - _setup_scope(user_id, tenant_id, _SENTRY_SESSION.correlation_id) - - -def add_correlation_id_to_scope(correlation_id: str) -> None: - _setup_scope(_SENTRY_SESSION.user_id, _SENTRY_SESSION.tenant_id, correlation_id) - - -def _setup_scope(user_id: str, tenant_id: str, correlation_id: Optional[str] = None) -> None: - scope = sentry_sdk.Scope.get_current_scope() - sentry_sdk.set_tag('tenant_id', tenant_id) - - user = {'id': user_id, 'tenant_id': tenant_id} - if correlation_id: - user['correlation_id'] = correlation_id - - scope.set_user(user) - - -def capture_exception(exception: BaseException) -> None: - sentry_sdk.capture_exception(exception) - - -def add_breadcrumb(message: str, category: str = 'cli') -> None: - sentry_sdk.add_breadcrumb(category=category, message=message, level='info') diff --git a/cycode/cyclient/headers.py b/cycode/cyclient/headers.py index 5d10f69b..937f4333 100644 --- a/cycode/cyclient/headers.py +++ b/cycode/cyclient/headers.py @@ -5,7 +5,6 @@ from cycode import __version__ from cycode.cli import consts from cycode.cli.user_settings.configuration_manager import ConfigurationManager -from cycode.cli.utils.sentry import add_correlation_id_to_scope from cycode.cyclient.logger import logger @@ -42,8 +41,6 @@ def get_correlation_id(self) -> str: self._id = str(uuid4()) logger.debug('Correlation ID: %s', self._id) - add_correlation_id_to_scope(self._id) - return self._id diff --git a/poetry.lock b/poetry.lock index 3f5f9388..a02636da 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "altgraph" @@ -1529,66 +1529,6 @@ files = [ {file = "ruff-0.11.7.tar.gz", hash = "sha256:655089ad3224070736dc32844fde783454f8558e71f501cb207485fe4eee23d4"}, ] -[[package]] -name = "sentry-sdk" -version = "2.42.1" -description = "Python client for Sentry (https://sentry.io)" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "sentry_sdk-2.42.1-py2.py3-none-any.whl", hash = "sha256:f8716b50c927d3beb41bc88439dc6bcd872237b596df5b14613e2ade104aee02"}, - {file = "sentry_sdk-2.42.1.tar.gz", hash = "sha256:8598cc6edcfe74cb8074ba6a7c15338cdee93d63d3eb9b9943b4b568354ad5b6"}, -] - -[package.dependencies] -certifi = "*" -urllib3 = ">=1.26.11" - -[package.extras] -aiohttp = ["aiohttp (>=3.5)"] -anthropic = ["anthropic (>=0.16)"] -arq = ["arq (>=0.23)"] -asyncpg = ["asyncpg (>=0.23)"] -beam = ["apache-beam (>=2.12)"] -bottle = ["bottle (>=0.12.13)"] -celery = ["celery (>=3)"] -celery-redbeat = ["celery-redbeat (>=2)"] -chalice = ["chalice (>=1.16.0)"] -clickhouse-driver = ["clickhouse-driver (>=0.2.0)"] -django = ["django (>=1.8)"] -falcon = ["falcon (>=1.4)"] -fastapi = ["fastapi (>=0.79.0)"] -flask = ["blinker (>=1.1)", "flask (>=0.11)", "markupsafe"] -google-genai = ["google-genai (>=1.29.0)"] -grpcio = ["grpcio (>=1.21.1)", "protobuf (>=3.8.0)"] -http2 = ["httpcore[http2] (==1.*)"] -httpx = ["httpx (>=0.16.0)"] -huey = ["huey (>=2)"] -huggingface-hub = ["huggingface_hub (>=0.22)"] -langchain = ["langchain (>=0.0.210)"] -langgraph = ["langgraph (>=0.6.6)"] -launchdarkly = ["launchdarkly-server-sdk (>=9.8.0)"] -litellm = ["litellm (>=1.77.5)"] -litestar = ["litestar (>=2.0.0)"] -loguru = ["loguru (>=0.5)"] -openai = ["openai (>=1.0.0)", "tiktoken (>=0.3.0)"] -openfeature = ["openfeature-sdk (>=0.7.1)"] -opentelemetry = ["opentelemetry-distro (>=0.35b0)"] -opentelemetry-experimental = ["opentelemetry-distro"] -pure-eval = ["asttokens", "executing", "pure_eval"] -pymongo = ["pymongo (>=3.1)"] -pyspark = ["pyspark (>=2.4.4)"] -quart = ["blinker (>=1.1)", "quart (>=0.16.1)"] -rq = ["rq (>=0.6)"] -sanic = ["sanic (>=0.8)"] -sqlalchemy = ["sqlalchemy (>=1.2)"] -starlette = ["starlette (>=0.19.1)"] -starlite = ["starlite (>=1.48)"] -statsig = ["statsig (>=0.55.3)"] -tornado = ["tornado (>=6)"] -unleash = ["UnleashClient (>=6.0.1)"] - [[package]] name = "setuptools" version = "80.9.0" @@ -1903,4 +1843,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "f0854d96f0878d9765ad704e15f5c7b53f2387a81df64a2d04e9221959720662" +content-hash = "318614ab911cb6132de25bea80686d7c9f046971678f4b12fd3e912a9949ce8e" diff --git a/pyproject.toml b/pyproject.toml index 65fa2d65..2bfddf44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,6 @@ arrow = ">=1.0.0,<1.4.0" binaryornot = ">=0.4.4,<0.5.0" requests = ">=2.32.4,<3.0" urllib3 = "1.26.19" # lock v1 to avoid issues with openssl and old Python versions (<3.9.11) on macOS -sentry-sdk = ">=2.8.0,<3.0" pyjwt = ">=2.8.0,<3.0" rich = ">=13.9.4, <14" patch-ng = "1.18.1" From e609c858193395a68d9526d397e3676efb5cc24f Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Wed, 4 Feb 2026 18:14:36 +0200 Subject: [PATCH 007/123] CM-58331 fix enum usage (#381) Co-authored-by: Claude Opus 4.5 --- .../cli/apps/ai_guardrails/install_command.py | 2 +- cycode/cli/apps/ai_guardrails/scan/payload.py | 6 ++-- .../ai_guardrails/scan/response_builders.py | 7 ++--- .../apps/ai_guardrails/scan/scan_command.py | 2 +- .../cli/apps/ai_guardrails/status_command.py | 2 +- .../apps/ai_guardrails/uninstall_command.py | 2 +- .../ai_guardrails/scan/test_scan_command.py | 31 +++++++++++++++++++ 7 files changed, 40 insertions(+), 12 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/install_command.py b/cycode/cli/apps/ai_guardrails/install_command.py index 882ebad4..a72d5d4c 100644 --- a/cycode/cli/apps/ai_guardrails/install_command.py +++ b/cycode/cli/apps/ai_guardrails/install_command.py @@ -31,7 +31,7 @@ def install_command( '--ide', help='IDE to install hooks for (e.g., "cursor", "claude-code", or "all" for all IDEs). Defaults to cursor.', ), - ] = AIIDEType.CURSOR, + ] = AIIDEType.CURSOR.value, repo_path: Annotated[ Optional[Path], typer.Option( diff --git a/cycode/cli/apps/ai_guardrails/scan/payload.py b/cycode/cli/apps/ai_guardrails/scan/payload.py index ce72a574..08e96f9a 100644 --- a/cycode/cli/apps/ai_guardrails/scan/payload.py +++ b/cycode/cli/apps/ai_guardrails/scan/payload.py @@ -155,7 +155,7 @@ def from_cursor_payload(cls, payload: dict) -> 'AIHookPayload': generation_id=payload.get('generation_id'), ide_user_email=payload.get('user_email'), model=payload.get('model'), - ide_provider=AIIDEType.CURSOR, + ide_provider=AIIDEType.CURSOR.value, ide_version=payload.get('cursor_version'), prompt=payload.get('prompt', ''), file_path=payload.get('file_path') or payload.get('path'), @@ -213,7 +213,7 @@ def from_claude_code_payload(cls, payload: dict) -> 'AIHookPayload': generation_id=generation_id, ide_user_email=None, # Claude Code doesn't provide this in hook payload model=model, - ide_provider=AIIDEType.CLAUDE_CODE, + ide_provider=AIIDEType.CLAUDE_CODE.value, ide_version=ide_version, prompt=payload.get('prompt', ''), file_path=file_path, @@ -248,7 +248,7 @@ def is_payload_for_ide(payload: dict, ide: str) -> bool: return True @classmethod - def from_payload(cls, payload: dict, tool: str = AIIDEType.CURSOR) -> 'AIHookPayload': + def from_payload(cls, payload: dict, tool: str = AIIDEType.CURSOR.value) -> 'AIHookPayload': """Create AIHookPayload from any tool's payload. Args: diff --git a/cycode/cli/apps/ai_guardrails/scan/response_builders.py b/cycode/cli/apps/ai_guardrails/scan/response_builders.py index f0da71b7..ff0a6aa4 100644 --- a/cycode/cli/apps/ai_guardrails/scan/response_builders.py +++ b/cycode/cli/apps/ai_guardrails/scan/response_builders.py @@ -117,7 +117,7 @@ def deny_prompt(self, user_message: str) -> dict: } -def get_response_builder(ide: str = AIIDEType.CURSOR) -> IDEResponseBuilder: +def get_response_builder(ide: str = AIIDEType.CURSOR.value) -> IDEResponseBuilder: """Get the response builder for a specific IDE. Args: @@ -129,10 +129,7 @@ def get_response_builder(ide: str = AIIDEType.CURSOR) -> IDEResponseBuilder: Raises: ValueError: If the IDE is not supported """ - # Normalize to AIIDEType if string passed - if isinstance(ide, str): - ide = ide.lower() - builder = _RESPONSE_BUILDERS.get(ide) + builder = _RESPONSE_BUILDERS.get(ide.lower()) if not builder: raise ValueError(f'Unsupported IDE: {ide}. Supported IDEs: {list(_RESPONSE_BUILDERS.keys())}') return builder diff --git a/cycode/cli/apps/ai_guardrails/scan/scan_command.py b/cycode/cli/apps/ai_guardrails/scan/scan_command.py index 288b0025..fe1c74a3 100644 --- a/cycode/cli/apps/ai_guardrails/scan/scan_command.py +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -69,7 +69,7 @@ def scan_command( help='IDE that sent the payload (e.g., "cursor"). Defaults to cursor.', hidden=True, ), - ] = AIIDEType.CURSOR, + ] = AIIDEType.CURSOR.value, ) -> None: """Scan content from AI IDE hooks for secrets. diff --git a/cycode/cli/apps/ai_guardrails/status_command.py b/cycode/cli/apps/ai_guardrails/status_command.py index 0808d806..ee1e5bcf 100644 --- a/cycode/cli/apps/ai_guardrails/status_command.py +++ b/cycode/cli/apps/ai_guardrails/status_command.py @@ -28,7 +28,7 @@ def status_command( '--ide', help='IDE to check status for (e.g., "cursor", "claude-code", or "all" for all IDEs). Defaults to cursor.', ), - ] = AIIDEType.CURSOR, + ] = AIIDEType.CURSOR.value, repo_path: Annotated[ Optional[Path], typer.Option( diff --git a/cycode/cli/apps/ai_guardrails/uninstall_command.py b/cycode/cli/apps/ai_guardrails/uninstall_command.py index be4288e3..f7b8341c 100644 --- a/cycode/cli/apps/ai_guardrails/uninstall_command.py +++ b/cycode/cli/apps/ai_guardrails/uninstall_command.py @@ -31,7 +31,7 @@ def uninstall_command( '--ide', help='IDE to uninstall hooks from (e.g., "cursor", "claude-code", "all"). Defaults to cursor.', ), - ] = AIIDEType.CURSOR, + ] = AIIDEType.CURSOR.value, repo_path: Annotated[ Optional[Path], typer.Option( diff --git a/tests/cli/commands/ai_guardrails/scan/test_scan_command.py b/tests/cli/commands/ai_guardrails/scan/test_scan_command.py index d1473c69..4bcb35f2 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_scan_command.py +++ b/tests/cli/commands/ai_guardrails/scan/test_scan_command.py @@ -6,7 +6,9 @@ import pytest from pytest_mock import MockerFixture +from typer.testing import CliRunner +from cycode.cli.apps.ai_guardrails import app as ai_guardrails_app from cycode.cli.apps.ai_guardrails.scan.scan_command import scan_command @@ -136,3 +138,32 @@ def test_claude_code_payload_with_claude_code_ide( mock_scan_command_deps['load_policy'].assert_called_once() mock_scan_command_deps['get_handler'].assert_called_once() mock_handler.assert_called_once() + + +class TestDefaultIdeParameterViaCli: + """Tests that verify default IDE parameter works correctly via CLI invocation.""" + + def test_scan_command_default_ide_via_cli(self, mocker: MockerFixture) -> None: + """Test scan_command works with default --ide when invoked via CLI. + + This test catches issues where Typer converts enum defaults to strings + incorrectly (e.g., AIIDEType.CURSOR becomes 'AIIDEType.CURSOR' instead of 'cursor'). + """ + mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command._initialize_clients') + mocker.patch( + 'cycode.cli.apps.ai_guardrails.scan.scan_command.load_policy', + return_value={'fail_open': True}, + ) + mock_handler = MagicMock(return_value={'continue': True}) + mocker.patch( + 'cycode.cli.apps.ai_guardrails.scan.scan_command.get_handler_for_event', + return_value=mock_handler, + ) + + runner = CliRunner() + payload = json.dumps({'hook_event_name': 'beforeSubmitPrompt', 'prompt': 'test'}) + + # Invoke via CLI without --ide flag to use default + result = runner.invoke(ai_guardrails_app, ['scan'], input=payload) + + assert result.exit_code == 0, f'Command failed: {result.output}' From f673b80befb2bb974105fc8357231f43378b9abb Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Thu, 12 Feb 2026 10:59:15 +0200 Subject: [PATCH 008/123] CM-59486 fix hook auth error message (#382) --- cycode/cli/apps/ai_guardrails/scan/scan_command.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/scan/scan_command.py b/cycode/cli/apps/ai_guardrails/scan/scan_command.py index fe1c74a3..add2bb83 100644 --- a/cycode/cli/apps/ai_guardrails/scan/scan_command.py +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -34,17 +34,17 @@ def _get_auth_error_message(error: Exception) -> str: """Get user-friendly message for authentication errors.""" if isinstance(error, click.ClickException): # Missing credentials - return f'{error.message} Please run `cycode configure` to set up your credentials.' + return f'{error.message} Please run `cycode auth` to set up your credentials.' if isinstance(error, HttpUnauthorizedError): # Invalid/expired credentials return ( 'Unable to authenticate to Cycode. Your credentials are invalid or have expired. ' - 'Please run `cycode configure` to update your credentials.' + 'Please run `cycode auth` to update your credentials.' ) # Fallback - return 'Authentication failed. Please run `cycode configure` to set up your credentials.' + return 'Authentication failed. Please run `cycode auth` to set up your credentials.' def _initialize_clients(ctx: typer.Context) -> None: From 8a53aed077458c3c28c271259bd222e7c4c4dd0e Mon Sep 17 00:00:00 2001 From: Ilia Shkolyar <60312091+ilia-cy@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:14:03 +0200 Subject: [PATCH 009/123] CM-59469 switch SCA/IAC from file_name to file_path in detection_details (#383) Co-authored-by: Claude Opus 4.6 --- cycode/cli/apps/scan/scan_result.py | 2 +- .../cli/printers/tables/sca_table_printer.py | 2 +- cycode/cli/printers/utils/detection_data.py | 2 +- .../utils/detection_ordering/sca_ordering.py | 2 +- tests/cli/commands/scan/test_scan_result.py | 47 +++++++++++++++++++ .../cli/printers/utils/test_detection_data.py | 41 ++++++++++++++++ 6 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 tests/cli/commands/scan/test_scan_result.py create mode 100644 tests/cli/printers/utils/test_detection_data.py diff --git a/cycode/cli/apps/scan/scan_result.py b/cycode/cli/apps/scan/scan_result.py index 31a36368..13fb8576 100644 --- a/cycode/cli/apps/scan/scan_result.py +++ b/cycode/cli/apps/scan/scan_result.py @@ -88,7 +88,7 @@ def _get_file_name_from_detection(scan_type: str, raw_detection: dict) -> str: if scan_type == consts.SECRET_SCAN_TYPE: return _get_secret_file_name_from_detection(raw_detection) - return raw_detection['detection_details']['file_name'] + return raw_detection['detection_details']['file_path'] def _get_secret_file_name_from_detection(raw_detection: dict) -> str: diff --git a/cycode/cli/printers/tables/sca_table_printer.py b/cycode/cli/printers/tables/sca_table_printer.py index c0bedcc7..064d21d1 100644 --- a/cycode/cli/printers/tables/sca_table_printer.py +++ b/cycode/cli/printers/tables/sca_table_printer.py @@ -86,7 +86,7 @@ def _enrich_table_with_values(table: Table, detection: Detection) -> None: table.add_cell(SEVERITY_COLUMN, 'N/A') table.add_cell(REPOSITORY_COLUMN, detection_details.get('repository_name')) - table.add_file_path_cell(CODE_PROJECT_COLUMN, detection_details.get('file_name')) + table.add_file_path_cell(CODE_PROJECT_COLUMN, detection_details.get('file_path')) table.add_cell(ECOSYSTEM_COLUMN, detection_details.get('ecosystem')) table.add_cell(PACKAGE_COLUMN, detection_details.get('package_name')) diff --git a/cycode/cli/printers/utils/detection_data.py b/cycode/cli/printers/utils/detection_data.py index 37bee310..679429a3 100644 --- a/cycode/cli/printers/utils/detection_data.py +++ b/cycode/cli/printers/utils/detection_data.py @@ -105,4 +105,4 @@ def get_detection_file_path(scan_type: str, detection: 'Detection') -> Path: return Path(file_path) - return Path(detection.detection_details.get('file_name', '')) + return Path(detection.detection_details.get('file_path', '')) diff --git a/cycode/cli/printers/utils/detection_ordering/sca_ordering.py b/cycode/cli/printers/utils/detection_ordering/sca_ordering.py index a8be3430..9e1f8022 100644 --- a/cycode/cli/printers/utils/detection_ordering/sca_ordering.py +++ b/cycode/cli/printers/utils/detection_ordering/sca_ordering.py @@ -49,7 +49,7 @@ def sort_and_group_detections(detections: list['Detection']) -> tuple[list['Dete grouped_by_repository = __group_by(sorted_detections, 'repository_name') for repository_group in grouped_by_repository.values(): - grouped_by_code_project = __group_by(repository_group, 'file_name') + grouped_by_code_project = __group_by(repository_group, 'file_path') for code_project_group in grouped_by_code_project.values(): grouped_by_package = __group_by(code_project_group, 'package_name') for package_group in grouped_by_package.values(): diff --git a/tests/cli/commands/scan/test_scan_result.py b/tests/cli/commands/scan/test_scan_result.py new file mode 100644 index 00000000..e85ca116 --- /dev/null +++ b/tests/cli/commands/scan/test_scan_result.py @@ -0,0 +1,47 @@ +import os + +from cycode.cli.apps.scan.scan_result import _get_file_name_from_detection +from cycode.cli.consts import IAC_SCAN_TYPE, SAST_SCAN_TYPE, SCA_SCAN_TYPE, SECRET_SCAN_TYPE + + +def test_get_file_name_from_detection_sca_uses_file_path() -> None: + raw_detection = { + 'detection_details': { + 'file_name': 'package.json', + 'file_path': '/repo/path/package.json', + }, + } + result = _get_file_name_from_detection(SCA_SCAN_TYPE, raw_detection) + assert result == '/repo/path/package.json' + + +def test_get_file_name_from_detection_iac_uses_file_path() -> None: + raw_detection = { + 'detection_details': { + 'file_name': 'main.tf', + 'file_path': '/repo/infra/main.tf', + }, + } + result = _get_file_name_from_detection(IAC_SCAN_TYPE, raw_detection) + assert result == '/repo/infra/main.tf' + + +def test_get_file_name_from_detection_sast_uses_file_path() -> None: + raw_detection = { + 'detection_details': { + 'file_path': '/repo/src/app.py', + }, + } + result = _get_file_name_from_detection(SAST_SCAN_TYPE, raw_detection) + assert result == '/repo/src/app.py' + + +def test_get_file_name_from_detection_secret_uses_file_path_and_file_name() -> None: + raw_detection = { + 'detection_details': { + 'file_path': '/repo/src', + 'file_name': '.env', + }, + } + result = _get_file_name_from_detection(SECRET_SCAN_TYPE, raw_detection) + assert result == os.path.join('/repo/src', '.env') diff --git a/tests/cli/printers/utils/test_detection_data.py b/tests/cli/printers/utils/test_detection_data.py new file mode 100644 index 00000000..603c25db --- /dev/null +++ b/tests/cli/printers/utils/test_detection_data.py @@ -0,0 +1,41 @@ +from pathlib import Path +from unittest.mock import MagicMock + +from cycode.cli.consts import IAC_SCAN_TYPE, SAST_SCAN_TYPE, SCA_SCAN_TYPE, SECRET_SCAN_TYPE +from cycode.cli.printers.utils.detection_data import get_detection_file_path + + +def _make_detection(**details: str) -> MagicMock: + detection = MagicMock() + detection.detection_details = dict(details) + return detection + + +def test_get_detection_file_path_sca_uses_file_path() -> None: + detection = _make_detection(file_name='package.json', file_path='/repo/path/package.json') + result = get_detection_file_path(SCA_SCAN_TYPE, detection) + assert result == Path('/repo/path/package.json') + + +def test_get_detection_file_path_iac_uses_file_path() -> None: + detection = _make_detection(file_name='main.tf', file_path='/repo/infra/main.tf') + result = get_detection_file_path(IAC_SCAN_TYPE, detection) + assert result == Path('/repo/infra/main.tf') + + +def test_get_detection_file_path_sca_fallback_empty() -> None: + detection = _make_detection() + result = get_detection_file_path(SCA_SCAN_TYPE, detection) + assert result == Path('') + + +def test_get_detection_file_path_secret() -> None: + detection = _make_detection(file_path='/repo/src', file_name='.env') + result = get_detection_file_path(SECRET_SCAN_TYPE, detection) + assert result == Path('/repo/src/.env') + + +def test_get_detection_file_path_sast() -> None: + detection = _make_detection(file_path='repo/src/app.py') + result = get_detection_file_path(SAST_SCAN_TYPE, detection) + assert result == Path('/repo/src/app.py') From e4cc4b5b4abe66bdd239aef2c6919d39f0cf2354 Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Mon, 16 Feb 2026 08:04:51 +0000 Subject: [PATCH 010/123] CM-59577: handle 401 errors gracefully in scans (#384) --- cycode/cli/exceptions/custom_exceptions.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/cycode/cli/exceptions/custom_exceptions.py b/cycode/cli/exceptions/custom_exceptions.py index 59c0f693..78781914 100644 --- a/cycode/cli/exceptions/custom_exceptions.py +++ b/cycode/cli/exceptions/custom_exceptions.py @@ -47,12 +47,9 @@ class ReportAsyncError(CycodeError): pass -class HttpUnauthorizedError(RequestError): +class HttpUnauthorizedError(RequestHttpError): def __init__(self, error_message: str, response: Response) -> None: - self.status_code = 401 - self.error_message = error_message - self.response = response - super().__init__(self.error_message) + super().__init__(401, error_message, response) def __str__(self) -> str: return f'HTTP unauthorized error occurred during the request. Message: {self.error_message}' From 5922901fd7937d3acb7b4a283fba492a60b21b94 Mon Sep 17 00:00:00 2001 From: Brad Smith Date: Wed, 18 Feb 2026 01:15:05 -0800 Subject: [PATCH 011/123] Update Python version requirements in README (#387) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 991ba56c..a4457d2d 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ This guide walks you through both installation and usage. # Prerequisites -- The Cycode CLI application requires Python version 3.9 or later. +- The Cycode CLI application requires Python version 3.9 or later. The MCP command is available only for Python 3.10 and above. If you're using an earlier Python version, this command will not be available. - Use the [`cycode auth` command](#using-the-auth-command) to authenticate to Cycode with the CLI - Alternatively, you can get a Cycode Client ID and Client Secret Key by following the steps detailed in the [Service Account Token](https://docs.cycode.com/docs/en/service-accounts) and [Personal Access Token](https://docs.cycode.com/v1/docs/managing-personal-access-tokens) pages, which contain details on getting these values. From b3ae1da3c6431905602f3d130d6bb8150a5a63d6 Mon Sep 17 00:00:00 2001 From: ronens88 <55343081+ronens88@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:23:11 +0200 Subject: [PATCH 012/123] CM-59712: add --maven-settings-file to report sbom path command (#385) Co-authored-by: Cursor Co-authored-by: Philip Hayton --- README.md | 6 ++++++ cycode/cli/apps/report/sbom/path/path_command.py | 16 +++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a4457d2d..2abfd3b2 100644 --- a/README.md +++ b/README.md @@ -1307,6 +1307,12 @@ To create an SBOM report for a path:\ For example:\ `cycode report sbom --format spdx-2.3 --include-vulnerabilities --include-dev-dependencies path /path/to/local/project` +The `path` subcommand supports the following additional options: + +| Option | Description | +|-------------------------|----------------------------------------------------------------------------------------------------------------------------------| +| `--maven-settings-file` | For Maven only, allows using a custom [settings.xml](https://maven.apache.org/settings.html) file when building the dependency tree | + # Import Command ## Importing SBOM diff --git a/cycode/cli/apps/report/sbom/path/path_command.py b/cycode/cli/apps/report/sbom/path/path_command.py index 93be3d3c..a127bfc7 100644 --- a/cycode/cli/apps/report/sbom/path/path_command.py +++ b/cycode/cli/apps/report/sbom/path/path_command.py @@ -1,6 +1,6 @@ import time from pathlib import Path -from typing import Annotated +from typing import Annotated, Optional import typer @@ -14,6 +14,8 @@ from cycode.cli.utils.progress_bar import SbomReportProgressBarSection from cycode.cli.utils.scan_utils import is_cycodeignore_allowed_by_scan_config +_SCA_RICH_HELP_PANEL = 'SCA options' + def path_command( ctx: typer.Context, @@ -21,7 +23,19 @@ def path_command( Path, typer.Argument(exists=True, resolve_path=True, help='Path to generate SBOM report for.', show_default=False), ], + maven_settings_file: Annotated[ + Optional[Path], + typer.Option( + '--maven-settings-file', + show_default=False, + help='When specified, Cycode will use this settings.xml file when building the maven dependency tree.', + dir_okay=False, + rich_help_panel=_SCA_RICH_HELP_PANEL, + ), + ] = None, ) -> None: + ctx.obj['maven_settings_file'] = maven_settings_file + client = get_report_cycode_client(ctx) report_parameters = ctx.obj['report_parameters'] output_format = report_parameters.output_format From 8fa780d569190c51caa7f43d153b0ca46045c9d7 Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Wed, 18 Feb 2026 10:31:59 +0000 Subject: [PATCH 013/123] CM-59691: update build processes and pyinstaller setup (#386) --- .github/workflows/build_executable.yml | 4 +- .github/workflows/tests_full.yml | 3 +- poetry.lock | 58 +++++++++++++------------- pyinstaller.spec | 4 +- pyproject.toml | 2 +- 5 files changed, 36 insertions(+), 35 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index ae14d3a2..333427a3 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -61,10 +61,10 @@ jobs: git checkout $LATEST_TAG echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV - - name: Set up Python 3.12 + - name: Set up Python 3.13 uses: actions/setup-python@v4 with: - python-version: '3.12' + python-version: '3.13' - name: Load cached Poetry setup id: cached-poetry diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index b8d1fc2c..aea09b4a 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -66,8 +66,7 @@ jobs: - name: Run executable test # we care about the one Python version that will be used to build the executable - # TODO(MarshalX): upgrade to Python 3.13 - if: matrix.python-version == '3.12' + if: matrix.python-version == '3.13' run: | poetry run pyinstaller pyinstaller.spec ./dist/cycode-cli version diff --git a/poetry.lock b/poetry.lock index a02636da..b20290ed 100644 --- a/poetry.lock +++ b/poetry.lock @@ -7,7 +7,7 @@ description = "Python graph (network) package" optional = false python-versions = "*" groups = ["executable"] -markers = "python_version < \"3.13\"" +markers = "python_version < \"3.15\"" files = [ {file = "altgraph-0.17.4-py2.py3-none-any.whl", hash = "sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff"}, {file = "altgraph-0.17.4.tar.gz", hash = "sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406"}, @@ -584,7 +584,7 @@ description = "Mach-O header analysis and editing" optional = false python-versions = "*" groups = ["executable"] -markers = "python_version < \"3.13\" and sys_platform == \"darwin\"" +markers = "python_version < \"3.15\" and sys_platform == \"darwin\"" files = [ {file = "macholib-1.16.3-py2.py3-none-any.whl", hash = "sha256:0e315d7583d38b8c77e815b1ecbdbf504a8258d8b3e17b61165c6feb60d18f2c"}, {file = "macholib-1.16.3.tar.gz", hash = "sha256:07ae9e15e8e4cd9a788013d81f5908b3609aa76f9b1421bae9c4d7606ec86a30"}, @@ -745,7 +745,7 @@ description = "Python PE parsing module" optional = false python-versions = ">=3.6.0" groups = ["executable"] -markers = "python_version < \"3.13\" and sys_platform == \"win32\"" +markers = "python_version < \"3.15\" and sys_platform == \"win32\"" files = [ {file = "pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f"}, {file = "pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632"}, @@ -973,50 +973,52 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyinstaller" -version = "5.13.2" +version = "6.19.0" description = "PyInstaller bundles a Python application and all its dependencies into a single package." optional = false -python-versions = "<3.13,>=3.7" +python-versions = "<3.15,>=3.8" groups = ["executable"] -markers = "python_version < \"3.13\"" -files = [ - {file = "pyinstaller-5.13.2-py3-none-macosx_10_13_universal2.whl", hash = "sha256:16cbd66b59a37f4ee59373a003608d15df180a0d9eb1a29ff3bfbfae64b23d0f"}, - {file = "pyinstaller-5.13.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8f6dd0e797ae7efdd79226f78f35eb6a4981db16c13325e962a83395c0ec7420"}, - {file = "pyinstaller-5.13.2-py3-none-manylinux2014_i686.whl", hash = "sha256:65133ed89467edb2862036b35d7c5ebd381670412e1e4361215e289c786dd4e6"}, - {file = "pyinstaller-5.13.2-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:7d51734423685ab2a4324ab2981d9781b203dcae42839161a9ee98bfeaabdade"}, - {file = "pyinstaller-5.13.2-py3-none-manylinux2014_s390x.whl", hash = "sha256:2c2fe9c52cb4577a3ac39626b84cf16cf30c2792f785502661286184f162ae0d"}, - {file = "pyinstaller-5.13.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c63ef6133eefe36c4b2f4daf4cfea3d6412ece2ca218f77aaf967e52a95ac9b8"}, - {file = "pyinstaller-5.13.2-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:aadafb6f213549a5906829bb252e586e2cf72a7fbdb5731810695e6516f0ab30"}, - {file = "pyinstaller-5.13.2-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:b2e1c7f5cceb5e9800927ddd51acf9cc78fbaa9e79e822c48b0ee52d9ce3c892"}, - {file = "pyinstaller-5.13.2-py3-none-win32.whl", hash = "sha256:421cd24f26144f19b66d3868b49ed673176765f92fa9f7914cd2158d25b6d17e"}, - {file = "pyinstaller-5.13.2-py3-none-win_amd64.whl", hash = "sha256:ddcc2b36052a70052479a9e5da1af067b4496f43686ca3cdda99f8367d0627e4"}, - {file = "pyinstaller-5.13.2-py3-none-win_arm64.whl", hash = "sha256:27cd64e7cc6b74c5b1066cbf47d75f940b71356166031deb9778a2579bb874c6"}, - {file = "pyinstaller-5.13.2.tar.gz", hash = "sha256:c8e5d3489c3a7cc5f8401c2d1f48a70e588f9967e391c3b06ddac1f685f8d5d2"}, +markers = "python_version < \"3.15\"" +files = [ + {file = "pyinstaller-6.19.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:4190e76b74f0c4b5c5f11ac360928cd2e36ec8e3194d437bf6b8648c7bc0c134"}, + {file = "pyinstaller-6.19.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8bd68abd812d8a6ba33b9f1810e91fee0f325969733721b78151f0065319ca11"}, + {file = "pyinstaller-6.19.0-py3-none-manylinux2014_i686.whl", hash = "sha256:1ec54ef967996ca61dacba676227e2b23219878ccce5ee9d6f3aada7b8ed8abf"}, + {file = "pyinstaller-6.19.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4ab2bb52e58448e14ddf9450601bdedd66800465043501c1d8f1cab87b60b122"}, + {file = "pyinstaller-6.19.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:da6d5c6391ccefe73554b9fa29b86001c8e378e0f20c2a4004f836ba537eff63"}, + {file = "pyinstaller-6.19.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a0fc5f6b3c55aa54353f0c74ffa59b1115433c1850c6f655d62b461a2ed6cbbe"}, + {file = "pyinstaller-6.19.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:e649ba6bd1b0b89b210ad92adb5fbdc8a42dd2c5ca4f72ef3a0bfec83a424b83"}, + {file = "pyinstaller-6.19.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:481a909c8e60c8692fc60fcb1344d984b44b943f8bc9682f2fcdae305ad297e6"}, + {file = "pyinstaller-6.19.0-py3-none-win32.whl", hash = "sha256:3c5c251054fe4cfaa04c34a363dcfbf811545438cb7198304cd444756bc2edd2"}, + {file = "pyinstaller-6.19.0-py3-none-win_amd64.whl", hash = "sha256:b5bb6536c6560330d364d91522250f254b107cf69129d9cbcd0e6727c570be33"}, + {file = "pyinstaller-6.19.0-py3-none-win_arm64.whl", hash = "sha256:c2d5a539b0bfe6159d5522c8c70e1c0e487f22c2badae0f97d45246223b798ea"}, + {file = "pyinstaller-6.19.0.tar.gz", hash = "sha256:ec73aeb8bd9b7f2f1240d328a4542e90b3c6e6fbc106014778431c616592a865"}, ] [package.dependencies] altgraph = "*" +importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""} +packaging = ">=22.0" pefile = {version = ">=2022.5.30", markers = "sys_platform == \"win32\""} -pyinstaller-hooks-contrib = ">=2021.4" +pyinstaller-hooks-contrib = ">=2026.0" pywin32-ctypes = {version = ">=0.2.1", markers = "sys_platform == \"win32\""} setuptools = ">=42.0.0" [package.extras] -encryption = ["tinyaes (>=1.0.0)"] +completion = ["argcomplete"] hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"] [[package]] name = "pyinstaller-hooks-contrib" -version = "2025.9" +version = "2026.0" description = "Community maintained hooks for PyInstaller" optional = false python-versions = ">=3.8" groups = ["executable"] -markers = "python_version < \"3.13\"" +markers = "python_version < \"3.15\"" files = [ - {file = "pyinstaller_hooks_contrib-2025.9-py3-none-any.whl", hash = "sha256:ccbfaa49399ef6b18486a165810155e5a8d4c59b41f20dc5da81af7482aaf038"}, - {file = "pyinstaller_hooks_contrib-2025.9.tar.gz", hash = "sha256:56e972bdaad4e9af767ed47d132362d162112260cbe488c9da7fee01f228a5a6"}, + {file = "pyinstaller_hooks_contrib-2026.0-py3-none-any.whl", hash = "sha256:0590db8edeba3e6c30c8474937021f5cd39c0602b4d10f74a064c73911efaca5"}, + {file = "pyinstaller_hooks_contrib-2026.0.tar.gz", hash = "sha256:0120893de491a000845470ca9c0b39284731ac6bace26f6849dea9627aaed48e"}, ] [package.dependencies] @@ -1165,7 +1167,7 @@ description = "A (partial) reimplementation of pywin32 using ctypes/cffi" optional = false python-versions = ">=3.6" groups = ["executable"] -markers = "python_version < \"3.13\" and sys_platform == \"win32\"" +markers = "python_version < \"3.15\" and sys_platform == \"win32\"" files = [ {file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"}, {file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"}, @@ -1536,7 +1538,7 @@ description = "Easily download, build, install, upgrade, and uninstall Python pa optional = false python-versions = ">=3.9" groups = ["executable"] -markers = "python_version < \"3.13\"" +markers = "python_version < \"3.15\"" files = [ {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, @@ -1843,4 +1845,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "318614ab911cb6132de25bea80686d7c9f046971678f4b12fd3e912a9949ce8e" +content-hash = "d705f54b6e814ba9b361cda482e5f23a7fbd0a41ae652f76ece6bfb78b00973f" diff --git a/pyinstaller.spec b/pyinstaller.spec index 39b8588f..c577c547 100644 --- a/pyinstaller.spec +++ b/pyinstaller.spec @@ -23,10 +23,10 @@ with open(_INIT_FILE_PATH, 'w', encoding='UTF-8') as file: a = Analysis( scripts=['cycode/cli/main.py'], - excludes=['tests'], + excludes=['tests', 'setuptools', 'pkg_resources'], ) -exe_args = [PYZ(a.pure, a.zipped_data), a.scripts, a.binaries, a.zipfiles, a.datas] +exe_args = [PYZ(a.pure), a.scripts, a.binaries, a.datas] if _ONEDIR_MODE: exe_args = [PYZ(a.pure), a.scripts] diff --git a/pyproject.toml b/pyproject.toml index 2bfddf44..06c69b28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,7 +60,7 @@ responses = ">=0.23.1,<0.24.0" pyfakefs = ">=5.7.2,<5.8.0" [tool.poetry.group.executable.dependencies] -pyinstaller = {version=">=5.13.2,<5.14.0", python=">=3.8,<3.13"} +pyinstaller = {version=">=6.0.0,<7.0.0", python=">=3.9,<3.15"} dunamai = ">=1.18.0,<1.22.0" [tool.poetry.group.dev.dependencies] From d956fdb11e5cb72361b4d9a67947d5ca1d43f2a6 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:03:03 +0200 Subject: [PATCH 014/123] CM-59792 read file hook save file path (#389) --- cycode/cli/apps/ai_guardrails/scan/handlers.py | 1 + cycode/cyclient/ai_security_manager_client.py | 2 ++ tests/cli/commands/ai_guardrails/scan/test_handlers.py | 3 +++ 3 files changed, 6 insertions(+) diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index 32be1241..2a762a8d 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -170,6 +170,7 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: scan_id=scan_id, block_reason=block_reason, error_message=error_message, + file_path=payload.file_path, ) diff --git a/cycode/cyclient/ai_security_manager_client.py b/cycode/cyclient/ai_security_manager_client.py index 1090ad8d..35c1d8c9 100644 --- a/cycode/cyclient/ai_security_manager_client.py +++ b/cycode/cyclient/ai_security_manager_client.py @@ -62,6 +62,7 @@ def create_event( scan_id: Optional[str] = None, block_reason: Optional['BlockReason'] = None, error_message: Optional[str] = None, + file_path: Optional[str] = None, ) -> None: """Create an AI hook event from hook payload.""" conversation_id = payload.conversation_id @@ -79,6 +80,7 @@ def create_event( 'mcp_server_name': payload.mcp_server_name, 'mcp_tool_name': payload.mcp_tool_name, 'error_message': error_message, + 'file_path': file_path, } try: diff --git a/tests/cli/commands/ai_guardrails/scan/test_handlers.py b/tests/cli/commands/ai_guardrails/scan/test_handlers.py index 634469b7..1adfe25b 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_handlers.py +++ b/tests/cli/commands/ai_guardrails/scan/test_handlers.py @@ -194,6 +194,7 @@ def test_handle_before_read_file_sensitive_path( call_args = mock_ctx.obj['ai_security_client'].create_event.call_args assert call_args.args[2] == AIHookOutcome.BLOCKED assert call_args.kwargs['block_reason'] == BlockReason.SENSITIVE_PATH + assert call_args.kwargs['file_path'] == '/path/to/.env' @patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') @@ -215,6 +216,7 @@ def test_handle_before_read_file_no_secrets( assert result == {'permission': 'allow'} call_args = mock_ctx.obj['ai_security_client'].create_event.call_args assert call_args.args[2] == AIHookOutcome.ALLOWED + assert call_args.kwargs['file_path'] == '/path/to/file.txt' @patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') @@ -238,6 +240,7 @@ def test_handle_before_read_file_with_secrets( call_args = mock_ctx.obj['ai_security_client'].create_event.call_args assert call_args.args[2] == AIHookOutcome.BLOCKED assert call_args.kwargs['block_reason'] == BlockReason.SECRETS_IN_FILE + assert call_args.kwargs['file_path'] == '/path/to/file.txt' @patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') From 097980b62a2a207c41734811434c06c8a97a26de Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Thu, 19 Feb 2026 09:23:43 +0000 Subject: [PATCH 015/123] CM 59844: update deps (#390) --- poetry.lock | 228 ++++++++- pyproject.toml | 4 +- tests/cli/apps/__init__.py | 0 tests/cli/apps/mcp/__init__.py | 0 tests/cli/apps/mcp/test_mcp_command.py | 315 ++++++++++++ tests/cyclient/test_client_base_exceptions.py | 162 +++++++ tests/test_models_deserialization.py | 451 ++++++++++++++++++ 7 files changed, 1140 insertions(+), 20 deletions(-) create mode 100644 tests/cli/apps/__init__.py create mode 100644 tests/cli/apps/mcp/__init__.py create mode 100644 tests/cli/apps/mcp/test_mcp_command.py create mode 100644 tests/cyclient/test_client_base_exceptions.py create mode 100644 tests/test_models_deserialization.py diff --git a/poetry.lock b/poetry.lock index b20290ed..807fb2f8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -107,6 +107,104 @@ files = [ {file = "certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43"}, ] +[[package]] +name = "cffi" +version = "2.0.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.10\" and platform_python_implementation != \"PyPy\"" +files = [ + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, + {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, + {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + [[package]] name = "chardet" version = "5.2.0" @@ -342,6 +440,80 @@ files = [ [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] +[[package]] +name = "cryptography" +version = "46.0.5" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.8" +groups = ["main"] +markers = "python_version >= \"3.10\"" +files = [ + {file = "cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731"}, + {file = "cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82"}, + {file = "cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1"}, + {file = "cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48"}, + {file = "cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4"}, + {file = "cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663"}, + {file = "cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826"}, + {file = "cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d"}, + {file = "cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a"}, + {file = "cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4"}, + {file = "cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c"}, + {file = "cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4"}, + {file = "cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9"}, + {file = "cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72"}, + {file = "cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595"}, + {file = "cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c"}, + {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a"}, + {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356"}, + {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da"}, + {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257"}, + {file = "cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7"}, + {file = "cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} +typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] +docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] +nox = ["nox[uv] (>=2024.4.15)"] +pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"] +sdist = ["build (>=1.0.0)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi (>=2024)", "cryptography-vectors (==46.0.5)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test-randomorder = ["pytest-randomly"] + [[package]] name = "dunamai" version = "1.21.2" @@ -620,35 +792,35 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] [[package]] name = "marshmallow" -version = "3.22.0" +version = "3.26.2" description = "A lightweight library for converting complex datatypes to and from native Python datatypes." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "marshmallow-3.22.0-py3-none-any.whl", hash = "sha256:71a2dce49ef901c3f97ed296ae5051135fd3febd2bf43afe0ae9a82143a494d9"}, - {file = "marshmallow-3.22.0.tar.gz", hash = "sha256:4972f529104a220bb8637d595aa4c9762afbe7f7a77d82dc58c1615d70c5823e"}, + {file = "marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73"}, + {file = "marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57"}, ] [package.dependencies] packaging = ">=17.0" [package.extras] -dev = ["marshmallow[tests]", "pre-commit (>=3.5,<4.0)", "tox"] -docs = ["alabaster (==1.0.0)", "autodocsumm (==0.2.13)", "sphinx (==8.0.2)", "sphinx-issues (==4.1.0)", "sphinx-version-warning (==1.1.2)"] -tests = ["pytest", "pytz", "simplejson"] +dev = ["marshmallow[tests]", "pre-commit (>=3.5,<5.0)", "tox"] +docs = ["autodocsumm (==0.2.14)", "furo (==2024.8.6)", "sphinx (==8.1.3)", "sphinx-copybutton (==0.5.2)", "sphinx-issues (==5.0.0)", "sphinxext-opengraph (==0.9.1)"] +tests = ["pytest", "simplejson"] [[package]] name = "mcp" -version = "1.18.0" +version = "1.26.0" description = "Model Context Protocol SDK" optional = false python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "mcp-1.18.0-py3-none-any.whl", hash = "sha256:42f10c270de18e7892fdf9da259029120b1ea23964ff688248c69db9d72b1d0a"}, - {file = "mcp-1.18.0.tar.gz", hash = "sha256:aa278c44b1efc0a297f53b68df865b988e52dd08182d702019edcf33a8e109f6"}, + {file = "mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca"}, + {file = "mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66"}, ] [package.dependencies] @@ -658,10 +830,13 @@ httpx-sse = ">=0.4" jsonschema = ">=4.20.0" pydantic = ">=2.11.0,<3.0.0" pydantic-settings = ">=2.5.2" +pyjwt = {version = ">=2.10.1", extras = ["crypto"]} python-multipart = ">=0.0.9" pywin32 = {version = ">=310", markers = "sys_platform == \"win32\""} sse-starlette = ">=1.6.1" starlette = ">=0.27" +typing-extensions = ">=4.9.0" +typing-inspection = ">=0.4.1" uvicorn = {version = ">=0.31.1", markers = "sys_platform != \"emscripten\""} [package.extras] @@ -767,6 +942,19 @@ files = [ dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] +[[package]] +name = "pycparser" +version = "3.0" +description = "C parser in Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "python_version >= \"3.10\" and platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" +files = [ + {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, + {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, +] + [[package]] name = "pydantic" version = "2.12.3" @@ -1038,6 +1226,9 @@ files = [ {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, ] +[package.dependencies] +cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} + [package.extras] crypto = ["cryptography (>=3.4.0)"] dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] @@ -1785,20 +1976,21 @@ typing-extensions = ">=4.12.0" [[package]] name = "urllib3" -version = "1.26.19" +version = "2.6.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +python-versions = ">=3.9" groups = ["main", "test"] files = [ - {file = "urllib3-1.26.19-py2.py3-none-any.whl", hash = "sha256:37a0344459b199fce0e80b0d3569837ec6b6937435c5244e7fd73fa6006830f3"}, - {file = "urllib3-1.26.19.tar.gz", hash = "sha256:3e3d753a8618b86d7de333b4223005f68720bcd6a7d2bcb9fbd2229ec7c1e429"}, + {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, + {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, ] [package.extras] -brotli = ["brotli (==1.0.9) ; os_name != \"nt\" and python_version < \"3\" and platform_python_implementation == \"CPython\"", "brotli (>=1.0.9) ; python_version >= \"3\" and platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; (os_name != \"nt\" or python_version >= \"3\") and platform_python_implementation != \"CPython\"", "brotlipy (>=0.6.0) ; os_name == \"nt\" and python_version < \"3\""] -secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress ; python_version == \"2.7\"", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] -socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "uvicorn" @@ -1845,4 +2037,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "d705f54b6e814ba9b361cda482e5f23a7fbd0a41ae652f76ece6bfb78b00973f" +content-hash = "593c613fcd6438e2133d90f3777c2050738bfa42bc7f5512e43c612b784a9870" diff --git a/pyproject.toml b/pyproject.toml index 06c69b28..cc6297c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,12 +36,12 @@ version = "0.0.0" # DON'T TOUCH. Placeholder. Will be filled automatically on po click = ">=8.1.0,<8.2.0" colorama = ">=0.4.3,<0.5.0" pyyaml = ">=6.0,<7.0" -marshmallow = ">=3.15.0,<3.23.0" # 3.23 dropped support for Python 3.8 +marshmallow = ">=3.15.0,<4.0.0" gitpython = ">=3.1.30,<3.2.0" arrow = ">=1.0.0,<1.4.0" binaryornot = ">=0.4.4,<0.5.0" requests = ">=2.32.4,<3.0" -urllib3 = "1.26.19" # lock v1 to avoid issues with openssl and old Python versions (<3.9.11) on macOS +urllib3 = ">=2.4.0,<3.0.0" pyjwt = ">=2.8.0,<3.0" rich = ">=13.9.4, <14" patch-ng = "1.18.1" diff --git a/tests/cli/apps/__init__.py b/tests/cli/apps/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/apps/mcp/__init__.py b/tests/cli/apps/mcp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/apps/mcp/test_mcp_command.py b/tests/cli/apps/mcp/test_mcp_command.py new file mode 100644 index 00000000..ebcc2373 --- /dev/null +++ b/tests/cli/apps/mcp/test_mcp_command.py @@ -0,0 +1,315 @@ +import json +import os +import sys +from unittest.mock import AsyncMock, patch + +import pytest + +if sys.version_info < (3, 10): + pytest.skip('MCP requires Python 3.10+', allow_module_level=True) + +from cycode.cli.apps.mcp.mcp_command import ( + _sanitize_file_path, + _TempFilesManager, +) + +pytestmark = pytest.mark.anyio + + +@pytest.fixture +def anyio_backend() -> str: + return 'asyncio' + + +# --- _sanitize_file_path input validation --- + + +def test_sanitize_file_path_rejects_empty_string() -> None: + with pytest.raises(ValueError, match='non-empty string'): + _sanitize_file_path('') + + +def test_sanitize_file_path_rejects_none() -> None: + with pytest.raises(ValueError, match='non-empty string'): + _sanitize_file_path(None) + + +def test_sanitize_file_path_rejects_non_string() -> None: + with pytest.raises(ValueError, match='non-empty string'): + _sanitize_file_path(123) + + +def test_sanitize_file_path_strips_null_bytes() -> None: + result = _sanitize_file_path('foo/bar\x00baz.py') + assert '\x00' not in result + + +def test_sanitize_file_path_passes_valid_path_through() -> None: + result = _sanitize_file_path('src/main.py') + assert os.path.normpath(result) == os.path.normpath('src/main.py') + + +# --- _TempFilesManager: path traversal prevention --- +# +# _sanitize_file_path delegates to pathvalidate which does NOT block +# path traversal (../ passes through). The real security boundary is +# the normpath containment check in _TempFilesManager.__enter__ (lines 136-139). +# These tests verify that the two layers together prevent escaping the temp dir. + + +def test_traversal_simple_dotdot_rejected() -> None: + """../../../etc/passwd must not escape the temp directory.""" + files = { + '../../../etc/passwd': 'malicious', + 'safe.py': 'ok', + } + with _TempFilesManager(files, 'test-traversal') as temp_files: + assert len(temp_files) == 1 + assert temp_files[0].endswith('safe.py') + for tf in temp_files: + assert '/etc/passwd' not in tf + + +def test_traversal_backslash_dotdot_rejected() -> None: + """..\\..\\windows\\system32 must not escape the temp directory.""" + files = { + '..\\..\\windows\\system32\\config': 'malicious', + 'safe.py': 'ok', + } + with _TempFilesManager(files, 'test-backslash') as temp_files: + assert len(temp_files) == 1 + assert temp_files[0].endswith('safe.py') + + +def test_traversal_embedded_dotdot_rejected() -> None: + """foo/../../../etc/passwd resolves outside temp dir and must be rejected.""" + files = { + 'foo/../../../etc/passwd': 'malicious', + 'safe.py': 'ok', + } + with _TempFilesManager(files, 'test-embedded') as temp_files: + assert len(temp_files) == 1 + assert temp_files[0].endswith('safe.py') + + +def test_traversal_absolute_path_rejected() -> None: + """Absolute paths must not be written outside the temp directory.""" + files = { + '/etc/passwd': 'malicious', + 'safe.py': 'ok', + } + with _TempFilesManager(files, 'test-absolute') as temp_files: + assert len(temp_files) == 1 + assert temp_files[0].endswith('safe.py') + + +def test_traversal_dotdot_only_rejected() -> None: + """A bare '..' path must be rejected.""" + files = { + '..': 'malicious', + 'safe.py': 'ok', + } + with _TempFilesManager(files, 'test-bare-dotdot') as temp_files: + assert len(temp_files) == 1 + + +def test_traversal_all_malicious_raises() -> None: + """If every file path is a traversal attempt, no files are created and ValueError is raised.""" + files = { + '../../../etc/passwd': 'malicious', + '../../shadow': 'also malicious', + } + with pytest.raises(ValueError, match='No valid files'), _TempFilesManager(files, 'test-all-malicious'): + pass + + +def test_all_created_files_are_inside_temp_dir() -> None: + """Every created file must be under the temp base directory.""" + files = { + 'a.py': 'aaa', + 'sub/b.py': 'bbb', + 'sub/deep/c.py': 'ccc', + } + manager = _TempFilesManager(files, 'test-containment') + with manager as temp_files: + base = os.path.normcase(os.path.normpath(manager.temp_base_dir)) + for tf in temp_files: + normalized = os.path.normcase(os.path.normpath(tf)) + assert normalized.startswith(base + os.sep), f'{tf} escaped temp dir {base}' + + +def test_mixed_valid_and_traversal_only_creates_valid() -> None: + """Valid files are created, traversal attempts are silently skipped.""" + files = { + '../escape.py': 'bad', + 'legit.py': 'good', + 'foo/../../escape2.py': 'bad', + 'src/app.py': 'good', + } + manager = _TempFilesManager(files, 'test-mixed') + with manager as temp_files: + base = os.path.normcase(os.path.normpath(manager.temp_base_dir)) + assert len(temp_files) == 2 + for tf in temp_files: + assert os.path.normcase(os.path.normpath(tf)).startswith(base + os.sep) + basenames = [os.path.basename(tf) for tf in temp_files] + assert 'legit.py' in basenames + assert 'app.py' in basenames + + +# --- _TempFilesManager: general functionality --- + + +def test_temp_files_manager_creates_files() -> None: + files = { + 'test1.py': 'print("hello")', + 'subdir/test2.js': 'console.log("world")', + } + with _TempFilesManager(files, 'test-call-id') as temp_files: + assert len(temp_files) == 2 + for tf in temp_files: + assert os.path.exists(tf) + + +def test_temp_files_manager_writes_correct_content() -> None: + files = {'hello.py': 'print("hello world")'} + with _TempFilesManager(files, 'test-content') as temp_files, open(temp_files[0]) as f: + assert f.read() == 'print("hello world")' + + +def test_temp_files_manager_cleans_up_on_exit() -> None: + files = {'cleanup.py': 'code'} + manager = _TempFilesManager(files, 'test-cleanup') + with manager as temp_files: + temp_dir = manager.temp_base_dir + assert os.path.exists(temp_dir) + assert len(temp_files) == 1 + assert not os.path.exists(temp_dir) + + +def test_temp_files_manager_empty_path_raises() -> None: + files = {'': 'empty path'} + with pytest.raises(ValueError, match='No valid files'), _TempFilesManager(files, 'test-empty-path'): + pass + + +def test_temp_files_manager_preserves_subdirectory_structure() -> None: + files = { + 'src/main.py': 'main', + 'src/utils/helper.py': 'helper', + } + with _TempFilesManager(files, 'test-dirs') as temp_files: + assert len(temp_files) == 2 + paths = [os.path.basename(tf) for tf in temp_files] + assert 'main.py' in paths + assert 'helper.py' in paths + + +# --- _run_cycode_command (async) --- + + +@pytest.mark.anyio +async def test_run_cycode_command_returns_dict() -> None: + from cycode.cli.apps.mcp.mcp_command import _run_cycode_command + + mock_process = AsyncMock() + mock_process.communicate.return_value = (b'', b'error output') + mock_process.returncode = 1 + + with patch('asyncio.create_subprocess_exec', return_value=mock_process): + result = await _run_cycode_command('--invalid-flag-for-test') + assert isinstance(result, dict) + assert 'error' in result + + +@pytest.mark.anyio +async def test_run_cycode_command_parses_json_output() -> None: + from cycode.cli.apps.mcp.mcp_command import _run_cycode_command + + mock_process = AsyncMock() + mock_process.communicate.return_value = (b'{"status": "ok"}', b'') + mock_process.returncode = 0 + + with patch('asyncio.create_subprocess_exec', return_value=mock_process): + result = await _run_cycode_command('version') + assert result == {'status': 'ok'} + + +@pytest.mark.anyio +async def test_run_cycode_command_handles_invalid_json() -> None: + from cycode.cli.apps.mcp.mcp_command import _run_cycode_command + + mock_process = AsyncMock() + mock_process.communicate.return_value = (b'not json{', b'') + mock_process.returncode = 0 + + with patch('asyncio.create_subprocess_exec', return_value=mock_process): + result = await _run_cycode_command('version') + assert result['error'] == 'Failed to parse JSON output' + + +@pytest.mark.anyio +async def test_run_cycode_command_timeout() -> None: + import asyncio + + from cycode.cli.apps.mcp.mcp_command import _run_cycode_command + + async def slow_communicate() -> tuple[bytes, bytes]: + await asyncio.sleep(10) + return b'', b'' + + mock_process = AsyncMock() + mock_process.communicate = slow_communicate + + with patch('asyncio.create_subprocess_exec', return_value=mock_process): + result = await _run_cycode_command('status', timeout=0.001) + assert isinstance(result, dict) + assert 'error' in result + assert 'timeout' in result['error'].lower() + + +# --- _cycode_scan_tool --- + + +@pytest.mark.anyio +async def test_cycode_scan_tool_no_files() -> None: + from cycode.cli.apps.mcp.mcp_command import _cycode_scan_tool + from cycode.cli.cli_types import ScanTypeOption + + result = await _cycode_scan_tool(ScanTypeOption.SECRET, {}) + parsed = json.loads(result) + assert 'error' in parsed + assert 'No files provided' in parsed['error'] + + +@pytest.mark.anyio +async def test_cycode_scan_tool_invalid_files() -> None: + from cycode.cli.apps.mcp.mcp_command import _cycode_scan_tool + from cycode.cli.cli_types import ScanTypeOption + + result = await _cycode_scan_tool(ScanTypeOption.SECRET, {'': 'content'}) + parsed = json.loads(result) + assert 'error' in parsed + + +# --- _create_mcp_server --- + + +def test_create_mcp_server() -> None: + from cycode.cli.apps.mcp.mcp_command import _create_mcp_server + + server = _create_mcp_server('127.0.0.1', 8000) + assert server is not None + assert server.name == 'cycode' + + +def test_create_mcp_server_registers_tools() -> None: + from cycode.cli.apps.mcp.mcp_command import _create_mcp_server + + server = _create_mcp_server('127.0.0.1', 8000) + tool_names = [t.name for t in server._tool_manager._tools.values()] + assert 'cycode_status' in tool_names + assert 'cycode_secret_scan' in tool_names + assert 'cycode_sca_scan' in tool_names + assert 'cycode_iac_scan' in tool_names + assert 'cycode_sast_scan' in tool_names diff --git a/tests/cyclient/test_client_base_exceptions.py b/tests/cyclient/test_client_base_exceptions.py new file mode 100644 index 00000000..f99453d3 --- /dev/null +++ b/tests/cyclient/test_client_base_exceptions.py @@ -0,0 +1,162 @@ +from unittest.mock import MagicMock + +import pytest +import responses +from requests.exceptions import ( + ConnectionError as RequestsConnectionError, +) +from requests.exceptions import ( + HTTPError, + SSLError, + Timeout, +) + +from cycode.cli.exceptions.custom_exceptions import ( + HttpUnauthorizedError, + RequestConnectionError, + RequestHttpError, + RequestSslError, + RequestTimeoutError, +) +from cycode.cyclient import config +from cycode.cyclient.cycode_client_base import CycodeClientBase + + +def _make_client() -> CycodeClientBase: + return CycodeClientBase(config.cycode_api_url) + + +# --- _handle_exception mapping --- + + +def test_handle_exception_timeout() -> None: + client = _make_client() + with pytest.raises(RequestTimeoutError): + client._handle_exception(Timeout('timed out')) + + +def test_handle_exception_ssl_error() -> None: + client = _make_client() + with pytest.raises(RequestSslError): + client._handle_exception(SSLError('cert verify failed')) + + +def test_handle_exception_connection_error() -> None: + client = _make_client() + with pytest.raises(RequestConnectionError): + client._handle_exception(RequestsConnectionError('refused')) + + +def test_handle_exception_http_error_401() -> None: + response = MagicMock() + response.status_code = 401 + response.text = 'Unauthorized' + error = HTTPError(response=response) + + client = _make_client() + with pytest.raises(HttpUnauthorizedError): + client._handle_exception(error) + + +def test_handle_exception_http_error_500() -> None: + response = MagicMock() + response.status_code = 500 + response.text = 'Internal Server Error' + error = HTTPError(response=response) + + client = _make_client() + with pytest.raises(RequestHttpError) as exc_info: + client._handle_exception(error) + assert exc_info.value.status_code == 500 + + +def test_handle_exception_unknown_error_reraises() -> None: + client = _make_client() + with pytest.raises(RuntimeError, match='something unexpected'): + client._handle_exception(RuntimeError('something unexpected')) + + +# --- HTTP integration via responses mock --- + + +@responses.activate +def test_get_returns_response_on_success() -> None: + client = _make_client() + url = f'{client.api_url}/test-endpoint' + responses.add(responses.GET, url, json={'ok': True}, status=200) + + response = client.get('test-endpoint') + assert response.status_code == 200 + assert response.json() == {'ok': True} + + +@responses.activate +def test_post_returns_response_on_success() -> None: + client = _make_client() + url = f'{client.api_url}/test-endpoint' + responses.add(responses.POST, url, json={'created': True}, status=201) + + response = client.post('test-endpoint', body={'data': 'value'}) + assert response.status_code == 201 + + +@responses.activate +def test_get_raises_timeout_error() -> None: + client = _make_client() + url = f'{client.api_url}/slow-endpoint' + responses.add(responses.GET, url, body=Timeout('Connection timed out')) + + with pytest.raises(RequestTimeoutError): + client.get('slow-endpoint') + + +@responses.activate +def test_get_raises_ssl_error() -> None: + client = _make_client() + url = f'{client.api_url}/ssl-endpoint' + responses.add(responses.GET, url, body=SSLError('certificate verify failed')) + + with pytest.raises(RequestSslError): + client.get('ssl-endpoint') + + +@responses.activate +def test_get_raises_connection_error() -> None: + client = _make_client() + url = f'{client.api_url}/down-endpoint' + responses.add(responses.GET, url, body=RequestsConnectionError('Connection refused')) + + with pytest.raises(RequestConnectionError): + client.get('down-endpoint') + + +@responses.activate +def test_get_raises_http_unauthorized_error() -> None: + client = _make_client() + url = f'{client.api_url}/auth-endpoint' + responses.add(responses.GET, url, json={'error': 'unauthorized'}, status=401) + + with pytest.raises(HttpUnauthorizedError): + client.get('auth-endpoint') + + +@responses.activate +def test_get_raises_http_error_on_500() -> None: + client = _make_client() + url = f'{client.api_url}/error-endpoint' + responses.add(responses.GET, url, json={'error': 'server error'}, status=500) + + with pytest.raises(RequestHttpError) as exc_info: + client.get('error-endpoint') + assert exc_info.value.status_code == 500 + + +@responses.activate +def test_get_raises_http_error_on_403() -> None: + client = _make_client() + url = f'{client.api_url}/forbidden-endpoint' + responses.add(responses.GET, url, json={'error': 'forbidden'}, status=403) + + with pytest.raises(RequestHttpError) as exc_info: + client.get('forbidden-endpoint') + assert exc_info.value.status_code == 403 diff --git a/tests/test_models_deserialization.py b/tests/test_models_deserialization.py new file mode 100644 index 00000000..4c7dcd72 --- /dev/null +++ b/tests/test_models_deserialization.py @@ -0,0 +1,451 @@ +from cycode.cyclient.models import ( + ApiToken, + ApiTokenGenerationPollingResponse, + ApiTokenGenerationPollingResponseSchema, + ApiTokenSchema, + AuthenticationSession, + AuthenticationSessionSchema, + ClassificationData, + ClassificationDataSchema, + Detection, + DetectionRule, + DetectionRuleSchema, + DetectionSchema, + Member, + MemberDetails, + MemberSchema, + ReportExecution, + ReportExecutionSchema, + RequestedMemberDetailsResultSchema, + RequestedSbomReportResultSchema, + SbomReport, + SbomReportStorageDetails, + SbomReportStorageDetailsSchema, + ScanConfiguration, + ScanConfigurationSchema, + ScanInitializationResponse, + ScanInitializationResponseSchema, + ScanResult, + ScanResultSchema, + ScanResultsSyncFlow, + ScanResultsSyncFlowSchema, + SupportedModulesPreferences, + SupportedModulesPreferencesSchema, + UserAgentOption, + UserAgentOptionScheme, +) + +# --- DetectionSchema --- + + +def test_detection_schema_load() -> None: + raw = { + 'id': 'det-123', + 'message': 'API key exposed', + 'type': 'secret', + 'severity': 'critical', + 'detection_type_id': 'secret-1', + 'detection_details': {'alert': True, 'value': 'sk_live_xxx'}, + 'detection_rule_id': 'rule-456', + } + result = DetectionSchema().load(raw) + assert isinstance(result, Detection) + assert result.id == 'det-123' + assert result.message == 'API key exposed' + assert result.type == 'secret' + assert result.severity == 'critical' + assert result.detection_type_id == 'secret-1' + assert result.detection_details == {'alert': True, 'value': 'sk_live_xxx'} + assert result.detection_rule_id == 'rule-456' + + +def test_detection_schema_load_defaults() -> None: + raw = { + 'message': 'Vulnerability found', + 'type': 'sca', + 'detection_type_id': 'vuln-1', + 'detection_details': {}, + 'detection_rule_id': 'rule-789', + } + result = DetectionSchema().load(raw) + assert result.id is None + assert result.severity is None + + +def test_detection_schema_excludes_unknown_fields() -> None: + raw = { + 'message': 'Test', + 'type': 'test', + 'detection_type_id': 'test-1', + 'detection_details': {}, + 'detection_rule_id': 'test-rule', + 'unknown_field': 'should_be_ignored', + 'another_unknown': 123, + } + result = DetectionSchema().load(raw) + assert isinstance(result, Detection) + assert not hasattr(result, 'unknown_field') + + +def test_detection_has_alert_true() -> None: + detection = Detection( + detection_type_id='secret-1', + type='secret', + message='Key found', + detection_details={'alert': {'severity': 'high'}}, + detection_rule_id='rule-1', + ) + assert detection.has_alert is True + + +def test_detection_has_alert_false() -> None: + detection = Detection( + detection_type_id='license-1', + type='sca', + message='License issue', + detection_details={'license': 'GPL'}, + detection_rule_id='rule-2', + ) + assert detection.has_alert is False + + +def test_detection_repr() -> None: + detection = Detection( + detection_type_id='secret-1', + type='secret', + message='API key exposed', + detection_details={'value': 'sk_live_xxx'}, + detection_rule_id='rule-1', + severity='critical', + ) + repr_str = repr(detection) + assert 'secret' in repr_str + assert 'critical' in repr_str + assert 'API key exposed' in repr_str + assert 'rule-1' in repr_str + + +# --- ScanResultSchema --- + + +def test_scan_result_schema_load_with_detections() -> None: + raw = { + 'did_detect': True, + 'scan_id': 'scan-abc', + 'detections': [ + { + 'id': 'det-1', + 'message': 'Secret found', + 'type': 'secret', + 'detection_type_id': 'secret-1', + 'detection_details': {'alert': {}}, + 'detection_rule_id': 'rule-1', + } + ], + 'err': '', + } + result = ScanResultSchema().load(raw) + assert isinstance(result, ScanResult) + assert result.did_detect is True + assert result.scan_id == 'scan-abc' + assert len(result.detections) == 1 + assert isinstance(result.detections[0], Detection) + assert result.detections[0].id == 'det-1' + + +def test_scan_result_schema_load_no_detections() -> None: + raw = { + 'did_detect': False, + 'scan_id': 'scan-def', + 'detections': None, + 'err': 'No files to scan', + } + result = ScanResultSchema().load(raw) + assert result.did_detect is False + assert result.detections is None + assert result.err == 'No files to scan' + + +def test_scan_result_schema_excludes_unknown_fields() -> None: + raw = { + 'did_detect': False, + 'scan_id': 'scan-1', + 'detections': None, + 'err': '', + 'extra_field': 'ignored', + } + result = ScanResultSchema().load(raw) + assert isinstance(result, ScanResult) + + +# --- ScanInitializationResponseSchema --- + + +def test_scan_initialization_response_schema_load() -> None: + raw = {'scan_id': 'scan-init-123', 'err': ''} + result = ScanInitializationResponseSchema().load(raw) + assert isinstance(result, ScanInitializationResponse) + assert result.scan_id == 'scan-init-123' + + +# --- AuthenticationSessionSchema --- + + +def test_authentication_session_schema_load() -> None: + raw = {'session_id': 'sess-123'} + result = AuthenticationSessionSchema().load(raw) + assert isinstance(result, AuthenticationSession) + assert result.session_id == 'sess-123' + + +# --- ApiTokenSchema (tests data_key mapping) --- + + +def test_api_token_schema_load_data_key() -> None: + raw = { + 'clientId': 'client-123', + 'secret': 'secret-456', + 'description': 'My API Token', + } + result = ApiTokenSchema().load(raw) + assert isinstance(result, ApiToken) + assert result.client_id == 'client-123' + assert result.secret == 'secret-456' + assert result.description == 'My API Token' + + +# --- ApiTokenGenerationPollingResponseSchema (nested) --- + + +def test_api_token_generation_polling_schema_load() -> None: + raw = { + 'status': 'completed', + 'api_token': { + 'clientId': 'client-abc', + 'secret': 'secret-xyz', + 'description': 'Generated token', + }, + } + result = ApiTokenGenerationPollingResponseSchema().load(raw) + assert isinstance(result, ApiTokenGenerationPollingResponse) + assert result.status == 'completed' + assert isinstance(result.api_token, ApiToken) + assert result.api_token.client_id == 'client-abc' + + +def test_api_token_generation_polling_schema_load_null_token() -> None: + raw = { + 'status': 'pending', + 'api_token': None, + } + result = ApiTokenGenerationPollingResponseSchema().load(raw) + assert result.status == 'pending' + assert result.api_token is None + + +# --- SbomReportStorageDetailsSchema / ReportExecutionSchema / RequestedSbomReportResultSchema --- + + +def test_sbom_report_storage_details_schema_load() -> None: + raw = {'path': '/reports/sbom.json', 'folder': '/reports', 'size': 4096} + result = SbomReportStorageDetailsSchema().load(raw) + assert isinstance(result, SbomReportStorageDetails) + assert result.path == '/reports/sbom.json' + assert result.size == 4096 + + +def test_report_execution_schema_load() -> None: + raw = { + 'id': 1, + 'status': 'completed', + 'error_message': None, + 'status_message': 'Success', + 'storage_details': {'path': '/reports/sbom.json', 'folder': '/reports', 'size': 4096}, + } + result = ReportExecutionSchema().load(raw) + assert isinstance(result, ReportExecution) + assert result.id == 1 + assert result.status == 'completed' + assert isinstance(result.storage_details, SbomReportStorageDetails) + + +def test_requested_sbom_report_result_schema_load() -> None: + raw = { + 'report_executions': [ + { + 'id': 1, + 'status': 'completed', + 'error_message': None, + 'status_message': 'Done', + 'storage_details': {'path': '/r/sbom.json', 'folder': '/r', 'size': 1024}, + }, + { + 'id': 2, + 'status': 'failed', + 'error_message': 'Timeout', + 'status_message': None, + 'storage_details': None, + }, + ] + } + result = RequestedSbomReportResultSchema().load(raw) + assert isinstance(result, SbomReport) + assert len(result.report_executions) == 2 + assert result.report_executions[0].storage_details.path == '/r/sbom.json' + assert result.report_executions[1].error_message == 'Timeout' + assert result.report_executions[1].storage_details is None + + +# --- UserAgentOptionScheme --- + + +def test_user_agent_option_schema_load() -> None: + raw = { + 'app_name': 'vscode_extension', + 'app_version': '0.2.3', + 'env_name': 'Visual Studio Code', + 'env_version': '1.78.2', + } + result = UserAgentOptionScheme().load(raw) + assert isinstance(result, UserAgentOption) + assert result.app_name == 'vscode_extension' + assert 'vscode_extension' in result.user_agent_suffix + assert 'AppVersion: 0.2.3' in result.user_agent_suffix + + +# --- MemberSchema / RequestedMemberDetailsResultSchema --- + + +def test_member_schema_load() -> None: + raw = {'external_id': 'user-ext-123'} + result = MemberSchema().load(raw) + assert isinstance(result, Member) + assert result.external_id == 'user-ext-123' + + +def test_requested_member_details_schema_load() -> None: + raw = { + 'items': [{'external_id': 'u1'}, {'external_id': 'u2'}], + 'page_size': 50, + 'next_page_token': 'token-abc', + } + result = RequestedMemberDetailsResultSchema().load(raw) + assert isinstance(result, MemberDetails) + assert len(result.items) == 2 + assert result.page_size == 50 + assert result.next_page_token == 'token-abc' + + +def test_requested_member_details_schema_load_null_token() -> None: + raw = { + 'items': [], + 'page_size': 50, + 'next_page_token': None, + } + result = RequestedMemberDetailsResultSchema().load(raw) + assert result.next_page_token is None + + +# --- ClassificationDataSchema / DetectionRuleSchema --- + + +def test_classification_data_schema_load() -> None: + raw = {'severity': 'high'} + result = ClassificationDataSchema().load(raw) + assert isinstance(result, ClassificationData) + assert result.severity == 'high' + + +def test_detection_rule_schema_load() -> None: + raw = { + 'classification_data': [{'severity': 'high'}, {'severity': 'medium'}], + 'detection_rule_id': 'rule-123', + 'custom_remediation_guidelines': 'Rotate the key', + 'remediation_guidelines': 'See docs', + 'description': 'Exposed API key', + 'policy_name': 'secrets-policy', + 'display_name': 'API Key Exposure', + } + result = DetectionRuleSchema().load(raw) + assert isinstance(result, DetectionRule) + assert len(result.classification_data) == 2 + assert result.classification_data[0].severity == 'high' + assert result.detection_rule_id == 'rule-123' + assert result.custom_remediation_guidelines == 'Rotate the key' + + +def test_detection_rule_schema_load_optional_nulls() -> None: + raw = { + 'classification_data': [{'severity': 'low'}], + 'detection_rule_id': 'rule-456', + 'custom_remediation_guidelines': None, + 'remediation_guidelines': None, + 'description': None, + 'policy_name': None, + 'display_name': None, + } + result = DetectionRuleSchema().load(raw) + assert result.custom_remediation_guidelines is None + assert result.display_name is None + + +# --- ScanResultsSyncFlowSchema --- + + +def test_scan_results_sync_flow_schema_load() -> None: + raw = { + 'id': 'sync-123', + 'detection_messages': [{'msg': 'found secret'}, {'msg': 'found vuln'}], + } + result = ScanResultsSyncFlowSchema().load(raw) + assert isinstance(result, ScanResultsSyncFlow) + assert result.id == 'sync-123' + assert len(result.detection_messages) == 2 + + +# --- SupportedModulesPreferencesSchema --- + + +def test_supported_modules_preferences_schema_load() -> None: + raw = { + 'secret_scanning': True, + 'leak_scanning': True, + 'iac_scanning': False, + 'sca_scanning': True, + 'ci_cd_scanning': False, + 'sast_scanning': True, + 'container_scanning': False, + 'access_review': True, + 'asoc': False, + 'cimon': True, + 'ai_machine_learning': True, + 'ai_large_language_model': False, + } + result = SupportedModulesPreferencesSchema().load(raw) + assert isinstance(result, SupportedModulesPreferences) + assert result.secret_scanning is True + assert result.iac_scanning is False + assert result.ai_large_language_model is False + + +# --- ScanConfigurationSchema --- + + +def test_scan_configuration_schema_load() -> None: + raw = { + 'scannable_extensions': ['.py', '.js', '.ts'], + 'is_cycode_ignore_allowed': True, + } + result = ScanConfigurationSchema().load(raw) + assert isinstance(result, ScanConfiguration) + assert result.scannable_extensions == ['.py', '.js', '.ts'] + assert result.is_cycode_ignore_allowed is True + + +def test_scan_configuration_schema_load_defaults() -> None: + raw = { + 'scannable_extensions': None, + } + result = ScanConfigurationSchema().load(raw) + assert result.scannable_extensions is None + assert result.is_cycode_ignore_allowed is True # load_default=True From f55dfbedb166168002e4e9e531a135b13de21b28 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 09:37:37 +0000 Subject: [PATCH 016/123] Bump starlette from 0.48.0 to 0.49.1 (#355) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/poetry.lock b/poetry.lock index 807fb2f8..9a11262a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "altgraph" @@ -31,8 +31,7 @@ version = "4.11.0" description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.9" -groups = ["main"] -markers = "python_version >= \"3.10\"" +groups = ["main", "dev"] files = [ {file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"}, {file = "anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4"}, @@ -535,12 +534,12 @@ version = "1.3.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["main", "test"] +groups = ["main", "dev", "test"] +markers = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, ] -markers = {main = "python_version == \"3.10\"", test = "python_version < \"3.11\""} [package.dependencies] typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} @@ -664,7 +663,7 @@ version = "3.11" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" -groups = ["main", "test"] +groups = ["main", "dev", "test"] files = [ {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, @@ -1786,8 +1785,7 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" -groups = ["main"] -markers = "python_version >= \"3.10\"" +groups = ["main", "dev"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -1817,15 +1815,14 @@ uvicorn = ["uvicorn (>=0.34.0)"] [[package]] name = "starlette" -version = "0.48.0" +version = "0.49.1" description = "The little ASGI library that shines." optional = false python-versions = ">=3.9" -groups = ["main"] -markers = "python_version >= \"3.10\"" +groups = ["main", "dev"] files = [ - {file = "starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659"}, - {file = "starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46"}, + {file = "starlette-0.49.1-py3-none-any.whl", hash = "sha256:d92ce9f07e4a3caa3ac13a79523bd18e3bc0042bb8ff2d759a8e7dd0e1859875"}, + {file = "starlette-0.49.1.tar.gz", hash = "sha256:481a43b71e24ed8c43b11ea02f5353d77840e01480881b8cb5a26b8cae64a8cb"}, ] [package.dependencies] @@ -1952,12 +1949,12 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["main", "test"] +groups = ["main", "dev", "test"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] -markers = {test = "python_version < \"3.11\""} +markers = {dev = "python_version < \"3.13\"", test = "python_version < \"3.11\""} [[package]] name = "typing-inspection" From 51e89619c07aef6151e4130aed46a19188961d8b Mon Sep 17 00:00:00 2001 From: omerr-cycode Date: Mon, 23 Feb 2026 08:54:28 +0200 Subject: [PATCH 017/123] CM-59965 add additional logging for SCA verbose mode (#392) --- .../sca/base_restore_dependencies.py | 29 ++++++++++++++++++- .../sca/go/restore_go_dependencies.py | 4 ++- .../files_collector/sca/sca_file_collector.py | 17 +++++++++-- cycode/cli/utils/shell_executor.py | 17 +++++++++-- 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/cycode/cli/files_collector/sca/base_restore_dependencies.py b/cycode/cli/files_collector/sca/base_restore_dependencies.py index 80ef4183..7e69a0d9 100644 --- a/cycode/cli/files_collector/sca/base_restore_dependencies.py +++ b/cycode/cli/files_collector/sca/base_restore_dependencies.py @@ -7,6 +7,9 @@ from cycode.cli.models import Document from cycode.cli.utils.path_utils import get_file_content, get_file_dir, get_path_from_context, join_paths from cycode.cli.utils.shell_executor import shell +from cycode.logger import get_logger + +logger = get_logger('SCA Restore') def build_dep_tree_path(path: str, generated_file_name: str) -> str: @@ -19,6 +22,16 @@ def execute_commands( output_file_path: Optional[str] = None, working_directory: Optional[str] = None, ) -> Optional[str]: + logger.debug( + 'Executing restore commands, %s', + { + 'commands_count': len(commands), + 'timeout_sec': timeout, + 'working_directory': working_directory, + 'output_file_path': output_file_path, + }, + ) + try: outputs = [] @@ -32,7 +45,8 @@ def execute_commands( if output_file_path: with open(output_file_path, 'w', encoding='UTF-8') as output_file: output_file.writelines(joined_output) - except Exception: + except Exception as e: + logger.debug('Unexpected error during command execution', exc_info=e) return None return joined_output @@ -75,8 +89,21 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: ) if output is None: # one of the commands failed return None + else: + logger.debug( + 'Lock file already exists, skipping restore commands, %s', + {'restore_file_path': restore_file_path}, + ) restore_file_content = get_file_content(restore_file_path) + logger.debug( + 'Restore file loaded, %s', + { + 'restore_file_path': restore_file_path, + 'content_size': len(restore_file_content) if restore_file_content else 0, + 'content_empty': not restore_file_content, + }, + ) return Document(relative_restore_file_path, restore_file_content, self.is_git_diff) def get_working_directory(self, document: Document) -> Optional[str]: diff --git a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py index 7c24e330..fc94eb03 100644 --- a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py +++ b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py @@ -4,8 +4,10 @@ import typer from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies -from cycode.cli.logger import logger from cycode.cli.models import Document +from cycode.logger import get_logger + +logger = get_logger('Go Restore Dependencies') GO_PROJECT_FILE_EXTENSIONS = ['.mod', '.sum'] GO_RESTORE_FILE_NAME = 'go.mod.graph' diff --git a/cycode/cli/files_collector/sca/sca_file_collector.py b/cycode/cli/files_collector/sca/sca_file_collector.py index 41f70316..801b5d6f 100644 --- a/cycode/cli/files_collector/sca/sca_file_collector.py +++ b/cycode/cli/files_collector/sca/sca_file_collector.py @@ -106,11 +106,17 @@ def _try_restore_dependencies( restore_dependencies_document = restore_dependencies.restore(document) if restore_dependencies_document is None: - logger.warning('Error occurred while trying to generate dependencies tree, %s', {'filename': document.path}) + logger.warning( + 'Error occurred while trying to generate dependencies tree, %s', + {'filename': document.path, 'handler': type(restore_dependencies).__name__}, + ) return None if restore_dependencies_document.content is None: - logger.warning('Error occurred while trying to generate dependencies tree, %s', {'filename': document.path}) + logger.warning( + 'Error occurred while trying to generate dependencies tree, %s', + {'filename': document.path, 'handler': type(restore_dependencies).__name__}, + ) restore_dependencies_document.content = '' else: is_monitor_action = ctx.obj.get('monitor', False) @@ -124,6 +130,13 @@ def _try_restore_dependencies( def _get_restore_handlers(ctx: typer.Context, is_git_diff: bool) -> list[BaseRestoreDependencies]: build_dep_tree_timeout = int(os.getenv('CYCODE_BUILD_DEP_TREE_TIMEOUT_SECONDS', BUILD_DEP_TREE_TIMEOUT)) + logger.debug( + 'SCA restore handler timeout, %s', + { + 'timeout_sec': build_dep_tree_timeout, + 'source': 'env' if os.getenv('CYCODE_BUILD_DEP_TREE_TIMEOUT_SECONDS') else 'default', + }, + ) return [ RestoreGradleDependencies(ctx, is_git_diff, build_dep_tree_timeout), RestoreMavenDependencies(ctx, is_git_diff, build_dep_tree_timeout), diff --git a/cycode/cli/utils/shell_executor.py b/cycode/cli/utils/shell_executor.py index 2529890b..b39d2a0b 100644 --- a/cycode/cli/utils/shell_executor.py +++ b/cycode/cli/utils/shell_executor.py @@ -1,4 +1,5 @@ import subprocess +import time from typing import Optional, Union import click @@ -21,15 +22,27 @@ def shell( logger.debug('Executing shell command: %s', command) try: + start = time.monotonic() result = subprocess.run( # noqa: S603 command, cwd=working_directory, timeout=timeout, check=True, capture_output=True ) - logger.debug('Shell command executed successfully') + duration_sec = round(time.monotonic() - start, 2) + stdout = result.stdout.decode('UTF-8').strip() + stderr = result.stderr.decode('UTF-8').strip() - return result.stdout.decode('UTF-8').strip() + logger.debug( + 'Shell command executed successfully, %s', + {'duration_sec': duration_sec, 'stdout': stdout if stdout else '', 'stderr': stderr if stderr else ''}, + ) + + return stdout except subprocess.CalledProcessError as e: if not silent_exc_info: logger.debug('Error occurred while running shell command', exc_info=e) + if e.stdout: + logger.debug('Shell command stdout: %s', e.stdout.decode('UTF-8').strip()) + if e.stderr: + logger.debug('Shell command stderr: %s', e.stderr.decode('UTF-8').strip()) except subprocess.TimeoutExpired as e: logger.debug('Command timed out', exc_info=e) raise typer.Abort(f'Command "{command}" timed out') from e From 457022c99a6e927ae63d1fe0967b2ee9e2464991 Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Mon, 23 Feb 2026 08:36:13 +0000 Subject: [PATCH 018/123] CM-53930: improve notarization output (#391) --- .github/workflows/build_executable.yml | 35 ++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 333427a3..1f1e2582 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -127,6 +127,22 @@ jobs: - name: Test executable run: time $PATH_TO_CYCODE_CLI_EXECUTABLE version + - name: Codesign onedir binaries + if: runner.os == 'macOS' && matrix.mode == 'onedir' + env: + APPLE_CERT_NAME: ${{ secrets.APPLE_CERT_NAME }} + run: | + # Sign all Mach-O binaries in the onedir output (excluding the main executable) + # Main executable must be signed last after all its dependencies + find dist/cycode-cli -type f ! -name "cycode-cli" | while read -r file; do + if file -b "$file" | grep -q "Mach-O"; then + codesign --force --sign "$APPLE_CERT_NAME" --timestamp --options runtime "$file" + fi + done + + # Re-sign the main executable with entitlements (must be last) + codesign --force --sign "$APPLE_CERT_NAME" --timestamp --options runtime --entitlements entitlements.plist dist/cycode-cli/cycode-cli + - name: Notarize macOS executable if: runner.os == 'macOS' env: @@ -137,11 +153,26 @@ jobs: # create keychain profile xcrun notarytool store-credentials "notarytool-profile" --apple-id "$APPLE_NOTARIZATION_EMAIL" --team-id "$APPLE_NOTARIZATION_TEAM_ID" --password "$APPLE_NOTARIZATION_PWD" - # create zip file (notarization does not support binaries) + # create zip file (notarization does not support bare binaries) ditto -c -k --keepParent dist/cycode-cli notarization.zip # notarize app (this will take a while) - xcrun notarytool submit notarization.zip --keychain-profile "notarytool-profile" --wait + NOTARIZE_OUTPUT=$(xcrun notarytool submit notarization.zip --keychain-profile "notarytool-profile" --wait 2>&1) || true + echo "$NOTARIZE_OUTPUT" + + # extract submission ID for log retrieval + SUBMISSION_ID=$(echo "$NOTARIZE_OUTPUT" | grep " id:" | head -1 | awk '{print $2}') + + # check notarization status explicitly + if echo "$NOTARIZE_OUTPUT" | grep -q "status: Accepted"; then + echo "Notarization succeeded!" + else + echo "Notarization failed! Fetching log for details..." + if [ -n "$SUBMISSION_ID" ]; then + xcrun notarytool log "$SUBMISSION_ID" --keychain-profile "notarytool-profile" || true + fi + exit 1 + fi # we can't staple the app because it's executable From 8e4450c70e8fa1fdef57019035b169cbafc20306 Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Tue, 24 Feb 2026 09:14:27 +0000 Subject: [PATCH 019/123] CM-53930: fix onedir signing issues on mac (#394) --- .github/workflows/build_executable.yml | 95 +++++++++++++++++++++++--- process_executable_file.py | 6 +- 2 files changed, 90 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 1f1e2582..6749ca79 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -125,20 +125,34 @@ jobs: echo "PATH_TO_CYCODE_CLI_EXECUTABLE=dist/cycode-cli/cycode-cli" >> $GITHUB_ENV - name: Test executable - run: time $PATH_TO_CYCODE_CLI_EXECUTABLE version + run: time $PATH_TO_CYCODE_CLI_EXECUTABLE status - name: Codesign onedir binaries if: runner.os == 'macOS' && matrix.mode == 'onedir' env: APPLE_CERT_NAME: ${{ secrets.APPLE_CERT_NAME }} run: | - # Sign all Mach-O binaries in the onedir output (excluding the main executable) - # Main executable must be signed last after all its dependencies - find dist/cycode-cli -type f ! -name "cycode-cli" | while read -r file; do + # The standalone _internal/Python fails codesign --verify --strict because it was + # extracted from Python.framework without Info.plist context. + # Fix: remove the bare copy and replace with the framework version's binary, + # then delete the framework directory (it's redundant). + if [ -d dist/cycode-cli/_internal/Python.framework ]; then + FRAMEWORK_PYTHON=$(find dist/cycode-cli/_internal/Python.framework/Versions -name "Python" -type f | head -1) + if [ -n "$FRAMEWORK_PYTHON" ]; then + echo "Replacing _internal/Python with framework binary" + rm dist/cycode-cli/_internal/Python + cp "$FRAMEWORK_PYTHON" dist/cycode-cli/_internal/Python + fi + rm -rf dist/cycode-cli/_internal/Python.framework + fi + + # Sign all Mach-O binaries (excluding the main executable) + while IFS= read -r file; do if file -b "$file" | grep -q "Mach-O"; then + echo "Signing: $file" codesign --force --sign "$APPLE_CERT_NAME" --timestamp --options runtime "$file" fi - done + done < <(find dist/cycode-cli -type f ! -name "cycode-cli") # Re-sign the main executable with entitlements (must be last) codesign --force --sign "$APPLE_CERT_NAME" --timestamp --options runtime --entitlements entitlements.plist dist/cycode-cli/cycode-cli @@ -176,15 +190,35 @@ jobs: # we can't staple the app because it's executable - - name: Test macOS signed executable + - name: Verify macOS code signatures if: runner.os == 'macOS' run: | - file -b $PATH_TO_CYCODE_CLI_EXECUTABLE - time $PATH_TO_CYCODE_CLI_EXECUTABLE version + FAILED=false + while IFS= read -r file; do + if file -b "$file" | grep -q "Mach-O"; then + if ! codesign --verify "$file" 2>&1; then + echo "INVALID: $file" + codesign -dv "$file" 2>&1 || true + FAILED=true + else + echo "OK: $file" + fi + fi + done < <(find dist/cycode-cli -type f) + + if [ "$FAILED" = true ]; then + echo "Found binaries with invalid signatures!" + exit 1 + fi - # verify signature codesign -dv --verbose=4 $PATH_TO_CYCODE_CLI_EXECUTABLE + - name: Test macOS signed executable + if: runner.os == 'macOS' + run: | + file -b $PATH_TO_CYCODE_CLI_EXECUTABLE + time $PATH_TO_CYCODE_CLI_EXECUTABLE status + - name: Import cert for Windows and setup envs if: runner.os == 'Windows' env: @@ -222,7 +256,7 @@ jobs: shell: cmd run: | :: call executable and expect correct output - .\dist\cycode-cli.exe version + .\dist\cycode-cli.exe status :: verify signature signtool.exe verify /v /pa ".\dist\cycode-cli.exe" @@ -236,6 +270,47 @@ jobs: name: ${{ env.ARTIFACT_NAME }} path: dist + - name: Verify macOS artifact end-to-end + if: runner.os == 'macOS' && matrix.mode == 'onedir' + uses: actions/download-artifact@v4 + with: + name: ${{ env.ARTIFACT_NAME }} + path: /tmp/artifact-verify + + - name: Verify macOS artifact signatures and run with quarantine + if: runner.os == 'macOS' && matrix.mode == 'onedir' + run: | + # extract the onedir zip exactly as an end user would + ARCHIVE=$(find /tmp/artifact-verify -name "*.zip" | head -1) + echo "Verifying archive: $ARCHIVE" + unzip "$ARCHIVE" -d /tmp/artifact-extracted + + # verify all Mach-O code signatures + FAILED=false + while IFS= read -r file; do + if file -b "$file" | grep -q "Mach-O"; then + if ! codesign --verify "$file" 2>&1; then + echo "INVALID: $file" + codesign -dv "$file" 2>&1 || true + FAILED=true + else + echo "OK: $file" + fi + fi + done < <(find /tmp/artifact-extracted -type f) + + if [ "$FAILED" = true ]; then + echo "Artifact contains binaries with invalid signatures!" + exit 1 + fi + + # simulate download quarantine and test execution + # this is the definitive test — it triggers the same dlopen checks end users experience + find /tmp/artifact-extracted -type f -exec xattr -w com.apple.quarantine "0081;$(printf '%x' $(date +%s));CI;$(uuidgen)" {} \; + EXECUTABLE=$(find /tmp/artifact-extracted -name "cycode-cli" -type f | head -1) + echo "Testing quarantined executable: $EXECUTABLE" + time "$EXECUTABLE" status + - name: Upload files to release if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish }} uses: svenstaro/upload-release-action@v2 diff --git a/process_executable_file.py b/process_executable_file.py index 367bb18d..36d6d0d6 100755 --- a/process_executable_file.py +++ b/process_executable_file.py @@ -140,6 +140,10 @@ def get_cli_archive_path(output_path: Path, is_onedir: bool) -> str: return os.path.join(output_path, get_cli_archive_filename(is_onedir)) +def archive_directory(input_path: Path, output_path: str) -> None: + shutil.make_archive(output_path.removesuffix(f'.{_ARCHIVE_FORMAT}'), _ARCHIVE_FORMAT, input_path) + + def process_executable_file(input_path: Path, is_onedir: bool) -> str: output_path = input_path.parent hash_file_path = get_cli_hash_path(output_path, is_onedir) @@ -150,7 +154,7 @@ def process_executable_file(input_path: Path, is_onedir: bool) -> str: write_hashes_db_to_file(normalized_hashes, hash_file_path) archived_file_path = get_cli_archive_path(output_path, is_onedir) - shutil.make_archive(archived_file_path, _ARCHIVE_FORMAT, input_path) + archive_directory(input_path, f'{archived_file_path}.{_ARCHIVE_FORMAT}') shutil.rmtree(input_path) else: file_hash = get_hash_of_file(input_path) From 718521abcea69720f4c419c5f4cb3e0c4fb1fff8 Mon Sep 17 00:00:00 2001 From: omerr-cycode Date: Mon, 2 Mar 2026 11:56:23 +0200 Subject: [PATCH 020/123] CM-59977: SCA maintainability improvements (#393) --- README.md | 44 ++- .../cli/apps/report/sbom/path/path_command.py | 25 +- cycode/cli/apps/sca_options.py | 47 +++ cycode/cli/apps/scan/scan_command.py | 40 +- cycode/cli/cli_types.py | 1 + .../sca/base_restore_dependencies.py | 32 +- .../sca/go/restore_go_dependencies.py | 8 +- .../sca/npm/restore_deno_dependencies.py | 46 +++ .../sca/npm/restore_npm_dependencies.py | 159 ++------ .../sca/npm/restore_pnpm_dependencies.py | 70 ++++ .../sca/npm/restore_yarn_dependencies.py | 70 ++++ .../cli/files_collector/sca/php/__init__.py | 0 .../sca/php/restore_composer_dependencies.py | 54 +++ .../files_collector/sca/python/__init__.py | 0 .../sca/python/restore_pipenv_dependencies.py | 45 +++ .../sca/python/restore_poetry_dependencies.py | 62 +++ .../files_collector/sca/sca_file_collector.py | 14 +- .../sca/npm/test_restore_deno_dependencies.py | 65 ++++ .../sca/npm/test_restore_npm_dependencies.py | 362 ++++-------------- .../sca/npm/test_restore_pnpm_dependencies.py | 91 +++++ .../sca/npm/test_restore_yarn_dependencies.py | 91 +++++ tests/cli/files_collector/sca/php/__init__.py | 0 .../php/test_restore_composer_dependencies.py | 82 ++++ .../files_collector/sca/python/__init__.py | 0 .../test_restore_pipenv_dependencies.py | 73 ++++ .../test_restore_poetry_dependencies.py | 99 +++++ 26 files changed, 1081 insertions(+), 499 deletions(-) create mode 100644 cycode/cli/apps/sca_options.py create mode 100644 cycode/cli/files_collector/sca/npm/restore_deno_dependencies.py create mode 100644 cycode/cli/files_collector/sca/npm/restore_pnpm_dependencies.py create mode 100644 cycode/cli/files_collector/sca/npm/restore_yarn_dependencies.py create mode 100644 cycode/cli/files_collector/sca/php/__init__.py create mode 100644 cycode/cli/files_collector/sca/php/restore_composer_dependencies.py create mode 100644 cycode/cli/files_collector/sca/python/__init__.py create mode 100644 cycode/cli/files_collector/sca/python/restore_pipenv_dependencies.py create mode 100644 cycode/cli/files_collector/sca/python/restore_poetry_dependencies.py create mode 100644 tests/cli/files_collector/sca/npm/test_restore_deno_dependencies.py create mode 100644 tests/cli/files_collector/sca/npm/test_restore_pnpm_dependencies.py create mode 100644 tests/cli/files_collector/sca/npm/test_restore_yarn_dependencies.py create mode 100644 tests/cli/files_collector/sca/php/__init__.py create mode 100644 tests/cli/files_collector/sca/php/test_restore_composer_dependencies.py create mode 100644 tests/cli/files_collector/sca/python/__init__.py create mode 100644 tests/cli/files_collector/sca/python/test_restore_pipenv_dependencies.py create mode 100644 tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py diff --git a/README.md b/README.md index 2abfd3b2..b512c813 100644 --- a/README.md +++ b/README.md @@ -668,15 +668,33 @@ In the previous example, if you wanted to only scan a branch named `dev`, you co > [!NOTE] > This option is only available to SCA scans. -We use the sbt-dependency-lock plugin to restore the lock file for SBT projects. -To disable lock restore in use `--no-restore` option. - -Prerequisites: -* `sbt-dependency-lock` plugin: Install the plugin by adding the following line to `project/plugins.sbt`: - - ```text - addSbtPlugin("software.purpledragon" % "sbt-dependency-lock" % "1.5.1") - ``` +When running an SCA scan, Cycode CLI automatically attempts to restore (generate) a dependency lockfile for each supported manifest file it finds. This allows scanning transitive dependencies, not just the ones listed directly in the manifest. To skip this step and scan only direct dependencies, use the `--no-restore` flag. + +The following ecosystems support automatic lockfile restoration: + +| Ecosystem | Manifest file | Lockfile generated | Tool invoked (when lockfile is absent) | +|---|---|---|---| +| npm | `package.json` | `package-lock.json` | `npm install --package-lock-only --ignore-scripts --no-audit` | +| Yarn | `package.json` | `yarn.lock` | `yarn install --ignore-scripts` | +| pnpm | `package.json` | `pnpm-lock.yaml` | `pnpm install --ignore-scripts` | +| Deno | `deno.json` / `deno.jsonc` | `deno.lock` | *(read existing lockfile only)* | +| Go | `go.mod` | `go.mod.graph` | `go list -m -json all` + `go mod graph` | +| Maven | `pom.xml` | `bcde.mvndeps` | `mvn dependency:tree` | +| Gradle | `build.gradle` / `build.gradle.kts` | `gradle-dependencies-generated.txt` | `gradle dependencies -q --console plain` | +| SBT | `build.sbt` | `build.sbt.lock` | `sbt dependencyLockWrite` | +| NuGet | `*.csproj` | `packages.lock.json` | `dotnet restore --use-lock-file` | +| Ruby | `Gemfile` | `Gemfile.lock` | `bundle --quiet` | +| Poetry | `pyproject.toml` | `poetry.lock` | `poetry lock` | +| Pipenv | `Pipfile` | `Pipfile.lock` | `pipenv lock` | +| PHP Composer | `composer.json` | `composer.lock` | `composer update --no-cache --no-install --no-scripts --ignore-platform-reqs` | + +If a lockfile already exists alongside the manifest, Cycode reads it directly without running any install command. + +**SBT prerequisite:** The `sbt-dependency-lock` plugin must be installed. Add the following line to `project/plugins.sbt`: + +```text +addSbtPlugin("software.purpledragon" % "sbt-dependency-lock" % "1.5.1") +``` ### Repository Scan @@ -1309,9 +1327,11 @@ For example:\ The `path` subcommand supports the following additional options: -| Option | Description | -|-------------------------|----------------------------------------------------------------------------------------------------------------------------------| -| `--maven-settings-file` | For Maven only, allows using a custom [settings.xml](https://maven.apache.org/settings.html) file when building the dependency tree | +| Option | Description | +|-----------------------------|-------------------------------------------------------------------------------------------------------------------------------------| +| `--no-restore` | Skip lockfile restoration and scan direct dependencies only. See [Lock Restore Option](#lock-restore-option) for details. | +| `--gradle-all-sub-projects` | Run the Gradle restore command for all sub-projects (use from the root of a multi-project Gradle build). | +| `--maven-settings-file` | For Maven only, allows using a custom [settings.xml](https://maven.apache.org/settings.html) file when building the dependency tree. | # Import Command diff --git a/cycode/cli/apps/report/sbom/path/path_command.py b/cycode/cli/apps/report/sbom/path/path_command.py index a127bfc7..a3ffa578 100644 --- a/cycode/cli/apps/report/sbom/path/path_command.py +++ b/cycode/cli/apps/report/sbom/path/path_command.py @@ -1,11 +1,17 @@ import time from pathlib import Path -from typing import Annotated, Optional +from typing import Annotated import typer from cycode.cli import consts from cycode.cli.apps.report.sbom.common import create_sbom_report, send_report_feedback +from cycode.cli.apps.sca_options import ( + GradleAllSubProjectsOption, + MavenSettingsFileOption, + NoRestoreOption, + apply_sca_restore_options_to_context, +) from cycode.cli.exceptions.handle_report_sbom_errors import handle_report_exception from cycode.cli.files_collector.path_documents import get_relevant_documents from cycode.cli.files_collector.sca.sca_file_collector import add_sca_dependencies_tree_documents_if_needed @@ -14,8 +20,6 @@ from cycode.cli.utils.progress_bar import SbomReportProgressBarSection from cycode.cli.utils.scan_utils import is_cycodeignore_allowed_by_scan_config -_SCA_RICH_HELP_PANEL = 'SCA options' - def path_command( ctx: typer.Context, @@ -23,18 +27,11 @@ def path_command( Path, typer.Argument(exists=True, resolve_path=True, help='Path to generate SBOM report for.', show_default=False), ], - maven_settings_file: Annotated[ - Optional[Path], - typer.Option( - '--maven-settings-file', - show_default=False, - help='When specified, Cycode will use this settings.xml file when building the maven dependency tree.', - dir_okay=False, - rich_help_panel=_SCA_RICH_HELP_PANEL, - ), - ] = None, + no_restore: NoRestoreOption = False, + gradle_all_sub_projects: GradleAllSubProjectsOption = False, + maven_settings_file: MavenSettingsFileOption = None, ) -> None: - ctx.obj['maven_settings_file'] = maven_settings_file + apply_sca_restore_options_to_context(ctx, no_restore, gradle_all_sub_projects, maven_settings_file) client = get_report_cycode_client(ctx) report_parameters = ctx.obj['report_parameters'] diff --git a/cycode/cli/apps/sca_options.py b/cycode/cli/apps/sca_options.py new file mode 100644 index 00000000..3c904ee6 --- /dev/null +++ b/cycode/cli/apps/sca_options.py @@ -0,0 +1,47 @@ +from pathlib import Path +from typing import Annotated, Optional + +import typer + +_SCA_RICH_HELP_PANEL = 'SCA options' + +NoRestoreOption = Annotated[ + bool, + typer.Option( + '--no-restore', + help='When specified, Cycode will not run restore command. Will scan direct dependencies [b]only[/]!', + rich_help_panel=_SCA_RICH_HELP_PANEL, + ), +] + +GradleAllSubProjectsOption = Annotated[ + bool, + typer.Option( + '--gradle-all-sub-projects', + help='When specified, Cycode will run gradle restore command for all sub projects. ' + 'Should run from root project directory [b]only[/]!', + rich_help_panel=_SCA_RICH_HELP_PANEL, + ), +] + +MavenSettingsFileOption = Annotated[ + Optional[Path], + typer.Option( + '--maven-settings-file', + show_default=False, + help='When specified, Cycode will use this settings.xml file when building the maven dependency tree.', + dir_okay=False, + rich_help_panel=_SCA_RICH_HELP_PANEL, + ), +] + + +def apply_sca_restore_options_to_context( + ctx: typer.Context, + no_restore: bool, + gradle_all_sub_projects: bool, + maven_settings_file: Optional[Path], +) -> None: + ctx.obj['no_restore'] = no_restore + ctx.obj['gradle_all_sub_projects'] = gradle_all_sub_projects + ctx.obj['maven_settings_file'] = maven_settings_file diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 9892f1b6..7aab9d27 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -5,6 +5,12 @@ import click import typer +from cycode.cli.apps.sca_options import ( + GradleAllSubProjectsOption, + MavenSettingsFileOption, + NoRestoreOption, + apply_sca_restore_options_to_context, +) from cycode.cli.apps.scan.remote_url_resolver import _try_get_git_remote_url from cycode.cli.cli_types import ExportTypeOption, ScanTypeOption, ScaScanTypeOption, SeverityOption from cycode.cli.consts import ( @@ -72,33 +78,9 @@ def scan_command( rich_help_panel=_SCA_RICH_HELP_PANEL, ), ] = False, - no_restore: Annotated[ - bool, - typer.Option( - '--no-restore', - help='When specified, Cycode will not run restore command. Will scan direct dependencies [b]only[/]!', - rich_help_panel=_SCA_RICH_HELP_PANEL, - ), - ] = False, - gradle_all_sub_projects: Annotated[ - bool, - typer.Option( - '--gradle-all-sub-projects', - help='When specified, Cycode will run gradle restore command for all sub projects. ' - 'Should run from root project directory [b]only[/]!', - rich_help_panel=_SCA_RICH_HELP_PANEL, - ), - ] = False, - maven_settings_file: Annotated[ - Optional[Path], - typer.Option( - '--maven-settings-file', - show_default=False, - help='When specified, Cycode will use this settings.xml file when building the maven dependency tree.', - dir_okay=False, - rich_help_panel=_SCA_RICH_HELP_PANEL, - ), - ] = None, + no_restore: NoRestoreOption = False, + gradle_all_sub_projects: GradleAllSubProjectsOption = False, + maven_settings_file: MavenSettingsFileOption = None, export_type: Annotated[ ExportTypeOption, typer.Option( @@ -152,10 +134,8 @@ def scan_command( ctx.obj['sync'] = sync ctx.obj['severity_threshold'] = severity_threshold ctx.obj['monitor'] = monitor - ctx.obj['maven_settings_file'] = maven_settings_file ctx.obj['report'] = report - ctx.obj['gradle_all_sub_projects'] = gradle_all_sub_projects - ctx.obj['no_restore'] = no_restore + apply_sca_restore_options_to_context(ctx, no_restore, gradle_all_sub_projects, maven_settings_file) scan_client = get_scan_cycode_client(ctx) ctx.obj['client'] = scan_client diff --git a/cycode/cli/cli_types.py b/cycode/cli/cli_types.py index bd88faea..ed277cc6 100644 --- a/cycode/cli/cli_types.py +++ b/cycode/cli/cli_types.py @@ -46,6 +46,7 @@ class SbomFormatOption(StrEnum): SPDX_2_2 = 'spdx-2.2' SPDX_2_3 = 'spdx-2.3' CYCLONEDX_1_4 = 'cyclonedx-1.4' + CYCLONEDX_1_6 = 'cyclonedx-1.6' class SbomOutputFormatOption(StrEnum): diff --git a/cycode/cli/files_collector/sca/base_restore_dependencies.py b/cycode/cli/files_collector/sca/base_restore_dependencies.py index 7e69a0d9..ac391727 100644 --- a/cycode/cli/files_collector/sca/base_restore_dependencies.py +++ b/cycode/cli/files_collector/sca/base_restore_dependencies.py @@ -1,5 +1,5 @@ -import os from abc import ABC, abstractmethod +from pathlib import Path from typing import Optional import typer @@ -32,6 +32,9 @@ def execute_commands( }, ) + if not commands: + return None + try: outputs = [] @@ -106,22 +109,43 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: ) return Document(relative_restore_file_path, restore_file_content, self.is_git_diff) + def get_manifest_dir(self, document: Document) -> Optional[str]: + """Return the directory containing the manifest file, resolving monitor-mode paths. + + Uses the same path resolution as get_manifest_file_path() to ensure consistency. + Falls back to document.absolute_path when the resolved manifest path is ambiguous. + """ + manifest_file_path = self.get_manifest_file_path(document) + if manifest_file_path: + parent = Path(manifest_file_path).parent + # Skip '.' (no parent) and filesystem root (its own parent) + if parent != Path('.') and parent != parent.parent: + return str(parent) + + base = document.absolute_path or document.path + if base: + parent = Path(base).parent + if parent != Path('.') and parent != parent.parent: + return str(parent) + + return None + def get_working_directory(self, document: Document) -> Optional[str]: - return os.path.dirname(document.absolute_path) + return str(Path(document.absolute_path).parent) def get_restored_lock_file_name(self, restore_file_path: str) -> str: return self.get_lock_file_name() def get_any_restore_file_already_exist(self, document: Document, restore_file_paths: list[str]) -> str: for restore_file_path in restore_file_paths: - if os.path.isfile(restore_file_path): + if Path(restore_file_path).is_file(): return restore_file_path return build_dep_tree_path(document.absolute_path, self.get_lock_file_name()) @staticmethod def verify_restore_file_already_exist(restore_file_path: str) -> bool: - return os.path.isfile(restore_file_path) + return Path(restore_file_path).is_file() @abstractmethod def is_project(self, document: Document) -> bool: diff --git a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py index fc94eb03..b98fbaf5 100644 --- a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py +++ b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py @@ -1,4 +1,4 @@ -import os +from pathlib import Path from typing import Optional import typer @@ -20,13 +20,13 @@ def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) super().__init__(ctx, is_git_diff, command_timeout, create_output_file_manually=True) def try_restore_dependencies(self, document: Document) -> Optional[Document]: - manifest_exists = os.path.isfile(self.get_working_directory(document) + os.sep + BUILD_GO_FILE_NAME) - lock_exists = os.path.isfile(self.get_working_directory(document) + os.sep + BUILD_GO_LOCK_FILE_NAME) + manifest_exists = (Path(self.get_working_directory(document)) / BUILD_GO_FILE_NAME).is_file() + lock_exists = (Path(self.get_working_directory(document)) / BUILD_GO_LOCK_FILE_NAME).is_file() if not manifest_exists or not lock_exists: logger.info('No manifest go.mod file found' if not manifest_exists else 'No manifest go.sum file found') - manifest_files_exists = manifest_exists & lock_exists + manifest_files_exists = manifest_exists and lock_exists if not manifest_files_exists: return None diff --git a/cycode/cli/files_collector/sca/npm/restore_deno_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_deno_dependencies.py new file mode 100644 index 00000000..d3aeb5e5 --- /dev/null +++ b/cycode/cli/files_collector/sca/npm/restore_deno_dependencies.py @@ -0,0 +1,46 @@ +from pathlib import Path +from typing import Optional + +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content +from cycode.logger import get_logger + +logger = get_logger('Deno Restore Dependencies') + +DENO_MANIFEST_FILE_NAMES = ('deno.json', 'deno.jsonc') +DENO_LOCK_FILE_NAME = 'deno.lock' + + +class RestoreDenoDependencies(BaseRestoreDependencies): + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + return Path(document.path).name in DENO_MANIFEST_FILE_NAMES + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + if not manifest_dir: + return None + + lockfile_path = Path(manifest_dir) / DENO_LOCK_FILE_NAME + if not lockfile_path.is_file(): + logger.debug('No deno.lock found alongside deno.json, skipping deno restore, %s', {'path': document.path}) + return None + + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, DENO_LOCK_FILE_NAME) + logger.debug('Using existing deno.lock, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [] + + def get_lock_file_name(self) -> str: + return DENO_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [DENO_LOCK_FILE_NAME] diff --git a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py index 9f8c0b66..d07bc4a5 100644 --- a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py +++ b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py @@ -1,21 +1,17 @@ -import os -from typing import Optional +from pathlib import Path import typer -from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies from cycode.cli.models import Document -from cycode.cli.utils.path_utils import get_file_content from cycode.logger import get_logger logger = get_logger('NPM Restore Dependencies') -NPM_PROJECT_FILE_EXTENSIONS = ['.json'] -NPM_LOCK_FILE_NAME = 'package-lock.json' -# Alternative lockfiles that should prevent npm install from running -ALTERNATIVE_LOCK_FILES = ['yarn.lock', 'pnpm-lock.yaml', 'deno.lock'] -NPM_LOCK_FILE_NAMES = [NPM_LOCK_FILE_NAME, *ALTERNATIVE_LOCK_FILES] NPM_MANIFEST_FILE_NAME = 'package.json' +NPM_LOCK_FILE_NAME = 'package-lock.json' +# These lockfiles indicate another package manager owns the project — NPM should not run +_ALTERNATIVE_LOCK_FILES = ('yarn.lock', 'pnpm-lock.yaml', 'deno.lock') class RestoreNpmDependencies(BaseRestoreDependencies): @@ -23,128 +19,25 @@ def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) super().__init__(ctx, is_git_diff, command_timeout) def is_project(self, document: Document) -> bool: - return any(document.path.endswith(ext) for ext in NPM_PROJECT_FILE_EXTENSIONS) - - def _resolve_manifest_directory(self, document: Document) -> Optional[str]: - """Resolve the directory containing the manifest file. - - Uses the same path resolution logic as get_manifest_file_path() to ensure consistency. - Falls back to absolute_path or document.path if needed. - - Returns: - Directory path if resolved, None otherwise. - """ - manifest_file_path = self.get_manifest_file_path(document) - manifest_dir = os.path.dirname(manifest_file_path) if manifest_file_path else None - - # Fallback: if manifest_dir is empty or root, try using absolute_path or document.path - if not manifest_dir or manifest_dir == os.sep or manifest_dir == '.': - base_path = document.absolute_path if document.absolute_path else document.path - if base_path: - manifest_dir = os.path.dirname(base_path) + """Match only package.json files that are not managed by Yarn or pnpm. - return manifest_dir - - def _find_existing_lockfile(self, manifest_dir: str) -> tuple[Optional[str], list[str]]: - """Find the first existing lockfile in the manifest directory. - - Args: - manifest_dir: Directory to search for lockfiles. - - Returns: - Tuple of (lockfile_path if found, list of checked lockfiles with status). + Yarn and pnpm projects are handled by their dedicated handlers, which run before + this one in the handler list. This handler is the npm fallback. """ - lock_file_paths = [os.path.join(manifest_dir, lock_file_name) for lock_file_name in NPM_LOCK_FILE_NAMES] - - existing_lock_file = None - checked_lockfiles = [] - for lock_file_path in lock_file_paths: - lock_file_name = os.path.basename(lock_file_path) - exists = os.path.isfile(lock_file_path) - checked_lockfiles.append(f'{lock_file_name}: {"exists" if exists else "not found"}') - if exists: - existing_lock_file = lock_file_path - break + if Path(document.path).name != NPM_MANIFEST_FILE_NAME: + return False - return existing_lock_file, checked_lockfiles + manifest_dir = self.get_manifest_dir(document) + if manifest_dir: + for lock_file in _ALTERNATIVE_LOCK_FILES: + if (Path(manifest_dir) / lock_file).is_file(): + logger.debug( + 'Skipping npm restore: alternative lockfile detected, %s', + {'path': document.path, 'lockfile': lock_file}, + ) + return False - def _create_document_from_lockfile(self, document: Document, lockfile_path: str) -> Optional[Document]: - """Create a Document from an existing lockfile. - - Args: - document: Original document (package.json). - lockfile_path: Path to the existing lockfile. - - Returns: - Document with lockfile content if successful, None otherwise. - """ - lock_file_name = os.path.basename(lockfile_path) - logger.info( - 'Skipping npm install: using existing lockfile, %s', - {'path': document.path, 'lockfile': lock_file_name, 'lockfile_path': lockfile_path}, - ) - - relative_restore_file_path = build_dep_tree_path(document.path, lock_file_name) - restore_file_content = get_file_content(lockfile_path) - - if restore_file_content is not None: - logger.debug( - 'Successfully loaded lockfile content, %s', - {'path': document.path, 'lockfile': lock_file_name, 'content_size': len(restore_file_content)}, - ) - return Document(relative_restore_file_path, restore_file_content, self.is_git_diff) - - logger.warning( - 'Lockfile exists but could not read content, %s', - {'path': document.path, 'lockfile': lock_file_name, 'lockfile_path': lockfile_path}, - ) - return None - - def try_restore_dependencies(self, document: Document) -> Optional[Document]: - """Override to prevent npm install when any lockfile exists. - - The base class uses document.absolute_path which might be None or incorrect. - We need to use the same path resolution logic as get_manifest_file_path() - to ensure we check for lockfiles in the correct location. - - If any lockfile exists (package-lock.json, pnpm-lock.yaml, yarn.lock, deno.lock), - we use it directly without running npm install to avoid generating invalid lockfiles. - """ - # Check if this is a project file first (same as base class caller does) - if not self.is_project(document): - logger.debug('Skipping restore: document is not recognized as npm project, %s', {'path': document.path}) - return None - - # Resolve the manifest directory - manifest_dir = self._resolve_manifest_directory(document) - if not manifest_dir: - logger.debug( - 'Cannot determine manifest directory, proceeding with base class restore flow, %s', - {'path': document.path}, - ) - return super().try_restore_dependencies(document) - - # Check for existing lockfiles - logger.debug( - 'Checking for existing lockfiles in directory, %s', {'directory': manifest_dir, 'path': document.path} - ) - existing_lock_file, checked_lockfiles = self._find_existing_lockfile(manifest_dir) - - logger.debug( - 'Lockfile check results, %s', - {'path': document.path, 'checked_lockfiles': ', '.join(checked_lockfiles)}, - ) - - # If any lockfile exists, use it directly without running npm install - if existing_lock_file: - return self._create_document_from_lockfile(document, existing_lock_file) - - # No lockfile exists, proceed with the normal restore flow which will run npm install - logger.info( - 'No existing lockfile found, proceeding with npm install to generate package-lock.json, %s', - {'path': document.path, 'directory': manifest_dir, 'checked_lockfiles': ', '.join(checked_lockfiles)}, - ) - return super().try_restore_dependencies(document) + return True def get_commands(self, manifest_file_path: str) -> list[list[str]]: return [ @@ -159,22 +52,16 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: ] ] - def get_restored_lock_file_name(self, restore_file_path: str) -> str: - return os.path.basename(restore_file_path) - def get_lock_file_name(self) -> str: return NPM_LOCK_FILE_NAME def get_lock_file_names(self) -> list[str]: - return NPM_LOCK_FILE_NAMES + return [NPM_LOCK_FILE_NAME] @staticmethod def prepare_manifest_file_path_for_command(manifest_file_path: str) -> str: - # Remove package.json from the path if manifest_file_path.endswith(NPM_MANIFEST_FILE_NAME): - # Use os.path.dirname to handle both Unix (/) and Windows (\) separators - # This is cross-platform and handles edge cases correctly - dir_path = os.path.dirname(manifest_file_path) - # If dir_path is empty or just '.', return an empty string (package.json in current dir) + parent = Path(manifest_file_path).parent + dir_path = str(parent) return dir_path if dir_path and dir_path != '.' else '' return manifest_file_path diff --git a/cycode/cli/files_collector/sca/npm/restore_pnpm_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_pnpm_dependencies.py new file mode 100644 index 00000000..bce7eff6 --- /dev/null +++ b/cycode/cli/files_collector/sca/npm/restore_pnpm_dependencies.py @@ -0,0 +1,70 @@ +import json +from pathlib import Path +from typing import Optional + +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content +from cycode.logger import get_logger + +logger = get_logger('Pnpm Restore Dependencies') + +PNPM_MANIFEST_FILE_NAME = 'package.json' +PNPM_LOCK_FILE_NAME = 'pnpm-lock.yaml' + + +def _indicates_pnpm(package_json_content: Optional[str]) -> bool: + """Return True if package.json content signals that this project uses pnpm.""" + if not package_json_content: + return False + try: + data = json.loads(package_json_content) + except (json.JSONDecodeError, ValueError): + return False + + package_manager = data.get('packageManager', '') + if isinstance(package_manager, str) and package_manager.startswith('pnpm'): + return True + + engines = data.get('engines', {}) + return isinstance(engines, dict) and 'pnpm' in engines + + +class RestorePnpmDependencies(BaseRestoreDependencies): + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + if Path(document.path).name != PNPM_MANIFEST_FILE_NAME: + return False + + manifest_dir = self.get_manifest_dir(document) + if manifest_dir and (Path(manifest_dir) / PNPM_LOCK_FILE_NAME).is_file(): + return True + + return _indicates_pnpm(document.content) + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + lockfile_path = Path(manifest_dir) / PNPM_LOCK_FILE_NAME if manifest_dir else None + + if lockfile_path and lockfile_path.is_file(): + # Lockfile already exists — read it directly without running pnpm + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, PNPM_LOCK_FILE_NAME) + logger.debug('Using existing pnpm-lock.yaml, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + # Lockfile absent but pnpm is indicated in package.json — generate it + return super().try_restore_dependencies(document) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [['pnpm', 'install', '--ignore-scripts']] + + def get_lock_file_name(self) -> str: + return PNPM_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [PNPM_LOCK_FILE_NAME] diff --git a/cycode/cli/files_collector/sca/npm/restore_yarn_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_yarn_dependencies.py new file mode 100644 index 00000000..79b0c4ec --- /dev/null +++ b/cycode/cli/files_collector/sca/npm/restore_yarn_dependencies.py @@ -0,0 +1,70 @@ +import json +from pathlib import Path +from typing import Optional + +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content +from cycode.logger import get_logger + +logger = get_logger('Yarn Restore Dependencies') + +YARN_MANIFEST_FILE_NAME = 'package.json' +YARN_LOCK_FILE_NAME = 'yarn.lock' + + +def _indicates_yarn(package_json_content: Optional[str]) -> bool: + """Return True if package.json content signals that this project uses Yarn.""" + if not package_json_content: + return False + try: + data = json.loads(package_json_content) + except (json.JSONDecodeError, ValueError): + return False + + package_manager = data.get('packageManager', '') + if isinstance(package_manager, str) and package_manager.startswith('yarn'): + return True + + engines = data.get('engines', {}) + return isinstance(engines, dict) and 'yarn' in engines + + +class RestoreYarnDependencies(BaseRestoreDependencies): + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + if Path(document.path).name != YARN_MANIFEST_FILE_NAME: + return False + + manifest_dir = self.get_manifest_dir(document) + if manifest_dir and (Path(manifest_dir) / YARN_LOCK_FILE_NAME).is_file(): + return True + + return _indicates_yarn(document.content) + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + lockfile_path = Path(manifest_dir) / YARN_LOCK_FILE_NAME if manifest_dir else None + + if lockfile_path and lockfile_path.is_file(): + # Lockfile already exists — read it directly without running yarn + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, YARN_LOCK_FILE_NAME) + logger.debug('Using existing yarn.lock, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + # Lockfile absent but yarn is indicated in package.json — generate it + return super().try_restore_dependencies(document) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [['yarn', 'install', '--ignore-scripts']] + + def get_lock_file_name(self) -> str: + return YARN_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [YARN_LOCK_FILE_NAME] diff --git a/cycode/cli/files_collector/sca/php/__init__.py b/cycode/cli/files_collector/sca/php/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/files_collector/sca/php/restore_composer_dependencies.py b/cycode/cli/files_collector/sca/php/restore_composer_dependencies.py new file mode 100644 index 00000000..98b3564c --- /dev/null +++ b/cycode/cli/files_collector/sca/php/restore_composer_dependencies.py @@ -0,0 +1,54 @@ +from pathlib import Path +from typing import Optional + +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content +from cycode.logger import get_logger + +logger = get_logger('Composer Restore Dependencies') + +COMPOSER_MANIFEST_FILE_NAME = 'composer.json' +COMPOSER_LOCK_FILE_NAME = 'composer.lock' + + +class RestoreComposerDependencies(BaseRestoreDependencies): + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + return Path(document.path).name == COMPOSER_MANIFEST_FILE_NAME + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + lockfile_path = Path(manifest_dir) / COMPOSER_LOCK_FILE_NAME if manifest_dir else None + + if lockfile_path and lockfile_path.is_file(): + # Lockfile already exists — read it directly without running composer + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, COMPOSER_LOCK_FILE_NAME) + logger.debug('Using existing composer.lock, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + # Lockfile absent — generate it + return super().try_restore_dependencies(document) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [ + [ + 'composer', + 'update', + '--no-cache', + '--no-install', + '--no-scripts', + '--ignore-platform-reqs', + ] + ] + + def get_lock_file_name(self) -> str: + return COMPOSER_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [COMPOSER_LOCK_FILE_NAME] diff --git a/cycode/cli/files_collector/sca/python/__init__.py b/cycode/cli/files_collector/sca/python/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/files_collector/sca/python/restore_pipenv_dependencies.py b/cycode/cli/files_collector/sca/python/restore_pipenv_dependencies.py new file mode 100644 index 00000000..df91707c --- /dev/null +++ b/cycode/cli/files_collector/sca/python/restore_pipenv_dependencies.py @@ -0,0 +1,45 @@ +from pathlib import Path +from typing import Optional + +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content +from cycode.logger import get_logger + +logger = get_logger('Pipenv Restore Dependencies') + +PIPENV_MANIFEST_FILE_NAME = 'Pipfile' +PIPENV_LOCK_FILE_NAME = 'Pipfile.lock' + + +class RestorePipenvDependencies(BaseRestoreDependencies): + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + return Path(document.path).name == PIPENV_MANIFEST_FILE_NAME + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + lockfile_path = Path(manifest_dir) / PIPENV_LOCK_FILE_NAME if manifest_dir else None + + if lockfile_path and lockfile_path.is_file(): + # Lockfile already exists — read it directly without running pipenv + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, PIPENV_LOCK_FILE_NAME) + logger.debug('Using existing Pipfile.lock, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + # Lockfile absent — generate it + return super().try_restore_dependencies(document) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [['pipenv', 'lock']] + + def get_lock_file_name(self) -> str: + return PIPENV_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [PIPENV_LOCK_FILE_NAME] diff --git a/cycode/cli/files_collector/sca/python/restore_poetry_dependencies.py b/cycode/cli/files_collector/sca/python/restore_poetry_dependencies.py new file mode 100644 index 00000000..f681bd63 --- /dev/null +++ b/cycode/cli/files_collector/sca/python/restore_poetry_dependencies.py @@ -0,0 +1,62 @@ +from pathlib import Path +from typing import Optional + +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content +from cycode.logger import get_logger + +logger = get_logger('Poetry Restore Dependencies') + +POETRY_MANIFEST_FILE_NAME = 'pyproject.toml' +POETRY_LOCK_FILE_NAME = 'poetry.lock' + +# Section header that signals this pyproject.toml is managed by Poetry +_POETRY_TOOL_SECTION = '[tool.poetry]' + + +def _indicates_poetry(pyproject_content: Optional[str]) -> bool: + """Return True if pyproject.toml content signals that this project uses Poetry.""" + if not pyproject_content: + return False + return _POETRY_TOOL_SECTION in pyproject_content + + +class RestorePoetryDependencies(BaseRestoreDependencies): + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + if Path(document.path).name != POETRY_MANIFEST_FILE_NAME: + return False + + manifest_dir = self.get_manifest_dir(document) + if manifest_dir and (Path(manifest_dir) / POETRY_LOCK_FILE_NAME).is_file(): + return True + + return _indicates_poetry(document.content) + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + lockfile_path = Path(manifest_dir) / POETRY_LOCK_FILE_NAME if manifest_dir else None + + if lockfile_path and lockfile_path.is_file(): + # Lockfile already exists — read it directly without running poetry + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, POETRY_LOCK_FILE_NAME) + logger.debug('Using existing poetry.lock, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + # Lockfile absent but Poetry is indicated in pyproject.toml — generate it + return super().try_restore_dependencies(document) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [['poetry', 'lock']] + + def get_lock_file_name(self) -> str: + return POETRY_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [POETRY_LOCK_FILE_NAME] diff --git a/cycode/cli/files_collector/sca/sca_file_collector.py b/cycode/cli/files_collector/sca/sca_file_collector.py index 801b5d6f..b194deef 100644 --- a/cycode/cli/files_collector/sca/sca_file_collector.py +++ b/cycode/cli/files_collector/sca/sca_file_collector.py @@ -9,8 +9,14 @@ from cycode.cli.files_collector.sca.go.restore_go_dependencies import RestoreGoDependencies from cycode.cli.files_collector.sca.maven.restore_gradle_dependencies import RestoreGradleDependencies from cycode.cli.files_collector.sca.maven.restore_maven_dependencies import RestoreMavenDependencies +from cycode.cli.files_collector.sca.npm.restore_deno_dependencies import RestoreDenoDependencies from cycode.cli.files_collector.sca.npm.restore_npm_dependencies import RestoreNpmDependencies +from cycode.cli.files_collector.sca.npm.restore_pnpm_dependencies import RestorePnpmDependencies +from cycode.cli.files_collector.sca.npm.restore_yarn_dependencies import RestoreYarnDependencies from cycode.cli.files_collector.sca.nuget.restore_nuget_dependencies import RestoreNugetDependencies +from cycode.cli.files_collector.sca.php.restore_composer_dependencies import RestoreComposerDependencies +from cycode.cli.files_collector.sca.python.restore_pipenv_dependencies import RestorePipenvDependencies +from cycode.cli.files_collector.sca.python.restore_poetry_dependencies import RestorePoetryDependencies from cycode.cli.files_collector.sca.ruby.restore_ruby_dependencies import RestoreRubyDependencies from cycode.cli.files_collector.sca.sbt.restore_sbt_dependencies import RestoreSbtDependencies from cycode.cli.models import Document @@ -143,8 +149,14 @@ def _get_restore_handlers(ctx: typer.Context, is_git_diff: bool) -> list[BaseRes RestoreSbtDependencies(ctx, is_git_diff, build_dep_tree_timeout), RestoreGoDependencies(ctx, is_git_diff, build_dep_tree_timeout), RestoreNugetDependencies(ctx, is_git_diff, build_dep_tree_timeout), - RestoreNpmDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestoreYarnDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestorePnpmDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestoreDenoDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestoreNpmDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be after Yarn & Pnpm for fallback RestoreRubyDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestorePoetryDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestorePipenvDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestoreComposerDependencies(ctx, is_git_diff, build_dep_tree_timeout), ] diff --git a/tests/cli/files_collector/sca/npm/test_restore_deno_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_deno_dependencies.py new file mode 100644 index 00000000..2d6e9a4b --- /dev/null +++ b/tests/cli/files_collector/sca/npm/test_restore_deno_dependencies.py @@ -0,0 +1,65 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +import typer + +from cycode.cli.files_collector.sca.npm.restore_deno_dependencies import ( + DENO_LOCK_FILE_NAME, + DENO_MANIFEST_FILE_NAMES, + RestoreDenoDependencies, +) +from cycode.cli.models import Document + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_deno(mock_ctx: typer.Context) -> RestoreDenoDependencies: + return RestoreDenoDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + @pytest.mark.parametrize('filename', DENO_MANIFEST_FILE_NAMES) + def test_deno_manifest_files_match(self, restore_deno: RestoreDenoDependencies, filename: str) -> None: + doc = Document(filename, '{}') + assert restore_deno.is_project(doc) is True + + @pytest.mark.parametrize('filename', ['package.json', 'tsconfig.json', 'deno.ts', 'main.ts', 'deno.lock']) + def test_non_deno_manifest_files_do_not_match(self, restore_deno: RestoreDenoDependencies, filename: str) -> None: + doc = Document(filename, '') + assert restore_deno.is_project(doc) is False + + +class TestTryRestoreDependencies: + def test_existing_deno_lock_returned(self, restore_deno: RestoreDenoDependencies, tmp_path: Path) -> None: + deno_lock_content = '{"version": "3", "packages": {}}' + (tmp_path / 'deno.json').write_text('{"imports": {}}') + (tmp_path / 'deno.lock').write_text(deno_lock_content) + + doc = Document(str(tmp_path / 'deno.json'), '{"imports": {}}', absolute_path=str(tmp_path / 'deno.json')) + result = restore_deno.try_restore_dependencies(doc) + + assert result is not None + assert DENO_LOCK_FILE_NAME in result.path + assert result.content == deno_lock_content + + def test_no_deno_lock_returns_none(self, restore_deno: RestoreDenoDependencies, tmp_path: Path) -> None: + (tmp_path / 'deno.json').write_text('{"imports": {}}') + + doc = Document(str(tmp_path / 'deno.json'), '{"imports": {}}', absolute_path=str(tmp_path / 'deno.json')) + result = restore_deno.try_restore_dependencies(doc) + + assert result is None + + def test_get_lock_file_name(self, restore_deno: RestoreDenoDependencies) -> None: + assert restore_deno.get_lock_file_name() == DENO_LOCK_FILE_NAME + + def test_get_commands_returns_empty(self, restore_deno: RestoreDenoDependencies) -> None: + assert restore_deno.get_commands('/path/to/deno.json') == [] diff --git a/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py index af990085..aa145de3 100644 --- a/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py +++ b/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py @@ -5,7 +5,6 @@ import typer from cycode.cli.files_collector.sca.npm.restore_npm_dependencies import ( - ALTERNATIVE_LOCK_FILES, NPM_LOCK_FILE_NAME, RestoreNpmDependencies, ) @@ -14,7 +13,6 @@ @pytest.fixture def mock_ctx(tmp_path: Path) -> typer.Context: - """Create a mock typer context.""" ctx = MagicMock(spec=typer.Context) ctx.obj = {'monitor': False} ctx.params = {'path': str(tmp_path)} @@ -22,326 +20,94 @@ def mock_ctx(tmp_path: Path) -> typer.Context: @pytest.fixture -def restore_npm_dependencies(mock_ctx: typer.Context) -> RestoreNpmDependencies: - """Create a RestoreNpmDependencies instance.""" +def restore_npm(mock_ctx: typer.Context) -> RestoreNpmDependencies: return RestoreNpmDependencies(mock_ctx, is_git_diff=False, command_timeout=30) -class TestRestoreNpmDependenciesAlternativeLockfiles: - """Test that lockfiles prevent npm install from running.""" +class TestIsProject: + def test_package_json_with_no_lockfile_matches(self, restore_npm: RestoreNpmDependencies, tmp_path: Path) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_npm.is_project(doc) is True - @pytest.mark.parametrize( - ('lockfile_name', 'lockfile_content', 'expected_content'), - [ - ('pnpm-lock.yaml', 'lockfileVersion: 5.4\n', 'lockfileVersion: 5.4\n'), - ('yarn.lock', '# yarn lockfile v1\n', '# yarn lockfile v1\n'), - ('deno.lock', '{"version": 2}\n', '{"version": 2}\n'), - ('package-lock.json', '{"lockfileVersion": 2}\n', '{"lockfileVersion": 2}\n'), - ], - ) - def test_lockfile_exists_should_skip_npm_install( - self, - restore_npm_dependencies: RestoreNpmDependencies, - tmp_path: Path, - lockfile_name: str, - lockfile_content: str, - expected_content: str, + def test_package_json_with_yarn_lock_does_not_match( + self, restore_npm: RestoreNpmDependencies, tmp_path: Path ) -> None: - """Test that when any lockfile exists, npm install is skipped.""" - # Setup: Create package.json and lockfile - package_json_path = tmp_path / 'package.json' - lockfile_path = tmp_path / lockfile_name - - package_json_path.write_text('{"name": "test", "version": "1.0.0"}') - lockfile_path.write_text(lockfile_content) + """Yarn projects are handled by RestoreYarnDependencies — NPM should not claim them.""" + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'yarn.lock').write_text('# yarn lockfile v1\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_npm.is_project(doc) is False + + def test_package_json_with_pnpm_lock_does_not_match( + self, restore_npm: RestoreNpmDependencies, tmp_path: Path + ) -> None: + """pnpm projects are handled by RestorePnpmDependencies — NPM should not claim them.""" + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'pnpm-lock.yaml').write_text('lockfileVersion: 5.4\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_npm.is_project(doc) is False - document = Document( - path=str(package_json_path), - content=package_json_path.read_text(), - absolute_path=str(package_json_path), - ) + def test_tsconfig_json_does_not_match(self, restore_npm: RestoreNpmDependencies) -> None: + doc = Document('tsconfig.json', '{}') + assert restore_npm.is_project(doc) is False - # Execute - result = restore_npm_dependencies.try_restore_dependencies(document) + def test_arbitrary_json_does_not_match(self, restore_npm: RestoreNpmDependencies) -> None: + for filename in ('jest.config.json', '.eslintrc.json', 'settings.json', 'bom.json'): + doc = Document(filename, '{}') + assert restore_npm.is_project(doc) is False, f'Expected False for {filename}' - # Verify: Should return lockfile content without running npm install - assert result is not None - assert lockfile_name in result.path - assert result.content == expected_content + def test_non_json_file_does_not_match(self, restore_npm: RestoreNpmDependencies) -> None: + for filename in ('readme.txt', 'script.js', 'Makefile'): + doc = Document(filename, '') + assert restore_npm.is_project(doc) is False, f'Expected False for {filename}' - def test_no_lockfile_exists_should_proceed_with_normal_flow( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path - ) -> None: - """Test that when no lockfile exists, normal flow proceeds (will run npm install).""" - # Setup: Create only package.json (no lockfile) - package_json_path = tmp_path / 'package.json' - package_json_path.write_text('{"name": "test", "version": "1.0.0"}') - document = Document( - path=str(package_json_path), - content=package_json_path.read_text(), - absolute_path=str(package_json_path), - ) +class TestTryRestoreDependencies: + def test_no_lockfile_calls_base_class(self, restore_npm: RestoreNpmDependencies, tmp_path: Path) -> None: + """When no lockfile exists, the base class (npm install) should be invoked.""" + (tmp_path / 'package.json').write_text('{"name": "test"}') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) - # Mock the base class's try_restore_dependencies to verify it's called with patch.object( - restore_npm_dependencies.__class__.__bases__[0], - 'try_restore_dependencies', - return_value=None, + restore_npm.__class__.__bases__[0], 'try_restore_dependencies', return_value=None ) as mock_super: - # Execute - restore_npm_dependencies.try_restore_dependencies(document) - - # Verify: Should call parent's try_restore_dependencies (which will run npm install) - mock_super.assert_called_once_with(document) + restore_npm.try_restore_dependencies(doc) + mock_super.assert_called_once_with(doc) - -class TestRestoreNpmDependenciesPathResolution: - """Test path resolution scenarios.""" - - @pytest.mark.parametrize( - 'has_absolute_path', - [True, False], - ) - def test_path_resolution_with_different_path_types( - self, - restore_npm_dependencies: RestoreNpmDependencies, - tmp_path: Path, - has_absolute_path: bool, + def test_lockfile_in_different_directory_still_calls_base_class( + self, restore_npm: RestoreNpmDependencies, tmp_path: Path ) -> None: - """Test path resolution with absolute or relative paths.""" - package_json_path = tmp_path / 'package.json' - pnpm_lock_path = tmp_path / 'pnpm-lock.yaml' - - package_json_path.write_text('{"name": "test"}') - pnpm_lock_path.write_text('lockfileVersion: 5.4\n') - - document = Document( - path=str(package_json_path), - content='{"name": "test"}', - absolute_path=str(package_json_path) if has_absolute_path else None, - ) - - result = restore_npm_dependencies.try_restore_dependencies(document) - - assert result is not None - assert result.content == 'lockfileVersion: 5.4\n' - - def test_path_resolution_in_monitor_mode(self, tmp_path: Path) -> None: - """Test path resolution in monitor mode.""" - # Setup monitor mode context - ctx = MagicMock(spec=typer.Context) - ctx.obj = {'monitor': True} - ctx.params = {'path': str(tmp_path)} - - restore_npm = RestoreNpmDependencies(ctx, is_git_diff=False, command_timeout=30) - - # Create files in a subdirectory - subdir = tmp_path / 'project' - subdir.mkdir() - package_json_path = subdir / 'package.json' - pnpm_lock_path = subdir / 'pnpm-lock.yaml' - - package_json_path.write_text('{"name": "test"}') - pnpm_lock_path.write_text('lockfileVersion: 5.4\n') - - # Document with a relative path - document = Document( - path='project/package.json', - content='{"name": "test"}', - absolute_path=str(package_json_path), - ) - - result = restore_npm.try_restore_dependencies(document) - - assert result is not None - assert result.content == 'lockfileVersion: 5.4\n' - - def test_path_resolution_with_nested_directory( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path - ) -> None: - """Test path resolution with a nested directory structure.""" - subdir = tmp_path / 'src' / 'app' - subdir.mkdir(parents=True) - - package_json_path = subdir / 'package.json' - pnpm_lock_path = subdir / 'pnpm-lock.yaml' - - package_json_path.write_text('{"name": "test"}') - pnpm_lock_path.write_text('lockfileVersion: 5.4\n') - - document = Document( - path=str(package_json_path), - content='{"name": "test"}', - absolute_path=str(package_json_path), - ) - - result = restore_npm_dependencies.try_restore_dependencies(document) - - assert result is not None - assert result.content == 'lockfileVersion: 5.4\n' - - -class TestRestoreNpmDependenciesEdgeCases: - """Test edge cases and error scenarios.""" - - def test_empty_lockfile_should_still_be_used( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path - ) -> None: - """Test that the empty lockfile is still used (prevents npm install).""" - package_json_path = tmp_path / 'package.json' - pnpm_lock_path = tmp_path / 'pnpm-lock.yaml' - - package_json_path.write_text('{"name": "test"}') - pnpm_lock_path.write_text('') # Empty file - - document = Document( - path=str(package_json_path), - content='{"name": "test"}', - absolute_path=str(package_json_path), - ) - - result = restore_npm_dependencies.try_restore_dependencies(document) - - # Should still return the empty lockfile (prevents npm install) - assert result is not None - assert result.content == '' - - def test_multiple_lockfiles_should_use_first_found( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path - ) -> None: - """Test that when multiple lockfiles exist, the first one found is used (package-lock.json has priority).""" - package_json_path = tmp_path / 'package.json' - package_lock_path = tmp_path / 'package-lock.json' - yarn_lock_path = tmp_path / 'yarn.lock' - pnpm_lock_path = tmp_path / 'pnpm-lock.yaml' - - package_json_path.write_text('{"name": "test"}') - package_lock_path.write_text('{"lockfileVersion": 2}\n') - yarn_lock_path.write_text('# yarn lockfile\n') - pnpm_lock_path.write_text('lockfileVersion: 5.4\n') - - document = Document( - path=str(package_json_path), - content='{"name": "test"}', - absolute_path=str(package_json_path), - ) - - result = restore_npm_dependencies.try_restore_dependencies(document) - - # Should use package-lock.json (first in the check order) - assert result is not None - assert 'package-lock.json' in result.path - assert result.content == '{"lockfileVersion": 2}\n' - - def test_multiple_alternative_lockfiles_should_use_first_found( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path - ) -> None: - """Test that when multiple alternative lockfiles exist (but no package-lock.json), - the first one found is used.""" - package_json_path = tmp_path / 'package.json' - yarn_lock_path = tmp_path / 'yarn.lock' - pnpm_lock_path = tmp_path / 'pnpm-lock.yaml' - - package_json_path.write_text('{"name": "test"}') - yarn_lock_path.write_text('# yarn lockfile\n') - pnpm_lock_path.write_text('lockfileVersion: 5.4\n') - - document = Document( - path=str(package_json_path), - content='{"name": "test"}', - absolute_path=str(package_json_path), - ) - - result = restore_npm_dependencies.try_restore_dependencies(document) - - # Should use yarn.lock (first in ALTERNATIVE_LOCK_FILES list) - assert result is not None - assert 'yarn.lock' in result.path - assert result.content == '# yarn lockfile\n' - - def test_lockfile_in_different_directory_should_not_be_found( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path - ) -> None: - """Test that lockfile in a different directory is not found.""" - package_json_path = tmp_path / 'package.json' + (tmp_path / 'package.json').write_text('{"name": "test"}') other_dir = tmp_path / 'other' other_dir.mkdir() - pnpm_lock_path = other_dir / 'pnpm-lock.yaml' - - package_json_path.write_text('{"name": "test"}') - pnpm_lock_path.write_text('lockfileVersion: 5.4\n') + (other_dir / 'pnpm-lock.yaml').write_text('lockfileVersion: 5.4\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) - document = Document( - path=str(package_json_path), - content='{"name": "test"}', - absolute_path=str(package_json_path), - ) - - # Mock the base class to verify it's called (since lockfile not found) with patch.object( - restore_npm_dependencies.__class__.__bases__[0], - 'try_restore_dependencies', - return_value=None, + restore_npm.__class__.__bases__[0], 'try_restore_dependencies', return_value=None ) as mock_super: - restore_npm_dependencies.try_restore_dependencies(document) - - # Should proceed with normal flow since lockfile not in same directory - mock_super.assert_called_once_with(document) - - def test_non_json_file_should_not_trigger_restore( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path - ) -> None: - """Test that non-JSON files don't trigger restore.""" - text_file = tmp_path / 'readme.txt' - text_file.write_text('Some text') - - document = Document( - path=str(text_file), - content='Some text', - absolute_path=str(text_file), - ) - - # Should return None because is_project() returns False - result = restore_npm_dependencies.try_restore_dependencies(document) - - assert result is None - - -class TestRestoreNpmDependenciesHelperMethods: - """Test helper methods.""" - - def test_is_project_with_json_file(self, restore_npm_dependencies: RestoreNpmDependencies) -> None: - """Test is_project identifies JSON files correctly.""" - document = Document('package.json', '{}') - assert restore_npm_dependencies.is_project(document) is True + restore_npm.try_restore_dependencies(doc) + mock_super.assert_called_once_with(doc) - document = Document('tsconfig.json', '{}') - assert restore_npm_dependencies.is_project(document) is True - def test_is_project_with_non_json_file(self, restore_npm_dependencies: RestoreNpmDependencies) -> None: - """Test is_project returns False for non-JSON files.""" - document = Document('readme.txt', 'text') - assert restore_npm_dependencies.is_project(document) is False +class TestGetLockFileName: + def test_get_lock_file_name(self, restore_npm: RestoreNpmDependencies) -> None: + assert restore_npm.get_lock_file_name() == NPM_LOCK_FILE_NAME - document = Document('script.js', 'code') - assert restore_npm_dependencies.is_project(document) is False + def test_get_lock_file_names_contains_only_npm_lock(self, restore_npm: RestoreNpmDependencies) -> None: + assert restore_npm.get_lock_file_names() == [NPM_LOCK_FILE_NAME] - def test_get_lock_file_name(self, restore_npm_dependencies: RestoreNpmDependencies) -> None: - """Test get_lock_file_name returns the correct name.""" - assert restore_npm_dependencies.get_lock_file_name() == NPM_LOCK_FILE_NAME - def test_get_lock_file_names(self, restore_npm_dependencies: RestoreNpmDependencies) -> None: - """Test get_lock_file_names returns all lockfile names.""" - lock_file_names = restore_npm_dependencies.get_lock_file_names() - assert NPM_LOCK_FILE_NAME in lock_file_names - for alt_lock in ALTERNATIVE_LOCK_FILES: - assert alt_lock in lock_file_names +class TestPrepareManifestFilePath: + def test_strips_package_json_filename(self, restore_npm: RestoreNpmDependencies) -> None: + path = str(Path('/path/to/package.json')) + expected = str(Path('/path/to')) + assert restore_npm.prepare_manifest_file_path_for_command(path) == expected - def test_prepare_manifest_file_path_for_command(self, restore_npm_dependencies: RestoreNpmDependencies) -> None: - """Test prepare_manifest_file_path_for_command removes package.json from the path.""" - result = restore_npm_dependencies.prepare_manifest_file_path_for_command('/path/to/package.json') - assert result == '/path/to' + def test_package_json_in_cwd_returns_empty_string(self, restore_npm: RestoreNpmDependencies) -> None: + assert restore_npm.prepare_manifest_file_path_for_command('package.json') == '' - result = restore_npm_dependencies.prepare_manifest_file_path_for_command('package.json') - assert result == '' + def test_non_package_json_path_returned_unchanged(self, restore_npm: RestoreNpmDependencies) -> None: + path = str(Path('/path/to/')) + assert restore_npm.prepare_manifest_file_path_for_command(path) == path diff --git a/tests/cli/files_collector/sca/npm/test_restore_pnpm_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_pnpm_dependencies.py new file mode 100644 index 00000000..312cce83 --- /dev/null +++ b/tests/cli/files_collector/sca/npm/test_restore_pnpm_dependencies.py @@ -0,0 +1,91 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +import typer + +from cycode.cli.files_collector.sca.npm.restore_pnpm_dependencies import ( + PNPM_LOCK_FILE_NAME, + RestorePnpmDependencies, +) +from cycode.cli.models import Document + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_pnpm(mock_ctx: typer.Context) -> RestorePnpmDependencies: + return RestorePnpmDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_package_json_with_pnpm_lock_matches(self, restore_pnpm: RestorePnpmDependencies, tmp_path: Path) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'pnpm-lock.yaml').write_text('lockfileVersion: 5.4\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_pnpm.is_project(doc) is True + + def test_package_json_with_package_manager_pnpm_matches(self, restore_pnpm: RestorePnpmDependencies) -> None: + content = '{"name": "test", "packageManager": "pnpm@8.6.2"}' + doc = Document('package.json', content) + assert restore_pnpm.is_project(doc) is True + + def test_package_json_with_engines_pnpm_matches(self, restore_pnpm: RestorePnpmDependencies) -> None: + content = '{"name": "test", "engines": {"pnpm": ">=8"}}' + doc = Document('package.json', content) + assert restore_pnpm.is_project(doc) is True + + def test_package_json_with_no_pnpm_signal_does_not_match( + self, restore_pnpm: RestorePnpmDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_pnpm.is_project(doc) is False + + def test_package_json_with_yarn_lock_does_not_match( + self, restore_pnpm: RestorePnpmDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'yarn.lock').write_text('# yarn lockfile v1\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_pnpm.is_project(doc) is False + + def test_tsconfig_json_does_not_match(self, restore_pnpm: RestorePnpmDependencies) -> None: + doc = Document('tsconfig.json', '{"compilerOptions": {}}') + assert restore_pnpm.is_project(doc) is False + + def test_package_manager_yarn_does_not_match(self, restore_pnpm: RestorePnpmDependencies) -> None: + content = '{"name": "test", "packageManager": "yarn@4.0.0"}' + doc = Document('package.json', content) + assert restore_pnpm.is_project(doc) is False + + def test_invalid_json_content_does_not_match(self, restore_pnpm: RestorePnpmDependencies) -> None: + doc = Document('package.json', 'not valid json') + assert restore_pnpm.is_project(doc) is False + + +class TestTryRestoreDependencies: + def test_existing_pnpm_lock_returned_directly(self, restore_pnpm: RestorePnpmDependencies, tmp_path: Path) -> None: + pnpm_lock_content = 'lockfileVersion: 5.4\n\npackages:\n /package@1.0.0:\n resolution: {}\n' + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'pnpm-lock.yaml').write_text(pnpm_lock_content) + + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + result = restore_pnpm.try_restore_dependencies(doc) + + assert result is not None + assert PNPM_LOCK_FILE_NAME in result.path + assert result.content == pnpm_lock_content + + def test_get_lock_file_name(self, restore_pnpm: RestorePnpmDependencies) -> None: + assert restore_pnpm.get_lock_file_name() == PNPM_LOCK_FILE_NAME + + def test_get_commands_returns_pnpm_install(self, restore_pnpm: RestorePnpmDependencies) -> None: + commands = restore_pnpm.get_commands('/path/to/package.json') + assert commands == [['pnpm', 'install', '--ignore-scripts']] diff --git a/tests/cli/files_collector/sca/npm/test_restore_yarn_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_yarn_dependencies.py new file mode 100644 index 00000000..13e321c9 --- /dev/null +++ b/tests/cli/files_collector/sca/npm/test_restore_yarn_dependencies.py @@ -0,0 +1,91 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +import typer + +from cycode.cli.files_collector.sca.npm.restore_yarn_dependencies import ( + YARN_LOCK_FILE_NAME, + RestoreYarnDependencies, +) +from cycode.cli.models import Document + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_yarn(mock_ctx: typer.Context) -> RestoreYarnDependencies: + return RestoreYarnDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_package_json_with_yarn_lock_matches(self, restore_yarn: RestoreYarnDependencies, tmp_path: Path) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'yarn.lock').write_text('# yarn lockfile v1\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_yarn.is_project(doc) is True + + def test_package_json_with_package_manager_yarn_matches(self, restore_yarn: RestoreYarnDependencies) -> None: + content = '{"name": "test", "packageManager": "yarn@4.0.2"}' + doc = Document('package.json', content) + assert restore_yarn.is_project(doc) is True + + def test_package_json_with_engines_yarn_matches(self, restore_yarn: RestoreYarnDependencies) -> None: + content = '{"name": "test", "engines": {"yarn": ">=1.22"}}' + doc = Document('package.json', content) + assert restore_yarn.is_project(doc) is True + + def test_package_json_with_no_yarn_signal_does_not_match( + self, restore_yarn: RestoreYarnDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_yarn.is_project(doc) is False + + def test_package_json_with_pnpm_lock_does_not_match( + self, restore_yarn: RestoreYarnDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'pnpm-lock.yaml').write_text('lockfileVersion: 5.4\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_yarn.is_project(doc) is False + + def test_tsconfig_json_does_not_match(self, restore_yarn: RestoreYarnDependencies) -> None: + doc = Document('tsconfig.json', '{"compilerOptions": {}}') + assert restore_yarn.is_project(doc) is False + + def test_package_manager_npm_does_not_match(self, restore_yarn: RestoreYarnDependencies) -> None: + content = '{"name": "test", "packageManager": "npm@9.0.0"}' + doc = Document('package.json', content) + assert restore_yarn.is_project(doc) is False + + def test_invalid_json_content_does_not_match(self, restore_yarn: RestoreYarnDependencies) -> None: + doc = Document('package.json', 'not valid json') + assert restore_yarn.is_project(doc) is False + + +class TestTryRestoreDependencies: + def test_existing_yarn_lock_returned_directly(self, restore_yarn: RestoreYarnDependencies, tmp_path: Path) -> None: + yarn_lock_content = '# yarn lockfile v1\n\npackage@1.0.0:\n resolved "https://example.com"\n' + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'yarn.lock').write_text(yarn_lock_content) + + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + result = restore_yarn.try_restore_dependencies(doc) + + assert result is not None + assert YARN_LOCK_FILE_NAME in result.path + assert result.content == yarn_lock_content + + def test_get_lock_file_name(self, restore_yarn: RestoreYarnDependencies) -> None: + assert restore_yarn.get_lock_file_name() == YARN_LOCK_FILE_NAME + + def test_get_commands_returns_yarn_install(self, restore_yarn: RestoreYarnDependencies) -> None: + commands = restore_yarn.get_commands('/path/to/package.json') + assert commands == [['yarn', 'install', '--ignore-scripts']] diff --git a/tests/cli/files_collector/sca/php/__init__.py b/tests/cli/files_collector/sca/php/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/php/test_restore_composer_dependencies.py b/tests/cli/files_collector/sca/php/test_restore_composer_dependencies.py new file mode 100644 index 00000000..463eeddb --- /dev/null +++ b/tests/cli/files_collector/sca/php/test_restore_composer_dependencies.py @@ -0,0 +1,82 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +import typer + +from cycode.cli.files_collector.sca.php.restore_composer_dependencies import ( + COMPOSER_LOCK_FILE_NAME, + RestoreComposerDependencies, +) +from cycode.cli.models import Document + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_composer(mock_ctx: typer.Context) -> RestoreComposerDependencies: + return RestoreComposerDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_composer_json_matches(self, restore_composer: RestoreComposerDependencies) -> None: + doc = Document('composer.json', '{"name": "vendor/project"}\n') + assert restore_composer.is_project(doc) is True + + def test_composer_json_in_subdir_matches(self, restore_composer: RestoreComposerDependencies) -> None: + doc = Document('myapp/composer.json', '{"name": "vendor/project"}\n') + assert restore_composer.is_project(doc) is True + + def test_composer_lock_does_not_match(self, restore_composer: RestoreComposerDependencies) -> None: + doc = Document('composer.lock', '{"_readme": []}\n') + assert restore_composer.is_project(doc) is False + + def test_package_json_does_not_match(self, restore_composer: RestoreComposerDependencies) -> None: + doc = Document('package.json', '{"name": "test"}\n') + assert restore_composer.is_project(doc) is False + + def test_other_json_does_not_match(self, restore_composer: RestoreComposerDependencies) -> None: + doc = Document('config.json', '{"setting": "value"}\n') + assert restore_composer.is_project(doc) is False + + +class TestTryRestoreDependencies: + def test_existing_composer_lock_returned_directly( + self, restore_composer: RestoreComposerDependencies, tmp_path: Path + ) -> None: + lock_content = '{\n "_readme": ["This file is @generated by Composer"],\n "packages": []\n}\n' + (tmp_path / 'composer.json').write_text('{"name": "vendor/project"}\n') + (tmp_path / 'composer.lock').write_text(lock_content) + + doc = Document( + str(tmp_path / 'composer.json'), + '{"name": "vendor/project"}\n', + absolute_path=str(tmp_path / 'composer.json'), + ) + result = restore_composer.try_restore_dependencies(doc) + + assert result is not None + assert COMPOSER_LOCK_FILE_NAME in result.path + assert result.content == lock_content + + def test_get_lock_file_name(self, restore_composer: RestoreComposerDependencies) -> None: + assert restore_composer.get_lock_file_name() == COMPOSER_LOCK_FILE_NAME + + def test_get_commands_returns_composer_update(self, restore_composer: RestoreComposerDependencies) -> None: + commands = restore_composer.get_commands('/path/to/composer.json') + assert commands == [ + [ + 'composer', + 'update', + '--no-cache', + '--no-install', + '--no-scripts', + '--ignore-platform-reqs', + ] + ] diff --git a/tests/cli/files_collector/sca/python/__init__.py b/tests/cli/files_collector/sca/python/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/python/test_restore_pipenv_dependencies.py b/tests/cli/files_collector/sca/python/test_restore_pipenv_dependencies.py new file mode 100644 index 00000000..9d34a7e3 --- /dev/null +++ b/tests/cli/files_collector/sca/python/test_restore_pipenv_dependencies.py @@ -0,0 +1,73 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +import typer + +from cycode.cli.files_collector.sca.python.restore_pipenv_dependencies import ( + PIPENV_LOCK_FILE_NAME, + RestorePipenvDependencies, +) +from cycode.cli.models import Document + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_pipenv(mock_ctx: typer.Context) -> RestorePipenvDependencies: + return RestorePipenvDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_pipfile_matches(self, restore_pipenv: RestorePipenvDependencies) -> None: + doc = Document('Pipfile', '[[source]]\nname = "pypi"\n') + assert restore_pipenv.is_project(doc) is True + + def test_pipfile_in_subdir_matches(self, restore_pipenv: RestorePipenvDependencies) -> None: + doc = Document('myapp/Pipfile', '[[source]]\nname = "pypi"\n') + assert restore_pipenv.is_project(doc) is True + + def test_pipfile_lock_does_not_match(self, restore_pipenv: RestorePipenvDependencies) -> None: + doc = Document('Pipfile.lock', '{"default": {}}\n') + assert restore_pipenv.is_project(doc) is False + + def test_requirements_txt_does_not_match(self, restore_pipenv: RestorePipenvDependencies) -> None: + doc = Document('requirements.txt', 'requests==2.31.0\n') + assert restore_pipenv.is_project(doc) is False + + def test_pyproject_toml_does_not_match(self, restore_pipenv: RestorePipenvDependencies) -> None: + doc = Document('pyproject.toml', '[build-system]\nrequires = ["setuptools"]\n') + assert restore_pipenv.is_project(doc) is False + + +class TestTryRestoreDependencies: + def test_existing_pipfile_lock_returned_directly( + self, restore_pipenv: RestorePipenvDependencies, tmp_path: Path + ) -> None: + lock_content = '{"_meta": {"hash": {"sha256": "abc"}}, "default": {}, "develop": {}}\n' + (tmp_path / 'Pipfile').write_text('[[source]]\nname = "pypi"\n') + (tmp_path / 'Pipfile.lock').write_text(lock_content) + + doc = Document( + str(tmp_path / 'Pipfile'), + '[[source]]\nname = "pypi"\n', + absolute_path=str(tmp_path / 'Pipfile'), + ) + result = restore_pipenv.try_restore_dependencies(doc) + + assert result is not None + assert PIPENV_LOCK_FILE_NAME in result.path + assert result.content == lock_content + + def test_get_lock_file_name(self, restore_pipenv: RestorePipenvDependencies) -> None: + assert restore_pipenv.get_lock_file_name() == PIPENV_LOCK_FILE_NAME + + def test_get_commands_returns_pipenv_lock(self, restore_pipenv: RestorePipenvDependencies) -> None: + commands = restore_pipenv.get_commands('/path/to/Pipfile') + assert commands == [['pipenv', 'lock']] diff --git a/tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py b/tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py new file mode 100644 index 00000000..73f0d14f --- /dev/null +++ b/tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py @@ -0,0 +1,99 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +import typer + +from cycode.cli.files_collector.sca.python.restore_poetry_dependencies import ( + POETRY_LOCK_FILE_NAME, + RestorePoetryDependencies, +) +from cycode.cli.models import Document + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_poetry(mock_ctx: typer.Context) -> RestorePoetryDependencies: + return RestorePoetryDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_pyproject_toml_with_poetry_lock_matches( + self, restore_poetry: RestorePoetryDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'pyproject.toml').write_text('[tool.poetry]\nname = "test"\n') + (tmp_path / 'poetry.lock').write_text('# This file is generated by Poetry\n') + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[tool.poetry]\nname = "test"\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + assert restore_poetry.is_project(doc) is True + + def test_pyproject_toml_with_tool_poetry_section_matches(self, restore_poetry: RestorePoetryDependencies) -> None: + content = '[tool.poetry]\nname = "my-project"\nversion = "1.0.0"\n' + doc = Document('pyproject.toml', content) + assert restore_poetry.is_project(doc) is True + + def test_pyproject_toml_without_poetry_section_does_not_match( + self, restore_poetry: RestorePoetryDependencies, tmp_path: Path + ) -> None: + content = '[build-system]\nrequires = ["setuptools"]\n' + (tmp_path / 'pyproject.toml').write_text(content) + doc = Document( + str(tmp_path / 'pyproject.toml'), + content, + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + assert restore_poetry.is_project(doc) is False + + def test_requirements_txt_does_not_match(self, restore_poetry: RestorePoetryDependencies) -> None: + doc = Document('requirements.txt', 'requests==2.31.0\n') + assert restore_poetry.is_project(doc) is False + + def test_setup_py_does_not_match(self, restore_poetry: RestorePoetryDependencies) -> None: + doc = Document('setup.py', 'from setuptools import setup\nsetup()\n') + assert restore_poetry.is_project(doc) is False + + def test_empty_content_does_not_match(self, restore_poetry: RestorePoetryDependencies, tmp_path: Path) -> None: + (tmp_path / 'pyproject.toml').write_text('') + doc = Document( + str(tmp_path / 'pyproject.toml'), + '', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + assert restore_poetry.is_project(doc) is False + + +class TestTryRestoreDependencies: + def test_existing_poetry_lock_returned_directly( + self, restore_poetry: RestorePoetryDependencies, tmp_path: Path + ) -> None: + lock_content = '# This file is generated by Poetry\n\n[[package]]\nname = "requests"\n' + (tmp_path / 'pyproject.toml').write_text('[tool.poetry]\nname = "test"\n') + (tmp_path / 'poetry.lock').write_text(lock_content) + + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[tool.poetry]\nname = "test"\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + result = restore_poetry.try_restore_dependencies(doc) + + assert result is not None + assert POETRY_LOCK_FILE_NAME in result.path + assert result.content == lock_content + + def test_get_lock_file_name(self, restore_poetry: RestorePoetryDependencies) -> None: + assert restore_poetry.get_lock_file_name() == POETRY_LOCK_FILE_NAME + + def test_get_commands_returns_poetry_lock(self, restore_poetry: RestorePoetryDependencies) -> None: + commands = restore_poetry.get_commands('/path/to/pyproject.toml') + assert commands == [['poetry', 'lock']] From d9ce12c3af14610b6896bb27e8be5ace5839b214 Mon Sep 17 00:00:00 2001 From: Mateusz Sterczewski Date: Tue, 3 Mar 2026 15:40:19 +0100 Subject: [PATCH 021/123] CM-60184-Scans using presigned post url (#395) --- cycode/cli/apps/scan/code_scanner.py | 80 +++++++++++++++++++- cycode/cli/apps/scan/commit_range_scanner.py | 59 +++++++++++++-- cycode/cli/consts.py | 7 +- cycode/cli/files_collector/zip_documents.py | 6 +- cycode/cli/utils/scan_batch.py | 6 +- cycode/cli/utils/scan_utils.py | 5 ++ cycode/cyclient/models.py | 20 +++++ cycode/cyclient/scan_client.py | 61 +++++++++++++++ 8 files changed, 228 insertions(+), 16 deletions(-) diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index 3ffefd0f..5e5d0555 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -29,12 +29,15 @@ generate_unique_scan_id, is_cycodeignore_allowed_by_scan_config, set_issue_detected_by_scan_results, + should_use_presigned_upload, ) from cycode.cyclient.models import ZippedFileScanResult from cycode.logger import get_logger if TYPE_CHECKING: from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip + from cycode.cli.printers.console_printer import ConsolePrinter + from cycode.cli.utils.progress_bar import BaseProgressBar from cycode.cyclient.scan_client import ScanClient start_scan_time = time.time() @@ -106,7 +109,10 @@ def _should_use_sync_flow(command_scan_type: str, scan_type: str, sync_option: b def _get_scan_documents_thread_func( - ctx: typer.Context, is_git_diff: bool, is_commit_range: bool, scan_parameters: dict + ctx: typer.Context, + is_git_diff: bool, + is_commit_range: bool, + scan_parameters: dict, ) -> Callable[[list[Document]], tuple[str, CliError, LocalScanResult]]: cycode_client = ctx.obj['client'] scan_type = ctx.obj['scan_type'] @@ -180,6 +186,36 @@ def _scan_batch_thread_func(batch: list[Document]) -> tuple[str, CliError, Local return _scan_batch_thread_func +def _run_presigned_upload_scan( + scan_batch_thread_func: Callable, + scan_type: str, + documents_to_scan: list[Document], + progress_bar: 'BaseProgressBar', + printer: 'ConsolePrinter', +) -> tuple: + try: + # Try to zip all documents as a single batch; ZipTooLargeError raised if it exceeds the scan type's limit + zip_documents(scan_type, documents_to_scan) + # It fits: skip batching and upload everything as one ZIP + return run_parallel_batched_scan( + scan_batch_thread_func, + scan_type, + documents_to_scan, + progress_bar=progress_bar, + skip_batching=True, + ) + except custom_exceptions.ZipTooLargeError: + printer.print_warning( + 'The scan is too large to upload as a single file. This may result in corrupted scan results.' + ) + return run_parallel_batched_scan( + scan_batch_thread_func, + scan_type, + documents_to_scan, + progress_bar=progress_bar, + ) + + def scan_documents( ctx: typer.Context, documents_to_scan: list[Document], @@ -203,9 +239,15 @@ def scan_documents( return scan_batch_thread_func = _get_scan_documents_thread_func(ctx, is_git_diff, is_commit_range, scan_parameters) - errors, local_scan_results = run_parallel_batched_scan( - scan_batch_thread_func, scan_type, documents_to_scan, progress_bar=progress_bar - ) + + if should_use_presigned_upload(scan_type): + errors, local_scan_results = _run_presigned_upload_scan( + scan_batch_thread_func, scan_type, documents_to_scan, progress_bar, printer + ) + else: + errors, local_scan_results = run_parallel_batched_scan( + scan_batch_thread_func, scan_type, documents_to_scan, progress_bar=progress_bar + ) try_set_aggregation_report_url_if_needed(ctx, scan_parameters, ctx.obj['client'], scan_type) @@ -217,6 +259,31 @@ def scan_documents( print_local_scan_results(ctx, local_scan_results, errors) +def _perform_scan_v4_async( + cycode_client: 'ScanClient', + zipped_documents: 'InMemoryZip', + scan_type: str, + scan_parameters: dict, + is_git_diff: bool, + is_commit_range: bool, +) -> ZippedFileScanResult: + upload_link = cycode_client.get_upload_link(scan_type) + logger.debug('Got upload link, %s', {'upload_id': upload_link.upload_id}) + + cycode_client.upload_to_presigned_post(upload_link.url, upload_link.presigned_post_fields, zipped_documents) + logger.debug('Uploaded zip to presigned URL') + + scan_async_result = cycode_client.scan_repository_from_upload_id( + scan_type, upload_link.upload_id, scan_parameters, is_git_diff, is_commit_range + ) + logger.debug( + 'Presigned upload scan request triggered, %s', + {'scan_id': scan_async_result.scan_id, 'upload_id': upload_link.upload_id}, + ) + + return poll_scan_results(cycode_client, scan_async_result.scan_id, scan_type, scan_parameters) + + def _perform_scan_async( cycode_client: 'ScanClient', zipped_documents: 'InMemoryZip', @@ -262,6 +329,11 @@ def _perform_scan( # it does not support commit range scans; should_use_sync_flow handles it return _perform_scan_sync(cycode_client, zipped_documents, scan_type, scan_parameters, is_git_diff) + if should_use_presigned_upload(scan_type): + return _perform_scan_v4_async( + cycode_client, zipped_documents, scan_type, scan_parameters, is_git_diff, is_commit_range + ) + return _perform_scan_async(cycode_client, zipped_documents, scan_type, scan_parameters, is_commit_range) diff --git a/cycode/cli/apps/scan/commit_range_scanner.py b/cycode/cli/apps/scan/commit_range_scanner.py index 85497d5f..54223a86 100644 --- a/cycode/cli/apps/scan/commit_range_scanner.py +++ b/cycode/cli/apps/scan/commit_range_scanner.py @@ -44,6 +44,7 @@ generate_unique_scan_id, is_cycodeignore_allowed_by_scan_config, set_issue_detected_by_scan_results, + should_use_presigned_upload, ) from cycode.cyclient.models import ZippedFileScanResult from cycode.logger import get_logger @@ -86,6 +87,38 @@ def _perform_commit_range_scan_async( return poll_scan_results(cycode_client, scan_async_result.scan_id, scan_type, scan_parameters, timeout) +def _perform_commit_range_scan_v4_async( + cycode_client: 'ScanClient', + from_commit_zipped_documents: 'InMemoryZip', + to_commit_zipped_documents: 'InMemoryZip', + scan_type: str, + scan_parameters: dict, + timeout: Optional[int] = None, +) -> ZippedFileScanResult: + from_upload_link = cycode_client.get_upload_link(scan_type) + logger.debug('Got from-commit upload link, %s', {'upload_id': from_upload_link.upload_id}) + + cycode_client.upload_to_presigned_post( + from_upload_link.url, from_upload_link.presigned_post_fields, from_commit_zipped_documents + ) + logger.debug('Uploaded from-commit zip') + + to_upload_link = cycode_client.get_upload_link(scan_type) + logger.debug('Got to-commit upload link, %s', {'upload_id': to_upload_link.upload_id}) + + cycode_client.upload_to_presigned_post( + to_upload_link.url, to_upload_link.presigned_post_fields, to_commit_zipped_documents + ) + logger.debug('Uploaded to-commit zip') + + scan_async_result = cycode_client.commit_range_scan_from_upload_ids( + scan_type, from_upload_link.upload_id, to_upload_link.upload_id, scan_parameters + ) + logger.debug('V4 commit range scan request triggered, %s', {'scan_id': scan_async_result.scan_id}) + + return poll_scan_results(cycode_client, scan_async_result.scan_id, scan_type, scan_parameters, timeout) + + def _scan_commit_range_documents( ctx: typer.Context, from_documents_to_scan: list[Document], @@ -118,14 +151,24 @@ def _scan_commit_range_documents( # for SAST it is files with diff between from_commit and to_commit to_commit_zipped_documents = zip_documents(scan_type, to_documents_to_scan) - scan_result = _perform_commit_range_scan_async( - cycode_client, - from_commit_zipped_documents, - to_commit_zipped_documents, - scan_type, - scan_parameters, - timeout, - ) + if should_use_presigned_upload(scan_type): + scan_result = _perform_commit_range_scan_v4_async( + cycode_client, + from_commit_zipped_documents, + to_commit_zipped_documents, + scan_type, + scan_parameters, + timeout, + ) + else: + scan_result = _perform_commit_range_scan_async( + cycode_client, + from_commit_zipped_documents, + to_commit_zipped_documents, + scan_type, + scan_parameters, + timeout, + ) enrich_scan_result_with_data_from_detection_rules(cycode_client, scan_result) progress_bar.update(ScanProgressBarSection.SCAN) diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 8f051edd..31ab6ef9 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -192,15 +192,18 @@ # 5MB in bytes (in decimal) FILE_MAX_SIZE_LIMIT_IN_BYTES = 5000000 +PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES = 5 * 1024 * 1024 * 1024 # 5 GB (S3 presigned POST limit) +PRESIGNED_UPLOAD_SCAN_TYPES = {SAST_SCAN_TYPE} + DEFAULT_ZIP_MAX_SIZE_LIMIT_IN_BYTES = 20 * 1024 * 1024 ZIP_MAX_SIZE_LIMIT_IN_BYTES = { SCA_SCAN_TYPE: 200 * 1024 * 1024, - SAST_SCAN_TYPE: 50 * 1024 * 1024, + SAST_SCAN_TYPE: PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES, } # scan in batches DEFAULT_SCAN_BATCH_MAX_SIZE_IN_BYTES = 9 * 1024 * 1024 -SCAN_BATCH_MAX_SIZE_IN_BYTES = {SAST_SCAN_TYPE: 50 * 1024 * 1024} +SCAN_BATCH_MAX_SIZE_IN_BYTES = {SAST_SCAN_TYPE: PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES} SCAN_BATCH_MAX_SIZE_IN_BYTES_ENV_VAR_NAME = 'SCAN_BATCH_MAX_SIZE_IN_BYTES' DEFAULT_SCAN_BATCH_MAX_FILES_COUNT = 1000 diff --git a/cycode/cli/files_collector/zip_documents.py b/cycode/cli/files_collector/zip_documents.py index 6f5edd81..7927bdc6 100644 --- a/cycode/cli/files_collector/zip_documents.py +++ b/cycode/cli/files_collector/zip_documents.py @@ -17,7 +17,11 @@ def _validate_zip_file_size(scan_type: str, zip_file_size: int) -> None: raise custom_exceptions.ZipTooLargeError(max_size_limit) -def zip_documents(scan_type: str, documents: list[Document], zip_file: Optional[InMemoryZip] = None) -> InMemoryZip: +def zip_documents( + scan_type: str, + documents: list[Document], + zip_file: Optional[InMemoryZip] = None, +) -> InMemoryZip: if zip_file is None: zip_file = InMemoryZip() diff --git a/cycode/cli/utils/scan_batch.py b/cycode/cli/utils/scan_batch.py index 8bfd7ed0..97e58bc7 100644 --- a/cycode/cli/utils/scan_batch.py +++ b/cycode/cli/utils/scan_batch.py @@ -111,9 +111,13 @@ def run_parallel_batched_scan( scan_type: str, documents: list[Document], progress_bar: 'BaseProgressBar', + skip_batching: bool = False, ) -> tuple[dict[str, 'CliError'], list['LocalScanResult']]: # batching is disabled for SCA; requested by Mor - batches = [documents] if scan_type == consts.SCA_SCAN_TYPE else split_documents_into_batches(scan_type, documents) + if scan_type == consts.SCA_SCAN_TYPE or skip_batching: + batches = [documents] + else: + batches = split_documents_into_batches(scan_type, documents) progress_bar.set_section_length(ScanProgressBarSection.SCAN, len(batches)) # * 3 # TODO(MarshalX): we should multiply the count of batches in SCAN section because each batch has 3 steps: diff --git a/cycode/cli/utils/scan_utils.py b/cycode/cli/utils/scan_utils.py index be86716b..819a4116 100644 --- a/cycode/cli/utils/scan_utils.py +++ b/cycode/cli/utils/scan_utils.py @@ -5,6 +5,7 @@ import typer +from cycode.cli import consts from cycode.cli.cli_types import SeverityOption if TYPE_CHECKING: @@ -31,6 +32,10 @@ def is_cycodeignore_allowed_by_scan_config(ctx: typer.Context) -> bool: return scan_config.is_cycode_ignore_allowed if scan_config else True +def should_use_presigned_upload(scan_type: str) -> bool: + return scan_type in consts.PRESIGNED_UPLOAD_SCAN_TYPES + + def generate_unique_scan_id() -> UUID: if 'PYTEST_TEST_UNIQUE_ID' in os.environ: return UUID(os.environ['PYTEST_TEST_UNIQUE_ID']) diff --git a/cycode/cyclient/models.py b/cycode/cyclient/models.py index c3144a53..904fe0ef 100644 --- a/cycode/cyclient/models.py +++ b/cycode/cyclient/models.py @@ -114,6 +114,26 @@ def build_dto(self, data: dict[str, Any], **_) -> 'ScanResult': return ScanResult(**data) +@dataclass +class UploadLinkResponse: + upload_id: str + url: str + presigned_post_fields: dict[str, str] + + +class UploadLinkResponseSchema(Schema): + class Meta: + unknown = EXCLUDE + + upload_id = fields.String() + url = fields.String() + presigned_post_fields = fields.Dict(keys=fields.String(), values=fields.String()) + + @post_load + def build_dto(self, data: dict[str, Any], **_) -> 'UploadLinkResponse': + return UploadLinkResponse(**data) + + class ScanInitializationResponse(Schema): def __init__(self, scan_id: Optional[str] = None, err: Optional[str] = None) -> None: super().__init__() diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index 4f2debca..24c5ac46 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Optional, Union from uuid import UUID +import requests from requests import Response from cycode.cli import consts @@ -25,6 +26,7 @@ def __init__( self.scan_config = scan_config self._SCAN_SERVICE_CLI_CONTROLLER_PATH = 'api/v1/cli-scan' + self._SCAN_SERVICE_V4_CLI_CONTROLLER_PATH = 'api/v4/scans/cli' self._DETECTIONS_SERVICE_CLI_CONTROLLER_PATH = 'api/v1/detections/cli' self._POLICIES_SERVICE_CONTROLLER_PATH_V3 = 'api/v3/policies' @@ -56,6 +58,10 @@ def get_scan_aggregation_report_url(self, aggregation_id: str, scan_type: str) - ) return models.ScanReportUrlResponseSchema().build_dto(response.json()) + def get_scan_service_v4_url_path(self, scan_type: str) -> str: + service_path = self.scan_config.get_service_name(scan_type) + return f'{service_path}/{self._SCAN_SERVICE_V4_CLI_CONTROLLER_PATH}' + def get_zipped_file_scan_async_url_path(self, scan_type: str, should_use_sync_flow: bool = False) -> str: async_scan_type = self.scan_config.get_async_scan_type(scan_type) async_entity_type = self.scan_config.get_async_entity_type(scan_type) @@ -123,6 +129,40 @@ def zipped_file_scan_async( ) return models.ScanInitializationResponseSchema().load(response.json()) + def get_upload_link(self, scan_type: str) -> models.UploadLinkResponse: + async_scan_type = self.scan_config.get_async_scan_type(scan_type) + url_path = f'{self.get_scan_service_v4_url_path(scan_type)}/{async_scan_type}/upload-link' + response = self.scan_cycode_client.get(url_path=url_path, hide_response_content_log=self._hide_response_log) + return models.UploadLinkResponseSchema().load(response.json()) + + def upload_to_presigned_post(self, url: str, fields: dict[str, str], zip_file: 'InMemoryZip') -> None: + multipart = {key: (None, value) for key, value in fields.items()} + multipart['file'] = (None, zip_file.read()) + # We are not using Cycode client, as we are calling aws S3. + response = requests.post(url, files=multipart, timeout=self.scan_cycode_client.timeout) + response.raise_for_status() + + def scan_repository_from_upload_id( + self, + scan_type: str, + upload_id: str, + scan_parameters: dict, + is_git_diff: bool = False, + is_commit_range: bool = False, + ) -> models.ScanInitializationResponse: + async_scan_type = self.scan_config.get_async_scan_type(scan_type) + url_path = f'{self.get_scan_service_v4_url_path(scan_type)}/{async_scan_type}/repository' + response = self.scan_cycode_client.post( + url_path=url_path, + body={ + 'upload_id': upload_id, + 'is_git_diff': is_git_diff, + 'is_commit_range': is_commit_range, + 'scan_parameters': json.dumps(scan_parameters), + }, + ) + return models.ScanInitializationResponseSchema().load(response.json()) + def commit_range_scan_async( self, from_commit_zip_file: InMemoryZip, @@ -161,6 +201,27 @@ def commit_range_scan_async( ) return models.ScanInitializationResponseSchema().load(response.json()) + def commit_range_scan_from_upload_ids( + self, + scan_type: str, + from_commit_upload_id: str, + to_commit_upload_id: str, + scan_parameters: dict, + is_git_diff: bool = False, + ) -> models.ScanInitializationResponse: + async_scan_type = self.scan_config.get_async_scan_type(scan_type) + url_path = f'{self.get_scan_service_v4_url_path(scan_type)}/{async_scan_type}/commit-range' + response = self.scan_cycode_client.post( + url_path=url_path, + body={ + 'from_commit_upload_id': from_commit_upload_id, + 'to_commit_upload_id': to_commit_upload_id, + 'is_git_diff': is_git_diff, + 'scan_parameters': json.dumps(scan_parameters), + }, + ) + return models.ScanInitializationResponseSchema().load(response.json()) + def get_scan_details_path(self, scan_type: str, scan_id: str) -> str: return f'{self.get_scan_service_url_path(scan_type)}/{scan_id}' From 6419aafce60542f606dbcc7c8473ec3de5bbe7ea Mon Sep 17 00:00:00 2001 From: Mateusz Sterczewski Date: Tue, 3 Mar 2026 18:31:16 +0100 Subject: [PATCH 022/123] CM-60459-Fallback on V4 upload failure (#396) --- cycode/cli/apps/scan/code_scanner.py | 10 ++++--- cycode/cli/apps/scan/commit_range_scanner.py | 28 ++++++++++++++------ 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index 5e5d0555..616f22b3 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -3,6 +3,7 @@ from platform import platform from typing import TYPE_CHECKING, Callable, Optional +import requests import typer from cycode.cli import consts @@ -330,9 +331,12 @@ def _perform_scan( return _perform_scan_sync(cycode_client, zipped_documents, scan_type, scan_parameters, is_git_diff) if should_use_presigned_upload(scan_type): - return _perform_scan_v4_async( - cycode_client, zipped_documents, scan_type, scan_parameters, is_git_diff, is_commit_range - ) + try: + return _perform_scan_v4_async( + cycode_client, zipped_documents, scan_type, scan_parameters, is_git_diff, is_commit_range + ) + except requests.exceptions.RequestException: + logger.warning('Direct upload to object storage failed. Falling back to upload via Cycode API. ') return _perform_scan_async(cycode_client, zipped_documents, scan_type, scan_parameters, is_commit_range) diff --git a/cycode/cli/apps/scan/commit_range_scanner.py b/cycode/cli/apps/scan/commit_range_scanner.py index 54223a86..d4ce4be8 100644 --- a/cycode/cli/apps/scan/commit_range_scanner.py +++ b/cycode/cli/apps/scan/commit_range_scanner.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Optional import click +import requests import typer from cycode.cli import consts @@ -152,14 +153,25 @@ def _scan_commit_range_documents( to_commit_zipped_documents = zip_documents(scan_type, to_documents_to_scan) if should_use_presigned_upload(scan_type): - scan_result = _perform_commit_range_scan_v4_async( - cycode_client, - from_commit_zipped_documents, - to_commit_zipped_documents, - scan_type, - scan_parameters, - timeout, - ) + try: + scan_result = _perform_commit_range_scan_v4_async( + cycode_client, + from_commit_zipped_documents, + to_commit_zipped_documents, + scan_type, + scan_parameters, + timeout, + ) + except requests.exceptions.RequestException: + logger.warning('Direct upload to object storage failed. Falling back to upload via Cycode API. ') + scan_result = _perform_commit_range_scan_async( + cycode_client, + from_commit_zipped_documents, + to_commit_zipped_documents, + scan_type, + scan_parameters, + timeout, + ) else: scan_result = _perform_commit_range_scan_async( cycode_client, From 058b06673fff380499ba43697590fd8c14c9c86c Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Thu, 5 Mar 2026 16:51:42 +0000 Subject: [PATCH 023/123] CM-60540: remove binaryornot dep (#397) --- cycode/cli/utils/binary_utils.py | 72 ++++++++++++++++++++++++++++++++ cycode/cli/utils/path_utils.py | 2 +- cycode/cli/utils/string_utils.py | 3 +- cycode/logger.py | 2 - poetry.lock | 48 ++++++--------------- pyproject.toml | 1 - tests/utils/test_binary_utils.py | 42 +++++++++++++++++++ 7 files changed, 128 insertions(+), 42 deletions(-) create mode 100644 cycode/cli/utils/binary_utils.py create mode 100644 tests/utils/test_binary_utils.py diff --git a/cycode/cli/utils/binary_utils.py b/cycode/cli/utils/binary_utils.py new file mode 100644 index 00000000..e61b7ddc --- /dev/null +++ b/cycode/cli/utils/binary_utils.py @@ -0,0 +1,72 @@ +_CONTROL_CHARS = b'\n\r\t\f\b' +_PRINTABLE_ASCII = _CONTROL_CHARS + bytes(range(32, 127)) +_PRINTABLE_HIGH_ASCII = bytes(range(127, 256)) + +# BOM signatures for encodings that legitimately contain null bytes +_BOM_ENCODINGS = ( + (b'\xff\xfe\x00\x00', 'utf-32-le'), + (b'\x00\x00\xfe\xff', 'utf-32-be'), + (b'\xff\xfe', 'utf-16-le'), + (b'\xfe\xff', 'utf-16-be'), +) + + +def _has_bom_encoding(bytes_to_check: bytes) -> bool: + """Check if bytes start with a BOM and can be decoded as that encoding.""" + for bom, encoding in _BOM_ENCODINGS: + if bytes_to_check.startswith(bom): + try: + bytes_to_check.decode(encoding) + return True + except (UnicodeDecodeError, LookupError): + pass + return False + + +def _is_decodable_as_utf8(bytes_to_check: bytes) -> bool: + """Try to decode bytes as UTF-8.""" + try: + bytes_to_check.decode('utf-8') + return True + except UnicodeDecodeError: + return False + + +def is_binary_string(bytes_to_check: bytes) -> bool: + """Check if a chunk of bytes appears to be binary content. + + Uses a simplified version of the Perl detection algorithm, matching + the structure of binaryornot's is_binary_string. + """ + if not bytes_to_check: + return False + + # Binary if control chars are > 30% of the string + low_chars = bytes_to_check.translate(None, _PRINTABLE_ASCII) + nontext_ratio1 = len(low_chars) / len(bytes_to_check) + + # Binary if high ASCII chars are < 5% of the string + high_chars = bytes_to_check.translate(None, _PRINTABLE_HIGH_ASCII) + nontext_ratio2 = len(high_chars) / len(bytes_to_check) + + is_likely_binary = (nontext_ratio1 > 0.3 and nontext_ratio2 < 0.05) or ( + nontext_ratio1 > 0.8 and nontext_ratio2 > 0.8 + ) + + # BOM-marked UTF-16/32 files legitimately contain null bytes. + # Check this first so they aren't misdetected as binary. + if _has_bom_encoding(bytes_to_check): + return False + + has_null_or_xff = b'\x00' in bytes_to_check or b'\xff' in bytes_to_check + + if is_likely_binary: + # Only let UTF-8 rescue data that doesn't contain null bytes. + # Null bytes are valid UTF-8 but almost never appear in real text files, + # whereas binary formats (e.g. .DS_Store) are full of them. + if has_null_or_xff: + return True + return not _is_decodable_as_utf8(bytes_to_check) + + # Null bytes or 0xff in otherwise normal-looking data indicate binary + return bool(has_null_or_xff) diff --git a/cycode/cli/utils/path_utils.py b/cycode/cli/utils/path_utils.py index ce60b0da..c2d59805 100644 --- a/cycode/cli/utils/path_utils.py +++ b/cycode/cli/utils/path_utils.py @@ -4,9 +4,9 @@ from typing import TYPE_CHECKING, AnyStr, Optional, Union import typer -from binaryornot.helpers import is_binary_string from cycode.cli.logger import logger +from cycode.cli.utils.binary_utils import is_binary_string if TYPE_CHECKING: from os import PathLike diff --git a/cycode/cli/utils/string_utils.py b/cycode/cli/utils/string_utils.py index 06d3a51c..43931239 100644 --- a/cycode/cli/utils/string_utils.py +++ b/cycode/cli/utils/string_utils.py @@ -5,9 +5,8 @@ import string from sys import getsizeof -from binaryornot.check import is_binary_string - from cycode.cli.consts import SCA_SHORTCUT_DEPENDENCY_PATHS +from cycode.cli.utils.binary_utils import is_binary_string def obfuscate_text(text: str) -> str: diff --git a/cycode/logger.py b/cycode/logger.py index 2fd44e4f..c5cdebcf 100644 --- a/cycode/logger.py +++ b/cycode/logger.py @@ -31,8 +31,6 @@ def _set_io_encodings() -> None: logging.getLogger('werkzeug').setLevel(logging.WARNING) logging.getLogger('schedule').setLevel(logging.WARNING) logging.getLogger('kubernetes').setLevel(logging.WARNING) -logging.getLogger('binaryornot').setLevel(logging.WARNING) -logging.getLogger('chardet').setLevel(logging.WARNING) logging.getLogger('git.cmd').setLevel(logging.WARNING) logging.getLogger('git.util').setLevel(logging.WARNING) diff --git a/poetry.lock b/poetry.lock index 9a11262a..30e77a12 100644 --- a/poetry.lock +++ b/poetry.lock @@ -31,7 +31,8 @@ version = "4.11.0" description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main"] +markers = "python_version >= \"3.10\"" files = [ {file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"}, {file = "anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4"}, @@ -79,21 +80,6 @@ files = [ {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, ] -[[package]] -name = "binaryornot" -version = "0.4.4" -description = "Ultra-lightweight pure Python package to check if a file is binary or text." -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "binaryornot-0.4.4-py2.py3-none-any.whl", hash = "sha256:b8b71173c917bddcd2c16070412e369c3ed7f0528926f70cac18a6c97fd563e4"}, - {file = "binaryornot-0.4.4.tar.gz", hash = "sha256:359501dfc9d40632edc9fac890e19542db1a287bbcfa58175b66658392018061"}, -] - -[package.dependencies] -chardet = ">=3.0.2" - [[package]] name = "certifi" version = "2025.10.5" @@ -204,18 +190,6 @@ files = [ [package.dependencies] pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} -[[package]] -name = "chardet" -version = "5.2.0" -description = "Universal encoding detector for Python 3" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, - {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, -] - [[package]] name = "charset-normalizer" version = "3.4.4" @@ -534,12 +508,12 @@ version = "1.3.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["main", "dev", "test"] -markers = "python_version < \"3.11\"" +groups = ["main", "test"] files = [ {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, ] +markers = {main = "python_version == \"3.10\"", test = "python_version < \"3.11\""} [package.dependencies] typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} @@ -663,7 +637,7 @@ version = "3.11" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" -groups = ["main", "dev", "test"] +groups = ["main", "test"] files = [ {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, @@ -1785,7 +1759,8 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +groups = ["main"] +markers = "python_version >= \"3.10\"" files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -1819,7 +1794,8 @@ version = "0.49.1" description = "The little ASGI library that shines." optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main"] +markers = "python_version >= \"3.10\"" files = [ {file = "starlette-0.49.1-py3-none-any.whl", hash = "sha256:d92ce9f07e4a3caa3ac13a79523bd18e3bc0042bb8ff2d759a8e7dd0e1859875"}, {file = "starlette-0.49.1.tar.gz", hash = "sha256:481a43b71e24ed8c43b11ea02f5353d77840e01480881b8cb5a26b8cae64a8cb"}, @@ -1949,12 +1925,12 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["main", "dev", "test"] +groups = ["main", "test"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] -markers = {dev = "python_version < \"3.13\"", test = "python_version < \"3.11\""} +markers = {test = "python_version < \"3.11\""} [[package]] name = "typing-inspection" @@ -2034,4 +2010,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "593c613fcd6438e2133d90f3777c2050738bfa42bc7f5512e43c612b784a9870" +content-hash = "4f1987623870103055d7f6d2bc359dae11c5fc3239b0e84ff337625bf7c1088d" diff --git a/pyproject.toml b/pyproject.toml index cc6297c9..98de72ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,6 @@ pyyaml = ">=6.0,<7.0" marshmallow = ">=3.15.0,<4.0.0" gitpython = ">=3.1.30,<3.2.0" arrow = ">=1.0.0,<1.4.0" -binaryornot = ">=0.4.4,<0.5.0" requests = ">=2.32.4,<3.0" urllib3 = ">=2.4.0,<3.0.0" pyjwt = ">=2.8.0,<3.0" diff --git a/tests/utils/test_binary_utils.py b/tests/utils/test_binary_utils.py new file mode 100644 index 00000000..c8fa7e53 --- /dev/null +++ b/tests/utils/test_binary_utils.py @@ -0,0 +1,42 @@ +import pytest + +from cycode.cli.utils.binary_utils import is_binary_string + + +@pytest.mark.parametrize( + ('data', 'expected'), + [ + # Empty / None-ish + (b'', False), + (None, False), + # Plain ASCII text + (b'Hello, world!', False), + (b'print("hello")\nfor i in range(10):\n pass\n', False), + # Whitespace-heavy text (tabs, newlines) is not binary + (b'\t\t\n\n\r\n some text\n', False), + # UTF-8 multibyte text (accented, CJK, emoji) + ('café résumé naïve'.encode(), False), + ('日本語テキスト'.encode(), False), + ('🎉🚀💻'.encode(), False), + # BOM-marked UTF-16/32 text is not binary + ('\ufeffHello UTF-16'.encode('utf-16-le'), False), + ('\ufeffHello UTF-16'.encode('utf-16-be'), False), + ('\ufeffHello UTF-32'.encode('utf-32-le'), False), + ('\ufeffHello UTF-32'.encode('utf-32-be'), False), + # Null bytes → binary + (b'\x00', True), + (b'hello\x00world', True), + (b'\x00\x01\x02\x03', True), + # 0xff in otherwise normal data → binary + (b'hello\xffworld', True), + # Mostly control chars + invalid UTF-8 → binary + (b'\x01\x02\x03\x04\x05\x06\x07\x0e\x0f\x10' * 10 + b'\x80', True), + # Real binary format headers + (b'\x89PNG\r\n\x1a\n' + b'\x00' * 100, True), + (b'\x7fELF' + b'\x00' * 100, True), + # DS_Store-like: null-byte-heavy valid UTF-8 → still binary + (b'\x00\x00\x00\x01Bud1' + b'\x00' * 100, True), + ], +) +def test_is_binary_string(data: bytes, expected: bool) -> None: + assert is_binary_string(data) is expected From b71a99253c90e58d32cadbea0b291dfe555351b5 Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Mon, 9 Mar 2026 10:16:00 +0000 Subject: [PATCH 024/123] CM-60683: update multipart dep and schedule monthly dep updates (#398) --- .github/dependabot.yml | 11 +++++++++++ poetry.lock | 10 +++++----- 2 files changed, 16 insertions(+), 5 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..0b845d3b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "monthly" diff --git a/poetry.lock b/poetry.lock index 30e77a12..6dce9f14 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "altgraph" @@ -1282,15 +1282,15 @@ cli = ["click (>=5.0)"] [[package]] name = "python-multipart" -version = "0.0.20" +version = "0.0.22" description = "A streaming multipart parser for Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104"}, - {file = "python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13"}, + {file = "python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155"}, + {file = "python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58"}, ] [[package]] From ae926aba8b637b0d6386a4bfecee399c0bdcce83 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:44:22 +0000 Subject: [PATCH 025/123] Bump docker/setup-buildx-action from 3 to 4 (#401) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-image.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index ae668a3a..49b7a7f7 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -61,7 +61,7 @@ jobs: uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') }} From 1dc3599ad630f72412fd21711c4401b77830d456 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:48:14 +0000 Subject: [PATCH 026/123] Bump actions/cache from 3 to 5 (#400) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build_executable.yml | 2 +- .github/workflows/docker-image.yml | 2 +- .github/workflows/pre_release.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/ruff.yml | 2 +- .github/workflows/tests.yml | 2 +- .github/workflows/tests_full.yml | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 6749ca79..a656bde0 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -68,7 +68,7 @@ jobs: - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@v3 + uses: actions/cache@v5 with: path: ~/.local key: poetry-${{ matrix.os }}-2 # increment to reset cache diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 49b7a7f7..67e6e735 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -34,7 +34,7 @@ jobs: - name: Load cached Poetry setup id: cached_poetry - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 8847499a..b275504f 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -39,7 +39,7 @@ jobs: - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@v3 + uses: actions/cache@v5 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 14ddbe77..00a86207 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,7 +38,7 @@ jobs: - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@v3 + uses: actions/cache@v5 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index eb32b58e..5ac6ee89 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -27,7 +27,7 @@ jobs: - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@v3 + uses: actions/cache@v5 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e2ebf709..2a68eba7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -32,7 +32,7 @@ jobs: - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@v3 + uses: actions/cache@v5 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index aea09b4a..1cc3b236 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -47,7 +47,7 @@ jobs: - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@v3 + uses: actions/cache@v5 with: path: ~/.local key: poetry-${{ matrix.os }}-${{ matrix.python-version }}-3 # increment to reset cache From d273c905728c87a616c36e46501ebb0b26ed8060 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:50:31 +0000 Subject: [PATCH 027/123] Bump actions/setup-python from 4 to 6 (#404) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build_executable.yml | 2 +- .github/workflows/docker-image.yml | 2 +- .github/workflows/pre_release.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/ruff.yml | 2 +- .github/workflows/tests.yml | 2 +- .github/workflows/tests_full.yml | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index a656bde0..9d8c24fc 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -62,7 +62,7 @@ jobs: echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV - name: Set up Python 3.13 - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: '3.13' diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 67e6e735..1c3d2f19 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -28,7 +28,7 @@ jobs: git checkout ${{ steps.latest_tag.outputs.LATEST_TAG }} - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.9' diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index b275504f..802f4e27 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -33,7 +33,7 @@ jobs: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.9' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 00a86207..88f86ef7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,7 @@ jobs: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.9' diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 5ac6ee89..ae6c7913 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@v3 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: 3.9 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2a68eba7..c69fe4ac 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,7 +26,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.9' diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index 1cc3b236..65426b13 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -41,7 +41,7 @@ jobs: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} From a8c309c70c88635dfb64e1b33a3e366dec8b5163 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:04:42 +0000 Subject: [PATCH 028/123] Bump pyfakefs from 5.7.4 to 5.10.2 (#403) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 12 ++++++------ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/poetry.lock b/poetry.lock index 6dce9f14..c85cee5d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "altgraph" @@ -1107,14 +1107,14 @@ yaml = ["pyyaml (>=6.0.1)"] [[package]] name = "pyfakefs" -version = "5.7.4" -description = "pyfakefs implements a fake file system that mocks the Python file system modules." +version = "5.10.2" +description = "Implements a fake file system that mocks the Python file system modules." optional = false python-versions = ">=3.7" groups = ["test"] files = [ - {file = "pyfakefs-5.7.4-py3-none-any.whl", hash = "sha256:3e763d700b91c54ade6388be2cfa4e521abc00e34f7defb84ee511c73031f45f"}, - {file = "pyfakefs-5.7.4.tar.gz", hash = "sha256:4971e65cc80a93a1e6f1e3a4654909c0c493186539084dc9301da3d68c8878fe"}, + {file = "pyfakefs-5.10.2-py3-none-any.whl", hash = "sha256:6ff0e84653a71efc6a73f9ee839c3141e3a7cdf4e1fb97666f82ac5b24308d64"}, + {file = "pyfakefs-5.10.2.tar.gz", hash = "sha256:8ae0e5421e08de4e433853a4609a06a1835f4bc2a3ce13b54f36713a897474ba"}, ] [[package]] @@ -2010,4 +2010,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "4f1987623870103055d7f6d2bc359dae11c5fc3239b0e84ff337625bf7c1088d" +content-hash = "0d8729b4ae9aae821d7f050f680fad1cb5f592ac931c280377fc7c092bfaef94" diff --git a/pyproject.toml b/pyproject.toml index 98de72ea..80a9d7ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,7 @@ pytest = ">=7.3.1,<7.4.0" pytest-mock = ">=3.10.0,<3.11.0" coverage = ">=7.2.3,<7.3.0" responses = ">=0.23.1,<0.24.0" -pyfakefs = ">=5.7.2,<5.8.0" +pyfakefs = ">=5.7.2,<5.11.0" [tool.poetry.group.executable.dependencies] pyinstaller = {version=">=6.0.0,<7.0.0", python=">=3.9,<3.15"} From cfdea2fce9852199b6bfb036f0fc8921988692ae Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:14:44 +0000 Subject: [PATCH 029/123] Bump arrow from 1.3.0 to 1.4.0 (#407) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 36 ++++++++++++++++++------------------ pyproject.toml | 2 +- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/poetry.lock b/poetry.lock index c85cee5d..19faf197 100644 --- a/poetry.lock +++ b/poetry.lock @@ -49,23 +49,23 @@ trio = ["trio (>=0.31.0)"] [[package]] name = "arrow" -version = "1.3.0" +version = "1.4.0" description = "Better dates & times for Python" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "arrow-1.3.0-py3-none-any.whl", hash = "sha256:c728b120ebc00eb84e01882a6f5e7927a53960aa990ce7dd2b10f39005a67f80"}, - {file = "arrow-1.3.0.tar.gz", hash = "sha256:d4540617648cb5f895730f1ad8c82a65f2dad0166f57b75f3ca54759c4d67a85"}, + {file = "arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205"}, + {file = "arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7"}, ] [package.dependencies] python-dateutil = ">=2.7.0" -types-python-dateutil = ">=2.8.10" +tzdata = {version = "*", markers = "python_version >= \"3.9\""} [package.extras] doc = ["doc8", "sphinx (>=7.0.0)", "sphinx-autobuild", "sphinx-autodoc-typehints", "sphinx_rtd_theme (>=1.3.0)"] -test = ["dateparser (==1.*)", "pre-commit", "pytest", "pytest-cov", "pytest-mock", "pytz (==2021.1)", "simplejson (==3.*)"] +test = ["dateparser (==1.*)", "pre-commit", "pytest", "pytest-cov", "pytest-mock", "pytz (==2025.2)", "simplejson (==3.*)"] [[package]] name = "attrs" @@ -1895,18 +1895,6 @@ rich = ">=10.11.0" shellingham = ">=1.3.0" typing-extensions = ">=3.7.4.3" -[[package]] -name = "types-python-dateutil" -version = "2.9.0.20251008" -description = "Typing stubs for python-dateutil" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "types_python_dateutil-2.9.0.20251008-py3-none-any.whl", hash = "sha256:b9a5232c8921cf7661b29c163ccc56055c418ab2c6eabe8f917cbcc73a4c4157"}, - {file = "types_python_dateutil-2.9.0.20251008.tar.gz", hash = "sha256:c3826289c170c93ebd8360c3485311187df740166dbab9dd3b792e69f2bc1f9c"}, -] - [[package]] name = "types-pyyaml" version = "6.0.12.20250915" @@ -1947,6 +1935,18 @@ files = [ [package.dependencies] typing-extensions = ">=4.12.0" +[[package]] +name = "tzdata" +version = "2025.3" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +groups = ["main"] +files = [ + {file = "tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1"}, + {file = "tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7"}, +] + [[package]] name = "urllib3" version = "2.6.3" @@ -2010,4 +2010,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "0d8729b4ae9aae821d7f050f680fad1cb5f592ac931c280377fc7c092bfaef94" +content-hash = "6dca87d737edf6e4481a27f9dbb0a1e20df217ed6da6105f23e09b8cb8588e28" diff --git a/pyproject.toml b/pyproject.toml index 80a9d7ee..e0bca155 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ colorama = ">=0.4.3,<0.5.0" pyyaml = ">=6.0,<7.0" marshmallow = ">=3.15.0,<4.0.0" gitpython = ">=3.1.30,<3.2.0" -arrow = ">=1.0.0,<1.4.0" +arrow = ">=1.0.0,<1.5.0" requests = ">=2.32.4,<3.0" urllib3 = ">=2.4.0,<3.0.0" pyjwt = ">=2.8.0,<3.0" From ae28578ae01ca1c64b852fab0411b35eecfd4980 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:23:33 +0000 Subject: [PATCH 030/123] Bump dunamai from 1.21.2 to 1.26.0 (#405) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 19faf197..449d282d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -489,14 +489,14 @@ test-randomorder = ["pytest-randomly"] [[package]] name = "dunamai" -version = "1.21.2" +version = "1.26.0" description = "Dynamic version generation" optional = false python-versions = ">=3.5" groups = ["executable"] files = [ - {file = "dunamai-1.21.2-py3-none-any.whl", hash = "sha256:87db76405bf9366f9b4925ff5bb1db191a9a1bd9f9693f81c4d3abb8298be6f0"}, - {file = "dunamai-1.21.2.tar.gz", hash = "sha256:05827fb5f032f5596bfc944b23f613c147e676de118681f3bb1559533d8a65c4"}, + {file = "dunamai-1.26.0-py3-none-any.whl", hash = "sha256:f584edf0fda0d308cce0961f807bc90a8fe3d9ff4d62f94e72eca7b43f0ed5f6"}, + {file = "dunamai-1.26.0.tar.gz", hash = "sha256:5396ac43aa20ed059040034e9f9798c7464cf4334c6fc3da3732e29273a2f97d"}, ] [package.dependencies] @@ -2010,4 +2010,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "6dca87d737edf6e4481a27f9dbb0a1e20df217ed6da6105f23e09b8cb8588e28" +content-hash = "a23b8c50dc226cc7929ff04299b3db7d76657b949125e9e1d9fa0d2ba77f7bfa" diff --git a/pyproject.toml b/pyproject.toml index e0bca155..7ff186c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,7 +60,7 @@ pyfakefs = ">=5.7.2,<5.11.0" [tool.poetry.group.executable.dependencies] pyinstaller = {version=">=6.0.0,<7.0.0", python=">=3.9,<3.15"} -dunamai = ">=1.18.0,<1.22.0" +dunamai = ">=1.18.0,<1.27.0" [tool.poetry.group.dev.dependencies] ruff = "0.11.7" From b551df0f747683f4667e5590dca1d0581e486486 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:32:20 +0000 Subject: [PATCH 031/123] Bump responses from 0.23.3 to 0.26.0 (#406) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 25 ++++++------------------- pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/poetry.lock b/poetry.lock index 449d282d..9fba096a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1462,24 +1462,23 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "responses" -version = "0.23.3" +version = "0.26.0" description = "A utility library for mocking out the `requests` Python library." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" groups = ["test"] files = [ - {file = "responses-0.23.3-py3-none-any.whl", hash = "sha256:e6fbcf5d82172fecc0aa1860fd91e58cbfd96cee5e96da5b63fa6eb3caa10dd3"}, - {file = "responses-0.23.3.tar.gz", hash = "sha256:205029e1cb334c21cb4ec64fc7599be48b859a0fd381a42443cdd600bfe8b16a"}, + {file = "responses-0.26.0-py3-none-any.whl", hash = "sha256:03ec4409088cd5c66b71ecbbbd27fe2c58ddfad801c66203457b3e6a04868c37"}, + {file = "responses-0.26.0.tar.gz", hash = "sha256:c7f6923e6343ef3682816ba421c006626777893cb0d5e1434f674b649bac9eb4"}, ] [package.dependencies] pyyaml = "*" requests = ">=2.30.0,<3.0" -types-PyYAML = "*" urllib3 = ">=1.25.10,<3.0" [package.extras] -tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli ; python_version < \"3.11\"", "tomli-w", "types-requests"] +tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli ; python_version < \"3.11\"", "tomli-w", "types-PyYAML", "types-requests"] [[package]] name = "rich" @@ -1895,18 +1894,6 @@ rich = ">=10.11.0" shellingham = ">=1.3.0" typing-extensions = ">=3.7.4.3" -[[package]] -name = "types-pyyaml" -version = "6.0.12.20250915" -description = "Typing stubs for PyYAML" -optional = false -python-versions = ">=3.9" -groups = ["test"] -files = [ - {file = "types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6"}, - {file = "types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3"}, -] - [[package]] name = "typing-extensions" version = "4.15.0" @@ -2010,4 +1997,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "a23b8c50dc226cc7929ff04299b3db7d76657b949125e9e1d9fa0d2ba77f7bfa" +content-hash = "8f8d90fd644445893aff1c2a4af1685426b310912fe56e2f61d2a53766154d08" diff --git a/pyproject.toml b/pyproject.toml index 7ff186c3..901286b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,7 @@ mock = ">=4.0.3,<4.1.0" pytest = ">=7.3.1,<7.4.0" pytest-mock = ">=3.10.0,<3.11.0" coverage = ">=7.2.3,<7.3.0" -responses = ">=0.23.1,<0.24.0" +responses = ">=0.23.1,<0.27.0" pyfakefs = ">=5.7.2,<5.11.0" [tool.poetry.group.executable.dependencies] From 9b0cd9be0126fa693eebad467433ed1c225b9f51 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:43:07 +0000 Subject: [PATCH 032/123] Bump docker/build-push-action from 6 to 7 (#402) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-image.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 1c3d2f19..4e2d4ee8 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -73,7 +73,7 @@ jobs: - name: Build and push id: docker_build if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') }} - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . platforms: linux/amd64,linux/arm64 @@ -83,7 +83,7 @@ jobs: - name: Verify build id: docker_verify_build if: ${{ github.event_name != 'workflow_dispatch' && !startsWith(github.ref, 'refs/tags/v') }} - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . platforms: linux/amd64,linux/arm64 From 4cd333d078c7b6820facfa8cec771798ddc88fd4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:00:52 +0000 Subject: [PATCH 033/123] Bump actions/download-artifact from 4 to 8 (#399) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build_executable.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 9d8c24fc..2807bcf8 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -272,7 +272,7 @@ jobs: - name: Verify macOS artifact end-to-end if: runner.os == 'macOS' && matrix.mode == 'onedir' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: ${{ env.ARTIFACT_NAME }} path: /tmp/artifact-verify From 3906f27961edfab4be36d5c60be0f431c0fc0650 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 13:32:11 +0000 Subject: [PATCH 034/123] Bump patch-ng from 1.18.1 to 1.19.0 (#408) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 6 +++--- pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index 9fba096a..0bd73e47 100644 --- a/poetry.lock +++ b/poetry.lock @@ -860,13 +860,13 @@ files = [ [[package]] name = "patch-ng" -version = "1.18.1" +version = "1.19.0" description = "Library to parse and apply unified diffs." optional = false python-versions = ">=3.6" groups = ["main"] files = [ - {file = "patch-ng-1.18.1.tar.gz", hash = "sha256:52fd46ee46f6c8667692682c1fd7134edc65a2d2d084ebec1d295a6087fc0291"}, + {file = "patch-ng-1.19.0.tar.gz", hash = "sha256:27484792f4ac1c15fe2f3e4cecf74bb9833d33b75c715b71d199f7e1e7d1f786"}, ] [[package]] @@ -1997,4 +1997,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "8f8d90fd644445893aff1c2a4af1685426b310912fe56e2f61d2a53766154d08" +content-hash = "04201585f115c406a49b035b4c3b3be7057baee685997ab57fe39cc964ad5352" diff --git a/pyproject.toml b/pyproject.toml index 901286b4..2beebaf2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ requests = ">=2.32.4,<3.0" urllib3 = ">=2.4.0,<3.0.0" pyjwt = ">=2.8.0,<3.0" rich = ">=13.9.4, <14" -patch-ng = "1.18.1" +patch-ng = "1.19.0" typer = "^0.15.3" tenacity = ">=9.0.0,<9.1.0" mcp = { version = ">=1.9.3,<2.0.0", markers = "python_version >= '3.10'" } From 867055d88d67f7cf817904a0922c945be3cc5185 Mon Sep 17 00:00:00 2001 From: omerr-cycode Date: Wed, 11 Mar 2026 17:43:41 +0200 Subject: [PATCH 035/123] CM-60869 SCA add restored files cleanup mechanism (#409) --- .../sca/base_restore_dependencies.py | 10 ++ .../sca/maven/restore_maven_dependencies.py | 27 ++-- tests/cli/files_collector/sca/__init__.py | 0 tests/cli/files_collector/sca/go/__init__.py | 0 .../sca/go/test_restore_go_dependencies.py | 90 +++++++++++ .../cli/files_collector/sca/maven/__init__.py | 0 .../maven/test_restore_gradle_dependencies.py | 120 ++++++++++++++ .../maven/test_restore_maven_dependencies.py | 124 +++++++++++++++ .../sca/npm/test_restore_npm_dependencies.py | 39 +++++ .../sca/npm/test_restore_pnpm_dependencies.py | 44 +++++- .../sca/npm/test_restore_yarn_dependencies.py | 44 +++++- .../cli/files_collector/sca/nuget/__init__.py | 0 .../nuget/test_restore_nuget_dependencies.py | 89 +++++++++++ .../php/test_restore_composer_dependencies.py | 51 +++++- .../test_restore_pipenv_dependencies.py | 47 +++++- .../test_restore_poetry_dependencies.py | 52 +++++- .../cli/files_collector/sca/ruby/__init__.py | 0 .../ruby/test_restore_ruby_dependencies.py | 89 +++++++++++ tests/cli/files_collector/sca/sbt/__init__.py | 0 .../sca/sbt/test_restore_sbt_dependencies.py | 89 +++++++++++ .../sca/test_base_restore_dependencies.py | 148 ++++++++++++++++++ 21 files changed, 1046 insertions(+), 17 deletions(-) create mode 100644 tests/cli/files_collector/sca/__init__.py create mode 100644 tests/cli/files_collector/sca/go/__init__.py create mode 100644 tests/cli/files_collector/sca/go/test_restore_go_dependencies.py create mode 100644 tests/cli/files_collector/sca/maven/__init__.py create mode 100644 tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py create mode 100644 tests/cli/files_collector/sca/maven/test_restore_maven_dependencies.py create mode 100644 tests/cli/files_collector/sca/nuget/__init__.py create mode 100644 tests/cli/files_collector/sca/nuget/test_restore_nuget_dependencies.py create mode 100644 tests/cli/files_collector/sca/ruby/__init__.py create mode 100644 tests/cli/files_collector/sca/ruby/test_restore_ruby_dependencies.py create mode 100644 tests/cli/files_collector/sca/sbt/__init__.py create mode 100644 tests/cli/files_collector/sca/sbt/test_restore_sbt_dependencies.py create mode 100644 tests/cli/files_collector/sca/test_base_restore_dependencies.py diff --git a/cycode/cli/files_collector/sca/base_restore_dependencies.py b/cycode/cli/files_collector/sca/base_restore_dependencies.py index ac391727..06431f72 100644 --- a/cycode/cli/files_collector/sca/base_restore_dependencies.py +++ b/cycode/cli/files_collector/sca/base_restore_dependencies.py @@ -92,7 +92,9 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: ) if output is None: # one of the commands failed return None + file_was_generated = True else: + file_was_generated = False logger.debug( 'Lock file already exists, skipping restore commands, %s', {'restore_file_path': restore_file_path}, @@ -107,6 +109,14 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: 'content_empty': not restore_file_content, }, ) + + if file_was_generated: + try: + Path(restore_file_path).unlink(missing_ok=True) + logger.debug('Cleaned up generated restore file, %s', {'restore_file_path': restore_file_path}) + except Exception as e: + logger.debug('Failed to clean up generated restore file', exc_info=e) + return Document(relative_restore_file_path, restore_file_content, self.is_git_diff) def get_manifest_dir(self, document: Document) -> Optional[str]: diff --git a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py index 34499bdf..740ccca9 100644 --- a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py @@ -1,4 +1,5 @@ from os import path +from pathlib import Path from typing import Optional import typer @@ -9,7 +10,10 @@ execute_commands, ) from cycode.cli.models import Document -from cycode.cli.utils.path_utils import get_file_content, get_file_dir, join_paths +from cycode.cli.utils.path_utils import get_file_content, join_paths +from cycode.logger import get_logger + +logger = get_logger('Maven Restore Dependencies') BUILD_MAVEN_FILE_NAME = 'pom.xml' MAVEN_CYCLONE_DEP_TREE_FILE_NAME = 'bom.json' @@ -42,15 +46,8 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: if document.content is None: return self.restore_from_secondary_command(document, manifest_file_path) - restore_dependencies_document = super().try_restore_dependencies(document) - if restore_dependencies_document is None: - return None - - restore_dependencies_document.content = get_file_content( - join_paths(get_file_dir(manifest_file_path), self.get_lock_file_name()) - ) - - return restore_dependencies_document + # super() reads the content and cleans up any generated file; no re-read needed + return super().try_restore_dependencies(document) def restore_from_secondary_command(self, document: Document, manifest_file_path: str) -> Optional[Document]: restore_content = execute_commands( @@ -62,11 +59,17 @@ def restore_from_secondary_command(self, document: Document, manifest_file_path: return None restore_file_path = build_dep_tree_path(document.absolute_path, MAVEN_DEP_TREE_FILE_NAME) + content = get_file_content(restore_file_path) + + try: + Path(restore_file_path).unlink(missing_ok=True) + except Exception as e: + logger.debug('Failed to clean up generated maven dep tree file', exc_info=e) + return Document( path=build_dep_tree_path(document.path, MAVEN_DEP_TREE_FILE_NAME), - content=get_file_content(restore_file_path), + content=content, is_git_diff_format=self.is_git_diff, - absolute_path=restore_file_path, ) def create_secondary_restore_commands(self, manifest_file_path: str) -> list[list[str]]: diff --git a/tests/cli/files_collector/sca/__init__.py b/tests/cli/files_collector/sca/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/go/__init__.py b/tests/cli/files_collector/sca/go/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/go/test_restore_go_dependencies.py b/tests/cli/files_collector/sca/go/test_restore_go_dependencies.py new file mode 100644 index 00000000..633d24e8 --- /dev/null +++ b/tests/cli/files_collector/sca/go/test_restore_go_dependencies.py @@ -0,0 +1,90 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.go.restore_go_dependencies import ( + GO_RESTORE_FILE_NAME, + RestoreGoDependencies, +) +from cycode.cli.models import Document + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_go(mock_ctx: typer.Context) -> RestoreGoDependencies: + return RestoreGoDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_go_mod_matches(self, restore_go: RestoreGoDependencies) -> None: + doc = Document('go.mod', 'module example.com/mymod\ngo 1.21\n') + assert restore_go.is_project(doc) is True + + def test_go_sum_matches(self, restore_go: RestoreGoDependencies) -> None: + doc = Document('go.sum', 'github.com/pkg/errors v0.9.1 h1:...\n') + assert restore_go.is_project(doc) is True + + def test_go_in_subdir_matches(self, restore_go: RestoreGoDependencies) -> None: + doc = Document('myapp/go.mod', 'module example.com/mymod\n') + assert restore_go.is_project(doc) is True + + def test_pom_xml_does_not_match(self, restore_go: RestoreGoDependencies) -> None: + doc = Document('pom.xml', '') + assert restore_go.is_project(doc) is False + + +class TestCleanup: + def test_generated_output_file_is_deleted_after_restore( + self, restore_go: RestoreGoDependencies, tmp_path: Path + ) -> None: + # Go handler requires both go.mod and go.sum to be present + (tmp_path / 'go.mod').write_text('module example.com/test\ngo 1.21\n') + (tmp_path / 'go.sum').write_text('github.com/pkg/errors v0.9.1 h1:abc\n') + doc = Document( + str(tmp_path / 'go.mod'), + 'module example.com/test\ngo 1.21\n', + absolute_path=str(tmp_path / 'go.mod'), + ) + output_path = tmp_path / GO_RESTORE_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + # Go uses create_output_file_manually=True; output_file_path is provided + target = output_file_path or str(output_path) + Path(target).write_text('example.com/test github.com/pkg/errors@v0.9.1\n') + return 'graph output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_go.try_restore_dependencies(doc) + + assert result is not None + assert not output_path.exists(), f'{GO_RESTORE_FILE_NAME} must be deleted after restore' + + def test_missing_go_sum_returns_none(self, restore_go: RestoreGoDependencies, tmp_path: Path) -> None: + (tmp_path / 'go.mod').write_text('module example.com/test\ngo 1.21\n') + # go.sum intentionally absent + doc = Document( + str(tmp_path / 'go.mod'), + 'module example.com/test\ngo 1.21\n', + absolute_path=str(tmp_path / 'go.mod'), + ) + + result = restore_go.try_restore_dependencies(doc) + + assert result is None diff --git a/tests/cli/files_collector/sca/maven/__init__.py b/tests/cli/files_collector/sca/maven/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py b/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py new file mode 100644 index 00000000..72ca8a7d --- /dev/null +++ b/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py @@ -0,0 +1,120 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.maven.restore_gradle_dependencies import ( + BUILD_GRADLE_DEP_TREE_FILE_NAME, + BUILD_GRADLE_FILE_NAME, + BUILD_GRADLE_KTS_FILE_NAME, + RestoreGradleDependencies, +) +from cycode.cli.models import Document + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False, 'gradle_all_sub_projects': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_gradle(mock_ctx: typer.Context) -> RestoreGradleDependencies: + return RestoreGradleDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_build_gradle_matches(self, restore_gradle: RestoreGradleDependencies) -> None: + doc = Document('build.gradle', 'apply plugin: "java"\n') + assert restore_gradle.is_project(doc) is True + + def test_build_gradle_kts_matches(self, restore_gradle: RestoreGradleDependencies) -> None: + doc = Document('build.gradle.kts', 'plugins { java }\n') + assert restore_gradle.is_project(doc) is True + + def test_pom_xml_does_not_match(self, restore_gradle: RestoreGradleDependencies) -> None: + doc = Document('pom.xml', '') + assert restore_gradle.is_project(doc) is False + + def test_settings_gradle_does_not_match(self, restore_gradle: RestoreGradleDependencies) -> None: + doc = Document('settings.gradle', 'rootProject.name = "test"') + assert restore_gradle.is_project(doc) is False + + +class TestCleanup: + def test_generated_dep_tree_file_is_deleted_after_restore( + self, restore_gradle: RestoreGradleDependencies, tmp_path: Path + ) -> None: + (tmp_path / BUILD_GRADLE_FILE_NAME).write_text('apply plugin: "java"\n') + doc = Document( + str(tmp_path / BUILD_GRADLE_FILE_NAME), + 'apply plugin: "java"\n', + absolute_path=str(tmp_path / BUILD_GRADLE_FILE_NAME), + ) + output_path = tmp_path / BUILD_GRADLE_DEP_TREE_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + # Gradle uses create_output_file_manually=True; output_file_path is provided + target = output_file_path or str(output_path) + Path(target).write_text('compileClasspath - Compile classpath:\n\\--- org.example:lib:1.0\n') + return 'dep tree output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_gradle.try_restore_dependencies(doc) + + assert result is not None + assert not output_path.exists(), f'{BUILD_GRADLE_DEP_TREE_FILE_NAME} must be deleted after restore' + + def test_preexisting_dep_tree_file_is_not_deleted( + self, restore_gradle: RestoreGradleDependencies, tmp_path: Path + ) -> None: + dep_tree_content = 'compileClasspath - Compile classpath:\n\\--- org.example:lib:1.0\n' + (tmp_path / BUILD_GRADLE_FILE_NAME).write_text('apply plugin: "java"\n') + output_path = tmp_path / BUILD_GRADLE_DEP_TREE_FILE_NAME + output_path.write_text(dep_tree_content) + doc = Document( + str(tmp_path / BUILD_GRADLE_FILE_NAME), + 'apply plugin: "java"\n', + absolute_path=str(tmp_path / BUILD_GRADLE_FILE_NAME), + ) + + result = restore_gradle.try_restore_dependencies(doc) + + assert result is not None + assert output_path.exists(), f'Pre-existing {BUILD_GRADLE_DEP_TREE_FILE_NAME} must not be deleted' + + def test_kts_build_file_also_cleaned_up(self, restore_gradle: RestoreGradleDependencies, tmp_path: Path) -> None: + (tmp_path / BUILD_GRADLE_KTS_FILE_NAME).write_text('plugins { java }\n') + doc = Document( + str(tmp_path / BUILD_GRADLE_KTS_FILE_NAME), + 'plugins { java }\n', + absolute_path=str(tmp_path / BUILD_GRADLE_KTS_FILE_NAME), + ) + output_path = tmp_path / BUILD_GRADLE_DEP_TREE_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + target = output_file_path or str(output_path) + Path(target).write_text('compileClasspath\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_gradle.try_restore_dependencies(doc) + + assert result is not None + assert not output_path.exists(), f'{BUILD_GRADLE_DEP_TREE_FILE_NAME} must be deleted after restore' diff --git a/tests/cli/files_collector/sca/maven/test_restore_maven_dependencies.py b/tests/cli/files_collector/sca/maven/test_restore_maven_dependencies.py new file mode 100644 index 00000000..f365bd92 --- /dev/null +++ b/tests/cli/files_collector/sca/maven/test_restore_maven_dependencies.py @@ -0,0 +1,124 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.maven.restore_maven_dependencies import ( + BUILD_MAVEN_FILE_NAME, + MAVEN_CYCLONE_DEP_TREE_FILE_NAME, + MAVEN_DEP_TREE_FILE_NAME, + RestoreMavenDependencies, +) +from cycode.cli.models import Document + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' +_MAVEN_MODULE = 'cycode.cli.files_collector.sca.maven.restore_maven_dependencies' + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False, 'maven_settings_file': None} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_maven(mock_ctx: typer.Context) -> RestoreMavenDependencies: + return RestoreMavenDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_pom_xml_matches(self, restore_maven: RestoreMavenDependencies) -> None: + doc = Document('pom.xml', '') + assert restore_maven.is_project(doc) is True + + def test_pom_xml_in_subdir_matches(self, restore_maven: RestoreMavenDependencies) -> None: + doc = Document('mymodule/pom.xml', '') + assert restore_maven.is_project(doc) is True + + def test_build_gradle_does_not_match(self, restore_maven: RestoreMavenDependencies) -> None: + doc = Document('build.gradle', '') + assert restore_maven.is_project(doc) is False + + +class TestCleanup: + def test_generated_bom_is_deleted_after_primary_restore( + self, restore_maven: RestoreMavenDependencies, tmp_path: Path + ) -> None: + """Primary path: super().try_restore_dependencies() generates target/bom.json and cleans it up.""" + pom_content = '4.0.0' + (tmp_path / BUILD_MAVEN_FILE_NAME).write_text(pom_content) + target_dir = tmp_path / 'target' + target_dir.mkdir() + bom_path = target_dir / MAVEN_CYCLONE_DEP_TREE_FILE_NAME + doc = Document( + str(tmp_path / BUILD_MAVEN_FILE_NAME), + pom_content, + absolute_path=str(tmp_path / BUILD_MAVEN_FILE_NAME), + ) + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + bom_path.write_text('{"bomFormat": "CycloneDX", "components": []}') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_maven.try_restore_dependencies(doc) + + assert result is not None + assert result.content is not None, 'Document content must be populated even after file deletion' + assert not bom_path.exists(), f'target/{MAVEN_CYCLONE_DEP_TREE_FILE_NAME} must be deleted after restore' + + def test_generated_dep_tree_is_deleted_after_secondary_restore( + self, restore_maven: RestoreMavenDependencies, tmp_path: Path + ) -> None: + """Secondary path (content=None): mvn dependency:tree generates bcde.mvndeps and it must be cleaned up.""" + (tmp_path / BUILD_MAVEN_FILE_NAME).write_text('') + dep_tree_path = tmp_path / MAVEN_DEP_TREE_FILE_NAME + # content=None triggers the secondary command path + doc = Document( + str(tmp_path / BUILD_MAVEN_FILE_NAME), + None, + absolute_path=str(tmp_path / BUILD_MAVEN_FILE_NAME), + ) + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + dep_tree_path.write_text('[INFO] com.example:my-app:jar:1.0.0\n') + return '[INFO] BUILD SUCCESS' + + with patch(f'{_MAVEN_MODULE}.execute_commands', side_effect=side_effect): + result = restore_maven.try_restore_dependencies(doc) + + assert result is not None + assert result.content is not None + assert not dep_tree_path.exists(), f'{MAVEN_DEP_TREE_FILE_NAME} must be deleted after restore' + + def test_preexisting_bom_is_not_deleted(self, restore_maven: RestoreMavenDependencies, tmp_path: Path) -> None: + pom_content = '4.0.0' + (tmp_path / BUILD_MAVEN_FILE_NAME).write_text(pom_content) + target_dir = tmp_path / 'target' + target_dir.mkdir() + bom_path = target_dir / MAVEN_CYCLONE_DEP_TREE_FILE_NAME + bom_path.write_text('{"bomFormat": "CycloneDX", "components": [{"name": "requests"}]}') + doc = Document( + str(tmp_path / BUILD_MAVEN_FILE_NAME), + pom_content, + absolute_path=str(tmp_path / BUILD_MAVEN_FILE_NAME), + ) + + result = restore_maven.try_restore_dependencies(doc) + + assert result is not None + assert bom_path.exists(), f'Pre-existing target/{MAVEN_CYCLONE_DEP_TREE_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py index aa145de3..c418b659 100644 --- a/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py +++ b/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py @@ -1,4 +1,5 @@ from pathlib import Path +from typing import Optional from unittest.mock import MagicMock, patch import pytest @@ -99,6 +100,44 @@ def test_get_lock_file_names_contains_only_npm_lock(self, restore_npm: RestoreNp assert restore_npm.get_lock_file_names() == [NPM_LOCK_FILE_NAME] +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_npm: RestoreNpmDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + lock_path = tmp_path / NPM_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('{"lockfileVersion": 3}') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_npm.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{NPM_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_npm: RestoreNpmDependencies, tmp_path: Path) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + lock_path = tmp_path / NPM_LOCK_FILE_NAME + lock_path.write_text('{"lockfileVersion": 3, "packages": {}}') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + + result = restore_npm.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {NPM_LOCK_FILE_NAME} must not be deleted' + + class TestPrepareManifestFilePath: def test_strips_package_json_filename(self, restore_npm: RestoreNpmDependencies) -> None: path = str(Path('/path/to/package.json')) diff --git a/tests/cli/files_collector/sca/npm/test_restore_pnpm_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_pnpm_dependencies.py index 312cce83..88502578 100644 --- a/tests/cli/files_collector/sca/npm/test_restore_pnpm_dependencies.py +++ b/tests/cli/files_collector/sca/npm/test_restore_pnpm_dependencies.py @@ -1,5 +1,6 @@ from pathlib import Path -from unittest.mock import MagicMock +from typing import Optional +from unittest.mock import MagicMock, patch import pytest import typer @@ -89,3 +90,44 @@ def test_get_lock_file_name(self, restore_pnpm: RestorePnpmDependencies) -> None def test_get_commands_returns_pnpm_install(self, restore_pnpm: RestorePnpmDependencies) -> None: commands = restore_pnpm.get_commands('/path/to/package.json') assert commands == [['pnpm', 'install', '--ignore-scripts']] + + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_pnpm: RestorePnpmDependencies, tmp_path: Path + ) -> None: + # pnpm: no pre-existing pnpm-lock.yaml but package.json indicates pnpm + content = '{"name": "test", "packageManager": "pnpm@8.6.2"}' + (tmp_path / 'package.json').write_text(content) + doc = Document(str(tmp_path / 'package.json'), content, absolute_path=str(tmp_path / 'package.json')) + lock_path = tmp_path / PNPM_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('lockfileVersion: 5.4\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_pnpm.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{PNPM_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_pnpm: RestorePnpmDependencies, tmp_path: Path) -> None: + lock_content = 'lockfileVersion: 5.4\n\npackages:\n /pkg@1.0.0:\n resolution: {}\n' + (tmp_path / 'package.json').write_text('{"name": "test"}') + lock_path = tmp_path / PNPM_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + + result = restore_pnpm.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {PNPM_LOCK_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/npm/test_restore_yarn_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_yarn_dependencies.py index 13e321c9..88175031 100644 --- a/tests/cli/files_collector/sca/npm/test_restore_yarn_dependencies.py +++ b/tests/cli/files_collector/sca/npm/test_restore_yarn_dependencies.py @@ -1,5 +1,6 @@ from pathlib import Path -from unittest.mock import MagicMock +from typing import Optional +from unittest.mock import MagicMock, patch import pytest import typer @@ -89,3 +90,44 @@ def test_get_lock_file_name(self, restore_yarn: RestoreYarnDependencies) -> None def test_get_commands_returns_yarn_install(self, restore_yarn: RestoreYarnDependencies) -> None: commands = restore_yarn.get_commands('/path/to/package.json') assert commands == [['yarn', 'install', '--ignore-scripts']] + + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_yarn: RestoreYarnDependencies, tmp_path: Path + ) -> None: + # Yarn: no pre-existing yarn.lock but package.json indicates yarn + content = '{"name": "test", "packageManager": "yarn@4.0.2"}' + (tmp_path / 'package.json').write_text(content) + doc = Document(str(tmp_path / 'package.json'), content, absolute_path=str(tmp_path / 'package.json')) + lock_path = tmp_path / YARN_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('# yarn lockfile v1\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_yarn.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{YARN_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_yarn: RestoreYarnDependencies, tmp_path: Path) -> None: + lock_content = '# yarn lockfile v1\n\npackage@1.0.0:\n resolved "https://example.com"\n' + (tmp_path / 'package.json').write_text('{"name": "test"}') + lock_path = tmp_path / YARN_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + + result = restore_yarn.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {YARN_LOCK_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/nuget/__init__.py b/tests/cli/files_collector/sca/nuget/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/nuget/test_restore_nuget_dependencies.py b/tests/cli/files_collector/sca/nuget/test_restore_nuget_dependencies.py new file mode 100644 index 00000000..0ec13441 --- /dev/null +++ b/tests/cli/files_collector/sca/nuget/test_restore_nuget_dependencies.py @@ -0,0 +1,89 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.nuget.restore_nuget_dependencies import ( + NUGET_LOCK_FILE_NAME, + RestoreNugetDependencies, +) +from cycode.cli.models import Document + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_nuget(mock_ctx: typer.Context) -> RestoreNugetDependencies: + return RestoreNugetDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_csproj_matches(self, restore_nuget: RestoreNugetDependencies) -> None: + doc = Document('MyProject.csproj', '') + assert restore_nuget.is_project(doc) is True + + def test_vbproj_matches(self, restore_nuget: RestoreNugetDependencies) -> None: + doc = Document('MyProject.vbproj', '') + assert restore_nuget.is_project(doc) is True + + def test_sln_does_not_match(self, restore_nuget: RestoreNugetDependencies) -> None: + doc = Document('MySolution.sln', '') + assert restore_nuget.is_project(doc) is False + + def test_packages_json_does_not_match(self, restore_nuget: RestoreNugetDependencies) -> None: + doc = Document('packages.json', '{}') + assert restore_nuget.is_project(doc) is False + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_nuget: RestoreNugetDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'MyProject.csproj').write_text('') + doc = Document( + str(tmp_path / 'MyProject.csproj'), + '', + absolute_path=str(tmp_path / 'MyProject.csproj'), + ) + lock_path = tmp_path / NUGET_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('{"version": 1, "dependencies": {}}') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_nuget.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{NUGET_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_nuget: RestoreNugetDependencies, tmp_path: Path) -> None: + lock_content = '{"version": 1, "dependencies": {"net8.0": {}}}' + (tmp_path / 'MyProject.csproj').write_text('') + lock_path = tmp_path / NUGET_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document( + str(tmp_path / 'MyProject.csproj'), + '', + absolute_path=str(tmp_path / 'MyProject.csproj'), + ) + + result = restore_nuget.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {NUGET_LOCK_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/php/test_restore_composer_dependencies.py b/tests/cli/files_collector/sca/php/test_restore_composer_dependencies.py index 463eeddb..6e3ea53b 100644 --- a/tests/cli/files_collector/sca/php/test_restore_composer_dependencies.py +++ b/tests/cli/files_collector/sca/php/test_restore_composer_dependencies.py @@ -1,5 +1,6 @@ from pathlib import Path -from unittest.mock import MagicMock +from typing import Optional +from unittest.mock import MagicMock, patch import pytest import typer @@ -68,6 +69,54 @@ def test_existing_composer_lock_returned_directly( def test_get_lock_file_name(self, restore_composer: RestoreComposerDependencies) -> None: assert restore_composer.get_lock_file_name() == COMPOSER_LOCK_FILE_NAME + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_composer: RestoreComposerDependencies, tmp_path: Path + ) -> None: + manifest_content = '{"name": "vendor/project"}\n' + (tmp_path / 'composer.json').write_text(manifest_content) + doc = Document(str(tmp_path / 'composer.json'), manifest_content, absolute_path=str(tmp_path / 'composer.json')) + lock_path = tmp_path / COMPOSER_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('{"_readme": [], "packages": []}') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_composer.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{COMPOSER_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted( + self, restore_composer: RestoreComposerDependencies, tmp_path: Path + ) -> None: + lock_content = '{\n "_readme": ["This file is @generated by Composer"],\n "packages": []\n}\n' + (tmp_path / 'composer.json').write_text('{"name": "vendor/project"}\n') + lock_path = tmp_path / COMPOSER_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document( + str(tmp_path / 'composer.json'), + '{"name": "vendor/project"}\n', + absolute_path=str(tmp_path / 'composer.json'), + ) + + result = restore_composer.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {COMPOSER_LOCK_FILE_NAME} must not be deleted' + + +class TestGetCommands: def test_get_commands_returns_composer_update(self, restore_composer: RestoreComposerDependencies) -> None: commands = restore_composer.get_commands('/path/to/composer.json') assert commands == [ diff --git a/tests/cli/files_collector/sca/python/test_restore_pipenv_dependencies.py b/tests/cli/files_collector/sca/python/test_restore_pipenv_dependencies.py index 9d34a7e3..a6d97320 100644 --- a/tests/cli/files_collector/sca/python/test_restore_pipenv_dependencies.py +++ b/tests/cli/files_collector/sca/python/test_restore_pipenv_dependencies.py @@ -1,5 +1,6 @@ from pathlib import Path -from unittest.mock import MagicMock +from typing import Optional +from unittest.mock import MagicMock, patch import pytest import typer @@ -71,3 +72,47 @@ def test_get_lock_file_name(self, restore_pipenv: RestorePipenvDependencies) -> def test_get_commands_returns_pipenv_lock(self, restore_pipenv: RestorePipenvDependencies) -> None: commands = restore_pipenv.get_commands('/path/to/Pipfile') assert commands == [['pipenv', 'lock']] + + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_pipenv: RestorePipenvDependencies, tmp_path: Path + ) -> None: + manifest_content = '[[source]]\nname = "pypi"\n' + (tmp_path / 'Pipfile').write_text(manifest_content) + doc = Document(str(tmp_path / 'Pipfile'), manifest_content, absolute_path=str(tmp_path / 'Pipfile')) + lock_path = tmp_path / PIPENV_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('{"_meta": {}, "default": {}, "develop": {}}') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_pipenv.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{PIPENV_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted( + self, restore_pipenv: RestorePipenvDependencies, tmp_path: Path + ) -> None: + lock_content = '{"_meta": {"hash": {"sha256": "abc"}}, "default": {}, "develop": {}}\n' + (tmp_path / 'Pipfile').write_text('[[source]]\nname = "pypi"\n') + lock_path = tmp_path / PIPENV_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document( + str(tmp_path / 'Pipfile'), '[[source]]\nname = "pypi"\n', absolute_path=str(tmp_path / 'Pipfile') + ) + + result = restore_pipenv.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {PIPENV_LOCK_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py b/tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py index 73f0d14f..cf4c312b 100644 --- a/tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py +++ b/tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py @@ -1,5 +1,6 @@ from pathlib import Path -from unittest.mock import MagicMock +from typing import Optional +from unittest.mock import MagicMock, patch import pytest import typer @@ -97,3 +98,52 @@ def test_get_lock_file_name(self, restore_poetry: RestorePoetryDependencies) -> def test_get_commands_returns_poetry_lock(self, restore_poetry: RestorePoetryDependencies) -> None: commands = restore_poetry.get_commands('/path/to/pyproject.toml') assert commands == [['poetry', 'lock']] + + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_poetry: RestorePoetryDependencies, tmp_path: Path + ) -> None: + # Poetry: no pre-existing poetry.lock but pyproject.toml indicates poetry + manifest_content = '[tool.poetry]\nname = "test"\nversion = "1.0.0"\n' + (tmp_path / 'pyproject.toml').write_text(manifest_content) + doc = Document( + str(tmp_path / 'pyproject.toml'), manifest_content, absolute_path=str(tmp_path / 'pyproject.toml') + ) + lock_path = tmp_path / POETRY_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('# This file is generated by Poetry\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_poetry.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{POETRY_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted( + self, restore_poetry: RestorePoetryDependencies, tmp_path: Path + ) -> None: + lock_content = '# This file is generated by Poetry\n\n[[package]]\nname = "requests"\n' + (tmp_path / 'pyproject.toml').write_text('[tool.poetry]\nname = "test"\n') + lock_path = tmp_path / POETRY_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[tool.poetry]\nname = "test"\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + + result = restore_poetry.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {POETRY_LOCK_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/ruby/__init__.py b/tests/cli/files_collector/sca/ruby/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/ruby/test_restore_ruby_dependencies.py b/tests/cli/files_collector/sca/ruby/test_restore_ruby_dependencies.py new file mode 100644 index 00000000..ac3e9d73 --- /dev/null +++ b/tests/cli/files_collector/sca/ruby/test_restore_ruby_dependencies.py @@ -0,0 +1,89 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.ruby.restore_ruby_dependencies import ( + RUBY_LOCK_FILE_NAME, + RestoreRubyDependencies, +) +from cycode.cli.models import Document + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_ruby(mock_ctx: typer.Context) -> RestoreRubyDependencies: + return RestoreRubyDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_gemfile_matches(self, restore_ruby: RestoreRubyDependencies) -> None: + doc = Document('Gemfile', "source 'https://rubygems.org'\n") + assert restore_ruby.is_project(doc) is True + + def test_gemfile_in_subdir_matches(self, restore_ruby: RestoreRubyDependencies) -> None: + doc = Document('myapp/Gemfile', "source 'https://rubygems.org'\n") + assert restore_ruby.is_project(doc) is True + + def test_gemfile_lock_does_not_match(self, restore_ruby: RestoreRubyDependencies) -> None: + doc = Document('Gemfile.lock', 'GEM\n remote: https://rubygems.org/\n') + assert restore_ruby.is_project(doc) is False + + def test_other_file_does_not_match(self, restore_ruby: RestoreRubyDependencies) -> None: + doc = Document('Rakefile', '') + assert restore_ruby.is_project(doc) is False + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_ruby: RestoreRubyDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'Gemfile').write_text("source 'https://rubygems.org'\n") + doc = Document( + str(tmp_path / 'Gemfile'), + "source 'https://rubygems.org'\n", + absolute_path=str(tmp_path / 'Gemfile'), + ) + lock_path = tmp_path / RUBY_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('GEM\n remote: https://rubygems.org/\n specs:\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_ruby.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{RUBY_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_ruby: RestoreRubyDependencies, tmp_path: Path) -> None: + lock_content = 'GEM\n remote: https://rubygems.org/\n specs:\n rake (13.0.6)\n' + (tmp_path / 'Gemfile').write_text("source 'https://rubygems.org'\ngem 'rake'\n") + lock_path = tmp_path / RUBY_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document( + str(tmp_path / 'Gemfile'), + "source 'https://rubygems.org'\ngem 'rake'\n", + absolute_path=str(tmp_path / 'Gemfile'), + ) + + result = restore_ruby.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {RUBY_LOCK_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/sbt/__init__.py b/tests/cli/files_collector/sca/sbt/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/sbt/test_restore_sbt_dependencies.py b/tests/cli/files_collector/sca/sbt/test_restore_sbt_dependencies.py new file mode 100644 index 00000000..415e5f94 --- /dev/null +++ b/tests/cli/files_collector/sca/sbt/test_restore_sbt_dependencies.py @@ -0,0 +1,89 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.sbt.restore_sbt_dependencies import ( + SBT_LOCK_FILE_NAME, + RestoreSbtDependencies, +) +from cycode.cli.models import Document + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_sbt(mock_ctx: typer.Context) -> RestoreSbtDependencies: + return RestoreSbtDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_sbt_file_matches(self, restore_sbt: RestoreSbtDependencies) -> None: + doc = Document('build.sbt', 'name := "my-project"\n') + assert restore_sbt.is_project(doc) is True + + def test_sbt_in_subdir_matches(self, restore_sbt: RestoreSbtDependencies) -> None: + doc = Document('myapp/build.sbt', 'name := "my-project"\n') + assert restore_sbt.is_project(doc) is True + + def test_build_gradle_does_not_match(self, restore_sbt: RestoreSbtDependencies) -> None: + doc = Document('build.gradle', '') + assert restore_sbt.is_project(doc) is False + + def test_pom_xml_does_not_match(self, restore_sbt: RestoreSbtDependencies) -> None: + doc = Document('pom.xml', '') + assert restore_sbt.is_project(doc) is False + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_sbt: RestoreSbtDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'build.sbt').write_text('name := "test"\n') + doc = Document( + str(tmp_path / 'build.sbt'), + 'name := "test"\n', + absolute_path=str(tmp_path / 'build.sbt'), + ) + lock_path = tmp_path / SBT_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('[{"org": "org.typelevel", "name": "cats-core", "version": "2.10.0"}]') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_sbt.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{SBT_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_sbt: RestoreSbtDependencies, tmp_path: Path) -> None: + lock_content = '[{"org": "org.typelevel", "name": "cats-core", "version": "2.10.0"}]' + (tmp_path / 'build.sbt').write_text('name := "test"\n') + lock_path = tmp_path / SBT_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document( + str(tmp_path / 'build.sbt'), + 'name := "test"\n', + absolute_path=str(tmp_path / 'build.sbt'), + ) + + result = restore_sbt.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {SBT_LOCK_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/test_base_restore_dependencies.py b/tests/cli/files_collector/sca/test_base_restore_dependencies.py new file mode 100644 index 00000000..b291a95f --- /dev/null +++ b/tests/cli/files_collector/sca/test_base_restore_dependencies.py @@ -0,0 +1,148 @@ +"""Tests for BaseRestoreDependencies cleanup behavior. + +Verifies that lock files generated by restore commands are deleted after +scanning, while pre-existing lock files are left untouched. +""" + +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies +from cycode.cli.models import Document + +_LOCK_FILE_NAME = 'generated.lock' +_MANIFEST_FILE_NAME = 'manifest.txt' +_LOCK_CONTENT = 'generated lock content' + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class _MinimalRestoreHandler(BaseRestoreDependencies): + """Minimal concrete subclass for directly testing BaseRestoreDependencies.""" + + def is_project(self, document: Document) -> bool: + return document.path.endswith(_MANIFEST_FILE_NAME) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [['echo', 'fake']] + + def get_lock_file_name(self) -> str: + return _LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [_LOCK_FILE_NAME] + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def handler(mock_ctx: typer.Context) -> _MinimalRestoreHandler: + return _MinimalRestoreHandler(mock_ctx, is_git_diff=False, command_timeout=30) + + +def _make_doc(tmp_path: Path) -> Document: + manifest = tmp_path / _MANIFEST_FILE_NAME + manifest.write_text('content') + return Document(str(manifest), 'content', absolute_path=str(manifest)) + + +def _make_execute_side_effect(lock_path: Path, content: str = _LOCK_CONTENT) -> object: + """Returns an execute_commands side_effect that writes the lock file.""" + + def side_effect( + commands: list, timeout: int, output_file_path: Optional[str] = None, working_directory: Optional[str] = None + ) -> str: + lock_path.write_text(content) + return 'output' + + return side_effect + + +class TestCleanupGeneratedFile: + def test_generated_lockfile_is_deleted_after_restore(self, handler: _MinimalRestoreHandler, tmp_path: Path) -> None: + doc = _make_doc(tmp_path) + lock_path = tmp_path / _LOCK_FILE_NAME + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=_make_execute_side_effect(lock_path)): + result = handler.try_restore_dependencies(doc) + + assert result is not None + assert result.content == _LOCK_CONTENT + assert not lock_path.exists(), 'Generated lock file must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, handler: _MinimalRestoreHandler, tmp_path: Path) -> None: + doc = _make_doc(tmp_path) + lock_path = tmp_path / _LOCK_FILE_NAME + lock_path.write_text('pre-existing content') + + result = handler.try_restore_dependencies(doc) + + assert result is not None + assert result.content == 'pre-existing content' + assert lock_path.exists(), 'Pre-existing lock file must not be deleted' + + def test_returned_document_content_matches_generated_file( + self, handler: _MinimalRestoreHandler, tmp_path: Path + ) -> None: + doc = _make_doc(tmp_path) + expected = '{"dependencies": {"requests": "^2.31"}}' + lock_path = tmp_path / _LOCK_FILE_NAME + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=_make_execute_side_effect(lock_path, expected)): + result = handler.try_restore_dependencies(doc) + + assert result is not None + assert result.content == expected + + def test_cleanup_does_not_raise_when_generated_file_missing( + self, handler: _MinimalRestoreHandler, tmp_path: Path + ) -> None: + """unlink(missing_ok=True) must not raise even if the command didn't create the file.""" + doc = _make_doc(tmp_path) + + def side_effect(**_kwargs: object) -> str: + return 'output' # returned non-None but didn't create the file + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = handler.try_restore_dependencies(doc) + + # File was never created; content is None but no exception raised + assert result is not None + assert result.content is None + + def test_failed_command_returns_none_and_no_file_created( + self, handler: _MinimalRestoreHandler, tmp_path: Path + ) -> None: + doc = _make_doc(tmp_path) + lock_path = tmp_path / _LOCK_FILE_NAME + + with patch(f'{_BASE_MODULE}.execute_commands', return_value=None): + result = handler.try_restore_dependencies(doc) + + assert result is None + assert not lock_path.exists() + + def test_generated_file_content_available_in_document_after_deletion( + self, handler: _MinimalRestoreHandler, tmp_path: Path + ) -> None: + """The Document must carry the file content even after the file is removed.""" + doc = _make_doc(tmp_path) + lock_path = tmp_path / _LOCK_FILE_NAME + expected = 'important scan data' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=_make_execute_side_effect(lock_path, expected)): + result = handler.try_restore_dependencies(doc) + + assert not lock_path.exists() + assert result is not None + assert result.content == expected From 5bf7393234ac766390ea4bb9ac70ba6a46a8ffc4 Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Mon, 16 Mar 2026 08:46:12 +0000 Subject: [PATCH 036/123] CM-61023: pin build deps (#411) --- .github/workflows/build_executable.yml | 16 ++++++++-------- .github/workflows/docker-image.yml | 21 ++++++++++++--------- .github/workflows/pre_release.yml | 12 ++++++------ .github/workflows/release.yml | 12 ++++++------ .github/workflows/ruff.yml | 13 ++++++++----- .github/workflows/tests.yml | 10 +++++----- .github/workflows/tests_full.yml | 10 +++++----- poetry.lock | 2 +- 8 files changed, 51 insertions(+), 45 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 2807bcf8..e410ba57 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Run Cimon if: matrix.os == 'ubuntu-22.04' - uses: cycodelabs/cimon-action@v0 + uses: cycodelabs/cimon-action@1c3e30d508634b3f4a60b02843126c9f93944d80 # v0.9.4 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} @@ -50,7 +50,7 @@ jobs: uploads.github.com - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 @@ -62,20 +62,20 @@ jobs: echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV - name: Set up Python 3.13 - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.13' - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ~/.local key: poetry-${{ matrix.os }}-2 # increment to reset cache - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 with: version: 2.2.1 @@ -265,14 +265,14 @@ jobs: run: echo "ARTIFACT_NAME=$(./process_executable_file.py dist/cycode-cli)" >> $GITHUB_ENV - name: Upload files as artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: ${{ env.ARTIFACT_NAME }} path: dist - name: Verify macOS artifact end-to-end if: runner.os == 'macOS' && matrix.mode == 'onedir' - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ${{ env.ARTIFACT_NAME }} path: /tmp/artifact-verify @@ -313,7 +313,7 @@ jobs: - name: Upload files to release if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish }} - uses: svenstaro/upload-release-action@v2 + uses: svenstaro/upload-release-action@b98a3b12e86552593f3e4e577ca8a62aa2f3f22b # v2 with: file: dist/* tag: ${{ env.LATEST_TAG }} diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 4e2d4ee8..fe38b63a 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -6,13 +6,16 @@ on: push: tags: [ 'v*.*.*' ] +permissions: + contents: read + jobs: docker: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 @@ -28,20 +31,20 @@ jobs: git checkout ${{ steps.latest_tag.outputs.LATEST_TAG }} - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.9' - name: Load cached Poetry setup id: cached_poetry - uses: actions/cache@v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache - name: Setup Poetry if: steps.cached_poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 with: version: 2.2.1 @@ -58,14 +61,14 @@ jobs: echo "CLI_VERSION=$(poetry version --short)" >> $GITHUB_OUTPUT - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Login to Docker Hub if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') }} - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKERHUB_USER }} password: ${{ secrets.DOCKERHUB_PASSWORD }} @@ -73,7 +76,7 @@ jobs: - name: Build and push id: docker_build if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') }} - uses: docker/build-push-action@v7 + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 with: context: . platforms: linux/amd64,linux/arm64 @@ -83,7 +86,7 @@ jobs: - name: Verify build id: docker_verify_build if: ${{ github.event_name != 'workflow_dispatch' && !startsWith(github.ref, 'refs/tags/v') }} - uses: docker/build-push-action@v7 + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 802f4e27..f256152a 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Run Cimon - uses: cycodelabs/cimon-action@v0 + uses: cycodelabs/cimon-action@1c3e30d508634b3f4a60b02843126c9f93944d80 # v0.9.4 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} @@ -28,25 +28,25 @@ jobs: *.sigstore.dev - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.9' - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 with: version: 2.2.1 @@ -74,4 +74,4 @@ jobs: run: poetry build - name: Publish a Python distribution to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@106e0b0b7c337fa67ed433972f777c6357f78598 # v1.13.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 88f86ef7..cd922bb0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Run Cimon - uses: cycodelabs/cimon-action@v0 + uses: cycodelabs/cimon-action@1c3e30d508634b3f4a60b02843126c9f93944d80 # v0.9.4 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} @@ -27,25 +27,25 @@ jobs: *.sigstore.dev - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.9' - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 with: version: 2.2.1 @@ -73,4 +73,4 @@ jobs: run: poetry build - name: Publish a Python distribution to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@106e0b0b7c337fa67ed433972f777c6357f78598 # v1.13.0 diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index ae6c7913..3099cbd7 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -2,12 +2,15 @@ name: Ruff (linter and code formatter) on: [ pull_request, push ] +permissions: + contents: read + jobs: ruff: runs-on: ubuntu-latest steps: - name: Run Cimon - uses: cycodelabs/cimon-action@v0 + uses: cycodelabs/cimon-action@1c3e30d508634b3f4a60b02843126c9f93944d80 # v0.9.4 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} @@ -18,23 +21,23 @@ jobs: pypi.org - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: 3.9 - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 with: version: 2.2.1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c69fe4ac..cfb1aa21 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Run Cimon - uses: cycodelabs/cimon-action@v0 + uses: cycodelabs/cimon-action@1c3e30d508634b3f4a60b02843126c9f93944d80 # v0.9.4 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} @@ -23,23 +23,23 @@ jobs: *.ingest.us.sentry.io - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.9' - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 with: version: 2.2.1 diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index 65426b13..1fdb091b 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Run Cimon if: matrix.os == 'ubuntu-latest' - uses: cycodelabs/cimon-action@v0 + uses: cycodelabs/cimon-action@1c3e30d508634b3f4a60b02843126c9f93944d80 # v0.9.4 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} @@ -36,25 +36,25 @@ jobs: *.ingest.us.sentry.io - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python-version }} - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ~/.local key: poetry-${{ matrix.os }}-${{ matrix.python-version }}-3 # increment to reset cache - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 with: version: 2.2.1 diff --git a/poetry.lock b/poetry.lock index 0bd73e47..f9bd66ed 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "altgraph" From 364da74d85e9ea7bcb9fae4201d73a99e1793248 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 08:57:37 +0000 Subject: [PATCH 037/123] Bump pyjwt from 2.10.1 to 2.12.0 (#412) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/poetry.lock b/poetry.lock index f9bd66ed..62539555 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "altgraph" @@ -1189,14 +1189,14 @@ setuptools = ">=42.0.0" [[package]] name = "pyjwt" -version = "2.10.1" +version = "2.12.0" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, - {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, + {file = "pyjwt-2.12.0-py3-none-any.whl", hash = "sha256:9bb459d1bdd0387967d287f5656bf7ec2b9a26645d1961628cda1764e087fd6e"}, + {file = "pyjwt-2.12.0.tar.gz", hash = "sha256:2f62390b667cd8257de560b850bb5a883102a388829274147f1d724453f8fb02"}, ] [package.dependencies] @@ -1204,9 +1204,9 @@ cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"cryp [package.extras] crypto = ["cryptography (>=3.4.0)"] -dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +dev = ["coverage[toml] (==7.10.7)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=8.4.2,<9.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] -tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] +tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"] [[package]] name = "pytest" From 279cc84dce8cfb698d8c45b0f7e499724c6e4b52 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:19:59 +0200 Subject: [PATCH 038/123] CM-60929: Add report mode to ai-guardrails install (#410) Co-authored-by: Claude Opus 4.6 --- cycode/cli/apps/ai_guardrails/__init__.py | 4 + cycode/cli/apps/ai_guardrails/consts.py | 41 ++++-- .../apps/ai_guardrails/ensure_auth_command.py | 21 +++ .../cli/apps/ai_guardrails/hooks_manager.py | 66 ++++++++- .../cli/apps/ai_guardrails/install_command.py | 52 +++++-- .../cli/apps/ai_guardrails/scan/handlers.py | 8 +- .../ai_guardrails/scan/test_payload.py | 2 + .../ai_guardrails/test_hooks_manager.py | 133 +++++++++++++++++- 8 files changed, 295 insertions(+), 32 deletions(-) create mode 100644 cycode/cli/apps/ai_guardrails/ensure_auth_command.py diff --git a/cycode/cli/apps/ai_guardrails/__init__.py b/cycode/cli/apps/ai_guardrails/__init__.py index f8486ed4..11267624 100644 --- a/cycode/cli/apps/ai_guardrails/__init__.py +++ b/cycode/cli/apps/ai_guardrails/__init__.py @@ -1,5 +1,6 @@ import typer +from cycode.cli.apps.ai_guardrails.ensure_auth_command import ensure_auth_command from cycode.cli.apps.ai_guardrails.install_command import install_command from cycode.cli.apps.ai_guardrails.scan.scan_command import scan_command from cycode.cli.apps.ai_guardrails.status_command import status_command @@ -17,3 +18,6 @@ name='scan', short_help='Scan content from AI IDE hooks for secrets (reads JSON from stdin).', )(scan_command) +app.command(hidden=True, name='ensure-auth', short_help='Ensure authentication, triggering auth if needed.')( + ensure_auth_command +) diff --git a/cycode/cli/apps/ai_guardrails/consts.py b/cycode/cli/apps/ai_guardrails/consts.py index 8714ec10..81539b30 100644 --- a/cycode/cli/apps/ai_guardrails/consts.py +++ b/cycode/cli/apps/ai_guardrails/consts.py @@ -6,6 +6,7 @@ """ import platform +from copy import deepcopy from enum import Enum from pathlib import Path from typing import NamedTuple @@ -25,6 +26,13 @@ class PolicyMode(str, Enum): WARN = 'warn' +class InstallMode(str, Enum): + """Installation mode for ai-guardrails install command.""" + + REPORT = 'report' + BLOCK = 'block' + + class IDEConfig(NamedTuple): """Configuration for an AI IDE.""" @@ -76,12 +84,15 @@ def _get_claude_code_hooks_dir() -> Path: # Command used in hooks CYCODE_SCAN_PROMPT_COMMAND = 'cycode ai-guardrails scan' +CYCODE_ENSURE_AUTH_COMMAND = 'cycode ai-guardrails ensure-auth' -def _get_cursor_hooks_config() -> dict: +def _get_cursor_hooks_config(async_mode: bool = False) -> dict: """Get Cursor-specific hooks configuration.""" config = IDE_CONFIGS[AIIDEType.CURSOR] - hooks = {event: [{'command': CYCODE_SCAN_PROMPT_COMMAND}] for event in config.hook_events} + command = f'{CYCODE_SCAN_PROMPT_COMMAND} &' if async_mode else CYCODE_SCAN_PROMPT_COMMAND + hooks = {event: [{'command': command}] for event in config.hook_events} + hooks['sessionStart'] = [{'command': CYCODE_ENSURE_AUTH_COMMAND}] return { 'version': 1, @@ -89,7 +100,7 @@ def _get_cursor_hooks_config() -> dict: } -def _get_claude_code_hooks_config() -> dict: +def _get_claude_code_hooks_config(async_mode: bool = False) -> dict: """Get Claude Code-specific hooks configuration. Claude Code uses a different hook format with nested structure: @@ -98,36 +109,48 @@ def _get_claude_code_hooks_config() -> dict: """ command = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide claude-code' + hook_entry = {'type': 'command', 'command': command} + if async_mode: + hook_entry['async'] = True + hook_entry['timeout'] = 20 + return { 'hooks': { + 'SessionStart': [ + { + 'matcher': 'startup', + 'hooks': [{'type': 'command', 'command': CYCODE_ENSURE_AUTH_COMMAND}], + } + ], 'UserPromptSubmit': [ { - 'hooks': [{'type': 'command', 'command': command}], + 'hooks': [deepcopy(hook_entry)], } ], 'PreToolUse': [ { 'matcher': 'Read', - 'hooks': [{'type': 'command', 'command': command}], + 'hooks': [deepcopy(hook_entry)], }, { 'matcher': 'mcp__.*', - 'hooks': [{'type': 'command', 'command': command}], + 'hooks': [deepcopy(hook_entry)], }, ], }, } -def get_hooks_config(ide: AIIDEType) -> dict: +def get_hooks_config(ide: AIIDEType, async_mode: bool = False) -> dict: """Get the hooks configuration for a specific IDE. Args: ide: The AI IDE type + async_mode: If True, hooks run asynchronously (non-blocking) Returns: Dict with hooks configuration for the specified IDE """ if ide == AIIDEType.CLAUDE_CODE: - return _get_claude_code_hooks_config() - return _get_cursor_hooks_config() + return _get_claude_code_hooks_config(async_mode=async_mode) + return _get_cursor_hooks_config(async_mode=async_mode) diff --git a/cycode/cli/apps/ai_guardrails/ensure_auth_command.py b/cycode/cli/apps/ai_guardrails/ensure_auth_command.py new file mode 100644 index 00000000..78b8bf83 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/ensure_auth_command.py @@ -0,0 +1,21 @@ +import typer + +from cycode.cli.apps.auth.auth_common import get_authorization_info +from cycode.cli.apps.auth.auth_manager import AuthManager +from cycode.cli.exceptions.handle_auth_errors import handle_auth_exception +from cycode.cli.logger import logger + + +def ensure_auth_command(ctx: typer.Context) -> None: + """Ensure the user is authenticated, triggering authentication if needed.""" + auth_info = get_authorization_info(ctx) + if auth_info is not None: + logger.debug('Already authenticated') + return + + logger.debug('Not authenticated, starting authentication') + try: + auth_manager = AuthManager() + auth_manager.authenticate() + except Exception as err: + handle_auth_exception(ctx, err) diff --git a/cycode/cli/apps/ai_guardrails/hooks_manager.py b/cycode/cli/apps/ai_guardrails/hooks_manager.py index b8d43c43..74c681be 100644 --- a/cycode/cli/apps/ai_guardrails/hooks_manager.py +++ b/cycode/cli/apps/ai_guardrails/hooks_manager.py @@ -5,17 +5,21 @@ Supports multiple IDEs: Cursor, Claude Code (future). """ +import copy import json from pathlib import Path from typing import Optional +import yaml + from cycode.cli.apps.ai_guardrails.consts import ( - CYCODE_SCAN_PROMPT_COMMAND, DEFAULT_IDE, IDE_CONFIGS, AIIDEType, + PolicyMode, get_hooks_config, ) +from cycode.cli.apps.ai_guardrails.scan.consts import DEFAULT_POLICY, POLICY_FILE_NAME from cycode.logger import get_logger logger = get_logger('AI Guardrails Hooks') @@ -58,6 +62,13 @@ def save_hooks_file(hooks_path: Path, hooks_config: dict) -> bool: return False +_CYCODE_COMMAND_MARKERS = ('cycode ai-guardrails',) + + +def _is_cycode_command(command: str) -> bool: + return any(marker in command for marker in _CYCODE_COMMAND_MARKERS) + + def is_cycode_hook_entry(entry: dict) -> bool: """Check if a hook entry is from cycode-cli. @@ -68,7 +79,7 @@ def is_cycode_hook_entry(entry: dict) -> bool: """ # Check Cursor format (flat command) command = entry.get('command', '') - if CYCODE_SCAN_PROMPT_COMMAND in command: + if _is_cycode_command(command): return True # Check Claude Code format (nested hooks array) @@ -76,14 +87,58 @@ def is_cycode_hook_entry(entry: dict) -> bool: for hook in hooks: if isinstance(hook, dict): hook_command = hook.get('command', '') - if CYCODE_SCAN_PROMPT_COMMAND in hook_command: + if _is_cycode_command(hook_command): return True return False +def _load_policy(policy_path: Path) -> dict: + """Load existing policy file merged with defaults, or return defaults if not found.""" + if not policy_path.exists(): + return copy.deepcopy(DEFAULT_POLICY) + try: + existing = yaml.safe_load(policy_path.read_text(encoding='utf-8')) or {} + except Exception: + existing = {} + return {**copy.deepcopy(DEFAULT_POLICY), **existing} + + +def create_policy_file(scope: str, mode: PolicyMode, repo_path: Optional[Path] = None) -> tuple[bool, str]: + """Create or update the ai-guardrails.yaml policy file. + + If the file already exists, only the mode field is updated. + If it doesn't exist, a new file is created from the default policy. + + Args: + scope: 'user' for user-level, 'repo' for repository-level + mode: The policy mode to set + repo_path: Repository path (required if scope is 'repo') + + Returns: + Tuple of (success, message) + """ + config_dir = repo_path / '.cycode' if scope == 'repo' and repo_path else Path.home() / '.cycode' + policy_path = config_dir / POLICY_FILE_NAME + + policy = _load_policy(policy_path) + + policy['mode'] = mode.value + + try: + config_dir.mkdir(parents=True, exist_ok=True) + policy_path.write_text(yaml.dump(policy, default_flow_style=False, sort_keys=False), encoding='utf-8') + return True, f'AI guardrails policy ({mode.value} mode) set: {policy_path}' + except Exception as e: + logger.error('Failed to create policy file', exc_info=e) + return False, f'Failed to create policy file: {policy_path}' + + def install_hooks( - scope: str = 'user', repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE + scope: str = 'user', + repo_path: Optional[Path] = None, + ide: AIIDEType = DEFAULT_IDE, + report_mode: bool = False, ) -> tuple[bool, str]: """ Install Cycode AI guardrails hooks. @@ -92,6 +147,7 @@ def install_hooks( scope: 'user' for user-level hooks, 'repo' for repository-level hooks repo_path: Repository path (required if scope is 'repo') ide: The AI IDE type (default: Cursor) + report_mode: If True, install hooks in async mode (non-blocking) Returns: Tuple of (success, message) @@ -104,7 +160,7 @@ def install_hooks( existing.setdefault('hooks', {}) # Get IDE-specific hooks configuration - hooks_config = get_hooks_config(ide) + hooks_config = get_hooks_config(ide, async_mode=report_mode) # Add/update Cycode hooks for event, entries in hooks_config['hooks'].items(): diff --git a/cycode/cli/apps/ai_guardrails/install_command.py b/cycode/cli/apps/ai_guardrails/install_command.py index a72d5d4c..a92a978f 100644 --- a/cycode/cli/apps/ai_guardrails/install_command.py +++ b/cycode/cli/apps/ai_guardrails/install_command.py @@ -11,8 +11,8 @@ validate_and_parse_ide, validate_scope, ) -from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS, AIIDEType -from cycode.cli.apps.ai_guardrails.hooks_manager import install_hooks +from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS, AIIDEType, InstallMode, PolicyMode +from cycode.cli.apps.ai_guardrails.hooks_manager import create_policy_file, install_hooks def install_command( @@ -43,6 +43,15 @@ def install_command( resolve_path=True, ), ] = None, + mode: Annotated[ + InstallMode, + typer.Option( + '--mode', + '-m', + help='Installation mode: "report" for async non-blocking hooks with warn policy, ' + '"block" for sync blocking hooks.', + ), + ] = InstallMode.REPORT, ) -> None: """Install AI guardrails hooks for supported IDEs. @@ -50,7 +59,8 @@ def install_command( and MCP tool calls for secrets before they are sent to AI models. Examples: - cycode ai-guardrails install # Install for all projects (user scope) + cycode ai-guardrails install # Install in report mode (default) + cycode ai-guardrails install --mode block # Install in block mode cycode ai-guardrails install --scope repo # Install for current repo only cycode ai-guardrails install --ide cursor # Install for Cursor IDE cycode ai-guardrails install --ide all # Install for all supported IDEs @@ -66,7 +76,8 @@ def install_command( results: list[tuple[str, bool, str]] = [] for current_ide in ides_to_install: ide_name = IDE_CONFIGS[current_ide].name - success, message = install_hooks(scope, repo_path, ide=current_ide) + report_mode = mode == InstallMode.REPORT + success, message = install_hooks(scope, repo_path, ide=current_ide, report_mode=report_mode) results.append((ide_name, success, message)) # Report results for each IDE @@ -81,14 +92,31 @@ def install_command( all_success = False if any_success: - console.print() - console.print('[bold]Next steps:[/]') - successful_ides = [name for name, success, _ in results if success] - ide_list = ', '.join(successful_ides) - console.print(f'1. Restart {ide_list} to activate the hooks') - console.print('2. (Optional) Customize policy in ~/.cycode/ai-guardrails.yaml') - console.print() - console.print('[dim]The hooks will scan prompts, file reads, and MCP tool calls for secrets.[/]') + policy_mode = PolicyMode.WARN if mode == InstallMode.REPORT else PolicyMode.BLOCK + _install_policy(scope, repo_path, policy_mode) + _print_next_steps(results, mode) if not all_success: raise typer.Exit(1) + + +def _install_policy(scope: str, repo_path: Optional[Path], policy_mode: PolicyMode) -> None: + policy_success, policy_message = create_policy_file(scope, policy_mode, repo_path) + if policy_success: + console.print(f'[green]✓[/] {policy_message}') + else: + console.print(f'[red]✗[/] {policy_message}', style='bold red') + + +def _print_next_steps(results: list[tuple[str, bool, str]], mode: InstallMode) -> None: + console.print() + console.print('[bold]Next steps:[/]') + successful_ides = [name for name, success, _ in results if success] + ide_list = ', '.join(successful_ides) + console.print(f'1. Restart {ide_list} to activate the hooks') + console.print('2. (Optional) Customize policy in ~/.cycode/ai-guardrails.yaml') + console.print() + if mode == InstallMode.REPORT: + console.print('[dim]Report mode: hooks run async (non-blocking) and policy is set to warn.[/]') + else: + console.print('[dim]The hooks will scan prompts, file reads, and MCP tool calls for secrets.[/]') diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index 2a762a8d..8c0a2ce7 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -345,12 +345,10 @@ def _scan_path_for_secrets(ctx: typer.Context, file_path: str, policy: dict) -> if not file_path or not os.path.exists(file_path): return None, None - with open(file_path, encoding='utf-8', errors='replace') as f: - content = f.read() - - # Truncate content based on policy max_bytes max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000) - content = truncate_utf8(content, max_bytes) + + with open(file_path, encoding='utf-8', errors='replace') as f: + content = f.read(max_bytes) # Get timeout from policy timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000) diff --git a/tests/cli/commands/ai_guardrails/scan/test_payload.py b/tests/cli/commands/ai_guardrails/scan/test_payload.py index 27c3010f..e17d833d 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_payload.py +++ b/tests/cli/commands/ai_guardrails/scan/test_payload.py @@ -29,6 +29,7 @@ def test_from_cursor_payload_prompt_event() -> None: assert unified.ide_provider == 'cursor' assert unified.ide_version == '0.42.0' assert unified.prompt == 'Test prompt' + assert type(unified.ide_provider) is str def test_from_cursor_payload_file_read_event() -> None: @@ -153,6 +154,7 @@ def test_from_claude_code_payload_prompt_event() -> None: assert unified.conversation_id == 'session-123' assert unified.ide_provider == 'claude-code' assert unified.prompt == 'Test prompt for Claude Code' + assert type(unified.ide_provider) is str def test_from_claude_code_payload_file_read_event() -> None: diff --git a/tests/cli/commands/ai_guardrails/test_hooks_manager.py b/tests/cli/commands/ai_guardrails/test_hooks_manager.py index f0dec6f7..ed1ada09 100644 --- a/tests/cli/commands/ai_guardrails/test_hooks_manager.py +++ b/tests/cli/commands/ai_guardrails/test_hooks_manager.py @@ -1,6 +1,18 @@ """Tests for AI guardrails hooks manager.""" -from cycode.cli.apps.ai_guardrails.hooks_manager import is_cycode_hook_entry +from pathlib import Path + +import yaml +from pyfakefs.fake_filesystem import FakeFilesystem + +from cycode.cli.apps.ai_guardrails.consts import ( + CYCODE_ENSURE_AUTH_COMMAND, + CYCODE_SCAN_PROMPT_COMMAND, + AIIDEType, + PolicyMode, + get_hooks_config, +) +from cycode.cli.apps.ai_guardrails.hooks_manager import create_policy_file, is_cycode_hook_entry def test_is_cycode_hook_entry_cursor_format() -> None: @@ -51,3 +63,122 @@ def test_is_cycode_hook_entry_partial_match() -> None: entry = {'command': 'cycode ai-guardrails scan --verbose'} assert is_cycode_hook_entry(entry) is True + + +def test_get_hooks_config_cursor_sync() -> None: + """Test Cursor hooks config in default (sync) mode.""" + config = get_hooks_config(AIIDEType.CURSOR) + hooks = config['hooks'] + scan_hooks = {k: v for k, v in hooks.items() if k != 'sessionStart'} + for entries in scan_hooks.values(): + for entry in entries: + assert entry['command'] == CYCODE_SCAN_PROMPT_COMMAND + assert '&' not in entry['command'] + + +def test_get_hooks_config_cursor_async() -> None: + """Test Cursor hooks config in async mode appends & to command.""" + config = get_hooks_config(AIIDEType.CURSOR, async_mode=True) + hooks = config['hooks'] + scan_hooks = {k: v for k, v in hooks.items() if k != 'sessionStart'} + for entries in scan_hooks.values(): + for entry in entries: + assert entry['command'].endswith('&') + assert CYCODE_SCAN_PROMPT_COMMAND in entry['command'] + + +def test_get_hooks_config_cursor_session_start() -> None: + """Test Cursor hooks config includes sessionStart auth check.""" + config = get_hooks_config(AIIDEType.CURSOR) + assert 'sessionStart' in config['hooks'] + entries = config['hooks']['sessionStart'] + assert len(entries) == 1 + assert entries[0]['command'] == CYCODE_ENSURE_AUTH_COMMAND + + +def test_get_hooks_config_claude_code_sync() -> None: + """Test Claude Code hooks config in default (sync) mode.""" + config = get_hooks_config(AIIDEType.CLAUDE_CODE) + scan_events = {k: v for k, v in config['hooks'].items() if k != 'SessionStart'} + for event_entries in scan_events.values(): + for event_entry in event_entries: + for hook in event_entry['hooks']: + assert 'async' not in hook + assert 'timeout' not in hook + + +def test_get_hooks_config_claude_code_async() -> None: + """Test Claude Code hooks config in async mode adds async and timeout.""" + config = get_hooks_config(AIIDEType.CLAUDE_CODE, async_mode=True) + scan_events = {k: v for k, v in config['hooks'].items() if k != 'SessionStart'} + for event_entries in scan_events.values(): + for event_entry in event_entries: + for hook in event_entry['hooks']: + assert hook['async'] is True + + +def test_get_hooks_config_claude_code_session_start() -> None: + """Test Claude Code hooks config includes SessionStart auth check.""" + config = get_hooks_config(AIIDEType.CLAUDE_CODE) + assert 'SessionStart' in config['hooks'] + entries = config['hooks']['SessionStart'] + assert len(entries) == 1 + assert entries[0]['hooks'][0]['command'] == CYCODE_ENSURE_AUTH_COMMAND + + +def test_create_policy_file_warn(fs: FakeFilesystem) -> None: + """Test creating warn-mode policy file.""" + fs.create_dir(Path.home()) + success, message = create_policy_file('user', PolicyMode.WARN) + + assert success is True + assert 'warn mode' in message + + policy_path = Path.home() / '.cycode' / 'ai-guardrails.yaml' + assert policy_path.exists() + + policy = yaml.safe_load(policy_path.read_text()) + assert policy['mode'] == 'warn' + + +def test_create_policy_file_block(fs: FakeFilesystem) -> None: + """Test creating block-mode policy file.""" + fs.create_dir(Path.home()) + success, message = create_policy_file('user', PolicyMode.BLOCK) + + assert success is True + assert 'block mode' in message + + policy_path = Path.home() / '.cycode' / 'ai-guardrails.yaml' + policy = yaml.safe_load(policy_path.read_text()) + assert policy['mode'] == 'block' + + +def test_create_policy_file_updates_existing(fs: FakeFilesystem) -> None: + """Test that re-running only updates mode and preserves other customizations.""" + policy_dir = Path.home() / '.cycode' + fs.create_dir(policy_dir) + policy_path = policy_dir / 'ai-guardrails.yaml' + policy_path.write_text(yaml.dump({'version': 1, 'mode': 'warn', 'custom_field': 'keep_me'})) + + success, _ = create_policy_file('user', PolicyMode.BLOCK) + + assert success is True + policy = yaml.safe_load(policy_path.read_text()) + assert policy['mode'] == 'block' + assert policy['custom_field'] == 'keep_me' + + +def test_create_policy_file_repo_scope(fs: FakeFilesystem) -> None: + """Test creating policy file in repo scope.""" + repo_path = Path('/my-repo') + fs.create_dir(repo_path) + + success, message = create_policy_file('repo', PolicyMode.WARN, repo_path=repo_path) + + assert success is True + policy_path = repo_path / '.cycode' / 'ai-guardrails.yaml' + assert policy_path.exists() + + policy = yaml.safe_load(policy_path.read_text()) + assert policy['mode'] == 'warn' From 6462beacb666f27678c256800aaea663c3d5952e Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Wed, 18 Mar 2026 09:38:13 +0000 Subject: [PATCH 039/123] CM-61023: fix pinning issue (#413) --- .github/workflows/build_executable.yml | 2 +- .github/workflows/docker-image.yml | 2 +- .github/workflows/pre_release.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/ruff.yml | 2 +- .github/workflows/tests.yml | 2 +- .github/workflows/tests_full.yml | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index e410ba57..9629bfeb 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -75,7 +75,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 + uses: snok/install-poetry@v1 with: version: 2.2.1 diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index fe38b63a..5442e873 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -44,7 +44,7 @@ jobs: - name: Setup Poetry if: steps.cached_poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 + uses: snok/install-poetry@v1 with: version: 2.2.1 diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index f256152a..00ede8ae 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -46,7 +46,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 + uses: snok/install-poetry@v1 with: version: 2.2.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd922bb0..5db7c57e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,7 +45,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 + uses: snok/install-poetry@v1 with: version: 2.2.1 diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 3099cbd7..56be0e21 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -37,7 +37,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 + uses: snok/install-poetry@v1 with: version: 2.2.1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cfb1aa21..297c318d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -39,7 +39,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 + uses: snok/install-poetry@v1 with: version: 2.2.1 diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index 1fdb091b..08cf3a9a 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -54,7 +54,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 + uses: snok/install-poetry@v1 with: version: 2.2.1 From 71f3690fcd214d2845eb1e0a7378e109bd859950 Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Wed, 18 Mar 2026 13:14:11 +0000 Subject: [PATCH 040/123] CM-61023: update pinned actions (#414) --- .github/workflows/build_executable.yml | 2 +- .github/workflows/docker-image.yml | 2 +- .github/workflows/pre_release.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- .github/workflows/ruff.yml | 2 +- .github/workflows/tests.yml | 2 +- .github/workflows/tests_full.yml | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 9629bfeb..e410ba57 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -75,7 +75,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 with: version: 2.2.1 diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 5442e873..fe38b63a 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -44,7 +44,7 @@ jobs: - name: Setup Poetry if: steps.cached_poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 with: version: 2.2.1 diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 00ede8ae..0e17facd 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -46,7 +46,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 with: version: 2.2.1 @@ -74,4 +74,4 @@ jobs: run: poetry build - name: Publish a Python distribution to PyPI - uses: pypa/gh-action-pypi-publish@106e0b0b7c337fa67ed433972f777c6357f78598 # v1.13.0 + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5db7c57e..1a3e3d26 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,7 +45,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 with: version: 2.2.1 @@ -73,4 +73,4 @@ jobs: run: poetry build - name: Publish a Python distribution to PyPI - uses: pypa/gh-action-pypi-publish@106e0b0b7c337fa67ed433972f777c6357f78598 # v1.13.0 + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 56be0e21..3099cbd7 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -37,7 +37,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 with: version: 2.2.1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 297c318d..cfb1aa21 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -39,7 +39,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 with: version: 2.2.1 diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index 08cf3a9a..1fdb091b 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -54,7 +54,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 with: version: 2.2.1 From 49ec71360d3c032639556788ce5355c96b846d24 Mon Sep 17 00:00:00 2001 From: Mateusz Sterczewski Date: Mon, 23 Mar 2026 12:43:04 +0100 Subject: [PATCH 041/123] CM-61376: Track CLI/IDE activation events (#415) Co-authored-by: Claude Sonnet 4.6 --- cycode/cli/app.py | 2 + cycode/cli/apps/activation_manager.py | 46 +++++++++++++++ cycode/cli/apps/auth/auth_command.py | 8 +++ cycode/cli/apps/scan/scan_command.py | 6 ++ cycode/cli/apps/status/get_cli_status.py | 5 ++ .../cli/user_settings/config_file_manager.py | 11 ++++ .../user_settings/configuration_manager.py | 6 ++ cycode/cyclient/cli_activation_client.py | 14 +++++ .../user_settings/test_activation_tracking.py | 57 +++++++++++++++++++ 9 files changed, 155 insertions(+) create mode 100644 cycode/cli/apps/activation_manager.py create mode 100644 cycode/cyclient/cli_activation_client.py create mode 100644 tests/user_settings/test_activation_tracking.py diff --git a/cycode/cli/app.py b/cycode/cli/app.py index 41391f99..0e9f9c7b 100644 --- a/cycode/cli/app.py +++ b/cycode/cli/app.py @@ -166,6 +166,8 @@ def app_callback( if user_agent: user_agent_option = UserAgentOptionScheme().loads(user_agent) CycodeClientBase.enrich_user_agent(user_agent_option.user_agent_suffix) + ctx.obj['plugin_app_name'] = user_agent_option.app_name + ctx.obj['plugin_app_version'] = user_agent_option.app_version if not no_update_notifier: ctx.call_on_close(lambda: check_latest_version_on_close(ctx)) diff --git a/cycode/cli/apps/activation_manager.py b/cycode/cli/apps/activation_manager.py new file mode 100644 index 00000000..8eed3caa --- /dev/null +++ b/cycode/cli/apps/activation_manager.py @@ -0,0 +1,46 @@ +from typing import TYPE_CHECKING, Optional + +from cycode import __version__ +from cycode.cli.config import configuration_manager +from cycode.cyclient.cli_activation_client import CliActivationClient +from cycode.logger import get_logger + +if TYPE_CHECKING: + from cycode.cyclient.cycode_client_base import CycodeClientBase + +logger = get_logger('Activation Manager') + +_CLI_CLIENT_NAME = 'cli' + + +def _get_client_and_version(plugin_app_name: Optional[str], plugin_app_version: Optional[str]) -> tuple[str, str]: + return plugin_app_name or _CLI_CLIENT_NAME, plugin_app_version or __version__ + + +def should_report_cli_activation( + plugin_app_name: Optional[str] = None, + plugin_app_version: Optional[str] = None, +) -> bool: + client, version = _get_client_and_version(plugin_app_name, plugin_app_version) + return configuration_manager.get_last_reported_activation_version(client) != version + + +def report_cli_activation( + cycode_client: 'CycodeClientBase', + plugin_app_name: Optional[str] = None, + plugin_app_version: Optional[str] = None, +) -> None: + """Report CLI/IDE activation to the backend if the (client, version) pair is new. + + Failures are swallowed — activation tracking is non-critical. + """ + try: + client, version = _get_client_and_version(plugin_app_name, plugin_app_version) + + if configuration_manager.get_last_reported_activation_version(client) == version: + return + + CliActivationClient(cycode_client).report_activation() + configuration_manager.update_last_reported_activation_version(client, version) + except Exception: + logger.debug('Failed to report CLI activation', exc_info=True) diff --git a/cycode/cli/apps/auth/auth_command.py b/cycode/cli/apps/auth/auth_command.py index 1184a916..005e8c3e 100644 --- a/cycode/cli/apps/auth/auth_command.py +++ b/cycode/cli/apps/auth/auth_command.py @@ -1,9 +1,11 @@ import typer +from cycode.cli.apps.activation_manager import report_cli_activation, should_report_cli_activation from cycode.cli.apps.auth.auth_manager import AuthManager from cycode.cli.exceptions.handle_auth_errors import handle_auth_exception from cycode.cli.logger import logger from cycode.cli.models import CliResult +from cycode.cli.utils.get_api_client import get_scan_cycode_client def auth_command(ctx: typer.Context) -> None: @@ -23,6 +25,12 @@ def auth_command(ctx: typer.Context) -> None: auth_manager = AuthManager() auth_manager.authenticate() + plugin_app_name = ctx.obj.get('plugin_app_name') + plugin_app_version = ctx.obj.get('plugin_app_version') + if should_report_cli_activation(plugin_app_name, plugin_app_version): + scan_client = get_scan_cycode_client(ctx) + report_cli_activation(scan_client.scan_cycode_client, plugin_app_name, plugin_app_version) + result = CliResult(success=True, message='Successfully logged into cycode') printer.print_result(result) except Exception as err: diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 7aab9d27..56dd2a56 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -5,6 +5,7 @@ import click import typer +from cycode.cli.apps.activation_manager import report_cli_activation, should_report_cli_activation from cycode.cli.apps.sca_options import ( GradleAllSubProjectsOption, MavenSettingsFileOption, @@ -140,6 +141,11 @@ def scan_command( scan_client = get_scan_cycode_client(ctx) ctx.obj['client'] = scan_client + plugin_app_name = ctx.obj.get('plugin_app_name') + plugin_app_version = ctx.obj.get('plugin_app_version') + if should_report_cli_activation(plugin_app_name, plugin_app_version): + report_cli_activation(scan_client.scan_cycode_client, plugin_app_name, plugin_app_version) + # Get remote URL from current working directory remote_url = _try_get_git_remote_url(os.getcwd()) diff --git a/cycode/cli/apps/status/get_cli_status.py b/cycode/cli/apps/status/get_cli_status.py index 0cf6e8fd..7018fa29 100644 --- a/cycode/cli/apps/status/get_cli_status.py +++ b/cycode/cli/apps/status/get_cli_status.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING from cycode import __version__ +from cycode.cli.apps.activation_manager import report_cli_activation, should_report_cli_activation from cycode.cli.apps.auth.auth_common import get_authorization_info from cycode.cli.apps.status.models import CliStatus, CliSupportedModulesStatus from cycode.cli.consts import PROGRAM_NAME @@ -22,7 +23,11 @@ def get_cli_status(ctx: 'Context') -> CliStatus: supported_modules_status = CliSupportedModulesStatus() if is_authenticated: try: + plugin_app_name = ctx.obj.get('plugin_app_name') + plugin_app_version = ctx.obj.get('plugin_app_version') client = get_scan_cycode_client(ctx) + if should_report_cli_activation(plugin_app_name, plugin_app_version): + report_cli_activation(client.scan_cycode_client, plugin_app_name, plugin_app_version) supported_modules_preferences = client.get_supported_modules_preferences() supported_modules_status.secret_scanning = supported_modules_preferences.secret_scanning diff --git a/cycode/cli/user_settings/config_file_manager.py b/cycode/cli/user_settings/config_file_manager.py index 5b029e39..cfab38d2 100644 --- a/cycode/cli/user_settings/config_file_manager.py +++ b/cycode/cli/user_settings/config_file_manager.py @@ -18,6 +18,7 @@ class ConfigFileManager(BaseFileManager): SCAN_SECTION_NAME: str = 'scan' INSTALLATION_ID_FIELD_NAME: str = 'installation_id' + LAST_REPORTED_ACTIVATION_VERSIONS_FIELD_NAME: str = 'last_reported_activation_versions' API_URL_FIELD_NAME: str = 'cycode_api_url' APP_URL_FIELD_NAME: str = 'cycode_app_url' VERBOSE_FIELD_NAME: str = 'verbose' @@ -68,6 +69,16 @@ def update_installation_id(self, installation_id: str) -> None: update_data = {self.ENVIRONMENT_SECTION_NAME: {self.INSTALLATION_ID_FIELD_NAME: installation_id}} self.write_content_to_file(update_data) + def get_last_reported_activation_versions(self) -> dict[str, str]: + value = self._get_value_from_environment_section(self.LAST_REPORTED_ACTIVATION_VERSIONS_FIELD_NAME) + return value if isinstance(value, dict) else {} + + def update_last_reported_activation_version(self, client: str, version: str) -> None: + versions = self.get_last_reported_activation_versions() + versions[client] = version + update_data = {self.ENVIRONMENT_SECTION_NAME: {self.LAST_REPORTED_ACTIVATION_VERSIONS_FIELD_NAME: versions}} + self.write_content_to_file(update_data) + def add_exclusion(self, scan_type: str, exclusion_type: str, new_exclusion: str) -> None: exclusions = self._get_exclusions_by_exclusion_type(scan_type, exclusion_type) if new_exclusion in exclusions: diff --git a/cycode/cli/user_settings/configuration_manager.py b/cycode/cli/user_settings/configuration_manager.py index 689ec0d5..f80f9a6e 100644 --- a/cycode/cli/user_settings/configuration_manager.py +++ b/cycode/cli/user_settings/configuration_manager.py @@ -94,6 +94,12 @@ def get_or_create_installation_id(self) -> str: return installation_id + def get_last_reported_activation_version(self, client: str) -> Optional[str]: + return self.global_config_file_manager.get_last_reported_activation_versions().get(client) + + def update_last_reported_activation_version(self, client: str, version: str) -> None: + self.global_config_file_manager.update_last_reported_activation_version(client, version) + def get_config_file_manager(self, scope: Optional[str] = None) -> ConfigFileManager: if scope == 'local': return self.local_config_file_manager diff --git a/cycode/cyclient/cli_activation_client.py b/cycode/cyclient/cli_activation_client.py new file mode 100644 index 00000000..2932c353 --- /dev/null +++ b/cycode/cyclient/cli_activation_client.py @@ -0,0 +1,14 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from cycode.cyclient.cycode_client_base import CycodeClientBase + +_CLI_ACTIVATION_PATH = 'scans/api/v4/cli-activation' + + +class CliActivationClient: + def __init__(self, cycode_client: 'CycodeClientBase') -> None: + self._cycode_client = cycode_client + + def report_activation(self) -> None: + self._cycode_client.put(url_path=_CLI_ACTIVATION_PATH) diff --git a/tests/user_settings/test_activation_tracking.py b/tests/user_settings/test_activation_tracking.py new file mode 100644 index 00000000..5c8faf02 --- /dev/null +++ b/tests/user_settings/test_activation_tracking.py @@ -0,0 +1,57 @@ +from pathlib import Path + +import pytest + +from cycode.cli.user_settings.config_file_manager import ConfigFileManager + + +@pytest.fixture +def config_manager(tmp_path: Path) -> ConfigFileManager: + return ConfigFileManager(tmp_path) + + +def test_get_last_reported_activation_versions_returns_empty_when_not_set( + config_manager: ConfigFileManager, +) -> None: + assert config_manager.get_last_reported_activation_versions() == {} + + +def test_update_and_get_last_reported_activation_version_cli(config_manager: ConfigFileManager) -> None: + config_manager.update_last_reported_activation_version('cli', '1.10.7') + + assert config_manager.get_last_reported_activation_versions() == {'cli': '1.10.7'} + + +def test_update_and_get_last_reported_activation_version_plugin(config_manager: ConfigFileManager) -> None: + config_manager.update_last_reported_activation_version('vscode_extension', '2.0.0') + + assert config_manager.get_last_reported_activation_versions() == {'vscode_extension': '2.0.0'} + + +def test_update_last_reported_activation_version_multiple_clients(config_manager: ConfigFileManager) -> None: + config_manager.update_last_reported_activation_version('cli', '1.10.7') + config_manager.update_last_reported_activation_version('vscode_extension', '2.0.0') + config_manager.update_last_reported_activation_version('jetbrains_extension', '1.5.0') + + assert config_manager.get_last_reported_activation_versions() == { + 'cli': '1.10.7', + 'vscode_extension': '2.0.0', + 'jetbrains_extension': '1.5.0', + } + + +def test_update_last_reported_activation_version_overwrites_existing(config_manager: ConfigFileManager) -> None: + config_manager.update_last_reported_activation_version('cli', '1.10.7') + config_manager.update_last_reported_activation_version('cli', '1.10.8') + + assert config_manager.get_last_reported_activation_versions() == {'cli': '1.10.8'} + + +def test_update_last_reported_activation_version_does_not_affect_other_clients( + config_manager: ConfigFileManager, +) -> None: + config_manager.update_last_reported_activation_version('cli', '1.10.7') + config_manager.update_last_reported_activation_version('vscode_extension', '2.0.0') + config_manager.update_last_reported_activation_version('cli', '1.10.8') + + assert config_manager.get_last_reported_activation_versions()['vscode_extension'] == '2.0.0' From 9d95dca1644c49deb19f4a1a664c50cc73f08adf Mon Sep 17 00:00:00 2001 From: omerr-cycode Date: Thu, 26 Mar 2026 16:58:21 +0200 Subject: [PATCH 042/123] CM-61587 MCP scan improvements (#418) --- README.md | 38 +++++- cycode/cli/apps/mcp/mcp_command.py | 182 ++++++++++++++++++------- tests/cli/apps/mcp/test_mcp_command.py | 90 +++++++++++- 3 files changed, 255 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index b512c813..dbe3b40b 100644 --- a/README.md +++ b/README.md @@ -384,12 +384,22 @@ The MCP server provides the following tools that AI systems can use: | Tool Name | Description | |----------------------|---------------------------------------------------------------------------------------------| -| `cycode_secret_scan` | Scan files for hardcoded secrets | -| `cycode_sca_scan` | Scan files for Software Composition Analysis (SCA) - vulnerabilities and license issues | -| `cycode_iac_scan` | Scan files for Infrastructure as Code (IaC) misconfigurations | -| `cycode_sast_scan` | Scan files for Static Application Security Testing (SAST) - code quality and security flaws | +| `cycode_secret_scan` | Scan for hardcoded secrets | +| `cycode_sca_scan` | Scan for Software Composition Analysis (SCA) - vulnerabilities and license issues | +| `cycode_iac_scan` | Scan for Infrastructure as Code (IaC) misconfigurations | +| `cycode_sast_scan` | Scan for Static Application Security Testing (SAST) - code quality and security flaws | | `cycode_status` | Get Cycode CLI version, authentication status, and configuration information | +Each scan tool accepts two mutually exclusive input modes: + +- **`paths`** *(preferred)* — one or more file or directory paths that exist on disk. Directories are scanned recursively. The Cycode engine handles file discovery and filtering, just as `cycode scan -t path ./src` does from the CLI. +- **`files`** *(fallback)* — a dictionary mapping file paths to their full content as strings. Use this only when the files are not available on disk (e.g. in-memory edits not yet saved). + +> [!TIP] +> Use `paths` whenever possible. Passing large files (like `package-lock.json`) as inline content can exceed token limits and slow down the AI client. With `paths`, the Cycode engine reads files directly from disk. + +All scan tools return a JSON object that includes a `"summary"` field with a human-readable violation count (e.g. `"Cycode found 3 violations: 1 CRITICAL, 2 HIGH."`) in addition to the full `"detections"` array. + ### Usage Examples #### Basic Command Examples @@ -547,6 +557,26 @@ cycode mcp -t streamable-http -H 127.0.0.2 -p 9000 & > [!NOTE] > The MCP server requires proper Cycode CLI authentication to function. Make sure you have authenticated using `cycode auth` or configured your credentials before starting the MCP server. +### Pre-authorizing Tools for Subagents (Claude Code) + +When Claude Code delegates work to background subagents (e.g. to run scans in parallel), those subagents cannot display interactive permission prompts. If the Cycode tools have not been pre-approved, scans will fail silently in subagent contexts. + +To pre-authorize the Cycode MCP tools so they work in all contexts including subagents, add them to the `allowedTools` list in your Claude Code settings (`~/.claude/settings.json`): + +```json +{ + "allowedTools": [ + "mcp__cycode__cycode_secret_scan", + "mcp__cycode__cycode_sca_scan", + "mcp__cycode__cycode_iac_scan", + "mcp__cycode__cycode_sast_scan", + "mcp__cycode__cycode_status" + ] +} +``` + +Once added, Claude Code will not prompt for approval when these tools are called, and they will work correctly inside subagents. + ### Troubleshooting MCP If you encounter issues with the MCP server, you can enable debug logging to get more detailed information about what's happening. There are two ways to enable debug logging: diff --git a/cycode/cli/apps/mcp/mcp_command.py b/cycode/cli/apps/mcp/mcp_command.py index 39bcce40..adfc0a3f 100644 --- a/cycode/cli/apps/mcp/mcp_command.py +++ b/cycode/cli/apps/mcp/mcp_command.py @@ -6,7 +6,7 @@ import sys import tempfile import uuid -from typing import Annotated, Any +from typing import Annotated, Any, Optional import typer from pathvalidate import sanitize_filepath @@ -28,7 +28,25 @@ _DEFAULT_RUN_COMMAND_TIMEOUT = 10 * 60 -_FILES_TOOL_FIELD = Field(description='Files to scan, mapping file paths to their content') +_FILES_TOOL_FIELD = Field( + default=None, + description=( + 'Files to scan, mapping file paths to their content. ' + 'Provide either this or "paths". ' + 'Note: for large codebases, prefer "paths" to avoid token overhead.' + ), +) +_PATHS_TOOL_FIELD = Field( + default=None, + description=( + 'Paths to scan — file paths or directory paths that exist on disk. ' + 'Directories are scanned recursively. ' + 'Provide either this or "files". ' + 'Preferred over "files" when the files already exist on disk.' + ), +) + +_SEVERITY_ORDER = ('CRITICAL', 'HIGH', 'MEDIUM', 'LOW') def _is_debug_mode() -> bool: @@ -163,9 +181,9 @@ def __exit__(self, *_) -> None: shutil.rmtree(self.temp_base_dir, ignore_errors=True) -async def _run_cycode_scan(scan_type: ScanTypeOption, temp_files: list[str]) -> dict[str, Any]: +async def _run_cycode_scan(scan_type: ScanTypeOption, paths: list[str]) -> dict[str, Any]: """Run cycode scan command and return the result.""" - return await _run_cycode_command(*['scan', '-t', str(scan_type), 'path', *temp_files]) + return await _run_cycode_command(*['scan', '-t', str(scan_type), 'path', *paths]) async def _run_cycode_status() -> dict[str, Any]: @@ -173,38 +191,89 @@ async def _run_cycode_status() -> dict[str, Any]: return await _run_cycode_command('status') -async def _cycode_scan_tool(scan_type: ScanTypeOption, files: dict[str, str] = _FILES_TOOL_FIELD) -> str: +def _build_scan_summary(result: dict[str, Any]) -> str: + """Build a human-readable summary line from a scan result dict. + + Args: + result: Parsed JSON scan result from the CLI. + + Returns: + A one-line summary string describing what was found. + """ + detections = result.get('detections', []) + errors = result.get('errors', []) + + if not detections: + if errors: + return f'Scan completed with {len(errors)} error(s) and no violations found.' + return 'No violations found.' + + total = len(detections) + severity_counts: dict[str, int] = {} + for d in detections: + sev = (d.get('severity') or 'UNKNOWN').upper() + severity_counts[sev] = severity_counts.get(sev, 0) + 1 + + parts = [f'{severity_counts[s]} {s}' for s in _SEVERITY_ORDER if s in severity_counts] + other_keys = [k for k in severity_counts if k not in _SEVERITY_ORDER] + parts += [f'{severity_counts[k]} {k}' for k in other_keys] + + label = 'violation' if total == 1 else 'violations' + return f'Cycode found {total} {label}: {", ".join(parts)}.' + + +async def _cycode_scan_tool( + scan_type: ScanTypeOption, + files: Optional[dict[str, str]] = None, + paths: Optional[list[str]] = None, +) -> str: _tool_call_id = _gen_random_id() _logger.info('Scan tool called, %s', {'scan_type': scan_type, 'call_id': _tool_call_id}) - if not files: - _logger.error('No files provided for scan') - return json.dumps({'error': 'No files provided'}) + if not files and not paths: + _logger.error('No files or paths provided for scan') + return json.dumps( + {'error': 'No files or paths provided. Pass file contents via "files" or disk paths via "paths".'} + ) try: - with _TempFilesManager(files, _tool_call_id) as temp_files: - original_count = len(files) - processed_count = len(temp_files) - - if processed_count < original_count: - _logger.warning( - 'Some files were rejected during sanitization, %s', - { - 'scan_type': scan_type, - 'original_count': original_count, - 'processed_count': processed_count, - 'call_id': _tool_call_id, - }, - ) + if paths: + missing = [p for p in paths if not os.path.exists(p)] + if missing: + return json.dumps({'error': f'Paths not found on disk: {missing}'}, indent=2) _logger.info( - 'Running Cycode scan, %s', - {'scan_type': scan_type, 'files_count': processed_count, 'call_id': _tool_call_id}, + 'Running Cycode scan (path-based), %s', + {'scan_type': scan_type, 'paths': paths, 'call_id': _tool_call_id}, ) - result = await _run_cycode_scan(scan_type, temp_files) + result = await _run_cycode_scan(scan_type, paths) + else: + with _TempFilesManager(files, _tool_call_id) as temp_files: + original_count = len(files) + processed_count = len(temp_files) + + if processed_count < original_count: + _logger.warning( + 'Some files were rejected during sanitization, %s', + { + 'scan_type': scan_type, + 'original_count': original_count, + 'processed_count': processed_count, + 'call_id': _tool_call_id, + }, + ) + + _logger.info( + 'Running Cycode scan (files-based), %s', + {'scan_type': scan_type, 'files_count': processed_count, 'call_id': _tool_call_id}, + ) + result = await _run_cycode_scan(scan_type, temp_files) - _logger.info('Scan completed, %s', {'scan_type': scan_type, 'call_id': _tool_call_id}) - return json.dumps(result, indent=2) + if 'error' not in result: + result['summary'] = _build_scan_summary(result) + + _logger.info('Scan completed, %s', {'scan_type': scan_type, 'call_id': _tool_call_id}) + return json.dumps(result, indent=2) except ValueError as e: _logger.error('Invalid input files, %s', {'scan_type': scan_type, 'call_id': _tool_call_id, 'error': str(e)}) return json.dumps({'error': f'Invalid input files: {e!s}'}, indent=2) @@ -213,8 +282,11 @@ async def _cycode_scan_tool(scan_type: ScanTypeOption, files: dict[str, str] = _ return json.dumps({'error': f'Scan failed: {e!s}'}, indent=2) -async def cycode_secret_scan(files: dict[str, str] = _FILES_TOOL_FIELD) -> str: - """Scan files for hardcoded secrets. +async def cycode_secret_scan( + paths: Optional[list[str]] = _PATHS_TOOL_FIELD, + files: Optional[dict[str, str]] = _FILES_TOOL_FIELD, +) -> str: + """Scan for hardcoded secrets. Use this tool when you need to: - scan code for hardcoded secrets, API keys, passwords, tokens @@ -222,16 +294,20 @@ async def cycode_secret_scan(files: dict[str, str] = _FILES_TOOL_FIELD) -> str: - detect potential security vulnerabilities from secret exposure Args: - files: Dictionary mapping file paths to their content + paths: File or directory paths on disk to scan (preferred). Directories are scanned recursively. + files: Dictionary mapping file paths to their content (fallback when files are not on disk). Returns: - JSON string containing scan results and any secrets found + JSON string with a "summary" field (human-readable violation count) plus full scan results. """ - return await _cycode_scan_tool(ScanTypeOption.SECRET, files) + return await _cycode_scan_tool(ScanTypeOption.SECRET, files=files, paths=paths) -async def cycode_sca_scan(files: dict[str, str] = _FILES_TOOL_FIELD) -> str: - """Scan files for Software Composition Analysis (SCA) - vulnerabilities and license issues. +async def cycode_sca_scan( + paths: Optional[list[str]] = _PATHS_TOOL_FIELD, + files: Optional[dict[str, str]] = _FILES_TOOL_FIELD, +) -> str: + """Scan for Software Composition Analysis (SCA) - vulnerabilities and license issues. Use this tool when you need to: - scan dependencies for known security vulnerabilities @@ -242,19 +318,24 @@ async def cycode_sca_scan(files: dict[str, str] = _FILES_TOOL_FIELD) -> str: Important: You must also include lock files (like package-lock.json, Pipfile.lock, etc.) to get accurate results. - You must provide manifest and lock files together. + When using "paths", pass the directory containing both manifest and lock files. + When using "files", provide both manifest and lock files together. Args: - files: Dictionary mapping file paths to their content + paths: File or directory paths on disk to scan (preferred). Directories are scanned recursively. + files: Dictionary mapping file paths to their content (fallback when files are not on disk). Returns: - JSON string containing scan results, vulnerabilities, and license issues found + JSON string with a "summary" field (human-readable violation count) plus full scan results. """ - return await _cycode_scan_tool(ScanTypeOption.SCA, files) + return await _cycode_scan_tool(ScanTypeOption.SCA, files=files, paths=paths) -async def cycode_iac_scan(files: dict[str, str] = _FILES_TOOL_FIELD) -> str: - """Scan files for Infrastructure as Code (IaC) misconfigurations. +async def cycode_iac_scan( + paths: Optional[list[str]] = _PATHS_TOOL_FIELD, + files: Optional[dict[str, str]] = _FILES_TOOL_FIELD, +) -> str: + """Scan for Infrastructure as Code (IaC) misconfigurations. Use this tool when you need to: - scan Terraform, CloudFormation, Kubernetes YAML files @@ -264,16 +345,20 @@ async def cycode_iac_scan(files: dict[str, str] = _FILES_TOOL_FIELD) -> str: - review Docker files for security issues Args: - files: Dictionary mapping file paths to their content + paths: File or directory paths on disk to scan (preferred). Directories are scanned recursively. + files: Dictionary mapping file paths to their content (fallback when files are not on disk). Returns: - JSON string containing scan results and any misconfigurations found + JSON string with a "summary" field (human-readable violation count) plus full scan results. """ - return await _cycode_scan_tool(ScanTypeOption.IAC, files) + return await _cycode_scan_tool(ScanTypeOption.IAC, files=files, paths=paths) -async def cycode_sast_scan(files: dict[str, str] = _FILES_TOOL_FIELD) -> str: - """Scan files for Static Application Security Testing (SAST) - code quality and security flaws. +async def cycode_sast_scan( + paths: Optional[list[str]] = _PATHS_TOOL_FIELD, + files: Optional[dict[str, str]] = _FILES_TOOL_FIELD, +) -> str: + """Scan for Static Application Security Testing (SAST) - code quality and security flaws. Use this tool when you need to: - scan source code for security vulnerabilities @@ -283,12 +368,13 @@ async def cycode_sast_scan(files: dict[str, str] = _FILES_TOOL_FIELD) -> str: - find SQL injection, XSS, and other application security issues Args: - files: Dictionary mapping file paths to their content + paths: File or directory paths on disk to scan (preferred). Directories are scanned recursively. + files: Dictionary mapping file paths to their content (fallback when files are not on disk). Returns: - JSON string containing scan results and any security flaws found + JSON string with a "summary" field (human-readable violation count) plus full scan results. """ - return await _cycode_scan_tool(ScanTypeOption.SAST, files) + return await _cycode_scan_tool(ScanTypeOption.SAST, files=files, paths=paths) async def cycode_status() -> str: diff --git a/tests/cli/apps/mcp/test_mcp_command.py b/tests/cli/apps/mcp/test_mcp_command.py index ebcc2373..cbb65b1c 100644 --- a/tests/cli/apps/mcp/test_mcp_command.py +++ b/tests/cli/apps/mcp/test_mcp_command.py @@ -9,6 +9,7 @@ pytest.skip('MCP requires Python 3.10+', allow_module_level=True) from cycode.cli.apps.mcp.mcp_command import ( + _build_scan_summary, _sanitize_file_path, _TempFilesManager, ) @@ -271,15 +272,26 @@ async def slow_communicate() -> tuple[bytes, bytes]: # --- _cycode_scan_tool --- +@pytest.mark.anyio +async def test_cycode_scan_tool_no_files_no_paths() -> None: + from cycode.cli.apps.mcp.mcp_command import _cycode_scan_tool + from cycode.cli.cli_types import ScanTypeOption + + result = await _cycode_scan_tool(ScanTypeOption.SECRET) + parsed = json.loads(result) + assert 'error' in parsed + assert 'No files or paths provided' in parsed['error'] + + @pytest.mark.anyio async def test_cycode_scan_tool_no_files() -> None: from cycode.cli.apps.mcp.mcp_command import _cycode_scan_tool from cycode.cli.cli_types import ScanTypeOption - result = await _cycode_scan_tool(ScanTypeOption.SECRET, {}) + result = await _cycode_scan_tool(ScanTypeOption.SECRET, files={}) parsed = json.loads(result) assert 'error' in parsed - assert 'No files provided' in parsed['error'] + assert 'No files or paths provided' in parsed['error'] @pytest.mark.anyio @@ -287,9 +299,81 @@ async def test_cycode_scan_tool_invalid_files() -> None: from cycode.cli.apps.mcp.mcp_command import _cycode_scan_tool from cycode.cli.cli_types import ScanTypeOption - result = await _cycode_scan_tool(ScanTypeOption.SECRET, {'': 'content'}) + result = await _cycode_scan_tool(ScanTypeOption.SECRET, files={'': 'content'}) + parsed = json.loads(result) + assert 'error' in parsed + + +@pytest.mark.anyio +async def test_cycode_scan_tool_paths_not_found() -> None: + from cycode.cli.apps.mcp.mcp_command import _cycode_scan_tool + from cycode.cli.cli_types import ScanTypeOption + + result = await _cycode_scan_tool(ScanTypeOption.SECRET, paths=['/nonexistent/path/that/does/not/exist']) parsed = json.loads(result) assert 'error' in parsed + assert 'not found on disk' in parsed['error'] + + +# --- _build_scan_summary --- + + +def test_build_scan_summary_no_detections() -> None: + result = _build_scan_summary({'scan_ids': [], 'detections': [], 'report_urls': [], 'errors': []}) + assert result == 'No violations found.' + + +def test_build_scan_summary_no_detections_with_errors() -> None: + result = _build_scan_summary({'detections': [], 'errors': [{'code': 'E001', 'message': 'oops'}]}) + assert '1 error' in result + assert 'no violations' in result.lower() + + +def test_build_scan_summary_single_violation() -> None: + result = _build_scan_summary({'detections': [{'severity': 'HIGH'}], 'errors': []}) + assert '1 violation' in result + assert 'HIGH' in result + + +def test_build_scan_summary_multiple_severities() -> None: + detections = [ + {'severity': 'CRITICAL'}, + {'severity': 'HIGH'}, + {'severity': 'HIGH'}, + {'severity': 'MEDIUM'}, + ] + result = _build_scan_summary({'detections': detections, 'errors': []}) + assert '4 violations' in result + assert '1 CRITICAL' in result + assert '2 HIGH' in result + assert '1 MEDIUM' in result + + +def test_build_scan_summary_severity_order() -> None: + """CRITICAL should appear before HIGH before MEDIUM before LOW.""" + detections = [ + {'severity': 'LOW'}, + {'severity': 'CRITICAL'}, + {'severity': 'MEDIUM'}, + {'severity': 'HIGH'}, + ] + result = _build_scan_summary({'detections': detections, 'errors': []}) + critical_pos = result.index('CRITICAL') + high_pos = result.index('HIGH') + medium_pos = result.index('MEDIUM') + low_pos = result.index('LOW') + assert critical_pos < high_pos < medium_pos < low_pos + + +def test_build_scan_summary_unknown_severity() -> None: + result = _build_scan_summary({'detections': [{'severity': None}], 'errors': []}) + assert '1 violation' in result + assert 'UNKNOWN' in result + + +def test_build_scan_summary_missing_detections_key() -> None: + result = _build_scan_summary({}) + assert result == 'No violations found.' # --- _create_mcp_server --- From cf6379cfeba69e4ac55ad19ea6d14f5b7247f0d9 Mon Sep 17 00:00:00 2001 From: omerr-cycode Date: Thu, 26 Mar 2026 17:04:24 +0200 Subject: [PATCH 043/123] CM-61547 add stop-on-error flag to stop file collection on errors (#416) --- README.md | 14 ++++ .../cli/apps/report/sbom/path/path_command.py | 5 +- cycode/cli/apps/sca_options.py | 11 +++ cycode/cli/apps/scan/code_scanner.py | 1 + cycode/cli/apps/scan/scan_command.py | 8 ++ cycode/cli/exceptions/custom_exceptions.py | 9 +++ cycode/cli/exceptions/handle_scan_errors.py | 6 ++ cycode/cli/files_collector/path_documents.py | 6 ++ .../files_collector/sca/sca_file_collector.py | 5 ++ .../cli/exceptions/test_handle_scan_errors.py | 1 + .../sca/test_sca_file_collector.py | 79 +++++++++++++++++++ 11 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 tests/cli/files_collector/sca/test_sca_file_collector.py diff --git a/README.md b/README.md index dbe3b40b..780d947b 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ This guide walks you through both installation and usage. 4. [Package Vulnerabilities](#package-vulnerabilities-option) 5. [License Compliance](#license-compliance-option) 6. [Lock Restore](#lock-restore-option) + 7. [Stop on Error](#stop-on-error-option) 2. [Repository Scan](#repository-scan) 1. [Branch Option](#branch-option) 3. [Path Scan](#path-scan) @@ -620,6 +621,7 @@ The Cycode CLI application offers several types of scans so that you can choose | `--monitor` | When specified, the scan results will be recorded in Cycode. | | `--cycode-report` | Display a link to the scan report in the Cycode platform in the console output. | | `--no-restore` | When specified, Cycode will not run the restore command. This will scan direct dependencies ONLY! | +| `--stop-on-error` | Abort the scan if any file collection or dependency restore failure occurs, instead of skipping the failed file and continuing. | | `--gradle-all-sub-projects` | Run gradle restore command for all sub projects. This should be run from | | `--maven-settings-file` | For Maven only, allows using a custom [settings.xml](https://maven.apache.org/settings.html) file when scanning for dependencies | | `--help` | Show options for given command. | @@ -726,6 +728,18 @@ If a lockfile already exists alongside the manifest, Cycode reads it directly wi addSbtPlugin("software.purpledragon" % "sbt-dependency-lock" % "1.5.1") ``` +#### Stop on Error Option + +By default, Cycode continues scanning even if a file cannot be read (e.g. due to a permission error) or a dependency lockfile cannot be generated during an SCA scan. The failed item is skipped with a warning and the scan proceeds with the remaining files. + +Use `--stop-on-error` to change this behaviour: the scan aborts immediately on the first such failure and reports the error. + +```bash +cycode scan -t sca --stop-on-error path ~/home/git/codebase +``` + +This is useful in CI pipelines where a silent failure would produce an incomplete scan result. When `--stop-on-error` is triggered you can either fix the underlying issue or, for SCA restore failures specifically, add `--no-restore` to skip lockfile generation and scan direct dependencies only. + ### Repository Scan A repository scan examines an entire local repository for any exposed secrets or insecure misconfigurations. This more holistic scan type looks at everything: the current state of your repository and its commit history. It will look not only for secrets that are currently exposed within the repository but previously deleted secrets as well. diff --git a/cycode/cli/apps/report/sbom/path/path_command.py b/cycode/cli/apps/report/sbom/path/path_command.py index a3ffa578..5f0f625a 100644 --- a/cycode/cli/apps/report/sbom/path/path_command.py +++ b/cycode/cli/apps/report/sbom/path/path_command.py @@ -10,6 +10,7 @@ GradleAllSubProjectsOption, MavenSettingsFileOption, NoRestoreOption, + StopOnErrorOption, apply_sca_restore_options_to_context, ) from cycode.cli.exceptions.handle_report_sbom_errors import handle_report_exception @@ -30,8 +31,9 @@ def path_command( no_restore: NoRestoreOption = False, gradle_all_sub_projects: GradleAllSubProjectsOption = False, maven_settings_file: MavenSettingsFileOption = None, + stop_on_error: StopOnErrorOption = False, ) -> None: - apply_sca_restore_options_to_context(ctx, no_restore, gradle_all_sub_projects, maven_settings_file) + apply_sca_restore_options_to_context(ctx, no_restore, gradle_all_sub_projects, maven_settings_file, stop_on_error) client = get_report_cycode_client(ctx) report_parameters = ctx.obj['report_parameters'] @@ -51,6 +53,7 @@ def path_command( consts.SCA_SCAN_TYPE, (str(path),), is_cycodeignore_allowed=is_cycodeignore_allowed_by_scan_config(ctx), + stop_on_error=stop_on_error, ) # TODO(MarshalX): combine perform_pre_scan_documents_actions with get_relevant_document. # unhardcode usage of context in perform_pre_scan_documents_actions diff --git a/cycode/cli/apps/sca_options.py b/cycode/cli/apps/sca_options.py index 3c904ee6..01def411 100644 --- a/cycode/cli/apps/sca_options.py +++ b/cycode/cli/apps/sca_options.py @@ -35,13 +35,24 @@ ), ] +StopOnErrorOption = Annotated[ + bool, + typer.Option( + '--stop-on-error', + help='When specified, stops the process if any file collection or restore failure occurs.', + rich_help_panel=_SCA_RICH_HELP_PANEL, + ), +] + def apply_sca_restore_options_to_context( ctx: typer.Context, no_restore: bool, gradle_all_sub_projects: bool, maven_settings_file: Optional[Path], + stop_on_error: bool = False, ) -> None: ctx.obj['no_restore'] = no_restore ctx.obj['gradle_all_sub_projects'] = gradle_all_sub_projects ctx.obj['maven_settings_file'] = maven_settings_file + ctx.obj['stop_on_error'] = stop_on_error diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index 616f22b3..4e551f68 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -58,6 +58,7 @@ def scan_disk_files(ctx: typer.Context, paths: tuple[str, ...]) -> None: scan_type, paths, is_cycodeignore_allowed=is_cycodeignore_allowed_by_scan_config(ctx), + stop_on_error=ctx.obj.get('stop_on_error', False), ) # Add entrypoint.cycode file at root path to mark the scan root (only for single path that is a directory) diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 56dd2a56..62697357 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -41,6 +41,13 @@ def scan_command( soft_fail: Annotated[ bool, typer.Option('--soft-fail', help='Run the scan without failing; always return a non-error status code.') ] = False, + stop_on_error: Annotated[ + bool, + typer.Option( + '--stop-on-error', + help='When specified, stops the scan if any file collection or restore failure occurs.', + ), + ] = False, severity_threshold: Annotated[ SeverityOption, typer.Option( @@ -131,6 +138,7 @@ def scan_command( ctx.obj['show_secret'] = show_secret ctx.obj['soft_fail'] = soft_fail + ctx.obj['stop_on_error'] = stop_on_error ctx.obj['scan_type'] = scan_type ctx.obj['sync'] = sync ctx.obj['severity_threshold'] = severity_threshold diff --git a/cycode/cli/exceptions/custom_exceptions.py b/cycode/cli/exceptions/custom_exceptions.py index 78781914..1200e559 100644 --- a/cycode/cli/exceptions/custom_exceptions.py +++ b/cycode/cli/exceptions/custom_exceptions.py @@ -64,6 +64,15 @@ def __str__(self) -> str: return f'The size of zip to scan is too large, size limit: {self.size_limit}' +class FileCollectionError(CycodeError): + def __init__(self, error_message: str) -> None: + self.error_message = error_message + super().__init__(self.error_message) + + def __str__(self) -> str: + return self.error_message + + class AuthProcessError(CycodeError): def __init__(self, error_message: str) -> None: self.error_message = error_message diff --git a/cycode/cli/exceptions/handle_scan_errors.py b/cycode/cli/exceptions/handle_scan_errors.py index 229e0f02..56af186c 100644 --- a/cycode/cli/exceptions/handle_scan_errors.py +++ b/cycode/cli/exceptions/handle_scan_errors.py @@ -26,6 +26,12 @@ def handle_scan_exception(ctx: typer.Context, err: Exception, *, return_exceptio 'Please try ignoring irrelevant paths using the `cycode ignore --by-path` command ' 'and execute the scan again', ), + custom_exceptions.FileCollectionError: CliError( + soft_fail=False, + code='file_collection_error', + message='File collection failed. ' + 'Use --no-restore to skip dependency restoration, or fix the underlying issue.', + ), custom_exceptions.TfplanKeyError: CliError( soft_fail=True, code='key_error', diff --git a/cycode/cli/files_collector/path_documents.py b/cycode/cli/files_collector/path_documents.py index 142c63bf..17f7dd41 100644 --- a/cycode/cli/files_collector/path_documents.py +++ b/cycode/cli/files_collector/path_documents.py @@ -2,6 +2,7 @@ from collections.abc import Generator from typing import TYPE_CHECKING +from cycode.cli.exceptions.custom_exceptions import FileCollectionError from cycode.cli.files_collector.file_excluder import excluder from cycode.cli.files_collector.iac.tf_content_generator import ( generate_tf_content_from_tfplan, @@ -109,6 +110,7 @@ def get_relevant_documents( *, is_git_diff: bool = False, is_cycodeignore_allowed: bool = True, + stop_on_error: bool = False, ) -> list[Document]: relevant_files = _get_relevant_files( progress_bar, progress_bar_section, scan_type, paths, is_cycodeignore_allowed=is_cycodeignore_allowed @@ -119,6 +121,10 @@ def get_relevant_documents( progress_bar.update(progress_bar_section) content = get_file_content(file) + if content is None: + if stop_on_error: + raise FileCollectionError(f'Failed to read file: {file}') + continue if not content: continue diff --git a/cycode/cli/files_collector/sca/sca_file_collector.py b/cycode/cli/files_collector/sca/sca_file_collector.py index b194deef..c9c17ebf 100644 --- a/cycode/cli/files_collector/sca/sca_file_collector.py +++ b/cycode/cli/files_collector/sca/sca_file_collector.py @@ -4,6 +4,7 @@ import typer from cycode.cli import consts +from cycode.cli.exceptions.custom_exceptions import FileCollectionError from cycode.cli.files_collector.repository_documents import get_file_content_from_commit_path from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies from cycode.cli.files_collector.sca.go.restore_go_dependencies import RestoreGoDependencies @@ -116,6 +117,10 @@ def _try_restore_dependencies( 'Error occurred while trying to generate dependencies tree, %s', {'filename': document.path, 'handler': type(restore_dependencies).__name__}, ) + if ctx.obj.get('stop_on_error', False): + raise FileCollectionError( + f'Failed to generate dependencies tree for {document.path} using {type(restore_dependencies).__name__}' + ) return None if restore_dependencies_document.content is None: diff --git a/tests/cli/exceptions/test_handle_scan_errors.py b/tests/cli/exceptions/test_handle_scan_errors.py index ce72e9de..fb14bc8a 100644 --- a/tests/cli/exceptions/test_handle_scan_errors.py +++ b/tests/cli/exceptions/test_handle_scan_errors.py @@ -32,6 +32,7 @@ def ctx() -> typer.Context: (custom_exceptions.HttpUnauthorizedError('msg', Response()), True), (custom_exceptions.ZipTooLargeError(1000), True), (custom_exceptions.TfplanKeyError('msg'), True), + (custom_exceptions.FileCollectionError('Failed to generate dependencies tree for pom.xml'), None), (git_proxy.get_invalid_git_repository_error()(), None), ], ) diff --git a/tests/cli/files_collector/sca/test_sca_file_collector.py b/tests/cli/files_collector/sca/test_sca_file_collector.py new file mode 100644 index 00000000..f645283d --- /dev/null +++ b/tests/cli/files_collector/sca/test_sca_file_collector.py @@ -0,0 +1,79 @@ +from unittest.mock import MagicMock + +import click +import pytest +import typer + +from cycode.cli.exceptions.custom_exceptions import FileCollectionError +from cycode.cli.files_collector.sca.sca_file_collector import _try_restore_dependencies +from cycode.cli.models import Document + + +def _make_ctx(*, stop_on_error: bool = False) -> typer.Context: + ctx = typer.Context(click.Command('path'), obj={'stop_on_error': stop_on_error, 'monitor': False}) + ctx.obj['path'] = '/some/path' + return ctx + + +def _make_handler(*, is_project: bool = True, restore_result: object = None) -> MagicMock: + handler = MagicMock() + handler.is_project.return_value = is_project + handler.restore.return_value = restore_result + return handler + + +class TestTryRestoreDependencies: + def test_returns_none_when_handler_does_not_match(self) -> None: + ctx = _make_ctx() + doc = Document('pom.xml', '', is_git_diff_format=False) + handler = _make_handler(is_project=False) + + result = _try_restore_dependencies(ctx, handler, doc) + + assert result is None + handler.restore.assert_not_called() + + def test_returns_none_on_restore_failure_without_stop_on_error(self) -> None: + ctx = _make_ctx(stop_on_error=False) + doc = Document('pom.xml', '', is_git_diff_format=False) + handler = _make_handler(is_project=True, restore_result=None) + + result = _try_restore_dependencies(ctx, handler, doc) + + assert result is None + + def test_raises_file_collection_error_on_restore_failure_with_stop_on_error(self) -> None: + ctx = _make_ctx(stop_on_error=True) + doc = Document('pom.xml', '', is_git_diff_format=False) + handler = _make_handler(is_project=True, restore_result=None) + handler.__class__.__name__ = 'RestoreMavenDependencies' + type(handler).__name__ = 'RestoreMavenDependencies' + + with pytest.raises(FileCollectionError) as exc_info, ctx: + _try_restore_dependencies(ctx, handler, doc) + + assert 'pom.xml' in str(exc_info.value) + + def test_returns_document_on_success(self) -> None: + ctx = _make_ctx() + doc = Document('pom.xml', '', is_git_diff_format=False) + restored_doc = Document('pom.xml.lock', 'dep-tree-content', is_git_diff_format=False) + handler = _make_handler(is_project=True, restore_result=restored_doc) + + with ctx: + result = _try_restore_dependencies(ctx, handler, doc) + + assert result is restored_doc + assert result.content == 'dep-tree-content' + + def test_sets_empty_content_when_restore_returns_document_with_none_content(self) -> None: + ctx = _make_ctx() + doc = Document('pom.xml', '', is_git_diff_format=False) + restored_doc = Document('pom.xml.lock', None, is_git_diff_format=False) + handler = _make_handler(is_project=True, restore_result=restored_doc) + + with ctx: + result = _try_restore_dependencies(ctx, handler, doc) + + assert result is not None + assert result.content == '' From 6cfc436abae0830e6181af57f0e5ec4b7a79bde4 Mon Sep 17 00:00:00 2001 From: Mateusz Sterczewski Date: Fri, 27 Mar 2026 10:31:16 +0100 Subject: [PATCH 044/123] CM-61550: Show upload progress and detect slow connections during scan (#419) Co-authored-by: Claude Sonnet 4.6 --- cycode/cli/apps/scan/code_scanner.py | 59 ++++++++++++++- cycode/cli/exceptions/custom_exceptions.py | 11 +++ cycode/cyclient/cycode_client_base.py | 85 ++++++++++++++++++++++ cycode/cyclient/scan_client.py | 52 +++++++++---- tests/cyclient/test_scan_client.py | 27 +++++++ 5 files changed, 216 insertions(+), 18 deletions(-) diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index 4e551f68..35ed1d03 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -47,6 +47,36 @@ logger = get_logger('Code Scanner') +class _UploadProgressAggregator: + """Aggregates upload progress across parallel batch uploads for display in the progress bar.""" + + def __init__(self, progress_bar: 'BaseProgressBar') -> None: + self._progress_bar = progress_bar + self._slots: list[list[int]] = [] + + def create_callback(self) -> Callable[[int, int], None]: + """Create a progress callback for one batch upload. Each batch gets its own slot.""" + slot = [0, 0] + self._slots.append(slot) + + def on_upload_progress(bytes_read: int, total_bytes: int) -> None: + slot[0] = bytes_read + slot[1] = total_bytes + + # Sum across all batch slots to show combined progress + total_read = sum(s[0] for s in self._slots) + total_size = sum(s[1] for s in self._slots) + + if total_read >= total_size: + self._progress_bar.update_right_side_label(None) + else: + mb_read = total_read / (1024 * 1024) + mb_total = total_size / (1024 * 1024) + self._progress_bar.update_right_side_label(f'Uploading {mb_read:.1f} / {mb_total:.1f} MB') + + return on_upload_progress + + def scan_disk_files(ctx: typer.Context, paths: tuple[str, ...]) -> None: scan_type = ctx.obj['scan_type'] progress_bar = ctx.obj['progress_bar'] @@ -121,6 +151,9 @@ def _get_scan_documents_thread_func( severity_threshold = ctx.obj['severity_threshold'] sync_option = ctx.obj['sync'] command_scan_type = ctx.info_name + progress_bar = ctx.obj['progress_bar'] + + aggregator = _UploadProgressAggregator(progress_bar) def _scan_batch_thread_func(batch: list[Document]) -> tuple[str, CliError, LocalScanResult]: local_scan_result = error = error_message = None @@ -143,6 +176,7 @@ def _scan_batch_thread_func(batch: list[Document]) -> tuple[str, CliError, Local is_commit_range, scan_parameters, should_use_sync_flow, + on_upload_progress=aggregator.create_callback(), ) enrich_scan_result_with_data_from_detection_rules(cycode_client, scan_result) @@ -268,11 +302,14 @@ def _perform_scan_v4_async( scan_parameters: dict, is_git_diff: bool, is_commit_range: bool, + on_upload_progress: Optional[Callable] = None, ) -> ZippedFileScanResult: upload_link = cycode_client.get_upload_link(scan_type) logger.debug('Got upload link, %s', {'upload_id': upload_link.upload_id}) - cycode_client.upload_to_presigned_post(upload_link.url, upload_link.presigned_post_fields, zipped_documents) + cycode_client.upload_to_presigned_post( + upload_link.url, upload_link.presigned_post_fields, zipped_documents, on_upload_progress + ) logger.debug('Uploaded zip to presigned URL') scan_async_result = cycode_client.scan_repository_from_upload_id( @@ -292,9 +329,14 @@ def _perform_scan_async( scan_type: str, scan_parameters: dict, is_commit_range: bool, + on_upload_progress: Optional[Callable] = None, ) -> ZippedFileScanResult: scan_async_result = cycode_client.zipped_file_scan_async( - zipped_documents, scan_type, scan_parameters, is_commit_range=is_commit_range + zipped_documents, + scan_type, + scan_parameters, + is_commit_range=is_commit_range, + on_upload_progress=on_upload_progress, ) logger.debug('Async scan request has been triggered successfully, %s', {'scan_id': scan_async_result.scan_id}) @@ -326,6 +368,7 @@ def _perform_scan( is_commit_range: bool, scan_parameters: dict, should_use_sync_flow: bool = False, + on_upload_progress: Optional[Callable] = None, ) -> ZippedFileScanResult: if should_use_sync_flow: # it does not support commit range scans; should_use_sync_flow handles it @@ -334,12 +377,20 @@ def _perform_scan( if should_use_presigned_upload(scan_type): try: return _perform_scan_v4_async( - cycode_client, zipped_documents, scan_type, scan_parameters, is_git_diff, is_commit_range + cycode_client, + zipped_documents, + scan_type, + scan_parameters, + is_git_diff, + is_commit_range, + on_upload_progress, ) except requests.exceptions.RequestException: logger.warning('Direct upload to object storage failed. Falling back to upload via Cycode API. ') - return _perform_scan_async(cycode_client, zipped_documents, scan_type, scan_parameters, is_commit_range) + return _perform_scan_async( + cycode_client, zipped_documents, scan_type, scan_parameters, is_commit_range, on_upload_progress + ) def poll_scan_results( diff --git a/cycode/cli/exceptions/custom_exceptions.py b/cycode/cli/exceptions/custom_exceptions.py index 1200e559..4a874c1f 100644 --- a/cycode/cli/exceptions/custom_exceptions.py +++ b/cycode/cli/exceptions/custom_exceptions.py @@ -55,6 +55,11 @@ def __str__(self) -> str: return f'HTTP unauthorized error occurred during the request. Message: {self.error_message}' +class SlowUploadConnectionError(CycodeError): + def __str__(self) -> str: + return 'Upload was interrupted mid-transfer, indicating a slow or unstable network connection.' + + class ZipTooLargeError(CycodeError): def __init__(self, size_limit: int) -> None: self.size_limit = size_limit @@ -102,6 +107,12 @@ def __str__(self) -> str: code='timeout_error', message='The request timed out. Please try again by executing the `cycode scan` command', ), + SlowUploadConnectionError: CliError( + soft_fail=True, + code='slow_upload_error', + message='The scan upload was interrupted. This is likely due to a slow or unstable network connection. ' + 'Please try again by executing the `cycode scan` command', + ), HttpUnauthorizedError: CliError( soft_fail=True, code='auth_error', diff --git a/cycode/cyclient/cycode_client_base.py b/cycode/cyclient/cycode_client_base.py index 4b2e2698..1aae7bcb 100644 --- a/cycode/cyclient/cycode_client_base.py +++ b/cycode/cyclient/cycode_client_base.py @@ -1,6 +1,7 @@ import os import platform import ssl +from io import BytesIO from typing import TYPE_CHECKING, Callable, ClassVar, Optional import requests @@ -15,6 +16,7 @@ RequestHttpError, RequestSslError, RequestTimeoutError, + SlowUploadConnectionError, ) from cycode.cyclient import config from cycode.cyclient.headers import get_cli_user_agent, get_correlation_id @@ -90,6 +92,23 @@ def _should_retry_exception(exception: BaseException) -> bool: return is_request_error or is_server_error +class UploadProgressTracker: + """File-like wrapper that tracks bytes read during upload and fires a progress callback.""" + + def __init__(self, data: bytes, callback: Optional[Callable[[int, int], None]]) -> None: + self._io = BytesIO(data) + self._callback = callback + self.bytes_read = 0 + self.len = len(data) + + def read(self, size: int = -1) -> bytes: + chunk = self._io.read(size) + self.bytes_read += len(chunk) + if self._callback and chunk: + self._callback(self.bytes_read, self.len) + return chunk + + class CycodeClientBase: MANDATORY_HEADERS: ClassVar[dict[str, str]] = { 'User-Agent': get_cli_user_agent(), @@ -117,6 +136,72 @@ def put(self, url_path: str, body: Optional[dict] = None, headers: Optional[dict def get(self, url_path: str, headers: Optional[dict] = None, **kwargs) -> Response: return self._execute(method='get', endpoint=url_path, headers=headers, **kwargs) + def post_multipart( + self, + url_path: str, + form_fields: dict, + files: dict, + on_upload_progress: Optional[Callable[[int, int], None]] = None, + hide_response_content_log: bool = False, + ) -> Response: + """POST a multipart form body with optional upload progress tracking and retry.""" + url = self.build_full_url(self.api_url, url_path) + logger.debug('Executing request, %s', {'method': 'POST', 'url': url}) + + # Encode the multipart body once up front so we can reuse the same bytes across retries. + # A dummy URL is used because requests.Request requires one, but only the encoded body matters here. + prepared = requests.Request('POST', 'https://dummy', data=form_fields, files=files).prepare() + + return self._send_multipart( + url=url, + body=prepared.body, + content_type=prepared.headers['Content-Type'], + on_upload_progress=on_upload_progress, + hide_response_content_log=hide_response_content_log, + ) + + @retry( + retry=retry_if_exception(_should_retry_exception), + stop=_RETRY_STOP_STRATEGY, + wait=_RETRY_WAIT_STRATEGY, + reraise=True, + before_sleep=_retry_before_sleep, + ) + def _send_multipart( + self, + url: str, + body: bytes, + content_type: str, + on_upload_progress: Optional[Callable[[int, int], None]], + hide_response_content_log: bool, + ) -> Response: + # Wrap the body in a fresh tracker each attempt so bytes_read starts from zero. + tracker = UploadProgressTracker(body, on_upload_progress) + headers = self.get_request_headers({'Content-Type': content_type}) + try: + response = _get_request_function()( + method='post', url=url, data=tracker, headers=headers, timeout=self.timeout + ) + + content = 'HIDDEN' if hide_response_content_log else response.text + logger.debug( + 'Receiving response, %s', + {'status_code': response.status_code, 'url': url, 'content': content}, + ) + + response.raise_for_status() + return response + except (exceptions.ChunkedEncodingError, exceptions.ConnectionError) as e: + # A connection drop before the full body was sent indicates a slow/unstable network. + if tracker.bytes_read < tracker.len: + raise SlowUploadConnectionError from e + # Full body was sent — map to our types so _should_retry_exception handles retry logic. + if isinstance(e, exceptions.ConnectionError): + raise RequestConnectionError from e + raise + except Exception as e: + self._handle_exception(e) + @retry( retry=retry_if_exception(_should_retry_exception), stop=_RETRY_STOP_STRATEGY, diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index 24c5ac46..b609c4c0 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -1,6 +1,6 @@ import json from copy import deepcopy -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING, Callable, Optional, Union from uuid import UUID import requests @@ -8,10 +8,14 @@ from cycode.cli import consts from cycode.cli.config import configuration_manager -from cycode.cli.exceptions.custom_exceptions import CycodeError, RequestHttpError +from cycode.cli.exceptions.custom_exceptions import ( + CycodeError, + RequestHttpError, + SlowUploadConnectionError, +) from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip from cycode.cyclient import models -from cycode.cyclient.cycode_client_base import CycodeClientBase +from cycode.cyclient.cycode_client_base import CycodeClientBase, UploadProgressTracker from cycode.cyclient.logger import logger if TYPE_CHECKING: @@ -114,18 +118,18 @@ def zipped_file_scan_async( scan_parameters: dict, is_git_diff: bool = False, is_commit_range: bool = False, + on_upload_progress: Optional[Callable[[int, int], None]] = None, ) -> models.ScanInitializationResponse: - files = {'file': ('multiple_files_scan.zip', zip_file.read())} - - response = self.scan_cycode_client.post( + response = self.scan_cycode_client.post_multipart( url_path=self.get_zipped_file_scan_async_url_path(scan_type), - data={ + form_fields={ 'is_git_diff': is_git_diff, 'scan_parameters': json.dumps(scan_parameters), 'is_commit_range': is_commit_range, 'compression_manifest': self._create_compression_manifest_string(zip_file), }, - files=files, + files={'file': ('multiple_files_scan.zip', zip_file.read(), 'application/octet-stream')}, + on_upload_progress=on_upload_progress, ) return models.ScanInitializationResponseSchema().load(response.json()) @@ -135,12 +139,32 @@ def get_upload_link(self, scan_type: str) -> models.UploadLinkResponse: response = self.scan_cycode_client.get(url_path=url_path, hide_response_content_log=self._hide_response_log) return models.UploadLinkResponseSchema().load(response.json()) - def upload_to_presigned_post(self, url: str, fields: dict[str, str], zip_file: 'InMemoryZip') -> None: - multipart = {key: (None, value) for key, value in fields.items()} - multipart['file'] = (None, zip_file.read()) - # We are not using Cycode client, as we are calling aws S3. - response = requests.post(url, files=multipart, timeout=self.scan_cycode_client.timeout) - response.raise_for_status() + def upload_to_presigned_post( + self, + url: str, + fields: dict[str, str], + zip_file: 'InMemoryZip', + on_upload_progress: Optional[Callable[[int, int], None]] = None, + ) -> None: + all_files = {key: (None, value) for key, value in fields.items()} + all_files['file'] = ('multiple_files_scan.zip', zip_file.read(), 'application/octet-stream') + + prepared = requests.Request('POST', 'https://dummy', files=all_files).prepare() + tracker = UploadProgressTracker(prepared.body, on_upload_progress) + + try: + # We are not using Cycode client, as we are calling aws S3. + response = requests.post( + url, + data=tracker, + headers={'Content-Type': prepared.headers['Content-Type']}, + timeout=self.scan_cycode_client.timeout, + ) + response.raise_for_status() + except (requests.exceptions.ChunkedEncodingError, requests.exceptions.ConnectionError) as e: + if tracker.bytes_read < tracker.len: + raise SlowUploadConnectionError from e + raise def scan_repository_from_upload_id( self, diff --git a/tests/cyclient/test_scan_client.py b/tests/cyclient/test_scan_client.py index d6928118..505d8d50 100644 --- a/tests/cyclient/test_scan_client.py +++ b/tests/cyclient/test_scan_client.py @@ -4,6 +4,7 @@ import pytest import requests import responses +from pytest_mock import MockerFixture from requests.exceptions import ConnectionError as RequestsConnectionError from cycode.cli.cli_types import ScanTypeOption @@ -12,6 +13,7 @@ HttpUnauthorizedError, RequestConnectionError, RequestTimeoutError, + SlowUploadConnectionError, ) from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip from cycode.cli.models import Document @@ -168,3 +170,28 @@ def test_get_scan_details( scan_details_response = scan_client.get_scan_details(scan_type, str(scan_id)) assert scan_details_response.id == str(scan_id) assert scan_details_response.scan_status == 'Completed' + + +@pytest.mark.parametrize('scan_type', list(ScanTypeOption)) +def test_zipped_file_scan_async_slow_upload_error( + scan_type: ScanTypeOption, scan_client: ScanClient, mocker: MockerFixture +) -> None: + """Test that a connection failure mid-transfer raises SlowUploadConnectionError.""" + zip_file = get_test_zip_file(scan_type) + + def _partial_upload_then_fail(**kwargs) -> None: + # Read only a small portion of the body to simulate a partial upload + data = kwargs.get('data') + if data is not None: + data.read(10) + raise requests.exceptions.ChunkedEncodingError('Connection broken mid-transfer') + + mocker.patch('cycode.cyclient.cycode_client_base._get_request_function', return_value=_partial_upload_then_fail) + mocker.patch.object( + scan_client.scan_cycode_client, + 'get_request_headers', + return_value={'Authorization': 'Bearer test'}, + ) + + with pytest.raises(SlowUploadConnectionError): + scan_client.zipped_file_scan_async(zip_file=zip_file, scan_type=scan_type, scan_parameters={}) From 14c71e0888b7ed5035e4db043e7f1c161be678f8 Mon Sep 17 00:00:00 2001 From: Mateusz Sterczewski Date: Thu, 2 Apr 2026 13:20:55 +0200 Subject: [PATCH 045/123] CM-62273: Add compression manifest to v4 presigned upload scans (#431) Co-authored-by: Claude Sonnet 4.6 --- cycode/cli/apps/scan/code_scanner.py | 2 +- cycode/cli/apps/scan/commit_range_scanner.py | 2 +- cycode/cyclient/scan_client.py | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index 35ed1d03..072e438e 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -313,7 +313,7 @@ def _perform_scan_v4_async( logger.debug('Uploaded zip to presigned URL') scan_async_result = cycode_client.scan_repository_from_upload_id( - scan_type, upload_link.upload_id, scan_parameters, is_git_diff, is_commit_range + scan_type, upload_link.upload_id, zipped_documents, scan_parameters, is_git_diff, is_commit_range ) logger.debug( 'Presigned upload scan request triggered, %s', diff --git a/cycode/cli/apps/scan/commit_range_scanner.py b/cycode/cli/apps/scan/commit_range_scanner.py index d4ce4be8..9691be6e 100644 --- a/cycode/cli/apps/scan/commit_range_scanner.py +++ b/cycode/cli/apps/scan/commit_range_scanner.py @@ -113,7 +113,7 @@ def _perform_commit_range_scan_v4_async( logger.debug('Uploaded to-commit zip') scan_async_result = cycode_client.commit_range_scan_from_upload_ids( - scan_type, from_upload_link.upload_id, to_upload_link.upload_id, scan_parameters + scan_type, from_upload_link.upload_id, to_upload_link.upload_id, from_commit_zipped_documents, scan_parameters ) logger.debug('V4 commit range scan request triggered, %s', {'scan_id': scan_async_result.scan_id}) diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index b609c4c0..18f400ac 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -170,6 +170,7 @@ def scan_repository_from_upload_id( self, scan_type: str, upload_id: str, + zip_file: InMemoryZip, scan_parameters: dict, is_git_diff: bool = False, is_commit_range: bool = False, @@ -183,6 +184,7 @@ def scan_repository_from_upload_id( 'is_git_diff': is_git_diff, 'is_commit_range': is_commit_range, 'scan_parameters': json.dumps(scan_parameters), + 'compression_manifest': self._create_compression_manifest_string(zip_file), }, ) return models.ScanInitializationResponseSchema().load(response.json()) @@ -230,6 +232,7 @@ def commit_range_scan_from_upload_ids( scan_type: str, from_commit_upload_id: str, to_commit_upload_id: str, + from_commit_zip_file: InMemoryZip, scan_parameters: dict, is_git_diff: bool = False, ) -> models.ScanInitializationResponse: @@ -242,6 +245,7 @@ def commit_range_scan_from_upload_ids( 'to_commit_upload_id': to_commit_upload_id, 'is_git_diff': is_git_diff, 'scan_parameters': json.dumps(scan_parameters), + 'compression_manifest': self._create_compression_manifest_string(from_commit_zip_file), }, ) return models.ScanInitializationResponseSchema().load(response.json()) From 2ac16e5d36fcff092e8e10aac47c9ce1174caa14 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:14:33 +0100 Subject: [PATCH 046/123] Bump pytest from 7.3.2 to 8.4.2 (#425) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 27 ++++++++++++++------------- pyproject.toml | 2 +- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/poetry.lock b/poetry.lock index 62539555..108c34d9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1123,7 +1123,7 @@ version = "2.19.2" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "test"] files = [ {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, @@ -1210,26 +1210,27 @@ tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"] [[package]] name = "pytest" -version = "7.3.2" +version = "8.4.2" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" groups = ["test"] files = [ - {file = "pytest-7.3.2-py3-none-any.whl", hash = "sha256:cdcbd012c9312258922f8cd3f1b62a6580fdced17db6014896053d47cddf9295"}, - {file = "pytest-7.3.2.tar.gz", hash = "sha256:ee990a3cc55ba808b80795a79944756f315c67c12b56abd3ac993a7b8c17030b"}, + {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, + {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, ] [package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=0.12,<2.0" -tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-mock" @@ -1997,4 +1998,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "04201585f115c406a49b035b4c3b3be7057baee685997ab57fe39cc964ad5352" +content-hash = "f8824deab2890da823b848258c784788f97ded6393d417100c9f9fdbd90bc79d" diff --git a/pyproject.toml b/pyproject.toml index 2beebaf2..3fd3af18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ pathvalidate = ">=3.3.1,<4.0.0" [tool.poetry.group.test.dependencies] mock = ">=4.0.3,<4.1.0" -pytest = ">=7.3.1,<7.4.0" +pytest = ">=7.3.1,<8.5.0" pytest-mock = ">=3.10.0,<3.11.0" coverage = ">=7.2.3,<7.3.0" responses = ">=0.23.1,<0.27.0" From 5c6876ab231ed744485519826e8bdd47db04f607 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:50:49 +0100 Subject: [PATCH 047/123] Bump coverage from 7.2.7 to 7.10.7 (#423) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 170 +++++++++++++++++++++++++++++++------------------ pyproject.toml | 2 +- 2 files changed, 108 insertions(+), 64 deletions(-) diff --git a/poetry.lock b/poetry.lock index 108c34d9..3b793f4a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -342,72 +342,116 @@ files = [ [[package]] name = "coverage" -version = "7.2.7" +version = "7.10.7" description = "Code coverage measurement for Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" groups = ["test"] files = [ - {file = "coverage-7.2.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8"}, - {file = "coverage-7.2.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb"}, - {file = "coverage-7.2.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6"}, - {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2"}, - {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063"}, - {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1"}, - {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353"}, - {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495"}, - {file = "coverage-7.2.7-cp310-cp310-win32.whl", hash = "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818"}, - {file = "coverage-7.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850"}, - {file = "coverage-7.2.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f"}, - {file = "coverage-7.2.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe"}, - {file = "coverage-7.2.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3"}, - {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f"}, - {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb"}, - {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833"}, - {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97"}, - {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a"}, - {file = "coverage-7.2.7-cp311-cp311-win32.whl", hash = "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a"}, - {file = "coverage-7.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562"}, - {file = "coverage-7.2.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4"}, - {file = "coverage-7.2.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4"}, - {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01"}, - {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6"}, - {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d"}, - {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de"}, - {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d"}, - {file = "coverage-7.2.7-cp312-cp312-win32.whl", hash = "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511"}, - {file = "coverage-7.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3"}, - {file = "coverage-7.2.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f"}, - {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb"}, - {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9"}, - {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd"}, - {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a"}, - {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959"}, - {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02"}, - {file = "coverage-7.2.7-cp37-cp37m-win32.whl", hash = "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f"}, - {file = "coverage-7.2.7-cp37-cp37m-win_amd64.whl", hash = "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0"}, - {file = "coverage-7.2.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5"}, - {file = "coverage-7.2.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5"}, - {file = "coverage-7.2.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9"}, - {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6"}, - {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e"}, - {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050"}, - {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5"}, - {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f"}, - {file = "coverage-7.2.7-cp38-cp38-win32.whl", hash = "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e"}, - {file = "coverage-7.2.7-cp38-cp38-win_amd64.whl", hash = "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c"}, - {file = "coverage-7.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9"}, - {file = "coverage-7.2.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2"}, - {file = "coverage-7.2.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7"}, - {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e"}, - {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1"}, - {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9"}, - {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250"}, - {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2"}, - {file = "coverage-7.2.7-cp39-cp39-win32.whl", hash = "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb"}, - {file = "coverage-7.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27"}, - {file = "coverage-7.2.7-pp37.pp38.pp39-none-any.whl", hash = "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d"}, - {file = "coverage-7.2.7.tar.gz", hash = "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59"}, + {file = "coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a"}, + {file = "coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13"}, + {file = "coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b"}, + {file = "coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807"}, + {file = "coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59"}, + {file = "coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61"}, + {file = "coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14"}, + {file = "coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2"}, + {file = "coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a"}, + {file = "coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417"}, + {file = "coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1"}, + {file = "coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256"}, + {file = "coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba"}, + {file = "coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf"}, + {file = "coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d"}, + {file = "coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f"}, + {file = "coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698"}, + {file = "coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843"}, + {file = "coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546"}, + {file = "coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c"}, + {file = "coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2"}, + {file = "coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a"}, + {file = "coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb"}, + {file = "coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb"}, + {file = "coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520"}, + {file = "coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd"}, + {file = "coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2"}, + {file = "coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681"}, + {file = "coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880"}, + {file = "coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63"}, + {file = "coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399"}, + {file = "coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235"}, + {file = "coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d"}, + {file = "coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a"}, + {file = "coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3"}, + {file = "coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f"}, + {file = "coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431"}, + {file = "coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07"}, + {file = "coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260"}, + {file = "coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239"}, ] [package.extras] @@ -1998,4 +2042,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "f8824deab2890da823b848258c784788f97ded6393d417100c9f9fdbd90bc79d" +content-hash = "25dc6986a2a4572b689edb26f9184faab8bee8e3a569f8e0cdc8ac35ded0b9fc" diff --git a/pyproject.toml b/pyproject.toml index 3fd3af18..0ed8d8c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ pathvalidate = ">=3.3.1,<4.0.0" mock = ">=4.0.3,<4.1.0" pytest = ">=7.3.1,<8.5.0" pytest-mock = ">=3.10.0,<3.11.0" -coverage = ">=7.2.3,<7.3.0" +coverage = ">=7.2.3,<7.11.0" responses = ">=0.23.1,<0.27.0" pyfakefs = ">=5.7.2,<5.11.0" From c404e4caca288a98d2294dcb7f0d06edfaf41ac1 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:22:22 +0300 Subject: [PATCH 048/123] =?UTF-8?q?CM-61568:=20Fix=20sensitive=20path=20sk?= =?UTF-8?q?ipping=20content=20scan=20and=20directory=20hand=E2=80=A6=20(#4?= =?UTF-8?q?32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 4.6 (1M context) --- .../cli/apps/ai_guardrails/scan/handlers.py | 34 ++++-- .../ai_guardrails/scan/test_handlers.py | 103 ++++++++++++++++++ 2 files changed, 128 insertions(+), 9 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index 8c0a2ce7..99fa29c8 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -116,7 +116,8 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: try: # Check path-based denylist first - if is_denied_path(file_path, policy): + is_sensitive_path = is_denied_path(file_path, policy) + if is_sensitive_path: block_reason = BlockReason.SENSITIVE_PATH if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK: outcome = AIHookOutcome.BLOCKED @@ -125,13 +126,21 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: user_message, 'This file path is classified as sensitive; do not read/send it to the model.', ) - # Warn mode - ask user for permission + # Warn mode - if content scan is enabled, emit a separate event for the + # sensitive path so the finally block can independently track the scan result. + # If content scan is disabled, a single event (from finally) is enough. outcome = AIHookOutcome.WARNED - user_message = f'Cycode flagged {file_path} as sensitive. Allow reading?' - return response_builder.ask_permission( - user_message, - 'This file path is classified as sensitive; proceed with caution.', - ) + if get_policy_value(file_read_config, 'scan_content', default=True): + ai_client.create_event( + payload, + AiHookEventType.FILE_READ, + outcome, + block_reason=BlockReason.SENSITIVE_PATH, + file_path=payload.file_path, + ) + # Reset for the content scan result tracked by the finally block + block_reason = None + outcome = AIHookOutcome.ALLOWED # Scan file content if enabled if get_policy_value(file_read_config, 'scan_content', default=True): @@ -152,7 +161,14 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: user_message, 'Possible secrets detected; proceed with caution.', ) - return response_builder.allow_permission() + + # If path was sensitive but content scan found no secrets (or scan disabled), still warn + if is_sensitive_path: + user_message = f'Cycode flagged {file_path} as sensitive. Allow reading?' + return response_builder.ask_permission( + user_message, + 'This file path is classified as sensitive; proceed with caution.', + ) return response_builder.allow_permission() except Exception as e: @@ -342,7 +358,7 @@ def _scan_path_for_secrets(ctx: typer.Context, file_path: str, policy: dict) -> Returns tuple of (violation_summary, scan_id) if secrets found, (None, scan_id) if clean. Raises exception on error or timeout. """ - if not file_path or not os.path.exists(file_path): + if not file_path or not os.path.isfile(file_path): return None, None max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000) diff --git a/tests/cli/commands/ai_guardrails/scan/test_handlers.py b/tests/cli/commands/ai_guardrails/scan/test_handlers.py index 1adfe25b..1ef1098c 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_handlers.py +++ b/tests/cli/commands/ai_guardrails/scan/test_handlers.py @@ -263,6 +263,109 @@ def test_handle_before_read_file_scan_disabled( mock_scan.assert_not_called() +@patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_path_for_secrets') +def test_handle_before_read_file_sensitive_path_warn_mode_scans_content( + mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that sensitive path in warn mode still scans file content and emits two events.""" + mock_is_denied.return_value = True + mock_scan.return_value = (None, 'scan-id-123') + default_policy['mode'] = 'warn' + payload = AIHookPayload( + event_name='file_read', + ide_provider='cursor', + file_path='/path/to/.env', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + # Content was scanned even though path is sensitive + mock_scan.assert_called_once() + # Still warns about sensitive path since no secrets found + assert result['permission'] == 'ask' + assert '.env' in result['user_message'] + + # Two events: sensitive path warn + content scan result (allowed, no secrets found) + assert mock_ctx.obj['ai_security_client'].create_event.call_count == 2 + first_event = mock_ctx.obj['ai_security_client'].create_event.call_args_list[0] + assert first_event.args[2] == AIHookOutcome.WARNED + assert first_event.kwargs['block_reason'] == BlockReason.SENSITIVE_PATH + second_event = mock_ctx.obj['ai_security_client'].create_event.call_args_list[1] + assert second_event.args[2] == AIHookOutcome.ALLOWED + assert second_event.kwargs['block_reason'] is None + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_path_for_secrets') +def test_handle_before_read_file_sensitive_path_warn_mode_with_secrets( + mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that sensitive path in warn mode reports secrets and emits two events.""" + mock_is_denied.return_value = True + mock_scan.return_value = ('Found 1 secret: API key', 'scan-id-456') + default_policy['mode'] = 'warn' + payload = AIHookPayload( + event_name='file_read', + ide_provider='cursor', + file_path='/path/to/.env', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + mock_scan.assert_called_once() + assert result['permission'] == 'ask' + assert 'Found 1 secret: API key' in result['user_message'] + + # Two events: sensitive path warn + secrets warn + assert mock_ctx.obj['ai_security_client'].create_event.call_count == 2 + first_event = mock_ctx.obj['ai_security_client'].create_event.call_args_list[0] + assert first_event.args[2] == AIHookOutcome.WARNED + assert first_event.kwargs['block_reason'] == BlockReason.SENSITIVE_PATH + second_event = mock_ctx.obj['ai_security_client'].create_event.call_args_list[1] + assert second_event.args[2] == AIHookOutcome.WARNED + assert second_event.kwargs['block_reason'] == BlockReason.SECRETS_IN_FILE + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_path_for_secrets') +def test_handle_before_read_file_sensitive_path_scan_disabled_warns( + mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that sensitive path in warn mode with scan disabled emits a single event.""" + mock_is_denied.return_value = True + default_policy['mode'] = 'warn' + default_policy['file_read']['scan_content'] = False + payload = AIHookPayload( + event_name='file_read', + ide_provider='cursor', + file_path='/path/to/.env', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + mock_scan.assert_not_called() + assert result['permission'] == 'ask' + assert '.env' in result['user_message'] + + # Single event: sensitive path warn (no separate scan event when scan is disabled) + mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.WARNED + assert call_args.kwargs['block_reason'] == BlockReason.SENSITIVE_PATH + + +def test_scan_path_for_secrets_directory(mock_ctx: MagicMock, default_policy: dict[str, Any], fs: Any) -> None: + """Test that _scan_path_for_secrets returns (None, None) for directories.""" + from cycode.cli.apps.ai_guardrails.scan.handlers import _scan_path_for_secrets + + fs.create_dir('/path/to/some_directory') + + result = _scan_path_for_secrets(mock_ctx, '/path/to/some_directory', default_policy) + + assert result == (None, None) + + # Tests for handle_before_mcp_execution From d77820946c8ee5d21ab122eeed7996f54bc1d43a Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Tue, 7 Apr 2026 16:15:07 +0100 Subject: [PATCH 049/123] CM-62406: handle more pre-push types gracefully (#433) --- .../apps/scan/pre_push/pre_push_command.py | 4 ++ .../files_collector/commit_range_documents.py | 21 ++++--- .../test_commit_range_documents.py | 56 +++++++++---------- 3 files changed, 44 insertions(+), 37 deletions(-) diff --git a/cycode/cli/apps/scan/pre_push/pre_push_command.py b/cycode/cli/apps/scan/pre_push/pre_push_command.py index d3339ea9..729f3571 100644 --- a/cycode/cli/apps/scan/pre_push/pre_push_command.py +++ b/cycode/cli/apps/scan/pre_push/pre_push_command.py @@ -44,6 +44,10 @@ def pre_push_command( timeout = configuration_manager.get_pre_push_command_timeout(command_scan_type) with TimeoutAfter(timeout): push_update_details = parse_pre_push_input() + if not push_update_details: + logger.info('No pre-push input found, nothing to scan') + return + commit_range = calculate_pre_push_commit_range(push_update_details) if not commit_range: logger.info( diff --git a/cycode/cli/files_collector/commit_range_documents.py b/cycode/cli/files_collector/commit_range_documents.py index a4a1a784..daa5c432 100644 --- a/cycode/cli/files_collector/commit_range_documents.py +++ b/cycode/cli/files_collector/commit_range_documents.py @@ -228,7 +228,7 @@ def parse_pre_receive_input() -> str: return pre_receive_input.splitlines()[0] -def parse_pre_push_input() -> str: +def parse_pre_push_input() -> Optional[str]: """Parse input to pre-push hook details. Example input: @@ -237,13 +237,11 @@ def parse_pre_push_input() -> str: refs/heads/main 9cf90954ef26e7c58284f8ebf7dcd0fcf711152a refs/heads/main 973a96d3e925b65941f7c47fa16129f1577d499f refs/heads/feature-branch 3378e52dcfa47fb11ce3a4a520bea5f85d5d0bf3 refs/heads/feature-branch 59564ef68745bca38c42fc57a7822efd519a6bd9 - :return: First, push update details (input's first line) + :return: First push update details (input's first line), or None if no input was provided """ # noqa: E501 pre_push_input = _read_hook_input_from_stdin() if not pre_push_input: - raise ValueError( - 'Pre push input was not found. Make sure that you are using this command only in pre-push hook' - ) + return None # each line represents a branch push request, handle the first one only return pre_push_input.splitlines()[0] @@ -332,6 +330,15 @@ def calculate_pre_push_commit_range(push_update_details: str) -> Optional[str]: """ local_ref, local_object_name, remote_ref, remote_object_name = push_update_details.split() + # Tag pushes don't contain file diffs that need scanning + if local_ref.startswith('refs/tags/') or remote_ref.startswith('refs/tags/'): + logger.info('Skipping scan for tag push: %s -> %s', local_ref, remote_ref) + return None + + # If deleting a ref (local_object_name is all zeros), no need to scan + if local_object_name == consts.EMPTY_COMMIT_SHA: + return None + if remote_object_name == consts.EMPTY_COMMIT_SHA: try: repo = git_proxy.get_repo(os.getcwd()) @@ -356,10 +363,6 @@ def calculate_pre_push_commit_range(push_update_details: str) -> Optional[str]: logger.debug('Failed to get repo for pre-push commit range calculation: %s', exc_info=e) return consts.COMMIT_RANGE_ALL_COMMITS - # If deleting a branch (local_object_name is all zeros), no need to scan - if local_object_name == consts.EMPTY_COMMIT_SHA: - return None - # For updates to existing branches, scan from remote to local return f'{remote_object_name}..{local_object_name}' diff --git a/tests/cli/files_collector/test_commit_range_documents.py b/tests/cli/files_collector/test_commit_range_documents.py index 501c1811..0b96a0e2 100644 --- a/tests/cli/files_collector/test_commit_range_documents.py +++ b/tests/cli/files_collector/test_commit_range_documents.py @@ -392,15 +392,15 @@ def test_parse_branch_deletion_input(self) -> None: result = parse_pre_push_input() assert result == pre_push_input - def test_parse_empty_input_raises_error(self) -> None: - """Test that empty input raises ValueError.""" - with patch('sys.stdin', StringIO('')), pytest.raises(ValueError, match='Pre push input was not found'): - parse_pre_push_input() + def test_parse_empty_input_returns_none(self) -> None: + """Test that empty input returns None instead of raising.""" + with patch('sys.stdin', StringIO('')): + assert parse_pre_push_input() is None - def test_parse_whitespace_only_input_raises_error(self) -> None: - """Test that whitespace-only input raises ValueError.""" - with patch('sys.stdin', StringIO(' \n\t ')), pytest.raises(ValueError, match='Pre push input was not found'): - parse_pre_push_input() + def test_parse_whitespace_only_input_returns_none(self) -> None: + """Test that whitespace-only input returns None instead of raising.""" + with patch('sys.stdin', StringIO(' \n\t ')): + assert parse_pre_push_input() is None class TestGetDefaultBranchesForMergeBase: @@ -758,26 +758,23 @@ def test_calculate_range_parsing_push_details(self) -> None: result = calculate_pre_push_commit_range(push_details) assert result == '789xyz456abc..abc123def456' - def test_calculate_range_with_tags(self) -> None: - """Test calculating commit range when pushing tags.""" + def test_calculate_range_with_new_tag_push_returns_none(self) -> None: + """Test that pushing a new tag returns None (no scanning needed).""" push_details = f'refs/tags/v1.0.0 1234567890abcdef refs/tags/v1.0.0 {consts.EMPTY_COMMIT_SHA}' + result = calculate_pre_push_commit_range(push_details) + assert result is None - with temporary_git_repository() as (temp_dir, repo): - # Create a commit - test_file = os.path.join(temp_dir, 'test.py') - with open(test_file, 'w') as f: - f.write("print('test')") - - repo.index.add(['test.py']) - commit = repo.index.commit('Test commit') - - # Create tag - repo.create_tag('v1.0.0', commit) + def test_calculate_range_with_tag_deletion_returns_none(self) -> None: + """Test that deleting a tag returns None (no scanning needed).""" + push_details = f'refs/tags/v1.0.0 {consts.EMPTY_COMMIT_SHA} refs/tags/v1.0.0 1234567890abcdef' + result = calculate_pre_push_commit_range(push_details) + assert result is None - with patch('os.getcwd', return_value=temp_dir): - result = calculate_pre_push_commit_range(push_details) - # For new tags, should try to find a merge base or fall back to --all - assert result in [f'{commit.hexsha}..{commit.hexsha}', '--all'] + def test_calculate_range_with_tag_update_returns_none(self) -> None: + """Test that updating a tag returns None (no scanning needed).""" + push_details = 'refs/tags/v1.0.0 1234567890abcdef refs/tags/v1.0.0 0987654321fedcba' + result = calculate_pre_push_commit_range(push_details) + assert result is None class TestPrePushHookIntegration: @@ -805,12 +802,15 @@ def test_simulate_pre_push_hook_input_format(self) -> None: # Test that we can calculate the commit range for each case commit_range = calculate_pre_push_commit_range(parsed) - if consts.EMPTY_COMMIT_SHA in push_input: - if push_input.startswith('refs/heads/') and push_input.split()[1] == consts.EMPTY_COMMIT_SHA: + if push_input.startswith('refs/tags/'): + # Tag pushes - should return None (no scanning needed) + assert commit_range is None + elif consts.EMPTY_COMMIT_SHA in push_input: + if push_input.split()[1] == consts.EMPTY_COMMIT_SHA: # Branch deletion - should return None assert commit_range is None else: - # New branch/tag - should return a range or --all + # New branch - should return a range or --all assert commit_range is not None else: # Regular update - should return proper range From f89bbe6435becd7bebf017a6fd5bf91450c31b29 Mon Sep 17 00:00:00 2001 From: RoniCycode <142726722+RoniCycode@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:21:30 +0300 Subject: [PATCH 050/123] CM-61986-add-mcp-and-email-enrichment-from-claude-json (#421) --- .../apps/ai_guardrails/scan/claude_config.py | 44 +++++++++++++++ cycode/cli/apps/ai_guardrails/scan/payload.py | 7 ++- .../ai_guardrails/scan/test_payload.py | 54 +++++++++++++++++++ .../ai_guardrails/test_claude_config.py | 54 +++++++++++++++++++ 4 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 cycode/cli/apps/ai_guardrails/scan/claude_config.py create mode 100644 tests/cli/commands/ai_guardrails/test_claude_config.py diff --git a/cycode/cli/apps/ai_guardrails/scan/claude_config.py b/cycode/cli/apps/ai_guardrails/scan/claude_config.py new file mode 100644 index 00000000..cff0a5d7 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/claude_config.py @@ -0,0 +1,44 @@ +"""Reader for ~/.claude.json configuration file. + +Extracts user email from the Claude Code global config file +for use in AI guardrails scan enrichment. +""" + +import json +from pathlib import Path +from typing import Optional + +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails Claude Config') + +_CLAUDE_CONFIG_PATH = Path.home() / '.claude.json' + + +def load_claude_config(config_path: Optional[Path] = None) -> Optional[dict]: + """Load and parse ~/.claude.json. + + Args: + config_path: Override path for testing. Defaults to ~/.claude.json. + + Returns: + Parsed dict or None if file is missing or invalid. + """ + path = config_path or _CLAUDE_CONFIG_PATH + if not path.exists(): + logger.debug('Claude config file not found', extra={'path': str(path)}) + return None + try: + content = path.read_text(encoding='utf-8') + return json.loads(content) + except Exception as e: + logger.debug('Failed to load Claude config file', exc_info=e) + return None + + +def get_user_email(config: dict) -> Optional[str]: + """Extract user email from Claude config. + + Reads oauthAccount.emailAddress from the config dict. + """ + return config.get('oauthAccount', {}).get('emailAddress') diff --git a/cycode/cli/apps/ai_guardrails/scan/payload.py b/cycode/cli/apps/ai_guardrails/scan/payload.py index 08e96f9a..9a19970c 100644 --- a/cycode/cli/apps/ai_guardrails/scan/payload.py +++ b/cycode/cli/apps/ai_guardrails/scan/payload.py @@ -7,6 +7,7 @@ from typing import Optional from cycode.cli.apps.ai_guardrails.consts import AIIDEType +from cycode.cli.apps.ai_guardrails.scan.claude_config import get_user_email, load_claude_config from cycode.cli.apps.ai_guardrails.scan.types import ( CLAUDE_CODE_EVENT_MAPPING, CLAUDE_CODE_EVENT_NAMES, @@ -207,11 +208,15 @@ def from_claude_code_payload(cls, payload: dict) -> 'AIHookPayload': # Extract IDE version, model, and generation ID from transcript file ide_version, model, generation_id = _extract_from_claude_transcript(payload.get('transcript_path')) + # Extract user email from ~/.claude.json + claude_config = load_claude_config() + ide_user_email = get_user_email(claude_config) if claude_config else None + return cls( event_name=canonical_event, conversation_id=payload.get('session_id'), generation_id=generation_id, - ide_user_email=None, # Claude Code doesn't provide this in hook payload + ide_user_email=ide_user_email, model=model, ide_provider=AIIDEType.CLAUDE_CODE.value, ide_version=ide_version, diff --git a/tests/cli/commands/ai_guardrails/scan/test_payload.py b/tests/cli/commands/ai_guardrails/scan/test_payload.py index e17d833d..1ef5fad0 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_payload.py +++ b/tests/cli/commands/ai_guardrails/scan/test_payload.py @@ -322,6 +322,60 @@ def test_from_claude_code_payload_gets_latest_user_uuid(mocker: MockerFixture) - assert unified.generation_id == 'latest-user-uuid' +# Claude Code email extraction tests + + +def test_from_claude_code_payload_extracts_email_from_config(mocker: MockerFixture) -> None: + """Test that ide_user_email is populated from ~/.claude.json.""" + mocker.patch( + 'cycode.cli.apps.ai_guardrails.scan.payload.load_claude_config', + return_value={'oauthAccount': {'emailAddress': 'user@example.com'}}, + ) + + claude_payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test', + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + assert unified.ide_user_email == 'user@example.com' + + +def test_from_claude_code_payload_email_none_when_config_missing(mocker: MockerFixture) -> None: + """Test that ide_user_email is None when ~/.claude.json is missing.""" + mocker.patch( + 'cycode.cli.apps.ai_guardrails.scan.payload.load_claude_config', + return_value=None, + ) + + claude_payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test', + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + assert unified.ide_user_email is None + + +def test_from_claude_code_payload_email_none_when_no_oauth(mocker: MockerFixture) -> None: + """Test that ide_user_email is None when oauthAccount is missing from config.""" + mocker.patch( + 'cycode.cli.apps.ai_guardrails.scan.payload.load_claude_config', + return_value={'someOtherKey': 'value'}, + ) + + claude_payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test', + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + assert unified.ide_user_email is None + + # IDE detection tests diff --git a/tests/cli/commands/ai_guardrails/test_claude_config.py b/tests/cli/commands/ai_guardrails/test_claude_config.py new file mode 100644 index 00000000..6bbdbcab --- /dev/null +++ b/tests/cli/commands/ai_guardrails/test_claude_config.py @@ -0,0 +1,54 @@ +"""Tests for Claude Code config file reader.""" + +import json +from pathlib import Path + +from pyfakefs.fake_filesystem import FakeFilesystem + +from cycode.cli.apps.ai_guardrails.scan.claude_config import get_user_email, load_claude_config + + +def test_load_claude_config_valid(fs: FakeFilesystem) -> None: + """Test loading a valid ~/.claude.json file.""" + config = {'oauthAccount': {'emailAddress': 'user@example.com'}} + config_path = Path.home() / '.claude.json' + fs.create_file(config_path, contents=json.dumps(config)) + + result = load_claude_config(config_path) + assert result == config + + +def test_load_claude_config_missing_file(fs: FakeFilesystem) -> None: + """Test loading when ~/.claude.json does not exist.""" + fs.create_dir(Path.home()) + config_path = Path.home() / '.claude.json' + + result = load_claude_config(config_path) + assert result is None + + +def test_load_claude_config_corrupt_file(fs: FakeFilesystem) -> None: + """Test loading when ~/.claude.json contains invalid JSON.""" + config_path = Path.home() / '.claude.json' + fs.create_file(config_path, contents='not valid json {{{') + + result = load_claude_config(config_path) + assert result is None + + +def test_get_user_email_present() -> None: + """Test extracting email when oauthAccount.emailAddress exists.""" + config = {'oauthAccount': {'emailAddress': 'user@example.com'}} + assert get_user_email(config) == 'user@example.com' + + +def test_get_user_email_missing_oauth_account() -> None: + """Test extracting email when oauthAccount key is missing.""" + config = {'someOtherKey': 'value'} + assert get_user_email(config) is None + + +def test_get_user_email_missing_email_address() -> None: + """Test extracting email when oauthAccount exists but emailAddress is missing.""" + config = {'oauthAccount': {'someOtherField': 'value'}} + assert get_user_email(config) is None From e1f5eb7a57652242c41683ac72b1135dfa98e5b1 Mon Sep 17 00:00:00 2001 From: Maor Davidzon <56628808+MaorDavidzon@users.noreply.github.com> Date: Sun, 19 Apr 2026 10:54:46 +0300 Subject: [PATCH 051/123] CM-62578: Expose Cycode API v4 through CLI commands (#435) Co-authored-by: Claude Opus 4.6 (1M context) --- Dockerfile | 2 +- README.md | 64 +++++- cycode/cli/app.py | 23 ++ cycode/cli/apps/api/__init__.py | 69 ++++++ cycode/cli/apps/api/api_command.py | 271 ++++++++++++++++++++++++ cycode/cli/apps/api/openapi_spec.py | 182 ++++++++++++++++ poetry.lock | 2 +- tests/cli/apps/api/__init__.py | 0 tests/cli/apps/api/test_api_command.py | 110 ++++++++++ tests/cli/apps/api/test_openapi_spec.py | 72 +++++++ 10 files changed, 792 insertions(+), 3 deletions(-) create mode 100644 cycode/cli/apps/api/__init__.py create mode 100644 cycode/cli/apps/api/api_command.py create mode 100644 cycode/cli/apps/api/openapi_spec.py create mode 100644 tests/cli/apps/api/__init__.py create mode 100644 tests/cli/apps/api/test_api_command.py create mode 100644 tests/cli/apps/api/test_openapi_spec.py diff --git a/Dockerfile b/Dockerfile index 40d6fad3..574d38ff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ FROM base AS builder ENV POETRY_VERSION=2.2.1 # deps are required to build cffi -RUN apk add --no-cache --virtual .build-deps gcc=14.2.0-r4 libffi-dev=3.4.7-r0 musl-dev=1.2.5-r9 && \ +RUN apk add --no-cache --virtual .build-deps gcc=14.2.0-r4 libffi-dev=3.4.7-r0 musl-dev=1.2.5-r11 && \ pip install --no-cache-dir "poetry==$POETRY_VERSION" "poetry-dynamic-versioning[plugin]" && \ apk del .build-deps gcc libffi-dev musl-dev diff --git a/README.md b/README.md index 780d947b..806390d0 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,11 @@ This guide walks you through both installation and usage. 2. [Available Options](#available-options) 3. [MCP Tools](#mcp-tools) 4. [Usage Examples](#usage-examples) -5. [Scan Command](#scan-command) +5. [Platform Command](#platform-command-beta) + 1. [Discovering Commands](#discovering-commands) + 2. [Examples](#platform-examples) + 3. [Notes & Limitations](#platform-notes--limitations) +6. [Scan Command](#scan-command) 1. [Running a Scan](#running-a-scan) 1. [Options](#options) 1. [Severity Threshold](#severity-option) @@ -605,6 +609,64 @@ This information can be helpful when: - Debugging transport-specific issues +# Platform Command \[BETA\] + +> [!WARNING] +> The `platform` command is in **beta**. Commands, arguments, and output formats are generated dynamically from the Cycode API spec and may change between releases without notice. Do not rely on them in production automation yet. + +The `cycode platform` command exposes the Cycode platform's read APIs as CLI commands. It groups endpoints by resource (e.g. `projects`, `violations`, `workflows`) and turns each endpoint's parameters into typed CLI arguments and `--option` flags. + +```bash +cycode platform projects list --page-size 50 +cycode platform violations count +cycode platform workflows view +``` + +The OpenAPI spec is fetched from the Cycode API on first use and cached at `~/.cycode/openapi-spec.json` for 24 hours. Unrelated commands (`cycode scan`, `cycode status`, etc.) do not trigger a fetch. + +> [!NOTE] +> You must be authenticated (`cycode auth` or `CYCODE_CLIENT_ID` / `CYCODE_CLIENT_SECRET` environment variables) for `cycode platform` to discover and run commands. Other Cycode CLI commands work without authentication. + +## Discovering Commands + +Because commands are generated from the spec, the source of truth for what's available is `--help`: + +```bash +cycode platform --help # list all resource groups +cycode platform projects --help # list actions on a resource +cycode platform projects list --help # list options/arguments for an action +``` + +## Platform Examples + +```bash +# List projects with pagination +cycode platform projects list --page-size 25 + +# View a single project by ID +cycode platform projects view + +# Count violations across the tenant +cycode platform violations count + +# Filter using query parameters (see `--help` for what each endpoint supports) +cycode platform violations list --severity CRITICAL +``` + +All output is JSON by default — pipe it through `jq` for ad-hoc filtering: + +```bash +cycode platform projects list --page-size 100 | jq '.items[].name' +``` + +## Platform Notes & Limitations + +- **Read-only today.** Only `GET` endpoints are exposed in this beta. +- **Spec-driven.** Adding a new endpoint to the API surfaces it automatically the next time the cache is refreshed. +- **No bundled spec.** The first `cycode platform` invocation after install (or after the 24h cache expires) performs a network fetch. On slow connections this first call may take a few seconds; subsequent calls are near-instant until the cache expires. +- **Override the cache TTL** with `CYCODE_SPEC_CACHE_TTL=`. + + # Scan Command ## Running a Scan diff --git a/cycode/cli/app.py b/cycode/cli/app.py index 0e9f9c7b..103e8b86 100644 --- a/cycode/cli/app.py +++ b/cycode/cli/app.py @@ -2,6 +2,7 @@ import sys from typing import Annotated, Optional +import click import typer from typer import rich_utils from typer._completion_classes import completion_init @@ -10,6 +11,7 @@ from cycode import __version__ from cycode.cli.apps import ai_guardrails, ai_remediation, auth, configure, ignore, report, report_import, scan, status +from cycode.cli.apps.api import get_platform_group if sys.version_info >= (3, 10): from cycode.cli.apps import mcp @@ -56,6 +58,27 @@ if sys.version_info >= (3, 10): app.add_typer(mcp.app) +# Register the `platform` command group (dynamically built from the OpenAPI spec). +# The group itself is constructed cheaply at import time; the spec is only fetched +# when the user actually invokes `cycode platform ...`. Unrelated commands like +# `cycode scan` and `cycode status` never trigger a spec fetch. +# +# Typer doesn't support adding native Click groups directly, so we monkey-patch +# typer.main.get_group to inject our `platform` group into the resolved Click group. +# The `app_typer is app` guard ensures we only modify our own app. +_platform_group = get_platform_group() +_original_get_group = typer.main.get_group + + +def _get_group_with_platform(app_typer: typer.Typer) -> click.Group: + group = _original_get_group(app_typer) + if app_typer is app and _platform_group.name not in group.commands: + group.add_command(_platform_group, _platform_group.name) + return group + + +typer.main.get_group = _get_group_with_platform + def check_latest_version_on_close(ctx: typer.Context) -> None: output = ctx.obj.get('output') diff --git a/cycode/cli/apps/api/__init__.py b/cycode/cli/apps/api/__init__.py new file mode 100644 index 00000000..e65f9c6f --- /dev/null +++ b/cycode/cli/apps/api/__init__.py @@ -0,0 +1,69 @@ +"""Cycode platform API CLI commands. + +Dynamically builds CLI command groups from the Cycode API v4 OpenAPI spec. +The spec is fetched lazily — only when the user invokes `cycode platform ...` — +and cached locally for 24 hours. +""" + +from typing import Any, Optional + +import click + +from cycode.logger import get_logger + +logger = get_logger('Platform') + +_PLATFORM_HELP = ( + '[BETA] Access the Cycode platform.\n\n' + 'Commands are generated dynamically from the Cycode API spec and may change ' + 'between releases. The spec is fetched on first use and cached for 24 hours.' +) + + +class PlatformGroup(click.Group): + """Lazy-loading Click group for `cycode platform` subcommands. + + The OpenAPI spec is only fetched when the user actually invokes + `cycode platform ...` (or asks for its help). Unrelated commands like + `cycode scan` or `cycode status` never trigger a spec fetch. + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._loaded: bool = False + + def _ensure_loaded(self, ctx: Optional[click.Context]) -> None: + if self._loaded: + return + self._loaded = True # set first to avoid re-entrancy on errors + + client_id = client_secret = None + if ctx is not None: + root = ctx.find_root() + if root.obj: + client_id = root.obj.get('client_id') + client_secret = root.obj.get('client_secret') + + try: + from cycode.cli.apps.api.api_command import build_api_command_groups + + for sub_group, name in build_api_command_groups(client_id, client_secret): + if name not in self.commands: + self.add_command(sub_group, name) + except Exception as e: + logger.debug('Could not load platform commands: %s', e) + # Surface the error to the user only when they're inside `platform` + click.echo(f'Error loading Cycode platform commands: {e}', err=True) + + def list_commands(self, ctx: click.Context) -> list[str]: + self._ensure_loaded(ctx) + return super().list_commands(ctx) + + def get_command(self, ctx: click.Context, cmd_name: str) -> Optional[click.Command]: + self._ensure_loaded(ctx) + return super().get_command(ctx, cmd_name) + + +def get_platform_group() -> click.Group: + """Return the top-level `platform` Click group (lazy-loading).""" + return PlatformGroup(name='platform', help=_PLATFORM_HELP, no_args_is_help=True) diff --git a/cycode/cli/apps/api/api_command.py b/cycode/cli/apps/api/api_command.py new file mode 100644 index 00000000..1926c93c --- /dev/null +++ b/cycode/cli/apps/api/api_command.py @@ -0,0 +1,271 @@ +"""OpenAPI-to-Typer translator: dynamically builds CLI commands from the Cycode API v4 spec.""" + +import json +import re +from typing import Any, Optional + +import click + +from cycode.cli.apps.api.openapi_spec import OpenAPISpecError, get_openapi_spec, parse_spec_commands +from cycode.logger import get_logger + +logger = get_logger('API Command') + +# Map OpenAPI parameter types to Click types +_CLICK_TYPE_MAP: dict[str, click.ParamType] = { + 'string': click.STRING, + 'integer': click.INT, + 'number': click.FLOAT, + 'boolean': click.BOOL, +} + + +def _normalize_tag(tag: str) -> str: + """Normalize an OpenAPI tag to a CLI-friendly command name. + + 'Scan Statistics' -> 'scan-statistics' + 'CLI scan statistics' -> 'cli-scan-statistics' + """ + return re.sub(r'[^a-z0-9]+', '-', tag.lower()).strip('-') + + +def _find_common_prefix(paths: list[str]) -> str: + """Find the longest common path prefix shared by all paths.""" + if not paths: + return '' + if len(paths) == 1: + # For single-path tags, use the parent directory as prefix + return '/'.join(paths[0].split('/')[:-1]) + + common = paths[0] + for p in paths[1:]: + while not p.startswith(common + '/') and common != p: + common = '/'.join(common.split('/')[:-1]) + return common + + +def _path_to_command_name(path: str, common_prefix: str, has_path_params: bool) -> str: + """Derive a CLI command name from an API path relative to the tag's common prefix. + + Rules: + 1. Strip the common prefix shared by all endpoints in the tag + 2. Remove path parameter segments ({id}) + 3. If nothing remains: 'list' (no path params) or 'view' (has path params) + 4. Otherwise: use remaining segments joined with hyphens + + Examples: + /v4/projects (prefix=/v4/projects) -> list + /v4/projects/{id} (prefix=/v4/projects) -> view + /v4/projects/assets (prefix=/v4/projects) -> assets + /v4/violations/count (prefix=/v4/violations) -> count + """ + # Strip common prefix + relative = path[len(common_prefix) :] if path.startswith(common_prefix) else path + relative = relative.strip('/') + + # Remove path parameter segments and empty parts + parts = [p for p in relative.split('/') if p and not p.startswith('{')] + + if not parts: + return 'view' if has_path_params else 'list' + + # Join remaining segments with hyphens, normalize to kebab-case + return re.sub(r'[^a-z0-9]+', '-', '-'.join(parts).lower()).strip('-') + + +def _param_to_option_name(name: str) -> str: + """Convert an OpenAPI parameter name to a CLI option name. + + 'page_size' -> '--page-size' + 'pageSize' -> '--page-size' + 'filter.status' -> '--filter-status' + """ + s = re.sub(r'([a-z])([A-Z])', r'\1-\2', name) + # Replace any non-alphanumeric characters with hyphens + s = re.sub(r'[^a-z0-9]+', '-', s.lower()).strip('-') + return f'--{s}' + + +def _make_api_request( + endpoint_path: str, + method: str, + path_params: dict[str, str], + query_params: dict[str, Any], + client_id: Optional[str] = None, + client_secret: Optional[str] = None, +) -> dict: + """Execute an API request using the CLI's standard auth client.""" + from urllib.parse import quote + + from cycode.cli.apps.api.openapi_spec import resolve_credentials + from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient + + cid, csecret = resolve_credentials(client_id, client_secret) + client = CycodeTokenBasedClient(cid, csecret) + + # Substitute path parameters (URL-encoded to prevent path traversal) + url_path = endpoint_path + for param_name, param_value in path_params.items(): + url_path = url_path.replace(f'{{{param_name}}}', quote(str(param_value), safe='')) + + filtered_query = {k: v for k, v in query_params.items() if v is not None} + + response = client.get(url_path.lstrip('/'), params=filtered_query) + return response.json() + + +def build_api_command_groups( + client_id: Optional[str] = None, + client_secret: Optional[str] = None, +) -> list[tuple[click.Group, str]]: + """Build Click command groups from the OpenAPI spec. + + Returns a list of (click_group, command_name) tuples. + """ + try: + spec = get_openapi_spec(client_id, client_secret) + except OpenAPISpecError as e: + logger.warning('Could not load OpenAPI spec: %s', e) + return [] + + groups = parse_spec_commands(spec) + result = [] + + for tag, endpoints in groups.items(): + tag_name = _normalize_tag(tag) + + group = click.Group(name=tag_name, help=f'[BETA] {tag}') + + # Compute common prefix from all GET (non-deprecated) endpoint paths in this tag + get_endpoints = [ep for ep in endpoints if ep['method'] == 'get' and not ep.get('deprecated')] + if not get_endpoints: + continue + + clean_paths = [re.sub(r'/\{[^}]+\}', '', ep['path']) for ep in get_endpoints] + common_prefix = _find_common_prefix(clean_paths) + + used_names: dict[str, int] = {} + + for endpoint in get_endpoints: + has_path_params = bool(endpoint['path_params']) + cmd_name = _path_to_command_name(endpoint['path'], common_prefix, has_path_params) + + # Fix redundancy: if command name matches the tag name, use list/view + # e.g. "cycode groups groups" -> "cycode groups list" + if cmd_name == tag_name: + cmd_name = 'view' if has_path_params else 'list' + + # Handle duplicate names (e.g. deprecated + new endpoint for same resource) + if cmd_name in used_names: + used_names[cmd_name] += 1 + cmd_name = f'{cmd_name}-v{used_names[cmd_name]}' + else: + used_names[cmd_name] = 1 + + cmd = _build_endpoint_command(cmd_name, endpoint) + group.add_command(cmd, cmd_name) + + result.append((group, tag_name)) + + return result + + +def _build_click_params(endpoint: dict) -> list[click.Parameter]: + """Build Click parameters from OpenAPI endpoint definition.""" + params: list[click.Parameter] = [] + + # Path parameters -> required arguments + for p in endpoint['path_params']: + param_type = _CLICK_TYPE_MAP.get(p.get('schema', {}).get('type', 'string'), click.STRING) + params.append( + click.Argument( + [p['name'].replace('-', '_')], + type=param_type, + required=True, + ) + ) + + # Query parameters -> --option flags + for p in endpoint['query_params']: + param_type = _CLICK_TYPE_MAP.get(p.get('schema', {}).get('type', 'string'), click.STRING) + option_name = _param_to_option_name(p['name']) + required = p.get('required', False) + default = p.get('schema', {}).get('default') + + schema = p.get('schema', {}) + if 'enum' in schema: + param_type = click.Choice(schema['enum']) + + params.append( + click.Option( + [option_name], + type=param_type, + required=required, + default=default, + help=p.get('description', ''), + show_default=default is not None, + ) + ) + + return params + + +def _build_endpoint_command(cmd_name: str, endpoint: dict) -> click.Command: + """Build a Click command for an API endpoint. + + Path parameters become required CLI arguments. + Query parameters become --option flags with proper types. + """ + ep_path = endpoint['path'] + ep_method = endpoint['method'] + ep_path_params = list(endpoint['path_params']) + ep_query_params = list(endpoint['query_params']) + ep_description = endpoint['description'] or endpoint['summary'] + + # Build a mapping from Click's normalized kwarg name to original OpenAPI param name + _path_param_map = {p['name'].replace('-', '_').lower(): p['name'] for p in ep_path_params} + _query_param_map = {re.sub(r'[^a-z0-9]+', '_', p['name'].lower()).strip('_'): p['name'] for p in ep_query_params} + + def _callback(**kwargs: Any) -> None: + ctx = click.get_current_context() + + # Extract path param values using the mapping + path_values = {} + for kwarg_key, original_name in _path_param_map.items(): + if kwarg_key in kwargs and kwargs[kwarg_key] is not None: + path_values[original_name] = kwargs[kwarg_key] + + # Extract query param values (skip None) + query_values = {} + for kwarg_key, original_name in _query_param_map.items(): + value = kwargs.get(kwarg_key) + if value is not None: + query_values[original_name] = value + + # Get auth from root context (set by app_callback) + root_ctx = ctx.find_root() + client_id = root_ctx.obj.get('client_id') if root_ctx.obj else None + client_secret = root_ctx.obj.get('client_secret') if root_ctx.obj else None + + try: + result = _make_api_request( + ep_path, + ep_method, + path_values, + query_values, + client_id=client_id, + client_secret=client_secret, + ) + except Exception as e: + click.echo(f'Error: {e}', err=True) + raise click.Abort from e + + click.echo(json.dumps(result, indent=2)) + + return click.Command( + name=cmd_name, + callback=_callback, + help=ep_description, + short_help=endpoint['summary'], + params=_build_click_params(endpoint), + ) diff --git a/cycode/cli/apps/api/openapi_spec.py b/cycode/cli/apps/api/openapi_spec.py new file mode 100644 index 00000000..74ffdb69 --- /dev/null +++ b/cycode/cli/apps/api/openapi_spec.py @@ -0,0 +1,182 @@ +"""OpenAPI spec manager: fetch, cache, and parse the Cycode API v4 spec.""" + +import json +import os +import time +from pathlib import Path +from typing import Optional + +from cycode.cli.consts import CYCODE_CONFIGURATION_DIRECTORY +from cycode.cli.user_settings.credentials_manager import CredentialsManager +from cycode.cyclient import config as cyclient_config +from cycode.logger import get_logger + +logger = get_logger('OpenAPI Spec') + +_CACHE_DIR = Path.home() / CYCODE_CONFIGURATION_DIRECTORY +_CACHE_FILE = _CACHE_DIR / 'openapi-spec.json' +_CACHE_TTL_SECONDS = int(os.getenv('CYCODE_SPEC_CACHE_TTL', str(24 * 60 * 60))) # 24h default + +_OPENAPI_SPEC_PATH = '/v4/api-docs/cycode-api-swagger.json' + + +def get_openapi_spec(client_id: Optional[str] = None, client_secret: Optional[str] = None) -> dict: + """Get the OpenAPI spec, using cache if fresh, otherwise fetching from API. + + The spec is only fetched when the user actually invokes `cycode platform ...`. + Fetch uses the HTTP client's default timeout; on a slow connection the first + invocation will block accordingly. Once cached, subsequent invocations within + the TTL are near-instant. + + Args: + client_id: Optional client ID override (from CLI flags). + client_secret: Optional client secret override (from CLI flags). + + Returns: + Parsed OpenAPI specification dictionary. + + Raises: + OpenAPISpecError: If spec cannot be loaded from cache or API. + """ + cached = _load_cached_spec() + if cached is not None: + return cached + + return _fetch_and_cache_spec(client_id, client_secret) + + +def _load_cached_spec() -> Optional[dict]: + """Load spec from local cache if it exists and is fresh.""" + if not _CACHE_FILE.exists(): + return None + + try: + mtime = _CACHE_FILE.stat().st_mtime + if time.time() - mtime > _CACHE_TTL_SECONDS: + logger.debug('Cached OpenAPI spec is stale (age > %ds)', _CACHE_TTL_SECONDS) + return None + + spec = json.loads(_CACHE_FILE.read_text(encoding='utf-8')) + logger.debug('Using cached OpenAPI spec from %s', _CACHE_FILE) + return spec + except Exception as e: + logger.warning('Failed to load cached OpenAPI spec: %s', e) + return None + + +def resolve_credentials(client_id: Optional[str] = None, client_secret: Optional[str] = None) -> tuple[str, str]: + """Resolve credentials from args or the CLI's standard credential chain.""" + if not client_id or not client_secret: + credentials_manager = CredentialsManager() + cred_id, cred_secret = credentials_manager.get_credentials() + client_id = client_id or cred_id + client_secret = client_secret or cred_secret + + if not client_id or not client_secret: + raise OpenAPISpecError( + 'Cycode credentials not found. Run `cycode auth` first, ' + 'or set CYCODE_CLIENT_ID and CYCODE_CLIENT_SECRET environment variables.' + ) + + return client_id, client_secret + + +def _fetch_and_cache_spec(client_id: Optional[str] = None, client_secret: Optional[str] = None) -> dict: + """Fetch OpenAPI spec from API and cache to disk. + + Uses CycodeTokenBasedClient for auth and retries. The spec is served from the app URL, + so we create a client with app_url as base instead of the default api_url. + """ + from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient + + cid, csecret = resolve_credentials(client_id, client_secret) + + # The spec is served from app.cycode.com, but token refresh POSTs to api.cycode.com. + # Ensure the token is fresh BEFORE overriding the base URL so that refresh + # targets the correct host. + client = CycodeTokenBasedClient(cid, csecret) + client.get_access_token() + client.api_url = cyclient_config.cycode_app_url + + spec_path = _OPENAPI_SPEC_PATH.lstrip('/') + logger.info('Fetching OpenAPI spec from %s/%s', cyclient_config.cycode_app_url, spec_path) + + try: + response = client.get(spec_path) + spec = response.json() + except Exception as e: + raise OpenAPISpecError( + f'Failed to fetch OpenAPI spec. Check your authentication and network connectivity. Error: {e}' + ) from e + + if not isinstance(spec, dict) or 'paths' not in spec: + raise OpenAPISpecError('Response does not look like a valid OpenAPI spec (missing "paths" key).') + + # Override server URL with API URL (supports on-premise installations) + spec['servers'] = [{'url': cyclient_config.cycode_api_url}] + + # Cache to disk + _cache_spec(spec) + + return spec + + +def _cache_spec(spec: dict) -> None: + """Write spec to local cache file atomically (write to temp file, then rename).""" + try: + _CACHE_DIR.mkdir(parents=True, exist_ok=True) + tmp_file = _CACHE_FILE.with_suffix('.json.tmp') + tmp_file.write_text(json.dumps(spec), encoding='utf-8') + tmp_file.replace(_CACHE_FILE) # atomic on POSIX and Windows + logger.debug('Cached OpenAPI spec to %s', _CACHE_FILE) + except Exception as e: + logger.warning('Failed to cache OpenAPI spec: %s', e) + + +def parse_spec_commands(spec: dict) -> dict[str, list[dict]]: + """Parse OpenAPI spec into resource groups with their endpoints. + + Groups endpoints by their first tag, returning a dict of: + {tag_name: [endpoint_info, ...]} + + Each endpoint_info contains: + - path: API path (e.g., '/v4/projects/{projectId}') + - method: HTTP method (e.g., 'get') + - summary: Human-readable summary + - description: Detailed description + - operation_id: Unique operation ID + - path_params: List of path parameter definitions + - query_params: List of query parameter definitions + """ + groups: dict[str, list[dict]] = {} + + for path, methods in spec.get('paths', {}).items(): + for method, details in methods.items(): + tags = details.get('tags', ['other']) + tag = tags[0] if tags else 'other' + + # Separate path and query parameters + parameters = details.get('parameters', []) + path_params = [p for p in parameters if p.get('in') == 'path'] + query_params = [p for p in parameters if p.get('in') == 'query'] + + endpoint_info = { + 'path': path, + 'method': method, + 'summary': details.get('summary', ''), + 'description': details.get('description', ''), + 'operation_id': details.get('operationId', ''), + 'path_params': path_params, + 'query_params': query_params, + 'deprecated': details.get('deprecated', False), + } + + if tag not in groups: + groups[tag] = [] + groups[tag].append(endpoint_info) + + return groups + + +class OpenAPISpecError(Exception): + """Raised when the OpenAPI spec cannot be loaded.""" diff --git a/poetry.lock b/poetry.lock index 3b793f4a..582ee5a5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "altgraph" diff --git a/tests/cli/apps/api/__init__.py b/tests/cli/apps/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/apps/api/test_api_command.py b/tests/cli/apps/api/test_api_command.py new file mode 100644 index 00000000..0e981f33 --- /dev/null +++ b/tests/cli/apps/api/test_api_command.py @@ -0,0 +1,110 @@ +"""Tests for the OpenAPI-to-Click translator.""" + +from cycode.cli.apps.api.api_command import ( + _find_common_prefix, + _normalize_tag, + _param_to_option_name, + _path_to_command_name, +) + +# --- _normalize_tag --- + + +def test_normalize_tag_simple() -> None: + assert _normalize_tag('Projects') == 'projects' + + +def test_normalize_tag_multi_word() -> None: + assert _normalize_tag('Scan Statistics') == 'scan-statistics' + + +def test_normalize_tag_with_special_chars() -> None: + assert _normalize_tag('CLI scan statistics') == 'cli-scan-statistics' + + +def test_normalize_tag_strips_leading_trailing_separators() -> None: + assert _normalize_tag(' Projects ') == 'projects' + + +# --- _param_to_option_name --- + + +def test_param_to_option_name_snake_case() -> None: + assert _param_to_option_name('page_size') == '--page-size' + + +def test_param_to_option_name_camel_case() -> None: + assert _param_to_option_name('pageSize') == '--page-size' + + +def test_param_to_option_name_with_dot() -> None: + assert _param_to_option_name('filter.status') == '--filter-status' + + +def test_param_to_option_name_already_kebab() -> None: + assert _param_to_option_name('page-size') == '--page-size' + + +# --- _find_common_prefix --- + + +def test_find_common_prefix_empty() -> None: + assert _find_common_prefix([]) == '' + + +def test_find_common_prefix_single_path() -> None: + # Single path: use parent directory as prefix + assert _find_common_prefix(['/v4/projects']) == '/v4' + + +def test_find_common_prefix_two_paths_with_common_parent() -> None: + assert _find_common_prefix(['/v4/projects', '/v4/projects/assets']) == '/v4/projects' + + +def test_find_common_prefix_two_paths_with_grandparent() -> None: + assert _find_common_prefix(['/v4/projects', '/v4/members']) == '/v4' + + +def test_find_common_prefix_identical_paths() -> None: + assert _find_common_prefix(['/v4/projects', '/v4/projects']) == '/v4/projects' + + +# --- _path_to_command_name --- + + +def test_path_to_command_name_collection() -> None: + # /v4/projects with prefix /v4/projects -> nothing left -> 'list' + assert _path_to_command_name('/v4/projects', '/v4/projects', has_path_params=False) == 'list' + + +def test_path_to_command_name_single_resource() -> None: + # /v4/projects/{id} with prefix /v4/projects -> only path param left -> 'view' + assert _path_to_command_name('/v4/projects/{projectId}', '/v4/projects', has_path_params=True) == 'view' + + +def test_path_to_command_name_sub_resource() -> None: + # /v4/projects/assets with prefix /v4/projects -> 'assets' + assert _path_to_command_name('/v4/projects/assets', '/v4/projects', has_path_params=False) == 'assets' + + +def test_path_to_command_name_sub_resource_count() -> None: + # /v4/violations/count with prefix /v4/violations -> 'count' + assert _path_to_command_name('/v4/violations/count', '/v4/violations', has_path_params=False) == 'count' + + +def test_path_to_command_name_multi_segment() -> None: + # /v4/projects/collisions/count with prefix /v4/projects -> 'collisions-count' + assert ( + _path_to_command_name('/v4/projects/collisions/count', '/v4/projects', has_path_params=False) + == 'collisions-count' + ) + + +def test_path_to_command_name_with_path_param_in_middle() -> None: + # /v4/workflows/{id}/jobs with prefix /v4/workflows -> 'jobs' (path param stripped) + assert _path_to_command_name('/v4/workflows/{workflowId}/jobs', '/v4/workflows', has_path_params=True) == 'jobs' + + +def test_path_to_command_name_kebab_case_normalization() -> None: + # Path with underscores or special chars -> kebab-case + assert _path_to_command_name('/v4/brokers/broker_metrics', '/v4/brokers', has_path_params=False) == 'broker-metrics' diff --git a/tests/cli/apps/api/test_openapi_spec.py b/tests/cli/apps/api/test_openapi_spec.py new file mode 100644 index 00000000..1b863d89 --- /dev/null +++ b/tests/cli/apps/api/test_openapi_spec.py @@ -0,0 +1,72 @@ +"""Tests for the OpenAPI spec parser.""" + +from cycode.cli.apps.api.openapi_spec import parse_spec_commands + + +def test_parse_spec_commands_groups_by_tag() -> None: + spec = { + 'paths': { + '/v4/projects': { + 'get': {'tags': ['Projects'], 'summary': 'Get projects'}, + }, + '/v4/violations': { + 'get': {'tags': ['Violations'], 'summary': 'Get violations'}, + }, + } + } + groups = parse_spec_commands(spec) + assert set(groups.keys()) == {'Projects', 'Violations'} + + +def test_parse_spec_commands_extracts_path_params() -> None: + spec = { + 'paths': { + '/v4/projects/{projectId}': { + 'get': { + 'tags': ['Projects'], + 'parameters': [ + {'name': 'projectId', 'in': 'path', 'required': True}, + {'name': 'page_size', 'in': 'query', 'required': False}, + ], + }, + }, + } + } + groups = parse_spec_commands(spec) + ep = groups['Projects'][0] + assert len(ep['path_params']) == 1 + assert ep['path_params'][0]['name'] == 'projectId' + assert len(ep['query_params']) == 1 + assert ep['query_params'][0]['name'] == 'page_size' + + +def test_parse_spec_commands_captures_deprecated_flag() -> None: + spec = { + 'paths': { + '/v4/old': { + 'get': {'tags': ['T'], 'summary': 'old', 'deprecated': True}, + }, + '/v4/new': { + 'get': {'tags': ['T'], 'summary': 'new'}, + }, + } + } + groups = parse_spec_commands(spec) + by_path = {ep['path']: ep for ep in groups['T']} + assert by_path['/v4/old']['deprecated'] is True + assert by_path['/v4/new']['deprecated'] is False + + +def test_parse_spec_commands_no_tags_uses_other() -> None: + spec = { + 'paths': { + '/v4/foo': {'get': {}}, + } + } + groups = parse_spec_commands(spec) + assert 'other' in groups + + +def test_parse_spec_commands_empty_spec() -> None: + assert parse_spec_commands({}) == {} + assert parse_spec_commands({'paths': {}}) == {} From 5405656e5f55f81135ffb32cfa224f98e04d61fd Mon Sep 17 00:00:00 2001 From: RoniCycode <142726722+RoniCycode@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:17:24 +0300 Subject: [PATCH 052/123] CM-62381-add-session-start-hook (#434) --- cycode/cli/apps/ai_guardrails/__init__.py | 23 +- cycode/cli/apps/ai_guardrails/consts.py | 6 +- .../apps/ai_guardrails/ensure_auth_command.py | 21 - .../apps/ai_guardrails/scan/claude_config.py | 115 +++++ .../apps/ai_guardrails/scan/cursor_config.py | 36 ++ .../cli/apps/ai_guardrails/scan/handlers.py | 3 - cycode/cli/apps/ai_guardrails/scan/payload.py | 6 +- .../ai_guardrails/session_start_command.py | 150 +++++++ cycode/cyclient/ai_security_manager_client.py | 18 + .../ai_guardrails/scan/test_handlers.py | 2 + .../ai_guardrails/test_hooks_manager.py | 12 +- .../test_session_start_command.py | 392 ++++++++++++++++++ 12 files changed, 738 insertions(+), 46 deletions(-) delete mode 100644 cycode/cli/apps/ai_guardrails/ensure_auth_command.py create mode 100644 cycode/cli/apps/ai_guardrails/scan/cursor_config.py create mode 100644 cycode/cli/apps/ai_guardrails/session_start_command.py create mode 100644 tests/cli/commands/ai_guardrails/test_session_start_command.py diff --git a/cycode/cli/apps/ai_guardrails/__init__.py b/cycode/cli/apps/ai_guardrails/__init__.py index 11267624..1443008d 100644 --- a/cycode/cli/apps/ai_guardrails/__init__.py +++ b/cycode/cli/apps/ai_guardrails/__init__.py @@ -1,23 +1,24 @@ import typer -from cycode.cli.apps.ai_guardrails.ensure_auth_command import ensure_auth_command -from cycode.cli.apps.ai_guardrails.install_command import install_command -from cycode.cli.apps.ai_guardrails.scan.scan_command import scan_command -from cycode.cli.apps.ai_guardrails.status_command import status_command -from cycode.cli.apps.ai_guardrails.uninstall_command import uninstall_command +from cycode.cli.apps.ai_guardrails.install_command import install_command as _install_command +from cycode.cli.apps.ai_guardrails.scan.scan_command import scan_command as _scan_command +from cycode.cli.apps.ai_guardrails.session_start_command import session_start_command as _session_start_command +from cycode.cli.apps.ai_guardrails.status_command import status_command as _status_command +from cycode.cli.apps.ai_guardrails.uninstall_command import uninstall_command as _uninstall_command app = typer.Typer(name='ai-guardrails', no_args_is_help=True, hidden=True) -app.command(hidden=True, name='install', short_help='Install AI guardrails hooks for supported IDEs.')(install_command) +app.command(hidden=True, name='install', short_help='Install AI guardrails hooks for supported IDEs.')(_install_command) app.command(hidden=True, name='uninstall', short_help='Remove AI guardrails hooks from supported IDEs.')( - uninstall_command + _uninstall_command ) -app.command(hidden=True, name='status', short_help='Show AI guardrails hook installation status.')(status_command) +app.command(hidden=True, name='status', short_help='Show AI guardrails hook installation status.')(_status_command) app.command( hidden=True, name='scan', short_help='Scan content from AI IDE hooks for secrets (reads JSON from stdin).', -)(scan_command) -app.command(hidden=True, name='ensure-auth', short_help='Ensure authentication, triggering auth if needed.')( - ensure_auth_command +)(_scan_command) +app.command(hidden=True, name='session-start', short_help='Handle session start: auth, conversation, session context.')( + _session_start_command ) +app.command(hidden=True, name='ensure-auth', short_help='[Deprecated] Alias for session-start.')(_session_start_command) diff --git a/cycode/cli/apps/ai_guardrails/consts.py b/cycode/cli/apps/ai_guardrails/consts.py index 81539b30..837096c8 100644 --- a/cycode/cli/apps/ai_guardrails/consts.py +++ b/cycode/cli/apps/ai_guardrails/consts.py @@ -84,7 +84,7 @@ def _get_claude_code_hooks_dir() -> Path: # Command used in hooks CYCODE_SCAN_PROMPT_COMMAND = 'cycode ai-guardrails scan' -CYCODE_ENSURE_AUTH_COMMAND = 'cycode ai-guardrails ensure-auth' +CYCODE_SESSION_START_COMMAND = 'cycode ai-guardrails session-start' def _get_cursor_hooks_config(async_mode: bool = False) -> dict: @@ -92,7 +92,7 @@ def _get_cursor_hooks_config(async_mode: bool = False) -> dict: config = IDE_CONFIGS[AIIDEType.CURSOR] command = f'{CYCODE_SCAN_PROMPT_COMMAND} &' if async_mode else CYCODE_SCAN_PROMPT_COMMAND hooks = {event: [{'command': command}] for event in config.hook_events} - hooks['sessionStart'] = [{'command': CYCODE_ENSURE_AUTH_COMMAND}] + hooks['sessionStart'] = [{'command': f'{CYCODE_SESSION_START_COMMAND} --ide cursor'}] return { 'version': 1, @@ -119,7 +119,7 @@ def _get_claude_code_hooks_config(async_mode: bool = False) -> dict: 'SessionStart': [ { 'matcher': 'startup', - 'hooks': [{'type': 'command', 'command': CYCODE_ENSURE_AUTH_COMMAND}], + 'hooks': [{'type': 'command', 'command': f'{CYCODE_SESSION_START_COMMAND} --ide claude-code'}], } ], 'UserPromptSubmit': [ diff --git a/cycode/cli/apps/ai_guardrails/ensure_auth_command.py b/cycode/cli/apps/ai_guardrails/ensure_auth_command.py deleted file mode 100644 index 78b8bf83..00000000 --- a/cycode/cli/apps/ai_guardrails/ensure_auth_command.py +++ /dev/null @@ -1,21 +0,0 @@ -import typer - -from cycode.cli.apps.auth.auth_common import get_authorization_info -from cycode.cli.apps.auth.auth_manager import AuthManager -from cycode.cli.exceptions.handle_auth_errors import handle_auth_exception -from cycode.cli.logger import logger - - -def ensure_auth_command(ctx: typer.Context) -> None: - """Ensure the user is authenticated, triggering authentication if needed.""" - auth_info = get_authorization_info(ctx) - if auth_info is not None: - logger.debug('Already authenticated') - return - - logger.debug('Not authenticated, starting authentication') - try: - auth_manager = AuthManager() - auth_manager.authenticate() - except Exception as err: - handle_auth_exception(ctx, err) diff --git a/cycode/cli/apps/ai_guardrails/scan/claude_config.py b/cycode/cli/apps/ai_guardrails/scan/claude_config.py index cff0a5d7..4b547427 100644 --- a/cycode/cli/apps/ai_guardrails/scan/claude_config.py +++ b/cycode/cli/apps/ai_guardrails/scan/claude_config.py @@ -13,6 +13,7 @@ logger = get_logger('AI Guardrails Claude Config') _CLAUDE_CONFIG_PATH = Path.home() / '.claude.json' +_CLAUDE_SETTINGS_PATH = Path.home() / '.claude' / 'settings.json' def load_claude_config(config_path: Optional[Path] = None) -> Optional[dict]: @@ -42,3 +43,117 @@ def get_user_email(config: dict) -> Optional[str]: Reads oauthAccount.emailAddress from the config dict. """ return config.get('oauthAccount', {}).get('emailAddress') + + +def get_mcp_servers(config: dict) -> Optional[dict]: + """Extract MCP servers from Claude config. + + Reads mcpServers from the config dict. + """ + return config.get('mcpServers') + + +def load_claude_settings(settings_path: Optional[Path] = None) -> Optional[dict]: + """Load and parse ~/.claude/settings.json. + + Args: + settings_path: Override path for testing. Defaults to ~/.claude/settings.json. + + Returns: + Parsed dict or None if file is missing or invalid. + """ + path = settings_path or _CLAUDE_SETTINGS_PATH + if not path.exists(): + logger.debug('Claude settings file not found', extra={'path': str(path)}) + return None + try: + content = path.read_text(encoding='utf-8') + return json.loads(content) + except Exception as e: + logger.debug('Failed to load Claude settings file', exc_info=e) + return None + + +def _resolve_marketplace_path(marketplace: dict) -> Optional[Path]: + """ + Resolve filesystem path for a directory-type marketplace. + """ + source = marketplace.get('source', {}) + if source.get('source') != 'directory': + return None + raw = source.get('path') + if not raw: + return None + path = Path(raw) + return path if path.is_dir() else None + + +def _load_plugin_json_file(plugin_path: Path, relative_path: str) -> Optional[dict]: + """Load and parse a JSON file inside a plugin directory. + + Returns None if the file is missing, unreadable, or has invalid JSON. + """ + target = plugin_path / relative_path + if not target.exists(): + return None + try: + return json.loads(target.read_text(encoding='utf-8')) + except Exception as e: + logger.debug('Failed to load plugin file', extra={'path': str(target)}, exc_info=e) + return None + + +def resolve_plugins(settings: dict) -> tuple[dict, dict]: + """Resolve enabled plugins to their MCP servers and metadata. + + Walks enabledPlugins from claude settings, resolves each plugin's 'marketplace' directory + via the 'extraKnownMarketplaces' field, and reads: + - /.mcp.json for MCP servers (merged into a flat dict) + - /.claude-plugin/plugin.json for metadata (name, version, description) + + Args: + settings: Parsed ~/.claude/settings.json dict. + + Returns: + Tuple of (merged_mcp_servers, enriched_plugins): + - merged_mcp_servers: {server_name: server_config, ...} + - enriched_plugins: {plugin_key: {"enabled": True, "name": ..., ...}, ...} + """ + enabled = settings.get('enabledPlugins') or {} + marketplaces = settings.get('extraKnownMarketplaces') or {} + merged_mcp: dict = {} + enriched: dict = {} + + for plugin_key, is_enabled in enabled.items(): + if not is_enabled: + continue + + entry: dict = {'enabled': True} + enriched[plugin_key] = entry + + if '@' not in plugin_key: + continue + + _plugin_name, marketplace_name = plugin_key.split('@', 1) + marketplace = marketplaces.get(marketplace_name) + if not marketplace: + continue + + plugin_path = _resolve_marketplace_path(marketplace) + if plugin_path is None: + continue + + metadata = _load_plugin_json_file(plugin_path, '.claude-plugin/plugin.json') or {} + for field in ('name', 'version', 'description'): + if field in metadata: + entry[field] = metadata[field] + + mcp_config = _load_plugin_json_file(plugin_path, '.mcp.json') or {} + plugin_server_names = [] + for server_name, server_cfg in (mcp_config.get('mcpServers') or {}).items(): + merged_mcp[server_name] = server_cfg + plugin_server_names.append(server_name) + if plugin_server_names: + entry['mcp_server_names'] = plugin_server_names + + return merged_mcp, enriched diff --git a/cycode/cli/apps/ai_guardrails/scan/cursor_config.py b/cycode/cli/apps/ai_guardrails/scan/cursor_config.py new file mode 100644 index 00000000..9a174a7a --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/cursor_config.py @@ -0,0 +1,36 @@ +"""Reader for ~/.cursor/mcp.json configuration file. + +Extracts MCP server definitions from the Cursor global config file +for use in AI guardrails session-context reporting. +""" + +import json +from pathlib import Path +from typing import Optional + +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails Cursor Config') + +_CURSOR_MCP_CONFIG_PATH = Path.home() / '.cursor' / 'mcp.json' + + +def load_cursor_config(config_path: Optional[Path] = None) -> Optional[dict]: + """Load and parse ~/.cursor/mcp.json. + + Args: + config_path: Override path for testing. Defaults to ~/.cursor/mcp.json. + + Returns: + Parsed dict or None if file is missing or invalid. + """ + path = config_path or _CURSOR_MCP_CONFIG_PATH + if not path.exists(): + logger.debug('Cursor MCP config file not found', extra={'path': str(path)}) + return None + try: + content = path.read_text(encoding='utf-8') + return json.loads(content) + except Exception as e: + logger.debug('Failed to load Cursor MCP config file', exc_info=e) + return None diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index 99fa29c8..fa0bddee 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -42,7 +42,6 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli response_builder = get_response_builder(ide) prompt_config = get_policy_value(policy, 'prompt', default={}) - ai_client.create_conversation(payload) if not get_policy_value(prompt_config, 'enabled', default=True): ai_client.create_event(payload, AiHookEventType.PROMPT, AIHookOutcome.ALLOWED) return response_builder.allow_prompt() @@ -100,7 +99,6 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: response_builder = get_response_builder(ide) file_read_config = get_policy_value(policy, 'file_read', default={}) - ai_client.create_conversation(payload) if not get_policy_value(file_read_config, 'enabled', default=True): ai_client.create_event(payload, AiHookEventType.FILE_READ, AIHookOutcome.ALLOWED) return response_builder.allow_permission() @@ -203,7 +201,6 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli response_builder = get_response_builder(ide) mcp_config = get_policy_value(policy, 'mcp', default={}) - ai_client.create_conversation(payload) if not get_policy_value(mcp_config, 'enabled', default=True): ai_client.create_event(payload, AiHookEventType.MCP_EXECUTION, AIHookOutcome.ALLOWED) return response_builder.allow_permission() diff --git a/cycode/cli/apps/ai_guardrails/scan/payload.py b/cycode/cli/apps/ai_guardrails/scan/payload.py index 9a19970c..ada40a3c 100644 --- a/cycode/cli/apps/ai_guardrails/scan/payload.py +++ b/cycode/cli/apps/ai_guardrails/scan/payload.py @@ -71,7 +71,7 @@ def _extract_generation_id(entry: dict) -> Optional[str]: return None -def _extract_from_claude_transcript( +def extract_from_claude_transcript( transcript_path: str, ) -> tuple[Optional[str], Optional[str], Optional[str]]: """Extract IDE version, model, and latest generation ID from Claude Code transcript file. @@ -123,7 +123,7 @@ class AIHookPayload: """Unified payload object that normalizes field names from different AI tools.""" # Event identification - event_name: str # Canonical event type (e.g., 'prompt', 'file_read', 'mcp_execution') + event_name: Optional[str] = None # Canonical event type (e.g., 'prompt', 'file_read', 'mcp_execution') conversation_id: Optional[str] = None generation_id: Optional[str] = None @@ -206,7 +206,7 @@ def from_claude_code_payload(cls, payload: dict) -> 'AIHookPayload': mcp_tool_name = parts[2] # Extract IDE version, model, and generation ID from transcript file - ide_version, model, generation_id = _extract_from_claude_transcript(payload.get('transcript_path')) + ide_version, model, generation_id = extract_from_claude_transcript(payload.get('transcript_path')) # Extract user email from ~/.claude.json claude_config = load_claude_config() diff --git a/cycode/cli/apps/ai_guardrails/session_start_command.py b/cycode/cli/apps/ai_guardrails/session_start_command.py new file mode 100644 index 00000000..a33dc439 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/session_start_command.py @@ -0,0 +1,150 @@ +import sys +from typing import TYPE_CHECKING, Annotated + +import typer + +from cycode.cli.apps.ai_guardrails.consts import AIIDEType +from cycode.cli.apps.ai_guardrails.scan.claude_config import ( + get_mcp_servers, + get_user_email, + load_claude_config, + load_claude_settings, + resolve_plugins, +) +from cycode.cli.apps.ai_guardrails.scan.cursor_config import load_cursor_config +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload, extract_from_claude_transcript +from cycode.cli.apps.ai_guardrails.scan.utils import safe_json_parse +from cycode.cli.apps.auth.auth_common import get_authorization_info +from cycode.cli.apps.auth.auth_manager import AuthManager +from cycode.cli.exceptions.handle_auth_errors import handle_auth_exception +from cycode.cli.utils.get_api_client import get_ai_security_manager_client +from cycode.logger import get_logger + +if TYPE_CHECKING: + from cycode.cyclient.ai_security_manager_client import AISecurityManagerClient + +logger = get_logger('AI Guardrails') + + +def _build_session_payload(payload: dict, ide: str) -> AIHookPayload: + """Build an AIHookPayload from a session-start stdin payload.""" + if ide == AIIDEType.CLAUDE_CODE: + claude_config = load_claude_config() + ide_user_email = get_user_email(claude_config) if claude_config else None + ide_version, _, _ = extract_from_claude_transcript(payload.get('transcript_path')) + + return AIHookPayload( + conversation_id=payload.get('session_id'), + ide_user_email=ide_user_email, + model=payload.get('model'), + ide_provider=AIIDEType.CLAUDE_CODE.value, + ide_version=ide_version, + ) + + # Cursor + return AIHookPayload( + conversation_id=payload.get('conversation_id'), + ide_user_email=payload.get('user_email'), + model=payload.get('model'), + ide_provider=AIIDEType.CURSOR.value, + ide_version=payload.get('cursor_version'), + ) + + +def _get_claude_code_session_context() -> tuple[dict, dict]: + """Return (mcp_servers, enabled_plugins) for Claude Code. + + Merges MCP servers from ~/.claude.json (user-configured) with those contributed + by enabled plugins. Plugin metadata (name, version, description) is included in + the enabled_plugins dict when resolvable. + """ + config = load_claude_config() + mcp_servers = dict(get_mcp_servers(config) or {}) if config else {} + + settings = load_claude_settings() + if settings: + plugin_mcp, enriched_plugins = resolve_plugins(settings) + mcp_servers.update(plugin_mcp) + else: + enriched_plugins = {} + + return mcp_servers, enriched_plugins + + +def _get_cursor_session_context() -> tuple[dict, dict]: + """Return (mcp_servers, enabled_plugins) for Cursor. Cursor has no plugin system.""" + config = load_cursor_config() + mcp_servers = dict(get_mcp_servers(config) or {}) if config else {} + return mcp_servers, {} + + +def _report_session_context(ai_client: 'AISecurityManagerClient', ide: str) -> None: + """Report IDE session context to the AI security manager. Never raises.""" + try: + if ide == AIIDEType.CLAUDE_CODE: + mcp_servers, enabled_plugins = _get_claude_code_session_context() + elif ide == AIIDEType.CURSOR: + mcp_servers, enabled_plugins = _get_cursor_session_context() + else: + return + + if not mcp_servers and not enabled_plugins: + return + ai_client.report_session_context(mcp_servers=mcp_servers, enabled_plugins=enabled_plugins) + except Exception as e: + logger.debug('Failed to report session context', exc_info=e) + + +def session_start_command( + ctx: typer.Context, + ide: Annotated[ + str, + typer.Option( + '--ide', + help='IDE that triggered the session start.', + hidden=True, + ), + ] = AIIDEType.CURSOR.value, +) -> None: + """Handle session start: ensure auth, create conversation, report session context.""" + # Step 1: Ensure authentication + auth_info = get_authorization_info(ctx) + if auth_info is None: + logger.debug('Not authenticated, starting authentication') + try: + auth_manager = AuthManager() + auth_manager.authenticate() + except Exception as err: + handle_auth_exception(ctx, err) + return + else: + logger.debug('Already authenticated') + + # Step 2: Read stdin payload (backward compat: old hooks pipe no stdin) + if sys.stdin.isatty(): + logger.debug('No stdin payload (TTY), skipping session initialization') + return + + stdin_data = sys.stdin.read().strip() + payload = safe_json_parse(stdin_data) + if not payload: + logger.debug('Empty or invalid stdin payload, skipping session initialization') + return + + # Step 3: Build session payload and initialize API client + session_payload = _build_session_payload(payload, ide) + + try: + ai_client = get_ai_security_manager_client(ctx) + except Exception as e: + logger.debug('Failed to initialize AI security client', exc_info=e) + return + + # Step 4: Create conversation + try: + ai_client.create_conversation(session_payload) + except Exception as e: + logger.debug('Failed to create conversation during session start', exc_info=e) + + # Step 5: Report session context (MCP servers) + _report_session_context(ai_client, ide) diff --git a/cycode/cyclient/ai_security_manager_client.py b/cycode/cyclient/ai_security_manager_client.py index 35c1d8c9..376b549e 100644 --- a/cycode/cyclient/ai_security_manager_client.py +++ b/cycode/cyclient/ai_security_manager_client.py @@ -17,6 +17,7 @@ class AISecurityManagerClient: _CONVERSATIONS_PATH = 'v4/ai-security/interactions/conversations' _EVENTS_PATH = 'v4/ai-security/interactions/events' + _SESSION_CONTEXT_PATH = 'v4/ai-security/interactions/session-context' def __init__(self, client: CycodeClientBase, service_config: 'AISecurityManagerServiceConfigBase') -> None: self.client = client @@ -88,3 +89,20 @@ def create_event( except Exception as e: logger.debug('Failed to create AI hook event', exc_info=e) # Don't fail the hook if tracking fails + + def report_session_context( + self, + mcp_servers: Optional[dict] = None, + enabled_plugins: Optional[dict] = None, + ) -> None: + """Report session context to the backend.""" + body: dict = { + 'mcp_servers': mcp_servers, + 'enabled_plugins': enabled_plugins, + } + + try: + self.client.post(self._build_endpoint_path(self._SESSION_CONTEXT_PATH), body=body) + except Exception as e: + logger.debug('Failed to report session context', exc_info=e) + # Don't fail the session if reporting fails diff --git a/tests/cli/commands/ai_guardrails/scan/test_handlers.py b/tests/cli/commands/ai_guardrails/scan/test_handlers.py index 1ef1098c..57c25b92 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_handlers.py +++ b/tests/cli/commands/ai_guardrails/scan/test_handlers.py @@ -67,6 +67,7 @@ def test_handle_before_submit_prompt_disabled( assert result == {'continue': True} mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + mock_ctx.obj['ai_security_client'].create_conversation.assert_not_called() @patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') @@ -80,6 +81,7 @@ def test_handle_before_submit_prompt_no_secrets( assert result == {'continue': True} mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + mock_ctx.obj['ai_security_client'].create_conversation.assert_not_called() call_args = mock_ctx.obj['ai_security_client'].create_event.call_args # outcome is arg[2], scan_id and block_reason are kwargs assert call_args.args[2] == AIHookOutcome.ALLOWED diff --git a/tests/cli/commands/ai_guardrails/test_hooks_manager.py b/tests/cli/commands/ai_guardrails/test_hooks_manager.py index ed1ada09..a5732bca 100644 --- a/tests/cli/commands/ai_guardrails/test_hooks_manager.py +++ b/tests/cli/commands/ai_guardrails/test_hooks_manager.py @@ -6,8 +6,8 @@ from pyfakefs.fake_filesystem import FakeFilesystem from cycode.cli.apps.ai_guardrails.consts import ( - CYCODE_ENSURE_AUTH_COMMAND, CYCODE_SCAN_PROMPT_COMMAND, + CYCODE_SESSION_START_COMMAND, AIIDEType, PolicyMode, get_hooks_config, @@ -88,12 +88,13 @@ def test_get_hooks_config_cursor_async() -> None: def test_get_hooks_config_cursor_session_start() -> None: - """Test Cursor hooks config includes sessionStart auth check.""" + """Test Cursor hooks config includes sessionStart with --ide flag.""" config = get_hooks_config(AIIDEType.CURSOR) assert 'sessionStart' in config['hooks'] entries = config['hooks']['sessionStart'] assert len(entries) == 1 - assert entries[0]['command'] == CYCODE_ENSURE_AUTH_COMMAND + assert CYCODE_SESSION_START_COMMAND in entries[0]['command'] + assert '--ide cursor' in entries[0]['command'] def test_get_hooks_config_claude_code_sync() -> None: @@ -118,12 +119,13 @@ def test_get_hooks_config_claude_code_async() -> None: def test_get_hooks_config_claude_code_session_start() -> None: - """Test Claude Code hooks config includes SessionStart auth check.""" + """Test Claude Code hooks config includes SessionStart with --ide flag.""" config = get_hooks_config(AIIDEType.CLAUDE_CODE) assert 'SessionStart' in config['hooks'] entries = config['hooks']['SessionStart'] assert len(entries) == 1 - assert entries[0]['hooks'][0]['command'] == CYCODE_ENSURE_AUTH_COMMAND + assert CYCODE_SESSION_START_COMMAND in entries[0]['hooks'][0]['command'] + assert '--ide claude-code' in entries[0]['hooks'][0]['command'] def test_create_policy_file_warn(fs: FakeFilesystem) -> None: diff --git a/tests/cli/commands/ai_guardrails/test_session_start_command.py b/tests/cli/commands/ai_guardrails/test_session_start_command.py new file mode 100644 index 00000000..48e0ebe3 --- /dev/null +++ b/tests/cli/commands/ai_guardrails/test_session_start_command.py @@ -0,0 +1,392 @@ +"""Tests for session-start command.""" + +import json +from io import StringIO +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.apps.ai_guardrails import session_start_command as _session_start_mod +from cycode.cli.apps.ai_guardrails.session_start_command import session_start_command + + +@pytest.fixture +def mock_ctx() -> MagicMock: + """Create a mock Typer context.""" + ctx = MagicMock(spec=typer.Context) + ctx.obj = {} + return ctx + + +# Auth tests + + +@patch.object(_session_start_mod, 'get_authorization_info') +def test_already_authenticated_skips_auth(mock_get_auth: MagicMock, mock_ctx: MagicMock) -> None: + """When already authenticated, AuthManager should not be called.""" + mock_get_auth.return_value = MagicMock() + + with patch('sys.stdin', new=StringIO('')): + session_start_command(mock_ctx) + + +@patch.object(_session_start_mod, 'AuthManager') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_not_authenticated_triggers_auth( + mock_get_auth: MagicMock, mock_auth_manager_cls: MagicMock, mock_ctx: MagicMock +) -> None: + """When not authenticated, AuthManager.authenticate should be called.""" + mock_get_auth.return_value = None + + with patch('sys.stdin', new=StringIO('')): + session_start_command(mock_ctx) + + mock_auth_manager_cls.return_value.authenticate.assert_called_once() + + +@patch.object(_session_start_mod, 'handle_auth_exception') +@patch.object(_session_start_mod, 'AuthManager') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_auth_failure_handled_gracefully( + mock_get_auth: MagicMock, + mock_auth_manager_cls: MagicMock, + mock_handle_err: MagicMock, + mock_ctx: MagicMock, +) -> None: + """Auth failure should be handled gracefully, not crash.""" + mock_get_auth.return_value = None + mock_auth_manager_cls.return_value.authenticate.side_effect = RuntimeError('auth failed') + + with patch('sys.stdin', new=StringIO('')): + session_start_command(mock_ctx) + + mock_handle_err.assert_called_once() + + +# Stdin / payload tests + + +@patch.object(_session_start_mod, 'get_authorization_info') +def test_tty_stdin_auth_only(mock_get_auth: MagicMock, mock_ctx: MagicMock) -> None: + """When stdin is a TTY (old hooks), only auth is performed.""" + mock_get_auth.return_value = MagicMock() + mock_stdin = MagicMock() + mock_stdin.isatty.return_value = True + + with patch('sys.stdin', new=mock_stdin): + session_start_command(mock_ctx) + + mock_stdin.read.assert_not_called() + + +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_empty_stdin_skips_session_init( + mock_get_auth: MagicMock, mock_get_client: MagicMock, mock_ctx: MagicMock +) -> None: + """Empty stdin should skip session initialization.""" + mock_get_auth.return_value = MagicMock() + + with patch('sys.stdin', new=StringIO('')): + session_start_command(mock_ctx) + + mock_get_client.assert_not_called() + + +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_invalid_json_stdin_skips_session_init( + mock_get_auth: MagicMock, mock_get_client: MagicMock, mock_ctx: MagicMock +) -> None: + """Invalid JSON stdin should skip session initialization.""" + mock_get_auth.return_value = MagicMock() + + with patch('sys.stdin', new=StringIO('not valid json')): + session_start_command(mock_ctx) + + mock_get_client.assert_not_called() + + +# Conversation creation tests + + +@patch.object(_session_start_mod, 'extract_from_claude_transcript') +@patch.object(_session_start_mod, 'load_claude_config') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_claude_code_creates_conversation( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_load_config: MagicMock, + mock_extract: MagicMock, + mock_ctx: MagicMock, +) -> None: + """Claude Code payload should create a conversation with session_id, model, email, version.""" + mock_get_auth.return_value = MagicMock() + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + mock_load_config.return_value = {'oauthAccount': {'emailAddress': 'user@example.com'}} + mock_extract.return_value = ('2.1.20', 'claude-opus', 'gen-abc') + + transcript_path = '/fake/transcript.jsonl' + payload = {'session_id': 'session-123', 'model': 'claude-opus', 'transcript_path': transcript_path} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='claude-code') + + mock_extract.assert_called_once_with(transcript_path) + mock_ai_client.create_conversation.assert_called_once() + call_payload = mock_ai_client.create_conversation.call_args[0][0] + assert call_payload.conversation_id == 'session-123' + assert call_payload.model == 'claude-opus' + assert call_payload.ide_user_email == 'user@example.com' + assert call_payload.ide_provider == 'claude-code' + assert call_payload.ide_version == '2.1.20' + + +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_cursor_creates_conversation( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_ctx: MagicMock, +) -> None: + """Cursor payload should create conversation with conversation_id and model.""" + mock_get_auth.return_value = MagicMock() + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + + payload = { + 'conversation_id': 'conv-456', + 'user_email': 'cursor-user@example.com', + 'model': 'gpt-4', + 'cursor_version': '0.42.0', + } + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='cursor') + + mock_ai_client.create_conversation.assert_called_once() + call_payload = mock_ai_client.create_conversation.call_args[0][0] + assert call_payload.conversation_id == 'conv-456' + assert call_payload.model == 'gpt-4' + assert call_payload.ide_user_email == 'cursor-user@example.com' + assert call_payload.ide_provider == 'cursor' + + +@patch.object(_session_start_mod, 'load_claude_config') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_conversation_creation_failure_non_blocking( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_load_config: MagicMock, + mock_ctx: MagicMock, +) -> None: + """Conversation creation failure should not crash the command.""" + mock_get_auth.return_value = MagicMock() + mock_ai_client = MagicMock() + mock_ai_client.create_conversation.side_effect = RuntimeError('API down') + mock_get_client.return_value = mock_ai_client + mock_load_config.return_value = None + + payload = {'session_id': 'session-123'} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='claude-code') + + # Should not raise + + +# MCP server reporting tests + + +@patch.object(_session_start_mod, 'load_claude_settings') +@patch.object(_session_start_mod, 'load_claude_config') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_claude_code_reports_mcp_servers( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_load_config: MagicMock, + mock_load_settings: MagicMock, + mock_ctx: MagicMock, +) -> None: + """Claude Code should report MCP servers from ~/.claude.json and enriched plugins.""" + mock_get_auth.return_value = MagicMock() + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + mcp_servers = { + 'gitlab': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-gitlab']}, + 'filesystem': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-filesystem']}, + } + mock_load_config.return_value = {'oauthAccount': {'emailAddress': 'u@e.com'}, 'mcpServers': mcp_servers} + # Marketplace won't resolve (no extraKnownMarketplaces) so plugin gets {"enabled": True} only. + mock_load_settings.return_value = {'enabledPlugins': {'cycode-dev@cycode-marketplace': True}} + + payload = {'session_id': 'session-123'} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='claude-code') + + mock_ai_client.report_session_context.assert_called_once_with( + mcp_servers=mcp_servers, + enabled_plugins={'cycode-dev@cycode-marketplace': {'enabled': True}}, + ) + + +@patch.object(_session_start_mod, 'load_claude_settings') +@patch.object(_session_start_mod, 'load_claude_config') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_claude_code_merges_plugin_mcp_servers_and_metadata( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_load_config: MagicMock, + mock_load_settings: MagicMock, + mock_ctx: MagicMock, + tmp_path: Path, +) -> None: + """Plugin MCP servers from /.mcp.json should merge into mcp_servers, + and plugin metadata from .claude-plugin/plugin.json should enrich enabled_plugins.""" + mock_get_auth.return_value = MagicMock() + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + + # Set up a fake plugin directory on disk. + plugin_dir = tmp_path / 'ai-prompts' + plugin_dir.mkdir() + (plugin_dir / '.mcp.json').write_text( + json.dumps({'mcpServers': {'aspire': {'command': 'aspire', 'args': ['mcp', 'start']}}}) + ) + claude_plugin_dir = plugin_dir / '.claude-plugin' + claude_plugin_dir.mkdir() + (claude_plugin_dir / 'plugin.json').write_text( + json.dumps({'name': 'cycode-dev', 'version': '1.0.28', 'description': 'Shared skills'}) + ) + + user_mcp_servers = {'gitlab': {'command': 'npx'}} + mock_load_config.return_value = {'mcpServers': user_mcp_servers} + mock_load_settings.return_value = { + 'enabledPlugins': {'cycode-dev@cycode-marketplace': True}, + 'extraKnownMarketplaces': {'cycode-marketplace': {'source': {'source': 'directory', 'path': str(plugin_dir)}}}, + } + + payload = {'session_id': 'session-123'} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='claude-code') + + mock_ai_client.report_session_context.assert_called_once_with( + mcp_servers={ + 'gitlab': {'command': 'npx'}, + 'aspire': {'command': 'aspire', 'args': ['mcp', 'start']}, + }, + enabled_plugins={ + 'cycode-dev@cycode-marketplace': { + 'enabled': True, + 'name': 'cycode-dev', + 'version': '1.0.28', + 'description': 'Shared skills', + 'mcp_server_names': ['aspire'], + } + }, + ) + + +@patch.object(_session_start_mod, 'load_claude_settings') +@patch.object(_session_start_mod, 'load_claude_config') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_claude_code_no_mcp_servers_no_plugins_skips_report( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_load_config: MagicMock, + mock_load_settings: MagicMock, + mock_ctx: MagicMock, +) -> None: + """When no mcpServers and no plugins, report_session_context should not be called.""" + mock_get_auth.return_value = MagicMock() + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + mock_load_config.return_value = {'oauthAccount': {'emailAddress': 'u@e.com'}} + mock_load_settings.return_value = None + + payload = {'session_id': 'session-123'} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='claude-code') + + mock_ai_client.report_session_context.assert_not_called() + + +@patch.object(_session_start_mod, 'load_cursor_config') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_cursor_reports_mcp_servers( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_load_cursor: MagicMock, + mock_ctx: MagicMock, +) -> None: + """Cursor should report MCP servers from ~/.cursor/mcp.json.""" + mock_get_auth.return_value = MagicMock() + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + mcp_servers = {'github': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-github']}} + mock_load_cursor.return_value = {'mcpServers': mcp_servers} + + payload = {'conversation_id': 'conv-456', 'model': 'gpt-4'} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='cursor') + + mock_ai_client.report_session_context.assert_called_once_with(mcp_servers=mcp_servers, enabled_plugins={}) + + +@patch.object(_session_start_mod, 'load_cursor_config') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_cursor_no_mcp_servers_skips_report( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_load_cursor: MagicMock, + mock_ctx: MagicMock, +) -> None: + """Cursor with no MCP config file should skip report_session_context.""" + mock_get_auth.return_value = MagicMock() + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + mock_load_cursor.return_value = None + + payload = {'conversation_id': 'conv-456', 'model': 'gpt-4'} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='cursor') + + mock_ai_client.report_session_context.assert_not_called() + + +@patch.object(_session_start_mod, 'handle_auth_exception') +@patch.object(_session_start_mod, 'AuthManager') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_unauthenticated_skips_session_init( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_auth_manager_cls: MagicMock, + mock_handle_err: MagicMock, + mock_ctx: MagicMock, +) -> None: + """When auth fails, session initialization should be skipped entirely.""" + mock_get_auth.return_value = None + mock_auth_manager_cls.return_value.authenticate.side_effect = RuntimeError('auth failed') + + payload = {'session_id': 'session-123'} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='claude-code') + + mock_get_client.assert_not_called() From 4213d72530e24f82bc3543d16990d0be2084289b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:09:20 +0100 Subject: [PATCH 053/123] Bump pygments from 2.19.2 to 2.20.0 (#420) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 582ee5a5..f810887f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "altgraph" @@ -1163,14 +1163,14 @@ files = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" description = "Pygments is a syntax highlighting package written in Python." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main", "test"] files = [ - {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, - {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, + {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, + {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, ] [package.extras] From fa1fe95a7373795435158295d68c67be8cc09754 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:46:41 +0100 Subject: [PATCH 054/123] Bump docker/setup-qemu-action from 3.7.0 to 4.0.0 (#427) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-image.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index fe38b63a..30552e74 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -61,7 +61,7 @@ jobs: echo "CLI_VERSION=$(poetry version --short)" >> $GITHUB_OUTPUT - name: Set up QEMU - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 From 980d72223cbf7637f440a95e7aef8480524aae37 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:53:52 +0100 Subject: [PATCH 055/123] Bump actions/checkout from 4.3.1 to 6.0.2 (#426) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build_executable.yml | 2 +- .github/workflows/docker-image.yml | 2 +- .github/workflows/pre_release.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/ruff.yml | 2 +- .github/workflows/tests.yml | 2 +- .github/workflows/tests_full.yml | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index e410ba57..74abc748 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -50,7 +50,7 @@ jobs: uploads.github.com - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 30552e74..af71fac6 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 0e17facd..f3d081d6 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -28,7 +28,7 @@ jobs: *.sigstore.dev - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1a3e3d26..462e2362 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ jobs: *.sigstore.dev - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 3099cbd7..038ee4ad 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -21,7 +21,7 @@ jobs: pypi.org - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cfb1aa21..968a45ad 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,7 +23,7 @@ jobs: *.ingest.us.sentry.io - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index 1fdb091b..70bd9128 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -36,7 +36,7 @@ jobs: *.ingest.us.sentry.io - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 From b2c22ba72608e371880c61bb423faa49c4bccd42 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:05:23 +0100 Subject: [PATCH 056/123] Bump docker/login-action from 3.7.0 to 4.0.0 (#430) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-image.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index af71fac6..e22c3205 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -68,7 +68,7 @@ jobs: - name: Login to Docker Hub if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') }} - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USER }} password: ${{ secrets.DOCKERHUB_PASSWORD }} From f0a093313db7ead8fe6b48832516518395b22dcc Mon Sep 17 00:00:00 2001 From: RoniCycode <142726722+RoniCycode@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:35:21 +0300 Subject: [PATCH 057/123] CM-62381-remove-matcher (#438) Co-authored-by: Maor Davidzon <56628808+MaorDavidzon@users.noreply.github.com> --- cycode/cli/apps/ai_guardrails/consts.py | 1 - cycode/cli/apps/ai_guardrails/scan/payload.py | 2 ++ cycode/cli/apps/ai_guardrails/session_start_command.py | 1 + cycode/cyclient/ai_security_manager_client.py | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/cycode/cli/apps/ai_guardrails/consts.py b/cycode/cli/apps/ai_guardrails/consts.py index 837096c8..2895c8d1 100644 --- a/cycode/cli/apps/ai_guardrails/consts.py +++ b/cycode/cli/apps/ai_guardrails/consts.py @@ -118,7 +118,6 @@ def _get_claude_code_hooks_config(async_mode: bool = False) -> dict: 'hooks': { 'SessionStart': [ { - 'matcher': 'startup', 'hooks': [{'type': 'command', 'command': f'{CYCODE_SESSION_START_COMMAND} --ide claude-code'}], } ], diff --git a/cycode/cli/apps/ai_guardrails/scan/payload.py b/cycode/cli/apps/ai_guardrails/scan/payload.py index ada40a3c..d8fd4c53 100644 --- a/cycode/cli/apps/ai_guardrails/scan/payload.py +++ b/cycode/cli/apps/ai_guardrails/scan/payload.py @@ -133,6 +133,8 @@ class AIHookPayload: ide_provider: str = None # AIIDEType value (e.g., 'cursor', 'claude-code') ide_version: Optional[str] = None + source: Optional[str] = None + # Event-specific data prompt: Optional[str] = None # For prompt events file_path: Optional[str] = None # For file_read events diff --git a/cycode/cli/apps/ai_guardrails/session_start_command.py b/cycode/cli/apps/ai_guardrails/session_start_command.py index a33dc439..5218afde 100644 --- a/cycode/cli/apps/ai_guardrails/session_start_command.py +++ b/cycode/cli/apps/ai_guardrails/session_start_command.py @@ -39,6 +39,7 @@ def _build_session_payload(payload: dict, ide: str) -> AIHookPayload: model=payload.get('model'), ide_provider=AIIDEType.CLAUDE_CODE.value, ide_version=ide_version, + source=payload.get('source'), ) # Cursor diff --git a/cycode/cyclient/ai_security_manager_client.py b/cycode/cyclient/ai_security_manager_client.py index 376b549e..f4ae31db 100644 --- a/cycode/cyclient/ai_security_manager_client.py +++ b/cycode/cyclient/ai_security_manager_client.py @@ -42,6 +42,7 @@ def create_conversation(self, payload: 'AIHookPayload') -> Optional[str]: 'model': payload.model, 'ide_provider': payload.ide_provider, 'ide_version': payload.ide_version, + 'source': payload.source, } try: From 7e49a742888c5fecac0a41c1a60f61b407ba79a9 Mon Sep 17 00:00:00 2001 From: omerr-cycode Date: Mon, 27 Apr 2026 15:40:31 +0300 Subject: [PATCH 058/123] CM-63288 cli add error code 2 for scan errors (#440) --- README.md | 8 +++ cycode/cli/apps/scan/scan_command.py | 5 +- cycode/cli/consts.py | 1 + .../sca/nuget/restore_nuget_dependencies.py | 12 ++++- tests/cli/commands/scan/test_scan_command.py | 52 +++++++++++++++++++ 5 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 tests/cli/commands/scan/test_scan_command.py diff --git a/README.md b/README.md index 806390d0..2604bb0f 100644 --- a/README.md +++ b/README.md @@ -802,6 +802,14 @@ cycode scan -t sca --stop-on-error path ~/home/git/codebase This is useful in CI pipelines where a silent failure would produce an incomplete scan result. When `--stop-on-error` is triggered you can either fix the underlying issue or, for SCA restore failures specifically, add `--no-restore` to skip lockfile generation and scan direct dependencies only. +When `--stop-on-error` is used, the CLI distinguishes between scan errors and policy violations via exit codes: + +| Exit code | Meaning | +|-----------|---------| +| `0` | Scan completed with no violations | +| `1` | Scan completed and violations were found | +| `2` | Scan aborted due to an error (only when `--stop-on-error` is set) | + ### Repository Scan A repository scan examines an entire local repository for any exposed secrets or insecure misconfigurations. This more holistic scan type looks at everything: the current state of your repository and its commit history. It will look not only for secrets that are currently exposed within the repository but previously deleted secrets as well. diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 62697357..9b2aa280 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -17,6 +17,7 @@ from cycode.cli.consts import ( ISSUE_DETECTED_STATUS_CODE, NO_ISSUES_STATUS_CODE, + SCAN_ERROR_STATUS_CODE, ) from cycode.cli.files_collector.file_excluder import excluder from cycode.cli.utils import scan_utils @@ -187,7 +188,9 @@ def scan_command_result_callback(ctx: click.Context, *_, **__) -> None: raise typer.Exit(0) exit_code = NO_ISSUES_STATUS_CODE - if scan_utils.is_scan_failed(ctx): + if ctx.obj.get('did_fail') and ctx.obj.get('stop_on_error'): + exit_code = SCAN_ERROR_STATUS_CODE + elif scan_utils.is_scan_failed(ctx): exit_code = ISSUE_DETECTED_STATUS_CODE raise typer.Exit(exit_code) diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 31ab6ef9..108aa0e1 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -277,6 +277,7 @@ ISSUE_DETECTED_STATUS_CODE = 1 NO_ISSUES_STATUS_CODE = 0 +SCAN_ERROR_STATUS_CODE = 2 LICENSE_COMPLIANCE_POLICY_ID = '8f681450-49e1-4f7e-85b7-0c8fe84b3a35' PACKAGE_VULNERABILITY_POLICY_ID = '9369d10a-9ac0-48d3-9921-5de7fe9a37a7' diff --git a/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py b/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py index 95ced0ff..9bd01cd0 100644 --- a/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py +++ b/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py @@ -15,7 +15,17 @@ def is_project(self, document: Document) -> bool: return any(document.path.endswith(ext) for ext in NUGET_PROJECT_FILE_EXTENSIONS) def get_commands(self, manifest_file_path: str) -> list[list[str]]: - return [['dotnet', 'restore', manifest_file_path, '--use-lock-file', '--verbosity', 'quiet']] + return [ + [ + 'dotnet', + 'restore', + manifest_file_path, + '--use-lock-file', + '--verbosity', + 'quiet', + '--ignore-failed-sources', + ] + ] def get_lock_file_name(self) -> str: return NUGET_LOCK_FILE_NAME diff --git a/tests/cli/commands/scan/test_scan_command.py b/tests/cli/commands/scan/test_scan_command.py new file mode 100644 index 00000000..de218da5 --- /dev/null +++ b/tests/cli/commands/scan/test_scan_command.py @@ -0,0 +1,52 @@ +import click +import pytest +import typer + +from cycode.cli.apps.scan.scan_command import scan_command_result_callback +from cycode.cli.consts import ISSUE_DETECTED_STATUS_CODE, NO_ISSUES_STATUS_CODE, SCAN_ERROR_STATUS_CODE + + +def _make_ctx(**obj_overrides: object) -> click.Context: + obj = { + 'soft_fail': False, + 'did_fail': False, + 'issue_detected': False, + 'stop_on_error': False, + } + obj.update(obj_overrides) + ctx = click.Context(click.Command('scan')) + ctx.obj = obj + return ctx + + +def _invoke_result_callback(ctx: click.Context) -> int: + with pytest.raises(typer.Exit) as exc_info, ctx: + scan_command_result_callback() + return exc_info.value.exit_code + + +class TestScanCommandResultCallback: + def test_no_issues_no_errors_exits_zero(self) -> None: + assert _invoke_result_callback(_make_ctx()) == NO_ISSUES_STATUS_CODE + + def test_issue_detected_exits_one(self) -> None: + assert _invoke_result_callback(_make_ctx(issue_detected=True)) == ISSUE_DETECTED_STATUS_CODE + + def test_did_fail_without_stop_on_error_exits_one(self) -> None: + assert _invoke_result_callback(_make_ctx(did_fail=True)) == ISSUE_DETECTED_STATUS_CODE + + def test_did_fail_with_stop_on_error_exits_two(self) -> None: + assert _invoke_result_callback(_make_ctx(did_fail=True, stop_on_error=True)) == SCAN_ERROR_STATUS_CODE + + def test_issue_detected_with_stop_on_error_exits_one(self) -> None: + # stop_on_error only affects the error code path, not violations + assert _invoke_result_callback(_make_ctx(issue_detected=True, stop_on_error=True)) == ISSUE_DETECTED_STATUS_CODE + + def test_soft_fail_overrides_violations(self) -> None: + assert _invoke_result_callback(_make_ctx(soft_fail=True, issue_detected=True)) == NO_ISSUES_STATUS_CODE + + def test_soft_fail_overrides_stop_on_error(self) -> None: + assert ( + _invoke_result_callback(_make_ctx(soft_fail=True, did_fail=True, stop_on_error=True)) + == NO_ISSUES_STATUS_CODE + ) From a5dc614203d9951c94100c0d4e2a735ecb3255c5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:04:43 +0100 Subject: [PATCH 059/123] Bump gitpython from 3.1.45 to 3.1.47 (#439) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 12 ++++++------ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/poetry.lock b/poetry.lock index f810887f..36bd6515 100644 --- a/poetry.lock +++ b/poetry.lock @@ -582,14 +582,14 @@ smmap = ">=3.0.1,<6" [[package]] name = "gitpython" -version = "3.1.45" +version = "3.1.47" description = "GitPython is a Python library used to interact with Git repositories" optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "gitpython-3.1.45-py3-none-any.whl", hash = "sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77"}, - {file = "gitpython-3.1.45.tar.gz", hash = "sha256:85b0ee964ceddf211c41b9f27a49086010a190fd8132a24e21f362a4b36a791c"}, + {file = "gitpython-3.1.47-py3-none-any.whl", hash = "sha256:489f590edfd6d20571b2c0e72c6a6ac6915ee8b8cd04572330e3842207a78905"}, + {file = "gitpython-3.1.47.tar.gz", hash = "sha256:dba27f922bd2b42cb54c87a8ab3cb6beb6bf07f3d564e21ac848913a05a8a3cd"}, ] [package.dependencies] @@ -597,8 +597,8 @@ gitdb = ">=4.0.1,<5" typing-extensions = {version = ">=3.10.0.2", markers = "python_version < \"3.10\""} [package.extras] -doc = ["sphinx (>=7.1.2,<7.2)", "sphinx-autodoc-typehints", "sphinx_rtd_theme"] -test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock ; python_version < \"3.8\"", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions ; python_version < \"3.11\""] +doc = ["sphinx (>=7.4.7,<8)", "sphinx-autodoc-typehints", "sphinx_rtd_theme"] +test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock ; python_version < \"3.8\"", "mypy (==1.18.2) ; python_version >= \"3.9\"", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions ; python_version < \"3.11\""] [[package]] name = "h11" @@ -2042,4 +2042,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "25dc6986a2a4572b689edb26f9184faab8bee8e3a569f8e0cdc8ac35ded0b9fc" +content-hash = "d9569b59b94a3333764ae66390d168630ca1c5988e7793167d15634ca9c502e2" diff --git a/pyproject.toml b/pyproject.toml index 0ed8d8c9..f6ce80f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ click = ">=8.1.0,<8.2.0" colorama = ">=0.4.3,<0.5.0" pyyaml = ">=6.0,<7.0" marshmallow = ">=3.15.0,<4.0.0" -gitpython = ">=3.1.30,<3.2.0" +gitpython = ">=3.1.47,<3.2.0" arrow = ">=1.0.0,<1.5.0" requests = ">=2.32.4,<3.0" urllib3 = ">=2.4.0,<3.0.0" From b7451e5f10031bfab2150ad00ea436b341a31bcf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:14:42 +0000 Subject: [PATCH 060/123] Bump cycodelabs/cimon-action from 0.9.4 to 0.10.0 (#429) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build_executable.yml | 2 +- .github/workflows/pre_release.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/ruff.yml | 2 +- .github/workflows/tests.yml | 2 +- .github/workflows/tests_full.yml | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 74abc748..e0a963d9 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Run Cimon if: matrix.os == 'ubuntu-22.04' - uses: cycodelabs/cimon-action@1c3e30d508634b3f4a60b02843126c9f93944d80 # v0.9.4 + uses: cycodelabs/cimon-action@f99ad5557cb80964bc2b2e76a47bf4b5ba6e323b # v0.10.0 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index f3d081d6..4464f0e4 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Run Cimon - uses: cycodelabs/cimon-action@1c3e30d508634b3f4a60b02843126c9f93944d80 # v0.9.4 + uses: cycodelabs/cimon-action@f99ad5557cb80964bc2b2e76a47bf4b5ba6e323b # v0.10.0 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 462e2362..b634c0f2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Run Cimon - uses: cycodelabs/cimon-action@1c3e30d508634b3f4a60b02843126c9f93944d80 # v0.9.4 + uses: cycodelabs/cimon-action@f99ad5557cb80964bc2b2e76a47bf4b5ba6e323b # v0.10.0 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 038ee4ad..8e64e255 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Run Cimon - uses: cycodelabs/cimon-action@1c3e30d508634b3f4a60b02843126c9f93944d80 # v0.9.4 + uses: cycodelabs/cimon-action@f99ad5557cb80964bc2b2e76a47bf4b5ba6e323b # v0.10.0 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 968a45ad..873005fb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Run Cimon - uses: cycodelabs/cimon-action@1c3e30d508634b3f4a60b02843126c9f93944d80 # v0.9.4 + uses: cycodelabs/cimon-action@f99ad5557cb80964bc2b2e76a47bf4b5ba6e323b # v0.10.0 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index 70bd9128..7e3badb6 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Run Cimon if: matrix.os == 'ubuntu-latest' - uses: cycodelabs/cimon-action@1c3e30d508634b3f4a60b02843126c9f93944d80 # v0.9.4 + uses: cycodelabs/cimon-action@f99ad5557cb80964bc2b2e76a47bf4b5ba6e323b # v0.10.0 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} From a9e0197184e029d6bb94772ce2718ad1c3f82a66 Mon Sep 17 00:00:00 2001 From: omerr-cycode Date: Wed, 6 May 2026 09:26:37 +0300 Subject: [PATCH 061/123] CM-55107 add support for UV package manager for SCA scans (#441) --- cycode/cli/consts.py | 3 + .../sca/python/restore_uv_dependencies.py | 59 ++++++++ .../files_collector/sca/sca_file_collector.py | 2 + .../python/test_restore_uv_dependencies.py | 138 ++++++++++++++++++ 4 files changed, 202 insertions(+) create mode 100644 cycode/cli/files_collector/sca/python/restore_uv_dependencies.py create mode 100644 tests/cli/files_collector/sca/python/test_restore_uv_dependencies.py diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 108aa0e1..52a6827d 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -91,6 +91,7 @@ 'build.scala', 'build.sbt.lock', 'pyproject.toml', + 'uv.lock', 'poetry.lock', 'pipfile', 'pipfile.lock', @@ -124,6 +125,7 @@ '.build', '.dart_tool', '.pub', + '.uv', ) PROJECT_FILES_BY_ECOSYSTEM_MAP = { @@ -145,6 +147,7 @@ 'nuget': ['packages.config', 'project.assets.json', 'packages.lock.json', 'nuget.config'], 'ruby_gems': ['Gemfile', 'Gemfile.lock'], 'sbt': ['build.sbt', 'build.scala', 'build.sbt.lock'], + 'pypi_uv': ['pyproject.toml', 'uv.lock'], 'pypi_poetry': ['pyproject.toml', 'poetry.lock'], 'pypi_pipenv': ['Pipfile', 'Pipfile.lock'], 'pypi_requirements': ['requirements.txt'], diff --git a/cycode/cli/files_collector/sca/python/restore_uv_dependencies.py b/cycode/cli/files_collector/sca/python/restore_uv_dependencies.py new file mode 100644 index 00000000..c05d857c --- /dev/null +++ b/cycode/cli/files_collector/sca/python/restore_uv_dependencies.py @@ -0,0 +1,59 @@ +from pathlib import Path +from typing import Optional + +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content +from cycode.logger import get_logger + +logger = get_logger('UV Restore Dependencies') + +UV_MANIFEST_FILE_NAME = 'pyproject.toml' +UV_LOCK_FILE_NAME = 'uv.lock' + +_UV_TOOL_SECTION = '[tool.uv]' + + +def _indicates_uv(pyproject_content: Optional[str]) -> bool: + """Return True if pyproject.toml content signals that this project uses UV.""" + if not pyproject_content: + return False + return _UV_TOOL_SECTION in pyproject_content + + +class RestoreUvDependencies(BaseRestoreDependencies): + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + if Path(document.path).name != UV_MANIFEST_FILE_NAME: + return False + + manifest_dir = self.get_manifest_dir(document) + if manifest_dir and (Path(manifest_dir) / UV_LOCK_FILE_NAME).is_file(): + return True + + return _indicates_uv(document.content) + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + lockfile_path = Path(manifest_dir) / UV_LOCK_FILE_NAME if manifest_dir else None + + if lockfile_path and lockfile_path.is_file(): + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, UV_LOCK_FILE_NAME) + logger.debug('Using existing uv.lock, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + return super().try_restore_dependencies(document) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [['uv', 'lock']] + + def get_lock_file_name(self) -> str: + return UV_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [UV_LOCK_FILE_NAME] diff --git a/cycode/cli/files_collector/sca/sca_file_collector.py b/cycode/cli/files_collector/sca/sca_file_collector.py index c9c17ebf..b57061b0 100644 --- a/cycode/cli/files_collector/sca/sca_file_collector.py +++ b/cycode/cli/files_collector/sca/sca_file_collector.py @@ -18,6 +18,7 @@ from cycode.cli.files_collector.sca.php.restore_composer_dependencies import RestoreComposerDependencies from cycode.cli.files_collector.sca.python.restore_pipenv_dependencies import RestorePipenvDependencies from cycode.cli.files_collector.sca.python.restore_poetry_dependencies import RestorePoetryDependencies +from cycode.cli.files_collector.sca.python.restore_uv_dependencies import RestoreUvDependencies from cycode.cli.files_collector.sca.ruby.restore_ruby_dependencies import RestoreRubyDependencies from cycode.cli.files_collector.sca.sbt.restore_sbt_dependencies import RestoreSbtDependencies from cycode.cli.models import Document @@ -159,6 +160,7 @@ def _get_restore_handlers(ctx: typer.Context, is_git_diff: bool) -> list[BaseRes RestoreDenoDependencies(ctx, is_git_diff, build_dep_tree_timeout), RestoreNpmDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be after Yarn & Pnpm for fallback RestoreRubyDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestoreUvDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be before Poetry for pyproject.toml RestorePoetryDependencies(ctx, is_git_diff, build_dep_tree_timeout), RestorePipenvDependencies(ctx, is_git_diff, build_dep_tree_timeout), RestoreComposerDependencies(ctx, is_git_diff, build_dep_tree_timeout), diff --git a/tests/cli/files_collector/sca/python/test_restore_uv_dependencies.py b/tests/cli/files_collector/sca/python/test_restore_uv_dependencies.py new file mode 100644 index 00000000..70e4e7ae --- /dev/null +++ b/tests/cli/files_collector/sca/python/test_restore_uv_dependencies.py @@ -0,0 +1,138 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.python.restore_uv_dependencies import ( + UV_LOCK_FILE_NAME, + RestoreUvDependencies, +) +from cycode.cli.models import Document + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_uv(mock_ctx: typer.Context) -> RestoreUvDependencies: + return RestoreUvDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_pyproject_toml_with_uv_lock_matches(self, restore_uv: RestoreUvDependencies, tmp_path: Path) -> None: + (tmp_path / 'pyproject.toml').write_text('[build-system]\nrequires = ["hatchling"]\n') + (tmp_path / 'uv.lock').write_text('version = 1\n') + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[build-system]\nrequires = ["hatchling"]\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + assert restore_uv.is_project(doc) is True + + def test_pyproject_toml_with_tool_uv_section_matches(self, restore_uv: RestoreUvDependencies) -> None: + content = '[tool.uv]\ndev-dependencies = ["pytest"]\n' + doc = Document('pyproject.toml', content) + assert restore_uv.is_project(doc) is True + + def test_pyproject_toml_without_uv_signals_does_not_match( + self, restore_uv: RestoreUvDependencies, tmp_path: Path + ) -> None: + content = '[tool.poetry]\nname = "my-project"\n' + (tmp_path / 'pyproject.toml').write_text(content) + doc = Document( + str(tmp_path / 'pyproject.toml'), + content, + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + assert restore_uv.is_project(doc) is False + + def test_requirements_txt_does_not_match(self, restore_uv: RestoreUvDependencies) -> None: + doc = Document('requirements.txt', 'requests==2.31.0\n') + assert restore_uv.is_project(doc) is False + + def test_empty_content_does_not_match(self, restore_uv: RestoreUvDependencies, tmp_path: Path) -> None: + (tmp_path / 'pyproject.toml').write_text('') + doc = Document( + str(tmp_path / 'pyproject.toml'), + '', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + assert restore_uv.is_project(doc) is False + + +class TestTryRestoreDependencies: + def test_existing_uv_lock_returned_directly(self, restore_uv: RestoreUvDependencies, tmp_path: Path) -> None: + lock_content = 'version = 1\n\n[[package]]\nname = "requests"\n' + (tmp_path / 'pyproject.toml').write_text('[tool.uv]\n') + (tmp_path / 'uv.lock').write_text(lock_content) + + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[tool.uv]\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + result = restore_uv.try_restore_dependencies(doc) + + assert result is not None + assert UV_LOCK_FILE_NAME in result.path + assert result.content == lock_content + + def test_get_lock_file_name(self, restore_uv: RestoreUvDependencies) -> None: + assert restore_uv.get_lock_file_name() == UV_LOCK_FILE_NAME + + def test_get_commands_returns_uv_lock(self, restore_uv: RestoreUvDependencies) -> None: + commands = restore_uv.get_commands('/path/to/pyproject.toml') + assert commands == [['uv', 'lock']] + + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_uv: RestoreUvDependencies, tmp_path: Path + ) -> None: + manifest_content = '[tool.uv]\ndev-dependencies = ["pytest"]\n' + (tmp_path / 'pyproject.toml').write_text(manifest_content) + doc = Document( + str(tmp_path / 'pyproject.toml'), manifest_content, absolute_path=str(tmp_path / 'pyproject.toml') + ) + lock_path = tmp_path / UV_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('version = 1\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_uv.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{UV_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_uv: RestoreUvDependencies, tmp_path: Path) -> None: + lock_content = 'version = 1\n\n[[package]]\nname = "requests"\n' + (tmp_path / 'pyproject.toml').write_text('[tool.uv]\n') + lock_path = tmp_path / UV_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[tool.uv]\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + + result = restore_uv.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {UV_LOCK_FILE_NAME} must not be deleted' From 865aa40b977ec76a6f503323d01f0a9b3501dd2d Mon Sep 17 00:00:00 2001 From: RoniCycode <142726722+RoniCycode@users.noreply.github.com> Date: Wed, 6 May 2026 10:30:41 +0300 Subject: [PATCH 062/123] CM-62381-add-user-email-for-claude-ide (#451) --- .../cli/apps/ai_guardrails/session_start_command.py | 12 ++++++++---- cycode/cyclient/ai_security_manager_client.py | 2 ++ .../ai_guardrails/test_session_start_command.py | 10 +++++++--- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/session_start_command.py b/cycode/cli/apps/ai_guardrails/session_start_command.py index 5218afde..f2d5031c 100644 --- a/cycode/cli/apps/ai_guardrails/session_start_command.py +++ b/cycode/cli/apps/ai_guardrails/session_start_command.py @@ -1,5 +1,5 @@ import sys -from typing import TYPE_CHECKING, Annotated +from typing import TYPE_CHECKING, Annotated, Optional import typer @@ -79,7 +79,7 @@ def _get_cursor_session_context() -> tuple[dict, dict]: return mcp_servers, {} -def _report_session_context(ai_client: 'AISecurityManagerClient', ide: str) -> None: +def _report_session_context(ai_client: 'AISecurityManagerClient', ide: str, user_email: Optional[str]) -> None: """Report IDE session context to the AI security manager. Never raises.""" try: if ide == AIIDEType.CLAUDE_CODE: @@ -91,7 +91,11 @@ def _report_session_context(ai_client: 'AISecurityManagerClient', ide: str) -> N if not mcp_servers and not enabled_plugins: return - ai_client.report_session_context(mcp_servers=mcp_servers, enabled_plugins=enabled_plugins) + ai_client.report_session_context( + mcp_servers=mcp_servers, + enabled_plugins=enabled_plugins, + user_email=user_email, + ) except Exception as e: logger.debug('Failed to report session context', exc_info=e) @@ -148,4 +152,4 @@ def session_start_command( logger.debug('Failed to create conversation during session start', exc_info=e) # Step 5: Report session context (MCP servers) - _report_session_context(ai_client, ide) + _report_session_context(ai_client, ide, session_payload.ide_user_email) diff --git a/cycode/cyclient/ai_security_manager_client.py b/cycode/cyclient/ai_security_manager_client.py index f4ae31db..f9b7b124 100644 --- a/cycode/cyclient/ai_security_manager_client.py +++ b/cycode/cyclient/ai_security_manager_client.py @@ -95,11 +95,13 @@ def report_session_context( self, mcp_servers: Optional[dict] = None, enabled_plugins: Optional[dict] = None, + user_email: Optional[str] = None, ) -> None: """Report session context to the backend.""" body: dict = { 'mcp_servers': mcp_servers, 'enabled_plugins': enabled_plugins, + 'user_email': user_email, } try: diff --git a/tests/cli/commands/ai_guardrails/test_session_start_command.py b/tests/cli/commands/ai_guardrails/test_session_start_command.py index 48e0ebe3..82a13043 100644 --- a/tests/cli/commands/ai_guardrails/test_session_start_command.py +++ b/tests/cli/commands/ai_guardrails/test_session_start_command.py @@ -222,7 +222,7 @@ def test_claude_code_reports_mcp_servers( 'gitlab': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-gitlab']}, 'filesystem': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-filesystem']}, } - mock_load_config.return_value = {'oauthAccount': {'emailAddress': 'u@e.com'}, 'mcpServers': mcp_servers} + mock_load_config.return_value = {'oauthAccount': {'emailAddress': 'test@test.com'}, 'mcpServers': mcp_servers} # Marketplace won't resolve (no extraKnownMarketplaces) so plugin gets {"enabled": True} only. mock_load_settings.return_value = {'enabledPlugins': {'cycode-dev@cycode-marketplace': True}} @@ -234,6 +234,7 @@ def test_claude_code_reports_mcp_servers( mock_ai_client.report_session_context.assert_called_once_with( mcp_servers=mcp_servers, enabled_plugins={'cycode-dev@cycode-marketplace': {'enabled': True}}, + user_email='test@test.com', ) @@ -293,6 +294,7 @@ def test_claude_code_merges_plugin_mcp_servers_and_metadata( 'mcp_server_names': ['aspire'], } }, + user_email=None, ) @@ -311,7 +313,7 @@ def test_claude_code_no_mcp_servers_no_plugins_skips_report( mock_get_auth.return_value = MagicMock() mock_ai_client = MagicMock() mock_get_client.return_value = mock_ai_client - mock_load_config.return_value = {'oauthAccount': {'emailAddress': 'u@e.com'}} + mock_load_config.return_value = {'oauthAccount': {'emailAddress': 'test@test.com'}} mock_load_settings.return_value = None payload = {'session_id': 'session-123'} @@ -343,7 +345,9 @@ def test_cursor_reports_mcp_servers( with patch('sys.stdin', new=StringIO(json.dumps(payload))): session_start_command(mock_ctx, ide='cursor') - mock_ai_client.report_session_context.assert_called_once_with(mcp_servers=mcp_servers, enabled_plugins={}) + mock_ai_client.report_session_context.assert_called_once_with( + mcp_servers=mcp_servers, enabled_plugins={}, user_email=None + ) @patch.object(_session_start_mod, 'load_cursor_config') From c45123c9bc6961f928d6a1ae137beeef0b6b5f57 Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Tue, 12 May 2026 13:41:08 +0100 Subject: [PATCH 063/123] Update CODEOWNERS (#455) --- CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index f05ffdb9..e59df91b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1 +1 @@ -* @elsapet @gotbadger @mateusz-sterczewski +* @avishaiamiel @omerr-cycode From 050789cfd8ef13e7d5c7123e6d6b8a958e5cedeb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 14:22:13 +0000 Subject: [PATCH 064/123] Bump gitpython from 3.1.47 to 3.1.50 (#454) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 36bd6515..2834c3c3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -582,14 +582,14 @@ smmap = ">=3.0.1,<6" [[package]] name = "gitpython" -version = "3.1.47" +version = "3.1.50" description = "GitPython is a Python library used to interact with Git repositories" optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "gitpython-3.1.47-py3-none-any.whl", hash = "sha256:489f590edfd6d20571b2c0e72c6a6ac6915ee8b8cd04572330e3842207a78905"}, - {file = "gitpython-3.1.47.tar.gz", hash = "sha256:dba27f922bd2b42cb54c87a8ab3cb6beb6bf07f3d564e21ac848913a05a8a3cd"}, + {file = "gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9"}, + {file = "gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc"}, ] [package.dependencies] @@ -2042,4 +2042,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "d9569b59b94a3333764ae66390d168630ca1c5988e7793167d15634ca9c502e2" +content-hash = "51f62d621288e2a4770e51f64bcde4a0f96017f8367e7bda660ed6816f6cf31a" diff --git a/pyproject.toml b/pyproject.toml index f6ce80f6..d07ef360 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ click = ">=8.1.0,<8.2.0" colorama = ">=0.4.3,<0.5.0" pyyaml = ">=6.0,<7.0" marshmallow = ">=3.15.0,<4.0.0" -gitpython = ">=3.1.47,<3.2.0" +gitpython = ">=3.1.50,<3.2.0" arrow = ">=1.0.0,<1.5.0" requests = ">=2.32.4,<3.0" urllib3 = ">=2.4.0,<3.0.0" From 2199836813d5599e6113a5263fad6983923b10a6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 14:34:14 +0000 Subject: [PATCH 065/123] Bump cycodelabs/cimon-action from 0.10.0 to 0.10.1 (#450) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build_executable.yml | 2 +- .github/workflows/pre_release.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/ruff.yml | 2 +- .github/workflows/tests.yml | 2 +- .github/workflows/tests_full.yml | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index e0a963d9..3b2d3444 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Run Cimon if: matrix.os == 'ubuntu-22.04' - uses: cycodelabs/cimon-action@f99ad5557cb80964bc2b2e76a47bf4b5ba6e323b # v0.10.0 + uses: cycodelabs/cimon-action@3ca67e875f34772093aa3bf3c185a711720bf5d9 # v0.10.1 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 4464f0e4..8475352f 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Run Cimon - uses: cycodelabs/cimon-action@f99ad5557cb80964bc2b2e76a47bf4b5ba6e323b # v0.10.0 + uses: cycodelabs/cimon-action@3ca67e875f34772093aa3bf3c185a711720bf5d9 # v0.10.1 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b634c0f2..c0bc0409 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Run Cimon - uses: cycodelabs/cimon-action@f99ad5557cb80964bc2b2e76a47bf4b5ba6e323b # v0.10.0 + uses: cycodelabs/cimon-action@3ca67e875f34772093aa3bf3c185a711720bf5d9 # v0.10.1 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 8e64e255..5d709182 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Run Cimon - uses: cycodelabs/cimon-action@f99ad5557cb80964bc2b2e76a47bf4b5ba6e323b # v0.10.0 + uses: cycodelabs/cimon-action@3ca67e875f34772093aa3bf3c185a711720bf5d9 # v0.10.1 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 873005fb..b09d7bbc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Run Cimon - uses: cycodelabs/cimon-action@f99ad5557cb80964bc2b2e76a47bf4b5ba6e323b # v0.10.0 + uses: cycodelabs/cimon-action@3ca67e875f34772093aa3bf3c185a711720bf5d9 # v0.10.1 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index 7e3badb6..ea264012 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Run Cimon if: matrix.os == 'ubuntu-latest' - uses: cycodelabs/cimon-action@f99ad5557cb80964bc2b2e76a47bf4b5ba6e323b # v0.10.0 + uses: cycodelabs/cimon-action@3ca67e875f34772093aa3bf3c185a711720bf5d9 # v0.10.1 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} From e42ebf645399035aa8fc7b9762a5f05906d3103d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 08:40:27 +0300 Subject: [PATCH 066/123] Bump actions/cache from 5.0.3 to 5.0.5 (#447) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build_executable.yml | 2 +- .github/workflows/docker-image.yml | 2 +- .github/workflows/pre_release.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/ruff.yml | 2 +- .github/workflows/tests.yml | 2 +- .github/workflows/tests_full.yml | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 3b2d3444..a843dcf5 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -68,7 +68,7 @@ jobs: - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.local key: poetry-${{ matrix.os }}-2 # increment to reset cache diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index e22c3205..ef92a88d 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -37,7 +37,7 @@ jobs: - name: Load cached Poetry setup id: cached_poetry - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 8475352f..fd183691 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -39,7 +39,7 @@ jobs: - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c0bc0409..acee4571 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,7 +38,7 @@ jobs: - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 5d709182..fcc7a882 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -30,7 +30,7 @@ jobs: - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b09d7bbc..bd275986 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -32,7 +32,7 @@ jobs: - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index ea264012..b6fee12e 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -47,7 +47,7 @@ jobs: - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.local key: poetry-${{ matrix.os }}-${{ matrix.python-version }}-3 # increment to reset cache From ff91b13678b1cdf84d0520f4210b52ad665ba12f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 08:55:49 +0300 Subject: [PATCH 067/123] Bump patch-ng from 1.19.0 to 1.19.1 (#444) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 7 ++++--- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index 2834c3c3..f5bec698 100644 --- a/poetry.lock +++ b/poetry.lock @@ -904,13 +904,14 @@ files = [ [[package]] name = "patch-ng" -version = "1.19.0" +version = "1.19.1" description = "Library to parse and apply unified diffs." optional = false python-versions = ">=3.6" groups = ["main"] files = [ - {file = "patch-ng-1.19.0.tar.gz", hash = "sha256:27484792f4ac1c15fe2f3e4cecf74bb9833d33b75c715b71d199f7e1e7d1f786"}, + {file = "patch_ng-1.19.1-py3-none-any.whl", hash = "sha256:d45fd47b3f74b48c3e336690341876bb26244a077a06f5f7e6e47c19c15c1ca4"}, + {file = "patch_ng-1.19.1.tar.gz", hash = "sha256:036a3cc00134ec53f37e92333958ee75e117f2e62a5ec2b85c7122e5e815c29e"}, ] [[package]] @@ -2042,4 +2043,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "51f62d621288e2a4770e51f64bcde4a0f96017f8367e7bda660ed6816f6cf31a" +content-hash = "9462529cbf317006a912a7debe8552a9217c22740ddc95330eae39fbaf8112f1" diff --git a/pyproject.toml b/pyproject.toml index d07ef360..2980b706 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ requests = ">=2.32.4,<3.0" urllib3 = ">=2.4.0,<3.0.0" pyjwt = ">=2.8.0,<3.0" rich = ">=13.9.4, <14" -patch-ng = "1.19.0" +patch-ng = "1.19.1" typer = "^0.15.3" tenacity = ">=9.0.0,<9.1.0" mcp = { version = ">=1.9.3,<2.0.0", markers = "python_version >= '3.10'" } From 82c263bd744b9288933efe09547ee803295eee74 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 09:05:17 +0300 Subject: [PATCH 068/123] Bump dunamai from 1.26.0 to 1.26.1 (#445) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index f5bec698..1a6ce6ee 100644 --- a/poetry.lock +++ b/poetry.lock @@ -533,14 +533,14 @@ test-randomorder = ["pytest-randomly"] [[package]] name = "dunamai" -version = "1.26.0" +version = "1.26.1" description = "Dynamic version generation" optional = false python-versions = ">=3.5" groups = ["executable"] files = [ - {file = "dunamai-1.26.0-py3-none-any.whl", hash = "sha256:f584edf0fda0d308cce0961f807bc90a8fe3d9ff4d62f94e72eca7b43f0ed5f6"}, - {file = "dunamai-1.26.0.tar.gz", hash = "sha256:5396ac43aa20ed059040034e9f9798c7464cf4334c6fc3da3732e29273a2f97d"}, + {file = "dunamai-1.26.1-py3-none-any.whl", hash = "sha256:2727d939c5b4257cb01ea404372803b477f5176e5a347c43beaf89cd5072e853"}, + {file = "dunamai-1.26.1.tar.gz", hash = "sha256:3b46007bd65b00b4824ead0a1aee365fd22d0ec2b9c219497d4fd48f52860c8b"}, ] [package.dependencies] @@ -2043,4 +2043,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "9462529cbf317006a912a7debe8552a9217c22740ddc95330eae39fbaf8112f1" +content-hash = "b67d2f0ceadcf2fbd351b056596da8a04656a3774c2cc26e13cf678b6f31561f" diff --git a/pyproject.toml b/pyproject.toml index 2980b706..c3d10f0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,7 +60,7 @@ pyfakefs = ">=5.7.2,<5.11.0" [tool.poetry.group.executable.dependencies] pyinstaller = {version=">=6.0.0,<7.0.0", python=">=3.9,<3.15"} -dunamai = ">=1.18.0,<1.27.0" +dunamai = ">=1.26.1,<1.27.0" [tool.poetry.group.dev.dependencies] ruff = "0.11.7" From da8a2ab31c874fd06586d2dffc9a082b23a5e089 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 09:17:18 +0300 Subject: [PATCH 069/123] Bump docker/build-push-action from 7.0.0 to 7.1.0 (#448) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-image.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index ef92a88d..f19b4e2d 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -76,7 +76,7 @@ jobs: - name: Build and push id: docker_build if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') }} - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . platforms: linux/amd64,linux/arm64 @@ -86,7 +86,7 @@ jobs: - name: Verify build id: docker_verify_build if: ${{ github.event_name != 'workflow_dispatch' && !startsWith(github.ref, 'refs/tags/v') }} - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . platforms: linux/amd64,linux/arm64 From e0e52810acd8e6f3397c5c840aad9fe4d30bc15f Mon Sep 17 00:00:00 2001 From: Amit Moskovitz Date: Mon, 18 May 2026 20:24:20 +0300 Subject: [PATCH 070/123] CM-64214: Fix missing dependency paths in Maven CLI scan (#456) Co-authored-by: Claude Sonnet 4.6 --- .../sca/maven/restore_maven_dependencies.py | 23 +++- .../sca/test_restore_maven_dependencies.py | 105 ++++++++++++++++++ 2 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 tests/cli/files_collector/sca/test_restore_maven_dependencies.py diff --git a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py index 740ccca9..53ed269f 100644 --- a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py @@ -1,3 +1,4 @@ +import json from os import path from pathlib import Path from typing import Optional @@ -20,6 +21,16 @@ MAVEN_DEP_TREE_FILE_NAME = 'bcde.mvndeps' +def _has_dependency_graph(bom_content: Optional[str]) -> bool: + try: + if not bom_content: + return False + bom = json.loads(bom_content) + return any(dep.get('dependsOn') for dep in bom.get('dependencies', [])) + except Exception: + return False + + class RestoreMavenDependencies(BaseRestoreDependencies): def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: super().__init__(ctx, is_git_diff, command_timeout) @@ -46,8 +57,16 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: if document.content is None: return self.restore_from_secondary_command(document, manifest_file_path) - # super() reads the content and cleans up any generated file; no re-read needed - return super().try_restore_dependencies(document) + restore_dependencies_document = super().try_restore_dependencies(document) + if restore_dependencies_document is None: + return None + + if not _has_dependency_graph(restore_dependencies_document.content): + fallback = self.restore_from_secondary_command(document, manifest_file_path) + if fallback is not None and fallback.content is not None: + return fallback + + return restore_dependencies_document def restore_from_secondary_command(self, document: Document, manifest_file_path: str) -> Optional[Document]: restore_content = execute_commands( diff --git a/tests/cli/files_collector/sca/test_restore_maven_dependencies.py b/tests/cli/files_collector/sca/test_restore_maven_dependencies.py new file mode 100644 index 00000000..fc49cb91 --- /dev/null +++ b/tests/cli/files_collector/sca/test_restore_maven_dependencies.py @@ -0,0 +1,105 @@ +import json +from unittest.mock import MagicMock, patch + +from cycode.cli.files_collector.sca.maven.restore_maven_dependencies import ( + RestoreMavenDependencies, + _has_dependency_graph, +) +from cycode.cli.models import Document + + +class TestHasDependencyGraph: + def test_returns_false_when_content_is_none(self) -> None: + assert _has_dependency_graph(None) is False + + def test_returns_false_when_content_is_empty_string(self) -> None: + assert _has_dependency_graph('') is False + + def test_returns_false_when_dependencies_section_is_missing(self) -> None: + content = json.dumps({'components': [{'name': 'foo'}]}) + assert _has_dependency_graph(content) is False + + def test_returns_false_when_all_dependencies_have_empty_depends_on(self) -> None: + content = json.dumps({'dependencies': [{'ref': 'pkg:maven/foo/bar@1.0', 'dependsOn': []}]}) + assert _has_dependency_graph(content) is False + + def test_returns_false_when_dependencies_list_is_empty(self) -> None: + content = json.dumps({'dependencies': []}) + assert _has_dependency_graph(content) is False + + def test_returns_true_when_at_least_one_dependency_has_depends_on(self) -> None: + content = json.dumps( + { + 'dependencies': [ + {'ref': 'pkg:maven/com.example/root@1.0', 'dependsOn': ['pkg:maven/io.netty/netty-all@4.1.0']}, + {'ref': 'pkg:maven/io.netty/netty-all@4.1.0', 'dependsOn': []}, + ] + } + ) + assert _has_dependency_graph(content) is True + + def test_returns_false_when_content_is_invalid_json(self) -> None: + assert _has_dependency_graph('not valid json {{{') is False + + +class TestRestoreMavenDependenciesFallback: + def _make_instance(self) -> RestoreMavenDependencies: + ctx = MagicMock() + ctx.obj = {} + return RestoreMavenDependencies(ctx=ctx, is_git_diff=False, command_timeout=60) + + def test_falls_back_to_secondary_command_when_bom_has_no_dependency_graph(self) -> None: + instance = self._make_instance() + document = MagicMock(spec=Document) + document.content = 'some content' + + bom_doc = MagicMock(spec=Document) + bom_doc.content = json.dumps({'dependencies': []}) + fallback_doc = MagicMock(spec=Document) + fallback_doc.content = '[INFO] com.example:root:jar:1.0\n+- io.netty:netty-all:jar:4.1.0' + + with ( + patch.object(instance, 'get_manifest_file_path', return_value='/project/pom.xml'), + patch( + 'cycode.cli.files_collector.sca.maven.restore_maven_dependencies.BaseRestoreDependencies.try_restore_dependencies', + return_value=bom_doc, + ), + patch.object(instance, 'restore_from_secondary_command', return_value=fallback_doc) as mock_fallback, + ): + result = instance.try_restore_dependencies(document) + + mock_fallback.assert_called_once_with(document, '/project/pom.xml') + assert result is fallback_doc + + def test_returns_bom_document_when_dependency_graph_is_present(self) -> None: + instance = self._make_instance() + document = MagicMock(spec=Document) + document.content = 'some content' + + bom_doc = MagicMock(spec=Document) + bom_doc.content = json.dumps( + { + 'dependencies': [ + {'ref': 'pkg:maven/com.example/root@1.0', 'dependsOn': ['pkg:maven/io.netty/netty@4.1.0']} + ] + } + ) + + with ( + patch.object(instance, 'get_manifest_file_path', return_value='/project/pom.xml'), + patch( + 'cycode.cli.files_collector.sca.maven.restore_maven_dependencies.BaseRestoreDependencies.try_restore_dependencies', + return_value=bom_doc, + ), + patch.object(instance, 'restore_from_secondary_command') as mock_fallback, + ): + result = instance.try_restore_dependencies(document) + + mock_fallback.assert_not_called() + assert result is bom_doc + + def test_uses_plugin_version_2_9_1(self) -> None: + instance = self._make_instance() + commands = instance.get_commands('/path/to/pom.xml') + assert len(commands) == 1 + assert 'org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeAggregateBom' in commands[0] From db56b13b9c37db1d2277a1c41d2a2248aa2cb4bb Mon Sep 17 00:00:00 2001 From: omerr-cycode Date: Tue, 19 May 2026 16:26:12 +0300 Subject: [PATCH 071/123] CM-64439 updated READ for MCP with certificates (#457) --- README.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/README.md b/README.md index 2604bb0f..d48c4fcc 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ This guide walks you through both installation and usage. 2. [Available Options](#available-options) 3. [MCP Tools](#mcp-tools) 4. [Usage Examples](#usage-examples) + 5. [Advanced Configuration](#advanced-configuration) 5. [Platform Command](#platform-command-beta) 1. [Discovering Commands](#discovering-commands) 2. [Examples](#platform-examples) @@ -559,6 +560,38 @@ cycode mcp -t streamable-http -H 127.0.0.2 -p 9000 & } ``` +### Advanced Configuration +##### Custom Certificates and Timeouts (Proxy Environments) + +If your organization uses a corporate proxy or a custom CA bundle for HTTPS inspection, you need to tell Cycode CLI (and the underlying Python TLS stack) where to find the trusted certificate bundle. You can also increase the MCP tool call timeout if scans are being cut short. + +| Environment Variable | Description | +|----------------------|-------------| +| `REQUESTS_CA_BUNDLE` | Path to a custom CA bundle file (`.pem` or `.crt`). Used by the `requests` library for all HTTPS calls made by Cycode CLI. | +| `SSL_CERT_FILE` | Path to a custom CA bundle file. Used by Python's low-level `ssl` module. Set this alongside `REQUESTS_CA_BUNDLE` for full coverage. | +| `MCP_TOOL_TIMEOUT` | Timeout (in seconds) that MCP clients such as Claude and GitHub Copilot wait for a tool call to complete. Increase this if long-running scans are being cut off before they finish. | + +> [!TIP] +> Set both `REQUESTS_CA_BUNDLE` and `SSL_CERT_FILE` to the same CA bundle path. `REQUESTS_CA_BUNDLE` covers the HTTP layer; `SSL_CERT_FILE` covers the lower-level TLS layer. Using only one may still cause certificate errors in some environments. + +Example `mcp.json` configuration with custom certificates and a longer timeout: + +```json +{ + "mcpServers": { + "cycode": { + "command": "cycode", + "args": ["mcp"], + "env": { + "REQUESTS_CA_BUNDLE": "/path/to/your/corporate-ca-bundle.pem", + "SSL_CERT_FILE": "/path/to/your/corporate-ca-bundle.pem", + "MCP_TOOL_TIMEOUT": "1800" + } + } + } +} +``` + > [!NOTE] > The MCP server requires proper Cycode CLI authentication to function. Make sure you have authenticated using `cycode auth` or configured your credentials before starting the MCP server. @@ -608,6 +641,8 @@ This information can be helpful when: - Identifying authentication problems - Debugging transport-specific issues +### MCP Configuration + # Platform Command \[BETA\] From 08499d3a888ce071726ea5dc5e13ff1cffa43793 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Mon, 25 May 2026 16:11:50 +0300 Subject: [PATCH 072/123] CM-64678: refactor ai-guardrails to single-file-per-IDE abstraction (#459) Co-authored-by: Claude Opus 4.7 (1M context) --- .../cli/apps/ai_guardrails/command_utils.py | 47 +- cycode/cli/apps/ai_guardrails/consts.py | 138 +----- .../cli/apps/ai_guardrails/hooks_manager.py | 189 +++----- .../cli/apps/ai_guardrails/ides/__init__.py | 44 ++ cycode/cli/apps/ai_guardrails/ides/base.py | 156 +++++++ .../apps/ai_guardrails/ides/claude_code.py | 389 ++++++++++++++++ cycode/cli/apps/ai_guardrails/ides/cursor.py | 119 +++++ .../cli/apps/ai_guardrails/install_command.py | 37 +- .../apps/ai_guardrails/scan/claude_config.py | 159 ------- .../apps/ai_guardrails/scan/cursor_config.py | 36 -- .../cli/apps/ai_guardrails/scan/handlers.py | 135 ++---- cycode/cli/apps/ai_guardrails/scan/payload.py | 269 +---------- .../ai_guardrails/scan/response_builders.py | 135 ------ .../apps/ai_guardrails/scan/scan_command.py | 108 +++-- cycode/cli/apps/ai_guardrails/scan/types.py | 38 +- .../ai_guardrails/session_start_command.py | 92 +--- .../cli/apps/ai_guardrails/status_command.py | 29 +- .../apps/ai_guardrails/uninstall_command.py | 34 +- .../commands/ai_guardrails/ides/__init__.py | 0 .../ai_guardrails/ides/test_claude_code.py | 262 +++++++++++ .../ai_guardrails/ides/test_contract.py | 141 ++++++ .../ai_guardrails/ides/test_cursor.py | 154 +++++++ .../ai_guardrails/scan/test_handlers.py | 98 ++-- .../ai_guardrails/scan/test_payload.py | 432 ------------------ .../scan/test_response_builders.py | 148 ------ .../ai_guardrails/scan/test_scan_command.py | 10 +- .../ai_guardrails/test_claude_config.py | 54 --- .../ai_guardrails/test_command_utils.py | 60 --- .../ai_guardrails/test_hooks_manager.py | 116 ++--- .../test_session_start_command.py | 24 +- 30 files changed, 1618 insertions(+), 2035 deletions(-) create mode 100644 cycode/cli/apps/ai_guardrails/ides/__init__.py create mode 100644 cycode/cli/apps/ai_guardrails/ides/base.py create mode 100644 cycode/cli/apps/ai_guardrails/ides/claude_code.py create mode 100644 cycode/cli/apps/ai_guardrails/ides/cursor.py delete mode 100644 cycode/cli/apps/ai_guardrails/scan/claude_config.py delete mode 100644 cycode/cli/apps/ai_guardrails/scan/cursor_config.py delete mode 100644 cycode/cli/apps/ai_guardrails/scan/response_builders.py create mode 100644 tests/cli/commands/ai_guardrails/ides/__init__.py create mode 100644 tests/cli/commands/ai_guardrails/ides/test_claude_code.py create mode 100644 tests/cli/commands/ai_guardrails/ides/test_contract.py create mode 100644 tests/cli/commands/ai_guardrails/ides/test_cursor.py delete mode 100644 tests/cli/commands/ai_guardrails/scan/test_payload.py delete mode 100644 tests/cli/commands/ai_guardrails/scan/test_response_builders.py delete mode 100644 tests/cli/commands/ai_guardrails/test_claude_config.py delete mode 100644 tests/cli/commands/ai_guardrails/test_command_utils.py diff --git a/cycode/cli/apps/ai_guardrails/command_utils.py b/cycode/cli/apps/ai_guardrails/command_utils.py index edc3104a..291fabcf 100644 --- a/cycode/cli/apps/ai_guardrails/command_utils.py +++ b/cycode/cli/apps/ai_guardrails/command_utils.py @@ -7,46 +7,11 @@ import typer from rich.console import Console -from cycode.cli.apps.ai_guardrails.consts import AIIDEType - console = Console() -def validate_and_parse_ide(ide: str) -> Optional[AIIDEType]: - """Validate IDE parameter, returning None for 'all'. - - Args: - ide: IDE name string (e.g., 'cursor', 'claude-code', 'all') - - Returns: - AIIDEType enum value, or None if 'all' was specified - - Raises: - typer.Exit: If IDE is invalid - """ - if ide.lower() == 'all': - return None - try: - return AIIDEType(ide.lower()) - except ValueError: - valid_ides = ', '.join([ide_type.value for ide_type in AIIDEType]) - console.print( - f'[red]Error:[/] Invalid IDE "{ide}". Supported IDEs: {valid_ides}, all', - style='bold red', - ) - raise typer.Exit(1) from None - - def validate_scope(scope: str, allowed_scopes: tuple[str, ...] = ('user', 'repo')) -> None: - """Validate scope parameter. - - Args: - scope: Scope string to validate - allowed_scopes: Tuple of allowed scope values - - Raises: - typer.Exit: If scope is invalid - """ + """Validate scope parameter.""" if scope not in allowed_scopes: scopes_list = ', '.join(f'"{s}"' for s in allowed_scopes) console.print(f'[red]Error:[/] Invalid scope. Use {scopes_list}.', style='bold red') @@ -54,15 +19,7 @@ def validate_scope(scope: str, allowed_scopes: tuple[str, ...] = ('user', 'repo' def resolve_repo_path(scope: str, repo_path: Optional[Path]) -> Optional[Path]: - """Resolve repository path, defaulting to current directory for repo scope. - - Args: - scope: The command scope ('user' or 'repo') - repo_path: Provided repo path or None - - Returns: - Resolved Path for repo scope, None for user scope - """ + """Default repo_path to cwd for 'repo' scope; leave None for 'user' scope.""" if scope == 'repo' and repo_path is None: return Path(os.getcwd()) return repo_path diff --git a/cycode/cli/apps/ai_guardrails/consts.py b/cycode/cli/apps/ai_guardrails/consts.py index 2895c8d1..8018fa73 100644 --- a/cycode/cli/apps/ai_guardrails/consts.py +++ b/cycode/cli/apps/ai_guardrails/consts.py @@ -1,22 +1,6 @@ -"""Constants for AI guardrails hooks management. +"""Shared constants and policy/mode enums for AI guardrails.""" -Currently supports: -- Cursor -- Claude Code -""" - -import platform -from copy import deepcopy from enum import Enum -from pathlib import Path -from typing import NamedTuple - - -class AIIDEType(str, Enum): - """Supported AI IDE types.""" - - CURSOR = 'cursor' - CLAUDE_CODE = 'claude-code' class PolicyMode(str, Enum): @@ -33,123 +17,7 @@ class InstallMode(str, Enum): BLOCK = 'block' -class IDEConfig(NamedTuple): - """Configuration for an AI IDE.""" - - name: str - hooks_dir: Path - repo_hooks_subdir: str # Subdirectory in repo for hooks (e.g., '.cursor') - hooks_file_name: str - hook_events: list[str] # List of supported hook event names for this IDE - - -def _get_cursor_hooks_dir() -> Path: - """Get Cursor hooks directory based on platform.""" - if platform.system() == 'Darwin': - return Path.home() / '.cursor' - if platform.system() == 'Windows': - return Path.home() / 'AppData' / 'Roaming' / 'Cursor' - # Linux - return Path.home() / '.config' / 'Cursor' - - -def _get_claude_code_hooks_dir() -> Path: - """Get Claude Code hooks directory. - - Claude Code uses ~/.claude on all platforms. - """ - return Path.home() / '.claude' - - -# IDE-specific configurations -IDE_CONFIGS: dict[AIIDEType, IDEConfig] = { - AIIDEType.CURSOR: IDEConfig( - name='Cursor', - hooks_dir=_get_cursor_hooks_dir(), - repo_hooks_subdir='.cursor', - hooks_file_name='hooks.json', - hook_events=['beforeSubmitPrompt', 'beforeReadFile', 'beforeMCPExecution'], - ), - AIIDEType.CLAUDE_CODE: IDEConfig( - name='Claude Code', - hooks_dir=_get_claude_code_hooks_dir(), - repo_hooks_subdir='.claude', - hooks_file_name='settings.json', - hook_events=['UserPromptSubmit', 'PreToolUse:Read', 'PreToolUse:mcp'], - ), -} - -# Default IDE -DEFAULT_IDE = AIIDEType.CURSOR - -# Command used in hooks +# Base CLI commands invoked from installed hooks. IDE classes append --ide flags +# (and any other suffix) on top of these. CYCODE_SCAN_PROMPT_COMMAND = 'cycode ai-guardrails scan' CYCODE_SESSION_START_COMMAND = 'cycode ai-guardrails session-start' - - -def _get_cursor_hooks_config(async_mode: bool = False) -> dict: - """Get Cursor-specific hooks configuration.""" - config = IDE_CONFIGS[AIIDEType.CURSOR] - command = f'{CYCODE_SCAN_PROMPT_COMMAND} &' if async_mode else CYCODE_SCAN_PROMPT_COMMAND - hooks = {event: [{'command': command}] for event in config.hook_events} - hooks['sessionStart'] = [{'command': f'{CYCODE_SESSION_START_COMMAND} --ide cursor'}] - - return { - 'version': 1, - 'hooks': hooks, - } - - -def _get_claude_code_hooks_config(async_mode: bool = False) -> dict: - """Get Claude Code-specific hooks configuration. - - Claude Code uses a different hook format with nested structure: - - hooks are arrays of objects with 'hooks' containing command arrays - - PreToolUse uses 'matcher' field to specify which tools to intercept - """ - command = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide claude-code' - - hook_entry = {'type': 'command', 'command': command} - if async_mode: - hook_entry['async'] = True - hook_entry['timeout'] = 20 - - return { - 'hooks': { - 'SessionStart': [ - { - 'hooks': [{'type': 'command', 'command': f'{CYCODE_SESSION_START_COMMAND} --ide claude-code'}], - } - ], - 'UserPromptSubmit': [ - { - 'hooks': [deepcopy(hook_entry)], - } - ], - 'PreToolUse': [ - { - 'matcher': 'Read', - 'hooks': [deepcopy(hook_entry)], - }, - { - 'matcher': 'mcp__.*', - 'hooks': [deepcopy(hook_entry)], - }, - ], - }, - } - - -def get_hooks_config(ide: AIIDEType, async_mode: bool = False) -> dict: - """Get the hooks configuration for a specific IDE. - - Args: - ide: The AI IDE type - async_mode: If True, hooks run asynchronously (non-blocking) - - Returns: - Dict with hooks configuration for the specified IDE - """ - if ide == AIIDEType.CLAUDE_CODE: - return _get_claude_code_hooks_config(async_mode=async_mode) - return _get_cursor_hooks_config(async_mode=async_mode) diff --git a/cycode/cli/apps/ai_guardrails/hooks_manager.py b/cycode/cli/apps/ai_guardrails/hooks_manager.py index 74c681be..1fe23bb2 100644 --- a/cycode/cli/apps/ai_guardrails/hooks_manager.py +++ b/cycode/cli/apps/ai_guardrails/hooks_manager.py @@ -1,8 +1,8 @@ -""" -Hooks manager for AI guardrails. +"""Hooks manager for AI guardrails. -Handles installation, removal, and status checking of AI IDE hooks. -Supports multiple IDEs: Cursor, Claude Code (future). +Generic install/uninstall/status logic. All IDE-specific concerns (settings +paths, hooks template shape) live on the `IDE` instance; this module is +agent-agnostic. """ import copy @@ -12,47 +12,45 @@ import yaml -from cycode.cli.apps.ai_guardrails.consts import ( - DEFAULT_IDE, - IDE_CONFIGS, - AIIDEType, - PolicyMode, - get_hooks_config, -) +from cycode.cli.apps.ai_guardrails.consts import PolicyMode +from cycode.cli.apps.ai_guardrails.ides.base import IDE from cycode.cli.apps.ai_guardrails.scan.consts import DEFAULT_POLICY, POLICY_FILE_NAME from cycode.logger import get_logger logger = get_logger('AI Guardrails Hooks') -def get_hooks_path(scope: str, repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE) -> Path: - """Get the hooks.json path for the given scope and IDE. +_CYCODE_COMMAND_MARKERS = ('cycode ai-guardrails',) + - Args: - scope: 'user' for user-level hooks, 'repo' for repository-level hooks - repo_path: Repository path (required if scope is 'repo') - ide: The AI IDE type (default: Cursor) - """ - config = IDE_CONFIGS[ide] - if scope == 'repo' and repo_path: - return repo_path / config.repo_hooks_subdir / config.hooks_file_name - return config.hooks_dir / config.hooks_file_name +def _is_cycode_command(command: str) -> bool: + return any(marker in command for marker in _CYCODE_COMMAND_MARKERS) + + +def is_cycode_hook_entry(entry: dict) -> bool: + """Detect Cycode hook entries in both Cursor (flat) and Claude Code (nested) shapes.""" + command = entry.get('command', '') + if _is_cycode_command(command): + return True + + for hook in entry.get('hooks', []): + if isinstance(hook, dict) and _is_cycode_command(hook.get('command', '')): + return True + + return False -def load_hooks_file(hooks_path: Path) -> Optional[dict]: - """Load existing hooks.json file.""" +def _load_hooks_file(hooks_path: Path) -> Optional[dict]: if not hooks_path.exists(): return None try: - content = hooks_path.read_text(encoding='utf-8') - return json.loads(content) + return json.loads(hooks_path.read_text(encoding='utf-8')) except Exception as e: logger.debug('Failed to load hooks file', exc_info=e) return None -def save_hooks_file(hooks_path: Path, hooks_config: dict) -> bool: - """Save hooks.json file.""" +def _save_hooks_file(hooks_path: Path, hooks_config: dict) -> bool: try: hooks_path.parent.mkdir(parents=True, exist_ok=True) hooks_path.write_text(json.dumps(hooks_config, indent=2), encoding='utf-8') @@ -62,39 +60,7 @@ def save_hooks_file(hooks_path: Path, hooks_config: dict) -> bool: return False -_CYCODE_COMMAND_MARKERS = ('cycode ai-guardrails',) - - -def _is_cycode_command(command: str) -> bool: - return any(marker in command for marker in _CYCODE_COMMAND_MARKERS) - - -def is_cycode_hook_entry(entry: dict) -> bool: - """Check if a hook entry is from cycode-cli. - - Handles both Cursor format (flat) and Claude Code format (nested). - - Cursor format: {"command": "cycode ai-guardrails scan"} - Claude Code format: {"hooks": [{"type": "command", "command": "cycode ai-guardrails scan --ide claude-code"}]} - """ - # Check Cursor format (flat command) - command = entry.get('command', '') - if _is_cycode_command(command): - return True - - # Check Claude Code format (nested hooks array) - hooks = entry.get('hooks', []) - for hook in hooks: - if isinstance(hook, dict): - hook_command = hook.get('command', '') - if _is_cycode_command(hook_command): - return True - - return False - - -def _load_policy(policy_path: Path) -> dict: - """Load existing policy file merged with defaults, or return defaults if not found.""" +def _load_policy_dict(policy_path: Path) -> dict: if not policy_path.exists(): return copy.deepcopy(DEFAULT_POLICY) try: @@ -107,22 +73,13 @@ def _load_policy(policy_path: Path) -> dict: def create_policy_file(scope: str, mode: PolicyMode, repo_path: Optional[Path] = None) -> tuple[bool, str]: """Create or update the ai-guardrails.yaml policy file. - If the file already exists, only the mode field is updated. - If it doesn't exist, a new file is created from the default policy. - - Args: - scope: 'user' for user-level, 'repo' for repository-level - mode: The policy mode to set - repo_path: Repository path (required if scope is 'repo') - - Returns: - Tuple of (success, message) + If the file already exists, only the mode field is updated; otherwise a new + file is created from the default policy. """ config_dir = repo_path / '.cycode' if scope == 'repo' and repo_path else Path.home() / '.cycode' policy_path = config_dir / POLICY_FILE_NAME - policy = _load_policy(policy_path) - + policy = _load_policy_dict(policy_path) policy['mode'] = mode.value try: @@ -135,35 +92,21 @@ def create_policy_file(scope: str, mode: PolicyMode, repo_path: Optional[Path] = def install_hooks( + ide: IDE, scope: str = 'user', repo_path: Optional[Path] = None, - ide: AIIDEType = DEFAULT_IDE, report_mode: bool = False, ) -> tuple[bool, str]: - """ - Install Cycode AI guardrails hooks. - - Args: - scope: 'user' for user-level hooks, 'repo' for repository-level hooks - repo_path: Repository path (required if scope is 'repo') - ide: The AI IDE type (default: Cursor) - report_mode: If True, install hooks in async mode (non-blocking) + """Install Cycode AI guardrails hooks for ``ide``.""" + hooks_path = ide.settings_path(scope, repo_path) - Returns: - Tuple of (success, message) - """ - hooks_path = get_hooks_path(scope, repo_path, ide) - - # Load existing hooks or create new - existing = load_hooks_file(hooks_path) or {'version': 1, 'hooks': {}} + existing = _load_hooks_file(hooks_path) or {'version': 1, 'hooks': {}} existing.setdefault('version', 1) existing.setdefault('hooks', {}) - # Get IDE-specific hooks configuration - hooks_config = get_hooks_config(ide, async_mode=report_mode) + rendered = ide.render_hooks_config(async_mode=report_mode) - # Add/update Cycode hooks - for event, entries in hooks_config['hooks'].items(): + for event, entries in rendered['hooks'].items(): existing['hooks'].setdefault(event, []) # Remove any existing Cycode entries for this event @@ -173,47 +116,31 @@ def install_hooks( for entry in entries: existing['hooks'][event].append(entry) - # Save - if save_hooks_file(hooks_path, existing): + if _save_hooks_file(hooks_path, existing): return True, f'AI guardrails hooks installed: {hooks_path}' return False, f'Failed to install hooks to {hooks_path}' -def uninstall_hooks( - scope: str = 'user', repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE -) -> tuple[bool, str]: - """ - Remove Cycode AI guardrails hooks. - - Args: - scope: 'user' for user-level hooks, 'repo' for repository-level hooks - repo_path: Repository path (required if scope is 'repo') - ide: The AI IDE type (default: Cursor) - - Returns: - Tuple of (success, message) - """ - hooks_path = get_hooks_path(scope, repo_path, ide) +def uninstall_hooks(ide: IDE, scope: str = 'user', repo_path: Optional[Path] = None) -> tuple[bool, str]: + """Remove Cycode AI guardrails hooks for ``ide``.""" + hooks_path = ide.settings_path(scope, repo_path) - existing = load_hooks_file(hooks_path) + existing = _load_hooks_file(hooks_path) if existing is None: return True, f'No hooks file found at {hooks_path}' - # Remove Cycode entries from all events modified = False for event in list(existing.get('hooks', {}).keys()): original_count = len(existing['hooks'][event]) existing['hooks'][event] = [e for e in existing['hooks'][event] if not is_cycode_hook_entry(e)] if len(existing['hooks'][event]) != original_count: modified = True - # Remove empty event lists if not existing['hooks'][event]: del existing['hooks'][event] if not modified: return True, 'No Cycode hooks found to remove' - # Save or delete if empty if not existing.get('hooks'): try: hooks_path.unlink() @@ -222,48 +149,35 @@ def uninstall_hooks( logger.debug('Failed to delete hooks file', exc_info=e) return False, f'Failed to remove hooks file: {hooks_path}' - if save_hooks_file(hooks_path, existing): + if _save_hooks_file(hooks_path, existing): return True, f'Cycode hooks removed from: {hooks_path}' return False, f'Failed to update hooks file: {hooks_path}' -def get_hooks_status(scope: str = 'user', repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE) -> dict: - """ - Get the status of AI guardrails hooks. - - Args: - scope: 'user' for user-level hooks, 'repo' for repository-level hooks - repo_path: Repository path (required if scope is 'repo') - ide: The AI IDE type (default: Cursor) - - Returns: - Dict with status information - """ - hooks_path = get_hooks_path(scope, repo_path, ide) +def get_hooks_status(ide: IDE, scope: str = 'user', repo_path: Optional[Path] = None) -> dict: + """Return installation status of Cycode hooks for ``ide``.""" + hooks_path = ide.settings_path(scope, repo_path) - status = { + status: dict = { 'scope': scope, - 'ide': ide.value, - 'ide_name': IDE_CONFIGS[ide].name, + 'ide': ide.name, + 'ide_name': ide.display_name, 'hooks_path': str(hooks_path), 'file_exists': hooks_path.exists(), 'cycode_installed': False, 'hooks': {}, } - existing = load_hooks_file(hooks_path) + existing = _load_hooks_file(hooks_path) if existing is None: return status - # Check each hook event for this IDE - ide_config = IDE_CONFIGS[ide] has_cycode_hooks = False - for event in ide_config.hook_events: - # Handle event:matcher format + for event in ide.hook_events: + # ':' filters entries to a specific tool/matcher. if ':' in event: actual_event, matcher_prefix = event.split(':', 1) all_entries = existing.get('hooks', {}).get(actual_event, []) - # Filter entries by matcher entries = [e for e in all_entries if e.get('matcher', '').startswith(matcher_prefix)] else: entries = existing.get('hooks', {}).get(event, []) @@ -278,5 +192,4 @@ def get_hooks_status(scope: str = 'user', repo_path: Optional[Path] = None, ide: } status['cycode_installed'] = has_cycode_hooks - return status diff --git a/cycode/cli/apps/ai_guardrails/ides/__init__.py b/cycode/cli/apps/ai_guardrails/ides/__init__.py new file mode 100644 index 00000000..92859701 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/ides/__init__.py @@ -0,0 +1,44 @@ +"""Registry of supported AI guardrails IDE integrations. + +Adding a new IDE: create `ides/.py` with a subclass of `IDE`, import it +here, and include an instance in the `IDES` tuple. Nothing else in the package +needs to change. +""" + +import typer + +from cycode.cli.apps.ai_guardrails.ides.base import IDE +from cycode.cli.apps.ai_guardrails.ides.claude_code import ClaudeCode +from cycode.cli.apps.ai_guardrails.ides.cursor import Cursor + +# Single source of truth: name → singleton instance. +# `--ide` choices and install/uninstall/status iteration both derive from this. +IDES: dict[str, IDE] = {ide.name: ide for ide in (Cursor(), ClaudeCode())} + +# Default IDE used when `--ide` is omitted. Kept here so the value is colocated +# with the registry; no module outside `ides/` needs to know which IDE wins. +DEFAULT_IDE_NAME = 'cursor' + + +def get_ide(name: str) -> IDE: + """Look up the IDE integration registered under ``name``. + + Raises ``typer.BadParameter`` when the name is unknown — surfaces as a + user-friendly CLI error rather than a KeyError stack trace. + """ + ide = IDES.get(name.lower()) + if ide is None: + valid = ', '.join(IDES.keys()) + raise typer.BadParameter(f'Unknown IDE "{name}". Supported: {valid}.') + return ide + + +def resolve_ides(name: str) -> list[IDE]: + """Resolve an ``--ide`` argument to one or all IDE instances. + + ``"all"`` returns every registered IDE; anything else returns a single + matching IDE (raising ``typer.BadParameter`` for unknown names). + """ + if name.lower() == 'all': + return list(IDES.values()) + return [get_ide(name)] diff --git a/cycode/cli/apps/ai_guardrails/ides/base.py b/cycode/cli/apps/ai_guardrails/ides/base.py new file mode 100644 index 00000000..55d5fb05 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/ides/base.py @@ -0,0 +1,156 @@ +"""Base abstractions for AI guardrails IDE integrations. + +Each AI IDE (Cursor, Claude Code, …) is represented by a subclass of `IDE` +that consolidates every IDE-specific concern in a single module: settings file +paths, hooks template rendering, payload parsing, response building, and any +IDE-specific session-context lookup. + +Adding a new IDE is a matter of: + 1. Subclassing `IDE` and implementing the abstract methods. + 2. Registering the instance in `cycode/cli/apps/ai_guardrails/ides/__init__.py`. + +The `HookDecision` dataclass is the canonical, IDE-agnostic return type for +event handlers; `IDE.build_hook_response` translates it into the IDE-specific +JSON response shape that the IDE expects on stdout. +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import ClassVar, Optional + +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType + + +class DecisionAction(str, Enum): + """Canonical decision action returned by event handlers.""" + + ALLOW = 'allow' + DENY = 'deny' + ASK = 'ask' + + +@dataclass(frozen=True) +class HookDecision: + """Canonical, IDE-agnostic decision returned by event handlers. + + Carries the event type so `IDE.build_hook_response` can pick the right + IDE-specific response shape (Cursor's "permission" style for tool events + vs. "continue" style for prompts; Claude Code's "hookSpecificOutput" + vs. "decision: block"). + """ + + action: DecisionAction + event_type: AiHookEventType + user_message: Optional[str] = None + agent_message: Optional[str] = None + + @classmethod + def allow(cls, event_type: AiHookEventType) -> 'HookDecision': + return cls(action=DecisionAction.ALLOW, event_type=event_type) + + @classmethod + def deny( + cls, event_type: AiHookEventType, user_message: str, agent_message: Optional[str] = None + ) -> 'HookDecision': + return cls( + action=DecisionAction.DENY, + event_type=event_type, + user_message=user_message, + agent_message=agent_message, + ) + + @classmethod + def ask(cls, event_type: AiHookEventType, user_message: str, agent_message: Optional[str] = None) -> 'HookDecision': + return cls( + action=DecisionAction.ASK, + event_type=event_type, + user_message=user_message, + agent_message=agent_message, + ) + + +class IDE(ABC): + """Per-IDE integration. Owns every IDE-specific concern in a single module. + + Subclasses declare identity via class attributes and implement the abstract + methods. Defaults are provided for `get_user_email` and `get_session_context` + so IDEs without those capabilities (e.g. no plugin system, no local + account file) can skip them. + """ + + # CLI value passed to --ide (e.g. 'cursor', 'claude-code'). + name: ClassVar[str] + # Human-friendly name for output ('Cursor', 'Claude Code'). + display_name: ClassVar[str] + # Event names for status display. Use ':' for IDEs that + # qualify a single hook by a sub-matcher (e.g. Claude Code's PreToolUse:Read). + hook_events: ClassVar[list[str]] + + # --- install / status --- + + @abstractmethod + def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: + """Return the hooks/settings file path for the given scope. + + `scope` is 'user' or 'repo'. `repo_path` is required when scope == 'repo'. + """ + + @abstractmethod + def render_hooks_config(self, async_mode: bool = False) -> dict: + """Return the settings blob to merge into the IDE's settings file. + + Shape is IDE-specific (Cursor uses a flat ``{event: [{command}]}`` dict; + Claude Code uses a nested ``{event: [{hooks: [{type, command}]}]}`` + dict). Both share the outer ``{"hooks": ...}`` wrapper so + ``hooks_manager`` can treat them uniformly. + """ + + # --- runtime scan --- + + @abstractmethod + def matches_payload(self, raw_payload: dict) -> bool: + """Return True if ``raw_payload`` originated from this IDE. + + Prevents double-processing when an IDE forwards another IDE's hook + event (e.g. Cursor reading Claude Code hooks from ~/.claude/settings.json). + """ + + @abstractmethod + def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: + """Normalize a raw stdin payload into the canonical ``AIHookPayload``.""" + + @abstractmethod + def build_hook_response(self, decision: HookDecision) -> dict: + """Translate a canonical ``HookDecision`` into the IDE-specific JSON. + + The result is what ``scan_command`` writes to stdout for the IDE to + act on. + """ + + # --- session lifecycle (optional; sensible defaults) --- + + def build_session_payload(self, raw_payload: dict) -> AIHookPayload: + """Build a session-start payload from the raw stdin payload. + + Default: a minimal payload tagged with this IDE's ``name``. IDEs + that need to enrich with transcript/version info should override. + """ + return AIHookPayload(ide_provider=self.name) + + def get_user_email(self) -> Optional[str]: + """Best-effort read of the user's email from IDE-specific config. + + Default: None. Override if the IDE stores a usable account locally. + """ + return None + + def get_session_context(self) -> tuple[dict, dict]: + """Return ``(mcp_servers, enabled_plugins)`` for session-context reporting. + + Default: empty dicts (no plugin system, no discoverable MCP config). + Override to surface MCP/plugin inventory. + """ + return {}, {} diff --git a/cycode/cli/apps/ai_guardrails/ides/claude_code.py b/cycode/cli/apps/ai_guardrails/ides/claude_code.py new file mode 100644 index 00000000..519914b9 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/ides/claude_code.py @@ -0,0 +1,389 @@ +"""Claude Code IDE integration for AI guardrails.""" + +import json +from collections.abc import Iterator +from copy import deepcopy +from pathlib import Path +from typing import ClassVar, Optional + +from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND +from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails Claude Code') + +_CLAUDE_CODE_EVENT_NAMES = frozenset({'UserPromptSubmit', 'PreToolUse'}) + +_USER_HOOKS_DIR = Path.home() / '.claude' +_HOOKS_FILE_NAME = 'settings.json' +_REPO_SUBDIR = '.claude' +_HOOK_EVENTS = ['UserPromptSubmit', 'PreToolUse:Read', 'PreToolUse:mcp'] + +_CLAUDE_CONFIG_PATH = Path.home() / '.claude.json' +_CLAUDE_SETTINGS_PATH = Path.home() / '.claude' / 'settings.json' + +_SCAN_COMMAND = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide claude-code' +_SESSION_START_COMMAND = f'{CYCODE_SESSION_START_COMMAND} --ide claude-code' + + +# --- transcript JSONL parsing ------------------------------------------------- + + +def _reverse_readline(path: Path, buf_size: int = 8192) -> Iterator[str]: + """Yield lines of `path` from end to start without loading the file. + + The Claude Code transcript can be very large; reading from the tail keeps + memory bounded since we only care about the most recent entries. + """ + with path.open('rb') as f: + f.seek(0, 2) + file_size = f.tell() + if file_size == 0: + return + + remaining = file_size + buffer = b'' + + while remaining > 0: + read_size = min(buf_size, remaining) + remaining -= read_size + f.seek(remaining) + chunk = f.read(read_size) + buffer = chunk + buffer + + while b'\n' in buffer: + newline_pos = buffer.rfind(b'\n') + if newline_pos == len(buffer) - 1: + newline_pos = buffer.rfind(b'\n', 0, newline_pos) + if newline_pos == -1: + break + line = buffer[newline_pos + 1 :] + buffer = buffer[: newline_pos + 1] + if line.strip(): + yield line.decode('utf-8', errors='replace') + + if buffer.strip(): + yield buffer.decode('utf-8', errors='replace') + + +def _extract_model(entry: dict) -> Optional[str]: + """Extract model from a transcript entry (top level or nested in message).""" + return entry.get('model') or (entry.get('message') or {}).get('model') + + +def _extract_generation_id(entry: dict) -> Optional[str]: + """Extract generation ID from a user-type transcript entry.""" + if entry.get('type') == 'user': + return entry.get('uuid') + return None + + +def extract_from_claude_transcript( + transcript_path: str, +) -> tuple[Optional[str], Optional[str], Optional[str]]: + """Extract ``(ide_version, model, generation_id)`` from a transcript. + + The transcript is a JSONL file scanned from end → start so the most recent + entries are read first. Any field may come back ``None`` if not found. + """ + if not transcript_path: + return None, None, None + + path = Path(transcript_path) + if not path.exists(): + return None, None, None + + ide_version = None + model = None + generation_id = None + + try: + for line in _reverse_readline(path): + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + ide_version = ide_version or entry.get('version') + model = model or _extract_model(entry) + generation_id = generation_id or _extract_generation_id(entry) + + if ide_version and model and generation_id: + break + except json.JSONDecodeError: + continue + except OSError: + pass + + return ide_version, model, generation_id + + +# --- ~/.claude.json + ~/.claude/settings.json parsing ------------------------- + + +def load_claude_config(config_path: Optional[Path] = None) -> Optional[dict]: + """Load and parse `~/.claude.json`. Returns None if missing/invalid.""" + path = config_path or _CLAUDE_CONFIG_PATH + if not path.exists(): + logger.debug('Claude config file not found', extra={'path': str(path)}) + return None + try: + return json.loads(path.read_text(encoding='utf-8')) + except Exception as e: + logger.debug('Failed to load Claude config file', exc_info=e) + return None + + +def _email_from_config(config: dict) -> Optional[str]: + """Read ``oauthAccount.emailAddress`` from a parsed Claude config.""" + return config.get('oauthAccount', {}).get('emailAddress') + + +def get_mcp_servers(config: dict) -> Optional[dict]: + """Read ``mcpServers`` from a parsed Claude config.""" + return config.get('mcpServers') + + +def load_claude_settings(settings_path: Optional[Path] = None) -> Optional[dict]: + """Load and parse `~/.claude/settings.json`. Returns None if missing/invalid.""" + path = settings_path or _CLAUDE_SETTINGS_PATH + if not path.exists(): + logger.debug('Claude settings file not found', extra={'path': str(path)}) + return None + try: + return json.loads(path.read_text(encoding='utf-8')) + except Exception as e: + logger.debug('Failed to load Claude settings file', exc_info=e) + return None + + +def _resolve_marketplace_path(marketplace: dict) -> Optional[Path]: + """Resolve filesystem path for a directory-type marketplace.""" + source = marketplace.get('source', {}) + if source.get('source') != 'directory': + return None + raw = source.get('path') + if not raw: + return None + path = Path(raw) + return path if path.is_dir() else None + + +def _load_plugin_json_file(plugin_path: Path, relative_path: str) -> Optional[dict]: + """Load and parse a JSON file inside a plugin directory. + + Returns None if the file is missing, unreadable, or has invalid JSON. + """ + target = plugin_path / relative_path + if not target.exists(): + return None + try: + return json.loads(target.read_text(encoding='utf-8')) + except Exception as e: + logger.debug('Failed to load plugin file', extra={'path': str(target)}, exc_info=e) + return None + + +def resolve_plugins(settings: dict) -> tuple[dict, dict]: + """Resolve enabled plugins to their MCP servers and metadata. + + Walks ``enabledPlugins`` from claude settings, resolves each plugin's + marketplace directory via ``extraKnownMarketplaces``, and reads: + - ``/.mcp.json`` for MCP servers (merged into a flat dict) + - ``/.claude-plugin/plugin.json`` for metadata (name, version, description) + + Returns ``(merged_mcp_servers, enriched_plugins)``. + """ + enabled = settings.get('enabledPlugins') or {} + marketplaces = settings.get('extraKnownMarketplaces') or {} + merged_mcp: dict = {} + enriched: dict = {} + + for plugin_key, is_enabled in enabled.items(): + if not is_enabled: + continue + + entry: dict = {'enabled': True} + enriched[plugin_key] = entry + + if '@' not in plugin_key: + continue + + _plugin_name, marketplace_name = plugin_key.split('@', 1) + marketplace = marketplaces.get(marketplace_name) + if not marketplace: + continue + + plugin_path = _resolve_marketplace_path(marketplace) + if plugin_path is None: + continue + + metadata = _load_plugin_json_file(plugin_path, '.claude-plugin/plugin.json') or {} + for field in ('name', 'version', 'description'): + if field in metadata: + entry[field] = metadata[field] + + mcp_config = _load_plugin_json_file(plugin_path, '.mcp.json') or {} + plugin_server_names = [] + for server_name, server_cfg in (mcp_config.get('mcpServers') or {}).items(): + merged_mcp[server_name] = server_cfg + plugin_server_names.append(server_name) + if plugin_server_names: + entry['mcp_server_names'] = plugin_server_names + + return merged_mcp, enriched + + +# --- IDE integration ---------------------------------------------------------- + + +class ClaudeCode(IDE): + name: ClassVar[str] = 'claude-code' + display_name: ClassVar[str] = 'Claude Code' + hook_events: ClassVar[list[str]] = list(_HOOK_EVENTS) + + def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: + if scope == 'repo' and repo_path: + return repo_path / _REPO_SUBDIR / _HOOKS_FILE_NAME + return _USER_HOOKS_DIR / _HOOKS_FILE_NAME + + def render_hooks_config(self, async_mode: bool = False) -> dict: + # Claude Code uses a nested hook structure with optional async/timeout. + hook_entry: dict = {'type': 'command', 'command': _SCAN_COMMAND} + if async_mode: + hook_entry['async'] = True + hook_entry['timeout'] = 20 + + return { + 'hooks': { + 'SessionStart': [ + { + 'hooks': [{'type': 'command', 'command': _SESSION_START_COMMAND}], + } + ], + 'UserPromptSubmit': [ + { + 'hooks': [deepcopy(hook_entry)], + } + ], + 'PreToolUse': [ + { + 'matcher': 'Read', + 'hooks': [deepcopy(hook_entry)], + }, + { + 'matcher': 'mcp__.*', + 'hooks': [deepcopy(hook_entry)], + }, + ], + }, + } + + def matches_payload(self, raw_payload: dict) -> bool: + return raw_payload.get('hook_event_name', '') in _CLAUDE_CODE_EVENT_NAMES + + def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: + hook_event_name = raw_payload.get('hook_event_name', '') + tool_name = raw_payload.get('tool_name', '') + tool_input = raw_payload.get('tool_input') + + if hook_event_name == 'UserPromptSubmit': + canonical_event: AiHookEventType | str = AiHookEventType.PROMPT + elif hook_event_name == 'PreToolUse': + canonical_event = AiHookEventType.FILE_READ if tool_name == 'Read' else AiHookEventType.MCP_EXECUTION + else: + canonical_event = hook_event_name + + # Extract file_path from tool_input for the Read tool. + file_path = None + if tool_name == 'Read' and isinstance(tool_input, dict): + file_path = tool_input.get('file_path') + + # For MCP tools, the entire tool_input is the arguments. + mcp_arguments = tool_input if tool_name.startswith('mcp__') else None + + # MCP tool name format: mcp____ + mcp_server_name = None + mcp_tool_name = None + if tool_name.startswith('mcp__'): + parts = tool_name.split('__') + if len(parts) >= 2: + mcp_server_name = parts[1] + if len(parts) >= 3: + mcp_tool_name = parts[2] + + ide_version, model, generation_id = extract_from_claude_transcript(raw_payload.get('transcript_path')) + + config = load_claude_config() + ide_user_email = _email_from_config(config) if config else None + + return AIHookPayload( + event_name=canonical_event, + conversation_id=raw_payload.get('session_id'), + generation_id=generation_id, + ide_user_email=ide_user_email, + model=model, + ide_provider=self.name, + ide_version=ide_version, + prompt=raw_payload.get('prompt', ''), + file_path=file_path, + mcp_server_name=mcp_server_name, + mcp_tool_name=mcp_tool_name, + mcp_arguments=mcp_arguments, + ) + + def build_hook_response(self, decision: HookDecision) -> dict: + if decision.event_type == AiHookEventType.PROMPT: + if decision.action == DecisionAction.ALLOW: + return {} + # Both DENY and (unexpected) ASK on prompts collapse to a block. + return {'decision': 'block', 'reason': decision.user_message or ''} + + # FILE_READ / MCP_EXECUTION → hookSpecificOutput shape. + if decision.action == DecisionAction.ALLOW: + return { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'allow', + } + } + return { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': decision.action.value, # 'deny' or 'ask' + 'permissionDecisionReason': decision.user_message or '', + } + } + + def build_session_payload(self, raw_payload: dict) -> AIHookPayload: + config = load_claude_config() + ide_user_email = _email_from_config(config) if config else None + ide_version, _, _ = extract_from_claude_transcript(raw_payload.get('transcript_path')) + + return AIHookPayload( + conversation_id=raw_payload.get('session_id'), + ide_user_email=ide_user_email, + model=raw_payload.get('model'), + ide_provider=self.name, + ide_version=ide_version, + source=raw_payload.get('source'), + ) + + def get_user_email(self) -> Optional[str]: + config = load_claude_config() + return _email_from_config(config) if config else None + + def get_session_context(self) -> tuple[dict, dict]: + config = load_claude_config() + mcp_servers: dict = dict(get_mcp_servers(config) or {}) if config else {} + + settings = load_claude_settings() + if settings: + plugin_mcp, enriched_plugins = resolve_plugins(settings) + mcp_servers.update(plugin_mcp) + else: + enriched_plugins = {} + + return mcp_servers, enriched_plugins diff --git a/cycode/cli/apps/ai_guardrails/ides/cursor.py b/cycode/cli/apps/ai_guardrails/ides/cursor.py new file mode 100644 index 00000000..4f6be1eb --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/ides/cursor.py @@ -0,0 +1,119 @@ +"""Cursor IDE integration for AI guardrails.""" + +import json +import platform +from pathlib import Path +from typing import ClassVar, Optional + +from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND +from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails Cursor') + +_CURSOR_EVENT_MAPPING: dict[str, AiHookEventType] = { + 'beforeSubmitPrompt': AiHookEventType.PROMPT, + 'beforeReadFile': AiHookEventType.FILE_READ, + 'beforeMCPExecution': AiHookEventType.MCP_EXECUTION, +} + +_HOOKS_FILE_NAME = 'hooks.json' +_REPO_SUBDIR = '.cursor' +_MCP_CONFIG_FILENAME = 'mcp.json' + +# Cursor was the original default IDE — its scan command omits --ide to stay +# byte-identical with already-installed hooks.json files. Session-start is +# always explicit because it was introduced after Claude Code support. +_SCAN_COMMAND = CYCODE_SCAN_PROMPT_COMMAND +_SESSION_START_COMMAND = f'{CYCODE_SESSION_START_COMMAND} --ide cursor' + + +def _user_hooks_dir() -> Path: + """Per-platform Cursor user-scope settings directory.""" + if platform.system() == 'Darwin': + return Path.home() / '.cursor' + if platform.system() == 'Windows': + return Path.home() / 'AppData' / 'Roaming' / 'Cursor' + return Path.home() / '.config' / 'Cursor' + + +def _load_cursor_mcp_config(config_path: Optional[Path] = None) -> Optional[dict]: + """Load and parse `~/.cursor/mcp.json`. Returns None if missing/invalid.""" + path = config_path or (Path.home() / '.cursor' / _MCP_CONFIG_FILENAME) + if not path.exists(): + logger.debug('Cursor MCP config file not found', extra={'path': str(path)}) + return None + try: + return json.loads(path.read_text(encoding='utf-8')) + except Exception as e: + logger.debug('Failed to load Cursor MCP config file', exc_info=e) + return None + + +class Cursor(IDE): + name: ClassVar[str] = 'cursor' + display_name: ClassVar[str] = 'Cursor' + hook_events: ClassVar[list[str]] = list(_CURSOR_EVENT_MAPPING) + + def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: + if scope == 'repo' and repo_path: + return repo_path / _REPO_SUBDIR / _HOOKS_FILE_NAME + return _user_hooks_dir() / _HOOKS_FILE_NAME + + def render_hooks_config(self, async_mode: bool = False) -> dict: + command = f'{_SCAN_COMMAND} &' if async_mode else _SCAN_COMMAND + hooks = {event: [{'command': command}] for event in self.hook_events} + hooks['sessionStart'] = [{'command': _SESSION_START_COMMAND}] + return {'version': 1, 'hooks': hooks} + + def matches_payload(self, raw_payload: dict) -> bool: + return raw_payload.get('hook_event_name', '') in _CURSOR_EVENT_MAPPING + + def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: + cursor_event_name = raw_payload.get('hook_event_name', '') + canonical_event = _CURSOR_EVENT_MAPPING.get(cursor_event_name, cursor_event_name) + return AIHookPayload( + event_name=canonical_event, + conversation_id=raw_payload.get('conversation_id'), + generation_id=raw_payload.get('generation_id'), + ide_user_email=raw_payload.get('user_email'), + model=raw_payload.get('model'), + ide_provider=self.name, + ide_version=raw_payload.get('cursor_version'), + prompt=raw_payload.get('prompt', ''), + file_path=raw_payload.get('file_path') or raw_payload.get('path'), + mcp_server_name=raw_payload.get('command'), + mcp_tool_name=raw_payload.get('tool_name') or raw_payload.get('tool'), + mcp_arguments=(raw_payload.get('arguments') or raw_payload.get('tool_input') or raw_payload.get('input')), + ) + + def build_hook_response(self, decision: HookDecision) -> dict: + if decision.event_type == AiHookEventType.PROMPT: + if decision.action == DecisionAction.ALLOW: + return {'continue': True} + return {'continue': False, 'user_message': decision.user_message or ''} + + # FILE_READ / MCP_EXECUTION → permission shape + if decision.action == DecisionAction.ALLOW: + return {'permission': 'allow'} + return { + 'permission': decision.action.value, # 'deny' or 'ask' + 'user_message': decision.user_message or '', + 'agent_message': decision.agent_message or '', + } + + def build_session_payload(self, raw_payload: dict) -> AIHookPayload: + return AIHookPayload( + conversation_id=raw_payload.get('conversation_id'), + ide_user_email=raw_payload.get('user_email'), + model=raw_payload.get('model'), + ide_provider=self.name, + ide_version=raw_payload.get('cursor_version'), + ) + + def get_session_context(self) -> tuple[dict, dict]: + config = _load_cursor_mcp_config() + mcp_servers = dict((config or {}).get('mcpServers') or {}) if config else {} + return mcp_servers, {} diff --git a/cycode/cli/apps/ai_guardrails/install_command.py b/cycode/cli/apps/ai_guardrails/install_command.py index a92a978f..0ee5aacb 100644 --- a/cycode/cli/apps/ai_guardrails/install_command.py +++ b/cycode/cli/apps/ai_guardrails/install_command.py @@ -5,14 +5,10 @@ import typer -from cycode.cli.apps.ai_guardrails.command_utils import ( - console, - resolve_repo_path, - validate_and_parse_ide, - validate_scope, -) -from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS, AIIDEType, InstallMode, PolicyMode +from cycode.cli.apps.ai_guardrails.command_utils import console, resolve_repo_path, validate_scope +from cycode.cli.apps.ai_guardrails.consts import InstallMode, PolicyMode from cycode.cli.apps.ai_guardrails.hooks_manager import create_policy_file, install_hooks +from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, IDES, resolve_ides def install_command( @@ -29,9 +25,9 @@ def install_command( str, typer.Option( '--ide', - help='IDE to install hooks for (e.g., "cursor", "claude-code", or "all" for all IDEs). Defaults to cursor.', + help=f'IDE to install hooks for ({", ".join(IDES)}, or "all" for every supported IDE).', ), - ] = AIIDEType.CURSOR.value, + ] = DEFAULT_IDE_NAME, repo_path: Annotated[ Optional[Path], typer.Option( @@ -55,35 +51,30 @@ def install_command( ) -> None: """Install AI guardrails hooks for supported IDEs. - This command configures the specified IDE to use Cycode for scanning prompts, file reads, - and MCP tool calls for secrets before they are sent to AI models. + Configures the specified IDE to use Cycode for scanning prompts, file reads, + and MCP tool calls for secrets before they reach the AI model. Examples: cycode ai-guardrails install # Install in report mode (default) cycode ai-guardrails install --mode block # Install in block mode cycode ai-guardrails install --scope repo # Install for current repo only - cycode ai-guardrails install --ide cursor # Install for Cursor IDE - cycode ai-guardrails install --ide all # Install for all supported IDEs - cycode ai-guardrails install --scope repo --repo-path /path/to/repo + cycode ai-guardrails install --ide claude-code # Install for a specific IDE + cycode ai-guardrails install --ide all # Install for every supported IDE """ - # Validate inputs validate_scope(scope) repo_path = resolve_repo_path(scope, repo_path) - ide_type = validate_and_parse_ide(ide) + ides_to_install = resolve_ides(ide) - ides_to_install: list[AIIDEType] = list(AIIDEType) if ide_type is None else [ide_type] + report_mode = mode == InstallMode.REPORT results: list[tuple[str, bool, str]] = [] for current_ide in ides_to_install: - ide_name = IDE_CONFIGS[current_ide].name - report_mode = mode == InstallMode.REPORT - success, message = install_hooks(scope, repo_path, ide=current_ide, report_mode=report_mode) - results.append((ide_name, success, message)) + success, message = install_hooks(current_ide, scope, repo_path, report_mode=report_mode) + results.append((current_ide.display_name, success, message)) - # Report results for each IDE any_success = False all_success = True - for _ide_name, success, message in results: + for _name, success, message in results: if success: console.print(f'[green]✓[/] {message}') any_success = True diff --git a/cycode/cli/apps/ai_guardrails/scan/claude_config.py b/cycode/cli/apps/ai_guardrails/scan/claude_config.py deleted file mode 100644 index 4b547427..00000000 --- a/cycode/cli/apps/ai_guardrails/scan/claude_config.py +++ /dev/null @@ -1,159 +0,0 @@ -"""Reader for ~/.claude.json configuration file. - -Extracts user email from the Claude Code global config file -for use in AI guardrails scan enrichment. -""" - -import json -from pathlib import Path -from typing import Optional - -from cycode.logger import get_logger - -logger = get_logger('AI Guardrails Claude Config') - -_CLAUDE_CONFIG_PATH = Path.home() / '.claude.json' -_CLAUDE_SETTINGS_PATH = Path.home() / '.claude' / 'settings.json' - - -def load_claude_config(config_path: Optional[Path] = None) -> Optional[dict]: - """Load and parse ~/.claude.json. - - Args: - config_path: Override path for testing. Defaults to ~/.claude.json. - - Returns: - Parsed dict or None if file is missing or invalid. - """ - path = config_path or _CLAUDE_CONFIG_PATH - if not path.exists(): - logger.debug('Claude config file not found', extra={'path': str(path)}) - return None - try: - content = path.read_text(encoding='utf-8') - return json.loads(content) - except Exception as e: - logger.debug('Failed to load Claude config file', exc_info=e) - return None - - -def get_user_email(config: dict) -> Optional[str]: - """Extract user email from Claude config. - - Reads oauthAccount.emailAddress from the config dict. - """ - return config.get('oauthAccount', {}).get('emailAddress') - - -def get_mcp_servers(config: dict) -> Optional[dict]: - """Extract MCP servers from Claude config. - - Reads mcpServers from the config dict. - """ - return config.get('mcpServers') - - -def load_claude_settings(settings_path: Optional[Path] = None) -> Optional[dict]: - """Load and parse ~/.claude/settings.json. - - Args: - settings_path: Override path for testing. Defaults to ~/.claude/settings.json. - - Returns: - Parsed dict or None if file is missing or invalid. - """ - path = settings_path or _CLAUDE_SETTINGS_PATH - if not path.exists(): - logger.debug('Claude settings file not found', extra={'path': str(path)}) - return None - try: - content = path.read_text(encoding='utf-8') - return json.loads(content) - except Exception as e: - logger.debug('Failed to load Claude settings file', exc_info=e) - return None - - -def _resolve_marketplace_path(marketplace: dict) -> Optional[Path]: - """ - Resolve filesystem path for a directory-type marketplace. - """ - source = marketplace.get('source', {}) - if source.get('source') != 'directory': - return None - raw = source.get('path') - if not raw: - return None - path = Path(raw) - return path if path.is_dir() else None - - -def _load_plugin_json_file(plugin_path: Path, relative_path: str) -> Optional[dict]: - """Load and parse a JSON file inside a plugin directory. - - Returns None if the file is missing, unreadable, or has invalid JSON. - """ - target = plugin_path / relative_path - if not target.exists(): - return None - try: - return json.loads(target.read_text(encoding='utf-8')) - except Exception as e: - logger.debug('Failed to load plugin file', extra={'path': str(target)}, exc_info=e) - return None - - -def resolve_plugins(settings: dict) -> tuple[dict, dict]: - """Resolve enabled plugins to their MCP servers and metadata. - - Walks enabledPlugins from claude settings, resolves each plugin's 'marketplace' directory - via the 'extraKnownMarketplaces' field, and reads: - - /.mcp.json for MCP servers (merged into a flat dict) - - /.claude-plugin/plugin.json for metadata (name, version, description) - - Args: - settings: Parsed ~/.claude/settings.json dict. - - Returns: - Tuple of (merged_mcp_servers, enriched_plugins): - - merged_mcp_servers: {server_name: server_config, ...} - - enriched_plugins: {plugin_key: {"enabled": True, "name": ..., ...}, ...} - """ - enabled = settings.get('enabledPlugins') or {} - marketplaces = settings.get('extraKnownMarketplaces') or {} - merged_mcp: dict = {} - enriched: dict = {} - - for plugin_key, is_enabled in enabled.items(): - if not is_enabled: - continue - - entry: dict = {'enabled': True} - enriched[plugin_key] = entry - - if '@' not in plugin_key: - continue - - _plugin_name, marketplace_name = plugin_key.split('@', 1) - marketplace = marketplaces.get(marketplace_name) - if not marketplace: - continue - - plugin_path = _resolve_marketplace_path(marketplace) - if plugin_path is None: - continue - - metadata = _load_plugin_json_file(plugin_path, '.claude-plugin/plugin.json') or {} - for field in ('name', 'version', 'description'): - if field in metadata: - entry[field] = metadata[field] - - mcp_config = _load_plugin_json_file(plugin_path, '.mcp.json') or {} - plugin_server_names = [] - for server_name, server_cfg in (mcp_config.get('mcpServers') or {}).items(): - merged_mcp[server_name] = server_cfg - plugin_server_names.append(server_name) - if plugin_server_names: - entry['mcp_server_names'] = plugin_server_names - - return merged_mcp, enriched diff --git a/cycode/cli/apps/ai_guardrails/scan/cursor_config.py b/cycode/cli/apps/ai_guardrails/scan/cursor_config.py deleted file mode 100644 index 9a174a7a..00000000 --- a/cycode/cli/apps/ai_guardrails/scan/cursor_config.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Reader for ~/.cursor/mcp.json configuration file. - -Extracts MCP server definitions from the Cursor global config file -for use in AI guardrails session-context reporting. -""" - -import json -from pathlib import Path -from typing import Optional - -from cycode.logger import get_logger - -logger = get_logger('AI Guardrails Cursor Config') - -_CURSOR_MCP_CONFIG_PATH = Path.home() / '.cursor' / 'mcp.json' - - -def load_cursor_config(config_path: Optional[Path] = None) -> Optional[dict]: - """Load and parse ~/.cursor/mcp.json. - - Args: - config_path: Override path for testing. Defaults to ~/.cursor/mcp.json. - - Returns: - Parsed dict or None if file is missing or invalid. - """ - path = config_path or _CURSOR_MCP_CONFIG_PATH - if not path.exists(): - logger.debug('Cursor MCP config file not found', extra={'path': str(path)}) - return None - try: - content = path.read_text(encoding='utf-8') - return json.loads(content) - except Exception as e: - logger.debug('Failed to load Cursor MCP config file', exc_info=e) - return None diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index fa0bddee..4a56a179 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -1,8 +1,11 @@ -""" -Hook handlers for AI IDE events. +"""Hook handlers for AI IDE events. + +Each handler receives a unified payload and policy, applies the scan + policy +logic, and returns a canonical ``HookDecision``. ``scan_command`` translates +that decision into the IDE-specific JSON response via ``IDE.build_hook_response``. -Each handler receives a unified payload from an IDE, applies policy rules, -and returns a response that either allows or blocks the action. +Handlers are agent-agnostic by design — adding a new IDE doesn't require +touching any handler in this module. """ import json @@ -14,9 +17,9 @@ import typer from cycode.cli.apps.ai_guardrails.consts import PolicyMode +from cycode.cli.apps.ai_guardrails.ides.base import HookDecision from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value -from cycode.cli.apps.ai_guardrails.scan.response_builders import get_response_builder from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType, AIHookOutcome, BlockReason from cycode.cli.apps.ai_guardrails.scan.utils import is_denied_path, truncate_utf8 from cycode.cli.apps.scan.code_scanner import _get_scan_documents_thread_func @@ -30,21 +33,17 @@ logger = get_logger('AI Guardrails') -def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> dict: - """ - Handle beforeSubmitPrompt hook. +HandlerFn = Callable[[typer.Context, AIHookPayload, dict], HookDecision] - Scans prompt text for secrets before it's sent to the AI model. - Returns {"continue": False} to block, {"continue": True} to allow. - """ + +def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision: + """Scan prompt text for secrets before it's sent to the AI model.""" ai_client = ctx.obj['ai_security_client'] - ide = payload.ide_provider - response_builder = get_response_builder(ide) prompt_config = get_policy_value(policy, 'prompt', default={}) if not get_policy_value(prompt_config, 'enabled', default=True): ai_client.create_event(payload, AiHookEventType.PROMPT, AIHookOutcome.ALLOWED) - return response_builder.allow_prompt() + return HookDecision.allow(AiHookEventType.PROMPT) mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK) prompt = payload.prompt or '' @@ -66,9 +65,9 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli if action == PolicyMode.BLOCK and mode == PolicyMode.BLOCK: outcome = AIHookOutcome.BLOCKED user_message = f'{violation_summary}. Remove secrets before sending.' - return response_builder.deny_prompt(user_message) + return HookDecision.deny(AiHookEventType.PROMPT, user_message) outcome = AIHookOutcome.WARNED - return response_builder.allow_prompt() + return HookDecision.allow(AiHookEventType.PROMPT) except Exception as e: outcome = ( AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED @@ -87,21 +86,14 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli ) -def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> dict: - """ - Handle beforeReadFile hook. - - Blocks sensitive files (via deny_globs) and scans file content for secrets. - Returns {"permission": "deny"} to block, {"permission": "allow"} to allow. - """ +def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision: + """Block sensitive paths and scan file content for secrets.""" ai_client = ctx.obj['ai_security_client'] - ide = payload.ide_provider - response_builder = get_response_builder(ide) file_read_config = get_policy_value(policy, 'file_read', default={}) if not get_policy_value(file_read_config, 'enabled', default=True): ai_client.create_event(payload, AiHookEventType.FILE_READ, AIHookOutcome.ALLOWED) - return response_builder.allow_permission() + return HookDecision.allow(AiHookEventType.FILE_READ) mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK) file_path = payload.file_path or '' @@ -113,20 +105,19 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: error_message = None try: - # Check path-based denylist first is_sensitive_path = is_denied_path(file_path, policy) if is_sensitive_path: block_reason = BlockReason.SENSITIVE_PATH if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK: outcome = AIHookOutcome.BLOCKED user_message = f'Cycode blocked sending {file_path} to the AI (sensitive path policy).' - return response_builder.deny_permission( + return HookDecision.deny( + AiHookEventType.FILE_READ, user_message, 'This file path is classified as sensitive; do not read/send it to the model.', ) - # Warn mode - if content scan is enabled, emit a separate event for the + # Warn mode: if content scan is enabled, emit a separate event for the # sensitive path so the finally block can independently track the scan result. - # If content scan is disabled, a single event (from finally) is enough. outcome = AIHookOutcome.WARNED if get_policy_value(file_read_config, 'scan_content', default=True): ai_client.create_event( @@ -136,11 +127,9 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: block_reason=BlockReason.SENSITIVE_PATH, file_path=payload.file_path, ) - # Reset for the content scan result tracked by the finally block block_reason = None outcome = AIHookOutcome.ALLOWED - # Scan file content if enabled if get_policy_value(file_read_config, 'scan_content', default=True): violation_summary, scan_id = _scan_path_for_secrets(ctx, file_path, policy) if violation_summary: @@ -148,27 +137,28 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK: outcome = AIHookOutcome.BLOCKED user_message = f'Cycode blocked reading {file_path}. {violation_summary}' - return response_builder.deny_permission( + return HookDecision.deny( + AiHookEventType.FILE_READ, user_message, 'Secrets detected; do not send this file to the model.', ) - # Warn mode - ask user for permission outcome = AIHookOutcome.WARNED user_message = f'Cycode detected secrets in {file_path}. {violation_summary}' - return response_builder.ask_permission( + return HookDecision.ask( + AiHookEventType.FILE_READ, user_message, 'Possible secrets detected; proceed with caution.', ) - # If path was sensitive but content scan found no secrets (or scan disabled), still warn if is_sensitive_path: user_message = f'Cycode flagged {file_path} as sensitive. Allow reading?' - return response_builder.ask_permission( + return HookDecision.ask( + AiHookEventType.FILE_READ, user_message, 'This file path is classified as sensitive; proceed with caution.', ) - return response_builder.allow_permission() + return HookDecision.allow(AiHookEventType.FILE_READ) except Exception as e: outcome = ( AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED @@ -188,22 +178,14 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: ) -def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> dict: - """ - Handle beforeMCPExecution hook. - - Scans tool arguments for secrets before MCP tool execution. - Returns {"permission": "deny"} to block, {"permission": "ask"} to warn, - {"permission": "allow"} to allow. - """ +def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision: + """Scan MCP tool arguments for secrets before execution.""" ai_client = ctx.obj['ai_security_client'] - ide = payload.ide_provider - response_builder = get_response_builder(ide) mcp_config = get_policy_value(policy, 'mcp', default={}) if not get_policy_value(mcp_config, 'enabled', default=True): ai_client.create_event(payload, AiHookEventType.MCP_EXECUTION, AIHookOutcome.ALLOWED) - return response_builder.allow_permission() + return HookDecision.allow(AiHookEventType.MCP_EXECUTION) mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK) tool = payload.mcp_tool_name or 'unknown' @@ -227,17 +209,19 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK: outcome = AIHookOutcome.BLOCKED user_message = f'Cycode blocked MCP tool call "{tool}". {violation_summary}' - return response_builder.deny_permission( + return HookDecision.deny( + AiHookEventType.MCP_EXECUTION, user_message, 'Do not pass secrets to tools. Use secret references (name/id) instead.', ) outcome = AIHookOutcome.WARNED - return response_builder.ask_permission( + return HookDecision.ask( + AiHookEventType.MCP_EXECUTION, f'{violation_summary} in MCP tool call "{tool}". Allow execution?', 'Possible secrets detected in tool arguments; proceed with caution.', ) - return response_builder.allow_permission() + return HookDecision.allow(AiHookEventType.MCP_EXECUTION) except Exception as e: outcome = ( AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED @@ -256,16 +240,9 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli ) -def get_handler_for_event(event_type: str) -> Optional[Callable[[typer.Context, AIHookPayload, dict], dict]]: - """Get the appropriate handler function for a canonical event type. - - Args: - event_type: Canonical event type string (from AiHookEventType enum) - - Returns: - Handler function or None if event type is not recognized - """ - handlers = { +def get_handler_for_event(event_type: str) -> Optional[HandlerFn]: + """Look up the handler for a canonical event type.""" + handlers: dict[str, HandlerFn] = { AiHookEventType.PROMPT.value: handle_before_submit_prompt, AiHookEventType.FILE_READ.value: handle_before_read_file, AiHookEventType.MCP_EXECUTION.value: handle_before_mcp_execution, @@ -275,32 +252,24 @@ def get_handler_for_event(event_type: str) -> Optional[Callable[[typer.Context, def _setup_scan_context(ctx: typer.Context) -> typer.Context: """Set up minimal context for scan_documents without progress bars or printing.""" - - # Set up minimal required context ctx.obj['progress_bar'] = DummyProgressBar([ScanProgressBarSection]) - ctx.obj['sync'] = True # Synchronous scan - ctx.obj['scan_type'] = ScanTypeOption.SECRET # AI guardrails always scans for secrets - ctx.obj['severity_threshold'] = SeverityOption.INFO # Report all severities - - # Set command name for scan logic + ctx.obj['sync'] = True + ctx.obj['scan_type'] = ScanTypeOption.SECRET + ctx.obj['severity_threshold'] = SeverityOption.INFO ctx.info_name = 'ai_guardrails' - return ctx def _perform_scan( ctx: typer.Context, documents: list[Document], scan_parameters: dict, timeout_seconds: float ) -> tuple[Optional[str], Optional[str]]: - """ - Perform a scan on documents and extract results. + """Run a scan on documents, returning (violation_summary, scan_id). - Returns tuple of (violation_summary, scan_id) if secrets found, (None, scan_id) if clean. - Raises exception if scan fails or times out (triggers fail_open policy). + Raises on scan failure / timeout so the fail-open policy can take over. """ if not documents: return None, None - # Get the thread function for scanning scan_batch_thread_func = _get_scan_documents_thread_func( ctx, is_git_diff=False, is_commit_range=False, scan_parameters=scan_parameters ) @@ -324,7 +293,6 @@ def _perform_scan( scan_id = local_scan_result.scan_id - # Check if there are any detections if local_scan_result.detections_count > 0: violation_summary = build_violation_summary([local_scan_result]) return violation_summary, scan_id @@ -333,12 +301,7 @@ def _perform_scan( def _scan_text_for_secrets(ctx: typer.Context, text: str, timeout_ms: int) -> tuple[Optional[str], Optional[str]]: - """ - Scan text content for secrets using Cycode CLI. - - Returns tuple of (violation_summary, scan_id) if secrets found, (None, scan_id) if clean. - Raises exception on error or timeout. - """ + """Scan text content for secrets using Cycode CLI.""" if not text: return None, None @@ -349,12 +312,7 @@ def _scan_text_for_secrets(ctx: typer.Context, text: str, timeout_ms: int) -> tu def _scan_path_for_secrets(ctx: typer.Context, file_path: str, policy: dict) -> tuple[Optional[str], Optional[str]]: - """ - Scan a file path for secrets. - - Returns tuple of (violation_summary, scan_id) if secrets found, (None, scan_id) if clean. - Raises exception on error or timeout. - """ + """Scan a file path for secrets.""" if not file_path or not os.path.isfile(file_path): return None, None @@ -363,7 +321,6 @@ def _scan_path_for_secrets(ctx: typer.Context, file_path: str, policy: dict) -> with open(file_path, encoding='utf-8', errors='replace') as f: content = f.read(max_bytes) - # Get timeout from policy timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000) timeout_seconds = timeout_ms / 1000.0 diff --git a/cycode/cli/apps/ai_guardrails/scan/payload.py b/cycode/cli/apps/ai_guardrails/scan/payload.py index d8fd4c53..19845601 100644 --- a/cycode/cli/apps/ai_guardrails/scan/payload.py +++ b/cycode/cli/apps/ai_guardrails/scan/payload.py @@ -1,275 +1,34 @@ -"""Unified payload object for AI hook events from different tools.""" +"""Canonical AI hook payload shared across IDE integrations. + +The dataclass is populated by `IDE.parse_hook_payload` (see +`cycode/cli/apps/ai_guardrails/ides/`). Per-IDE parsing logic lives on the +respective IDE class. +""" -import json -from collections.abc import Iterator from dataclasses import dataclass -from pathlib import Path from typing import Optional -from cycode.cli.apps.ai_guardrails.consts import AIIDEType -from cycode.cli.apps.ai_guardrails.scan.claude_config import get_user_email, load_claude_config -from cycode.cli.apps.ai_guardrails.scan.types import ( - CLAUDE_CODE_EVENT_MAPPING, - CLAUDE_CODE_EVENT_NAMES, - CURSOR_EVENT_MAPPING, - CURSOR_EVENT_NAMES, - AiHookEventType, -) - - -def _reverse_readline(path: Path, buf_size: int = 8192) -> Iterator[str]: - """Read a file line by line from the end without loading entire file into memory. - - Yields lines in reverse order (last line first). - """ - with path.open('rb') as f: - f.seek(0, 2) # Seek to end - file_size = f.tell() - if file_size == 0: - return - - remaining = file_size - buffer = b'' - - while remaining > 0: - # Read a chunk from the end - read_size = min(buf_size, remaining) - remaining -= read_size - f.seek(remaining) - chunk = f.read(read_size) - buffer = chunk + buffer - - # Yield complete lines from buffer - while b'\n' in buffer: - # Find the last newline - newline_pos = buffer.rfind(b'\n') - if newline_pos == len(buffer) - 1: - # Trailing newline, look for previous one - newline_pos = buffer.rfind(b'\n', 0, newline_pos) - if newline_pos == -1: - break - # Yield the line after this newline - line = buffer[newline_pos + 1 :] - buffer = buffer[: newline_pos + 1] - if line.strip(): - yield line.decode('utf-8', errors='replace') - - # Yield any remaining content as the first line of the file - if buffer.strip(): - yield buffer.decode('utf-8', errors='replace') - - -def _extract_model(entry: dict) -> Optional[str]: - """Extract model from a transcript entry (top level or nested in message).""" - return entry.get('model') or (entry.get('message') or {}).get('model') - - -def _extract_generation_id(entry: dict) -> Optional[str]: - """Extract generation ID from a user-type transcript entry.""" - if entry.get('type') == 'user': - return entry.get('uuid') - return None - - -def extract_from_claude_transcript( - transcript_path: str, -) -> tuple[Optional[str], Optional[str], Optional[str]]: - """Extract IDE version, model, and latest generation ID from Claude Code transcript file. - - The transcript is a JSONL file where each line is a JSON object. - We look for 'version' (IDE version), 'model', and 'uuid' (generation ID) fields. - The generation_id is the UUID of the latest 'user' type message. - - Scans from end to start since latest entries are at the end. - Uses reverse reading to avoid loading entire file into memory. - - Returns: - Tuple of (ide_version, model, generation_id), any may be None if not found. - """ - if not transcript_path: - return None, None, None - - path = Path(transcript_path) - if not path.exists(): - return None, None, None - - ide_version = None - model = None - generation_id = None - - try: - for line in _reverse_readline(path): - line = line.strip() - if not line: - continue - try: - entry = json.loads(line) - ide_version = ide_version or entry.get('version') - model = model or _extract_model(entry) - generation_id = generation_id or _extract_generation_id(entry) - - if ide_version and model and generation_id: - break - except json.JSONDecodeError: - continue - except OSError: - pass - - return ide_version, model, generation_id - @dataclass class AIHookPayload: - """Unified payload object that normalizes field names from different AI tools.""" + """Unified payload that normalizes field names across IDEs.""" # Event identification - event_name: Optional[str] = None # Canonical event type (e.g., 'prompt', 'file_read', 'mcp_execution') + event_name: Optional[str] = None # Canonical event type from AiHookEventType conversation_id: Optional[str] = None generation_id: Optional[str] = None # User and IDE information ide_user_email: Optional[str] = None model: Optional[str] = None - ide_provider: str = None # AIIDEType value (e.g., 'cursor', 'claude-code') + ide_provider: Optional[str] = None # Matches IDE.name (e.g. 'cursor', 'claude-code') ide_version: Optional[str] = None source: Optional[str] = None # Event-specific data - prompt: Optional[str] = None # For prompt events - file_path: Optional[str] = None # For file_read events - mcp_server_name: Optional[str] = None # For mcp_execution events - mcp_tool_name: Optional[str] = None # For mcp_execution events - mcp_arguments: Optional[dict] = None # For mcp_execution events - - @classmethod - def from_cursor_payload(cls, payload: dict) -> 'AIHookPayload': - """Create AIHookPayload from Cursor IDE payload. - - Maps Cursor-specific event names to canonical event types. - """ - cursor_event_name = payload.get('hook_event_name', '') - # Map Cursor event name to canonical type, fallback to original if not found - canonical_event = CURSOR_EVENT_MAPPING.get(cursor_event_name, cursor_event_name) - - return cls( - event_name=canonical_event, - conversation_id=payload.get('conversation_id'), - generation_id=payload.get('generation_id'), - ide_user_email=payload.get('user_email'), - model=payload.get('model'), - ide_provider=AIIDEType.CURSOR.value, - ide_version=payload.get('cursor_version'), - prompt=payload.get('prompt', ''), - file_path=payload.get('file_path') or payload.get('path'), - mcp_server_name=payload.get('command'), # MCP server name - mcp_tool_name=payload.get('tool_name') or payload.get('tool'), - mcp_arguments=payload.get('arguments') or payload.get('tool_input') or payload.get('input'), - ) - - @classmethod - def from_claude_code_payload(cls, payload: dict) -> 'AIHookPayload': - """Create AIHookPayload from Claude Code IDE payload. - - Claude Code has a different structure: - - hook_event_name: 'UserPromptSubmit' or 'PreToolUse' - - For PreToolUse: tool_name determines if it's file read ('Read') or MCP ('mcp__*') - - tool_input contains tool arguments (e.g., file_path for Read tool) - - transcript_path points to JSONL file with version and model info - """ - hook_event_name = payload.get('hook_event_name', '') - tool_name = payload.get('tool_name', '') - tool_input = payload.get('tool_input') - - if hook_event_name == 'UserPromptSubmit': - canonical_event = AiHookEventType.PROMPT - elif hook_event_name == 'PreToolUse': - canonical_event = AiHookEventType.FILE_READ if tool_name == 'Read' else AiHookEventType.MCP_EXECUTION - else: - # Unknown event, use the raw event name - canonical_event = CLAUDE_CODE_EVENT_MAPPING.get(hook_event_name, hook_event_name) - - # Extract file_path from tool_input for Read tool - file_path = None - if tool_name == 'Read' and isinstance(tool_input, dict): - file_path = tool_input.get('file_path') - - # For MCP tools, the entire tool_input is the arguments - mcp_arguments = tool_input if tool_name.startswith('mcp__') else None - - # Extract MCP server and tool name from tool_name (format: mcp____) - mcp_server_name = None - mcp_tool_name = None - if tool_name.startswith('mcp__'): - parts = tool_name.split('__') - if len(parts) >= 2: - mcp_server_name = parts[1] - if len(parts) >= 3: - mcp_tool_name = parts[2] - - # Extract IDE version, model, and generation ID from transcript file - ide_version, model, generation_id = extract_from_claude_transcript(payload.get('transcript_path')) - - # Extract user email from ~/.claude.json - claude_config = load_claude_config() - ide_user_email = get_user_email(claude_config) if claude_config else None - - return cls( - event_name=canonical_event, - conversation_id=payload.get('session_id'), - generation_id=generation_id, - ide_user_email=ide_user_email, - model=model, - ide_provider=AIIDEType.CLAUDE_CODE.value, - ide_version=ide_version, - prompt=payload.get('prompt', ''), - file_path=file_path, - mcp_server_name=mcp_server_name, - mcp_tool_name=mcp_tool_name, - mcp_arguments=mcp_arguments, - ) - - @staticmethod - def is_payload_for_ide(payload: dict, ide: str) -> bool: - """Check if the payload's event name matches the expected IDE. - - This prevents double-processing when Cursor reads Claude Code hooks - or vice versa. If the payload's hook_event_name doesn't match the - expected IDE's event names, we should skip processing. - - Args: - payload: The raw payload from the IDE - ide: The IDE name or AIIDEType enum value - - Returns: - True if the payload matches the IDE, False otherwise. - """ - hook_event_name = payload.get('hook_event_name', '') - - if ide == AIIDEType.CLAUDE_CODE: - return hook_event_name in CLAUDE_CODE_EVENT_NAMES - if ide == AIIDEType.CURSOR: - return hook_event_name in CURSOR_EVENT_NAMES - - # Unknown IDE, allow processing - return True - - @classmethod - def from_payload(cls, payload: dict, tool: str = AIIDEType.CURSOR.value) -> 'AIHookPayload': - """Create AIHookPayload from any tool's payload. - - Args: - payload: The raw payload from the IDE - tool: The IDE/tool name or AIIDEType enum value - - Returns: - AIHookPayload instance - - Raises: - ValueError: If the tool is not supported - """ - if tool == AIIDEType.CURSOR: - return cls.from_cursor_payload(payload) - if tool == AIIDEType.CLAUDE_CODE: - return cls.from_claude_code_payload(payload) - raise ValueError(f'Unsupported IDE/tool: {tool}') + prompt: Optional[str] = None # PROMPT events + file_path: Optional[str] = None # FILE_READ events + mcp_server_name: Optional[str] = None # MCP_EXECUTION events + mcp_tool_name: Optional[str] = None + mcp_arguments: Optional[dict] = None diff --git a/cycode/cli/apps/ai_guardrails/scan/response_builders.py b/cycode/cli/apps/ai_guardrails/scan/response_builders.py deleted file mode 100644 index ff0a6aa4..00000000 --- a/cycode/cli/apps/ai_guardrails/scan/response_builders.py +++ /dev/null @@ -1,135 +0,0 @@ -""" -Response builders for different AI IDE hooks. - -Each IDE has its own response format for hooks. This module provides -an abstract interface and concrete implementations for each supported IDE. -""" - -from abc import ABC, abstractmethod - -from cycode.cli.apps.ai_guardrails.consts import AIIDEType - - -class IDEResponseBuilder(ABC): - """Abstract base class for IDE-specific response builders.""" - - @abstractmethod - def allow_permission(self) -> dict: - """Build response to allow file read or MCP execution.""" - - @abstractmethod - def deny_permission(self, user_message: str, agent_message: str) -> dict: - """Build response to deny file read or MCP execution.""" - - @abstractmethod - def ask_permission(self, user_message: str, agent_message: str) -> dict: - """Build response to ask user for permission (warn mode).""" - - @abstractmethod - def allow_prompt(self) -> dict: - """Build response to allow prompt submission.""" - - @abstractmethod - def deny_prompt(self, user_message: str) -> dict: - """Build response to deny prompt submission.""" - - -class CursorResponseBuilder(IDEResponseBuilder): - """Response builder for Cursor IDE hooks. - - Cursor hook response formats: - - beforeSubmitPrompt: {"continue": bool, "user_message": str} - - beforeReadFile: {"permission": str, "user_message": str, "agent_message": str} - - beforeMCPExecution: {"permission": str, "user_message": str, "agent_message": str} - """ - - def allow_permission(self) -> dict: - """Allow file read or MCP execution.""" - return {'permission': 'allow'} - - def deny_permission(self, user_message: str, agent_message: str) -> dict: - """Deny file read or MCP execution.""" - return {'permission': 'deny', 'user_message': user_message, 'agent_message': agent_message} - - def ask_permission(self, user_message: str, agent_message: str) -> dict: - """Ask user for permission (warn mode).""" - return {'permission': 'ask', 'user_message': user_message, 'agent_message': agent_message} - - def allow_prompt(self) -> dict: - """Allow prompt submission.""" - return {'continue': True} - - def deny_prompt(self, user_message: str) -> dict: - """Deny prompt submission.""" - return {'continue': False, 'user_message': user_message} - - -class ClaudeCodeResponseBuilder(IDEResponseBuilder): - """Response builder for Claude Code IDE hooks. - - Claude Code hook response formats: - - UserPromptSubmit: {} for allow, {"decision": "block", "reason": str} for deny - - PreToolUse: hookSpecificOutput with permissionDecision (allow/deny/ask) - """ - - def allow_permission(self) -> dict: - """Allow file read or MCP execution.""" - return { - 'hookSpecificOutput': { - 'hookEventName': 'PreToolUse', - 'permissionDecision': 'allow', - } - } - - def deny_permission(self, user_message: str, agent_message: str) -> dict: - """Deny file read or MCP execution.""" - return { - 'hookSpecificOutput': { - 'hookEventName': 'PreToolUse', - 'permissionDecision': 'deny', - 'permissionDecisionReason': user_message, - } - } - - def ask_permission(self, user_message: str, agent_message: str) -> dict: - """Ask user for permission (warn mode).""" - return { - 'hookSpecificOutput': { - 'hookEventName': 'PreToolUse', - 'permissionDecision': 'ask', - 'permissionDecisionReason': user_message, - } - } - - def allow_prompt(self) -> dict: - """Allow prompt submission (empty response means allow).""" - return {} - - def deny_prompt(self, user_message: str) -> dict: - """Deny prompt submission.""" - return {'decision': 'block', 'reason': user_message} - - -# Registry of response builders by IDE type -_RESPONSE_BUILDERS: dict[str, IDEResponseBuilder] = { - AIIDEType.CURSOR: CursorResponseBuilder(), - AIIDEType.CLAUDE_CODE: ClaudeCodeResponseBuilder(), -} - - -def get_response_builder(ide: str = AIIDEType.CURSOR.value) -> IDEResponseBuilder: - """Get the response builder for a specific IDE. - - Args: - ide: The IDE name (e.g., 'cursor', 'claude-code') or AIIDEType enum - - Returns: - IDEResponseBuilder instance for the specified IDE - - Raises: - ValueError: If the IDE is not supported - """ - builder = _RESPONSE_BUILDERS.get(ide.lower()) - if not builder: - raise ValueError(f'Unsupported IDE: {ide}. Supported IDEs: {list(_RESPONSE_BUILDERS.keys())}') - return builder diff --git a/cycode/cli/apps/ai_guardrails/scan/scan_command.py b/cycode/cli/apps/ai_guardrails/scan/scan_command.py index add2bb83..bd31d33e 100644 --- a/cycode/cli/apps/ai_guardrails/scan/scan_command.py +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -1,26 +1,22 @@ -""" -Scan command for AI guardrails. +"""Scan command for AI guardrails IDE hooks. -This command handles AI IDE hooks by reading JSON from stdin and outputting -a JSON response to stdout. It scans prompts, file reads, and MCP tool calls -for secrets before they are sent to AI models. +Reads a JSON payload from stdin, routes it through the IDE-specific parser and +the shared event handlers, then writes an IDE-specific JSON response to stdout. -Supports multiple IDEs with different hook event types. The specific hook events -supported depend on the IDE being used (e.g., Cursor supports beforeSubmitPrompt, -beforeReadFile, beforeMCPExecution). +The handlers in ``handlers.py`` are agent-agnostic (they return +``HookDecision``); ``IDE.build_hook_response`` is the per-IDE translation step. """ import sys -from typing import Annotated +from typing import Annotated, Optional, Union import click import typer -from cycode.cli.apps.ai_guardrails.consts import AIIDEType +from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, get_ide +from cycode.cli.apps.ai_guardrails.ides.base import HookDecision from cycode.cli.apps.ai_guardrails.scan.handlers import get_handler_for_event -from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.policy import load_policy -from cycode.cli.apps.ai_guardrails.scan.response_builders import get_response_builder from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType from cycode.cli.apps.ai_guardrails.scan.utils import output_json, safe_json_parse from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError @@ -31,7 +27,7 @@ def _get_auth_error_message(error: Exception) -> str: - """Get user-friendly message for authentication errors.""" + """User-friendly message for authentication errors.""" if isinstance(error, click.ClickException): # Missing credentials return f'{error.message} Please run `cycode auth` to set up your credentials.' @@ -47,6 +43,23 @@ def _get_auth_error_message(error: Exception) -> str: return 'Authentication failed. Please run `cycode auth` to set up your credentials.' +def _deny_for_event( + event_name: Optional[Union[str, AiHookEventType]], + user_message: str, + agent_message: Optional[str] = None, +) -> HookDecision: + """Build a deny decision matched to ``event_name``'s response shape. + + PROMPT events use the prompt-block shape (no agent_message). For anything + else — including unknown event names — fall back to FILE_READ since + FILE_READ and MCP_EXECUTION share the same response shape on both IDEs. + """ + if event_name == AiHookEventType.PROMPT: + return HookDecision.deny(AiHookEventType.PROMPT, user_message) + target = event_name if isinstance(event_name, AiHookEventType) else AiHookEventType.FILE_READ + return HookDecision.deny(target, user_message, agent_message) + + def _initialize_clients(ctx: typer.Context) -> None: """Initialize API clients. @@ -69,44 +82,36 @@ def scan_command( help='IDE that sent the payload (e.g., "cursor"). Defaults to cursor.', hidden=True, ), - ] = AIIDEType.CURSOR.value, + ] = DEFAULT_IDE_NAME, ) -> None: """Scan content from AI IDE hooks for secrets. - This command reads a JSON payload from stdin containing hook event data - and outputs a JSON response to stdout indicating whether to allow or block the action. - - The hook event type is determined from the event field in the payload (field name - varies by IDE). Each IDE may support different hook events for scanning prompts, - file access, and tool executions. - - Example usage (from IDE hooks configuration): - { "command": "cycode ai-guardrails scan" } + Reads a JSON payload from stdin and outputs a JSON response to stdout + indicating whether to allow or block the action. """ + ide_integration = get_ide(ide) + stdin_data = sys.stdin.read().strip() payload = safe_json_parse(stdin_data) - tool = ide.lower() - response_builder = get_response_builder(tool) - if not payload: logger.debug('Empty or invalid JSON payload received') - output_json(response_builder.allow_prompt()) + output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT))) return - # Check if the payload matches the expected IDE - prevents double-processing - # when Cursor reads Claude Code hooks from ~/.claude/settings.json - if not AIHookPayload.is_payload_for_ide(payload, tool): + # Prevent cross-IDE processing (e.g. Cursor reading Claude Code hooks + # from ~/.claude/settings.json). + if not ide_integration.matches_payload(payload): logger.debug( 'Payload event does not match expected IDE, skipping', - extra={'hook_event_name': payload.get('hook_event_name'), 'expected_ide': tool}, + extra={'hook_event_name': payload.get('hook_event_name'), 'expected_ide': ide_integration.name}, ) - output_json(response_builder.allow_prompt()) + output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT))) return - unified_payload = AIHookPayload.from_payload(payload, tool=tool) + unified_payload = ide_integration.parse_hook_payload(payload) event_name = unified_payload.event_name - logger.debug('Processing AI guardrails hook', extra={'event_name': event_name, 'tool': tool}) + logger.debug('Processing AI guardrails hook', extra={'event_name': event_name, 'ide': ide_integration.name}) workspace_roots = payload.get('workspace_roots', ['.']) policy = load_policy(workspace_roots[0]) @@ -117,26 +122,33 @@ def scan_command( handler = get_handler_for_event(event_name) if handler is None: logger.debug('Unknown hook event, allowing by default', extra={'event_name': event_name}) - output_json(response_builder.allow_prompt()) + output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT))) return - response = handler(ctx, unified_payload, policy) - logger.debug('Hook handler completed', extra={'event_name': event_name, 'response': response}) - output_json(response) + decision = handler(ctx, unified_payload, policy) + logger.debug('Hook handler completed', extra={'event_name': event_name, 'action': decision.action.value}) + output_json(ide_integration.build_hook_response(decision)) except (click.ClickException, HttpUnauthorizedError) as e: - error_message = _get_auth_error_message(e) - if event_name == AiHookEventType.PROMPT: - output_json(response_builder.deny_prompt(error_message)) - return - output_json(response_builder.deny_permission(error_message, 'Authentication required')) + output_json( + ide_integration.build_hook_response( + _deny_for_event(event_name, _get_auth_error_message(e), 'Authentication required') + ) + ) except Exception as e: logger.error('Hook handler failed', exc_info=e) if policy.get('fail_open', True): - output_json(response_builder.allow_prompt()) - return - if event_name == AiHookEventType.PROMPT: - output_json(response_builder.deny_prompt('Cycode guardrails error - blocking due to fail-closed policy')) + output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT))) return - output_json(response_builder.deny_permission('Cycode guardrails error', 'Blocking due to fail-closed policy')) + output_json( + ide_integration.build_hook_response( + _deny_for_event( + event_name, + 'Cycode guardrails error - blocking due to fail-closed policy' + if event_name == AiHookEventType.PROMPT + else 'Cycode guardrails error', + 'Blocking due to fail-closed policy', + ) + ) + ) diff --git a/cycode/cli/apps/ai_guardrails/scan/types.py b/cycode/cli/apps/ai_guardrails/scan/types.py index 585c7820..da42ed23 100644 --- a/cycode/cli/apps/ai_guardrails/scan/types.py +++ b/cycode/cli/apps/ai_guardrails/scan/types.py @@ -1,4 +1,9 @@ -"""Type definitions for AI guardrails.""" +"""Canonical event types and outcome enums for AI guardrails. + +Per-IDE event-name mappings live on the IDE class (in +`cycode/cli/apps/ai_guardrails/ides/`); only the IDE-agnostic enums are kept +here. +""" import sys @@ -13,36 +18,13 @@ def __str__(self) -> str: class AiHookEventType(StrEnum): - """Canonical event types for AI guardrails. - - These are IDE-agnostic event types. Each IDE's specific event names - are mapped to these canonical types using the mapping dictionaries below. - """ + """Canonical, IDE-agnostic hook event types.""" PROMPT = 'Prompt' FILE_READ = 'FileRead' MCP_EXECUTION = 'McpExecution' -# IDE-specific event name mappings to canonical types -CURSOR_EVENT_MAPPING = { - 'beforeSubmitPrompt': AiHookEventType.PROMPT, - 'beforeReadFile': AiHookEventType.FILE_READ, - 'beforeMCPExecution': AiHookEventType.MCP_EXECUTION, -} - -# Claude Code event mapping - note that PreToolUse requires tool_name inspection -# to determine the actual event type (file read vs MCP execution) -CLAUDE_CODE_EVENT_MAPPING = { - 'UserPromptSubmit': AiHookEventType.PROMPT, - 'PreToolUse': None, # Requires tool_name inspection to determine actual type -} - -# Set of known event names per IDE (for IDE detection) -CURSOR_EVENT_NAMES = set(CURSOR_EVENT_MAPPING.keys()) -CLAUDE_CODE_EVENT_NAMES = set(CLAUDE_CODE_EVENT_MAPPING.keys()) - - class AIHookOutcome(StrEnum): """Outcome of an AI hook event evaluation.""" @@ -52,11 +34,7 @@ class AIHookOutcome(StrEnum): class BlockReason(StrEnum): - """Reason why an AI hook event was blocked. - - These are categorical reasons sent to the backend for tracking/analytics, - separate from the detailed user-facing messages. - """ + """Categorical reason for blocking (sent to backend for tracking).""" SECRETS_IN_PROMPT = 'secrets_in_prompt' SECRETS_IN_FILE = 'secrets_in_file' diff --git a/cycode/cli/apps/ai_guardrails/session_start_command.py b/cycode/cli/apps/ai_guardrails/session_start_command.py index f2d5031c..cda53c62 100644 --- a/cycode/cli/apps/ai_guardrails/session_start_command.py +++ b/cycode/cli/apps/ai_guardrails/session_start_command.py @@ -1,18 +1,12 @@ +"""Handle AI guardrails session start: auth, conversation creation, session context.""" + import sys from typing import TYPE_CHECKING, Annotated, Optional import typer -from cycode.cli.apps.ai_guardrails.consts import AIIDEType -from cycode.cli.apps.ai_guardrails.scan.claude_config import ( - get_mcp_servers, - get_user_email, - load_claude_config, - load_claude_settings, - resolve_plugins, -) -from cycode.cli.apps.ai_guardrails.scan.cursor_config import load_cursor_config -from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload, extract_from_claude_transcript +from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, get_ide +from cycode.cli.apps.ai_guardrails.ides.base import IDE from cycode.cli.apps.ai_guardrails.scan.utils import safe_json_parse from cycode.cli.apps.auth.auth_common import get_authorization_info from cycode.cli.apps.auth.auth_manager import AuthManager @@ -26,69 +20,10 @@ logger = get_logger('AI Guardrails') -def _build_session_payload(payload: dict, ide: str) -> AIHookPayload: - """Build an AIHookPayload from a session-start stdin payload.""" - if ide == AIIDEType.CLAUDE_CODE: - claude_config = load_claude_config() - ide_user_email = get_user_email(claude_config) if claude_config else None - ide_version, _, _ = extract_from_claude_transcript(payload.get('transcript_path')) - - return AIHookPayload( - conversation_id=payload.get('session_id'), - ide_user_email=ide_user_email, - model=payload.get('model'), - ide_provider=AIIDEType.CLAUDE_CODE.value, - ide_version=ide_version, - source=payload.get('source'), - ) - - # Cursor - return AIHookPayload( - conversation_id=payload.get('conversation_id'), - ide_user_email=payload.get('user_email'), - model=payload.get('model'), - ide_provider=AIIDEType.CURSOR.value, - ide_version=payload.get('cursor_version'), - ) - - -def _get_claude_code_session_context() -> tuple[dict, dict]: - """Return (mcp_servers, enabled_plugins) for Claude Code. - - Merges MCP servers from ~/.claude.json (user-configured) with those contributed - by enabled plugins. Plugin metadata (name, version, description) is included in - the enabled_plugins dict when resolvable. - """ - config = load_claude_config() - mcp_servers = dict(get_mcp_servers(config) or {}) if config else {} - - settings = load_claude_settings() - if settings: - plugin_mcp, enriched_plugins = resolve_plugins(settings) - mcp_servers.update(plugin_mcp) - else: - enriched_plugins = {} - - return mcp_servers, enriched_plugins - - -def _get_cursor_session_context() -> tuple[dict, dict]: - """Return (mcp_servers, enabled_plugins) for Cursor. Cursor has no plugin system.""" - config = load_cursor_config() - mcp_servers = dict(get_mcp_servers(config) or {}) if config else {} - return mcp_servers, {} - - -def _report_session_context(ai_client: 'AISecurityManagerClient', ide: str, user_email: Optional[str]) -> None: +def _report_session_context(ai_client: 'AISecurityManagerClient', ide: IDE, user_email: Optional[str]) -> None: """Report IDE session context to the AI security manager. Never raises.""" try: - if ide == AIIDEType.CLAUDE_CODE: - mcp_servers, enabled_plugins = _get_claude_code_session_context() - elif ide == AIIDEType.CURSOR: - mcp_servers, enabled_plugins = _get_cursor_session_context() - else: - return - + mcp_servers, enabled_plugins = ide.get_session_context() if not mcp_servers and not enabled_plugins: return ai_client.report_session_context( @@ -109,16 +44,17 @@ def session_start_command( help='IDE that triggered the session start.', hidden=True, ), - ] = AIIDEType.CURSOR.value, + ] = DEFAULT_IDE_NAME, ) -> None: """Handle session start: ensure auth, create conversation, report session context.""" + ide_integration = get_ide(ide) + # Step 1: Ensure authentication auth_info = get_authorization_info(ctx) if auth_info is None: logger.debug('Not authenticated, starting authentication') try: - auth_manager = AuthManager() - auth_manager.authenticate() + AuthManager().authenticate() except Exception as err: handle_auth_exception(ctx, err) return @@ -136,8 +72,8 @@ def session_start_command( logger.debug('Empty or invalid stdin payload, skipping session initialization') return - # Step 3: Build session payload and initialize API client - session_payload = _build_session_payload(payload, ide) + # Step 3: Build session payload + initialize API client + session_payload = ide_integration.build_session_payload(payload) try: ai_client = get_ai_security_manager_client(ctx) @@ -151,5 +87,5 @@ def session_start_command( except Exception as e: logger.debug('Failed to create conversation during session start', exc_info=e) - # Step 5: Report session context (MCP servers) - _report_session_context(ai_client, ide, session_payload.ide_user_email) + # Step 5: Report session context (MCP servers, enabled plugins) + _report_session_context(ai_client, ide_integration, session_payload.ide_user_email) diff --git a/cycode/cli/apps/ai_guardrails/status_command.py b/cycode/cli/apps/ai_guardrails/status_command.py index ee1e5bcf..da201545 100644 --- a/cycode/cli/apps/ai_guardrails/status_command.py +++ b/cycode/cli/apps/ai_guardrails/status_command.py @@ -7,9 +7,9 @@ import typer from rich.table import Table -from cycode.cli.apps.ai_guardrails.command_utils import console, validate_and_parse_ide, validate_scope -from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS, AIIDEType +from cycode.cli.apps.ai_guardrails.command_utils import console, validate_scope from cycode.cli.apps.ai_guardrails.hooks_manager import get_hooks_status +from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, IDES, resolve_ides def status_command( @@ -26,9 +26,9 @@ def status_command( str, typer.Option( '--ide', - help='IDE to check status for (e.g., "cursor", "claude-code", or "all" for all IDEs). Defaults to cursor.', + help=f'IDE to check status for ({", ".join(IDES)}, or "all").', ), - ] = AIIDEType.CURSOR.value, + ] = DEFAULT_IDE_NAME, repo_path: Annotated[ Optional[Path], typer.Option( @@ -43,32 +43,30 @@ def status_command( ) -> None: """Show AI guardrails hook installation status. - Displays the current status of Cycode AI guardrails hooks for the specified IDE. - Examples: cycode ai-guardrails status # Show both user and repo status cycode ai-guardrails status --scope user # Show only user-level status cycode ai-guardrails status --scope repo # Show only repo-level status - cycode ai-guardrails status --ide cursor # Check status for Cursor IDE - cycode ai-guardrails status --ide all # Check status for all supported IDEs + cycode ai-guardrails status --ide claude-code + cycode ai-guardrails status --ide all # Check every supported IDE """ - # Validate inputs (status allows 'all' scope) validate_scope(scope, allowed_scopes=('user', 'repo', 'all')) if repo_path is None: repo_path = Path(os.getcwd()) - ide_type = validate_and_parse_ide(ide) - - ides_to_check: list[AIIDEType] = list(AIIDEType) if ide_type is None else [ide_type] + ides_to_check = resolve_ides(ide) scopes_to_check = ['user', 'repo'] if scope == 'all' else [scope] for current_ide in ides_to_check: - ide_name = IDE_CONFIGS[current_ide].name console.print() - console.print(f'[bold cyan]═══ {ide_name} ═══[/]') + console.print(f'[bold cyan]═══ {current_ide.display_name} ═══[/]') for check_scope in scopes_to_check: - status = get_hooks_status(check_scope, repo_path if check_scope == 'repo' else None, ide=current_ide) + status = get_hooks_status( + current_ide, + check_scope, + repo_path if check_scope == 'repo' else None, + ) console.print() console.print(f'[bold]{check_scope.upper()} SCOPE[/]') @@ -83,7 +81,6 @@ def status_command( else: console.print('[yellow]○ Cycode AI guardrails: NOT INSTALLED[/]') - # Show hook details table = Table(show_header=True, header_style='bold') table.add_column('Hook Event') table.add_column('Cycode Enabled') diff --git a/cycode/cli/apps/ai_guardrails/uninstall_command.py b/cycode/cli/apps/ai_guardrails/uninstall_command.py index f7b8341c..f9a995f3 100644 --- a/cycode/cli/apps/ai_guardrails/uninstall_command.py +++ b/cycode/cli/apps/ai_guardrails/uninstall_command.py @@ -5,14 +5,9 @@ import typer -from cycode.cli.apps.ai_guardrails.command_utils import ( - console, - resolve_repo_path, - validate_and_parse_ide, - validate_scope, -) -from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS, AIIDEType +from cycode.cli.apps.ai_guardrails.command_utils import console, resolve_repo_path, validate_scope from cycode.cli.apps.ai_guardrails.hooks_manager import uninstall_hooks +from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, IDES, resolve_ides def uninstall_command( @@ -29,9 +24,9 @@ def uninstall_command( str, typer.Option( '--ide', - help='IDE to uninstall hooks from (e.g., "cursor", "claude-code", "all"). Defaults to cursor.', + help=f'IDE to uninstall hooks from ({", ".join(IDES)}, or "all").', ), - ] = AIIDEType.CURSOR.value, + ] = DEFAULT_IDE_NAME, repo_path: Annotated[ Optional[Path], typer.Option( @@ -46,32 +41,27 @@ def uninstall_command( ) -> None: """Remove AI guardrails hooks from supported IDEs. - This command removes Cycode hooks from the IDE's hooks configuration. - Other hooks (if any) will be preserved. + Removes Cycode hooks from the IDE's hooks configuration. Other hooks + (if any) are preserved. Examples: cycode ai-guardrails uninstall # Remove user-level hooks cycode ai-guardrails uninstall --scope repo # Remove repo-level hooks - cycode ai-guardrails uninstall --ide cursor # Uninstall from Cursor IDE - cycode ai-guardrails uninstall --ide all # Uninstall from all supported IDEs + cycode ai-guardrails uninstall --ide claude-code # Uninstall from a specific IDE + cycode ai-guardrails uninstall --ide all # Uninstall from every supported IDE """ - # Validate inputs validate_scope(scope) repo_path = resolve_repo_path(scope, repo_path) - ide_type = validate_and_parse_ide(ide) - - ides_to_uninstall: list[AIIDEType] = list(AIIDEType) if ide_type is None else [ide_type] + ides_to_uninstall = resolve_ides(ide) results: list[tuple[str, bool, str]] = [] for current_ide in ides_to_uninstall: - ide_name = IDE_CONFIGS[current_ide].name - success, message = uninstall_hooks(scope, repo_path, ide=current_ide) - results.append((ide_name, success, message)) + success, message = uninstall_hooks(current_ide, scope, repo_path) + results.append((current_ide.display_name, success, message)) - # Report results for each IDE any_success = False all_success = True - for _ide_name, success, message in results: + for _name, success, message in results: if success: console.print(f'[green]✓[/] {message}') any_success = True diff --git a/tests/cli/commands/ai_guardrails/ides/__init__.py b/tests/cli/commands/ai_guardrails/ides/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py new file mode 100644 index 00000000..f997abe3 --- /dev/null +++ b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py @@ -0,0 +1,262 @@ +"""Claude Code IDE integration tests.""" + +import json +from pathlib import Path +from unittest.mock import patch + +from pyfakefs.fake_filesystem import FakeFilesystem +from pytest_mock import MockerFixture + +from cycode.cli.apps.ai_guardrails.ides.base import HookDecision +from cycode.cli.apps.ai_guardrails.ides.claude_code import ClaudeCode, _email_from_config, load_claude_config +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType + + +def test_matches_payload_only_claude_events() -> None: + claude = ClaudeCode() + assert claude.matches_payload({'hook_event_name': 'UserPromptSubmit'}) is True + assert claude.matches_payload({'hook_event_name': 'PreToolUse'}) is True + assert claude.matches_payload({'hook_event_name': 'beforeSubmitPrompt'}) is False + assert claude.matches_payload({'hook_event_name': 'beforeReadFile'}) is False + + +def test_parse_prompt_payload() -> None: + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'Test prompt', + } + ) + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id == 'session-123' + assert unified.ide_provider == 'claude-code' + assert unified.prompt == 'Test prompt' + + +def test_parse_file_read_payload() -> None: + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'PreToolUse', + 'session_id': 'session-456', + 'tool_name': 'Read', + 'tool_input': {'file_path': '/path/to/secret.env'}, + } + ) + assert unified.event_name == AiHookEventType.FILE_READ + assert unified.file_path == '/path/to/secret.env' + assert unified.mcp_tool_name is None + + +def test_parse_mcp_execution_payload() -> None: + args = {'resource_type': 'merge_request', 'parent_id': 'org/repo', 'resource_id': '4'} + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'PreToolUse', + 'tool_name': 'mcp__gitlab__discussion_list', + 'tool_input': args, + } + ) + + assert unified.event_name == AiHookEventType.MCP_EXECUTION + assert unified.mcp_server_name == 'gitlab' + assert unified.mcp_tool_name == 'discussion_list' + assert unified.mcp_arguments == args + + +def test_parse_empty_payload_defaults() -> None: + unified = ClaudeCode().parse_hook_payload({'hook_event_name': 'UserPromptSubmit'}) + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id is None + assert unified.prompt == '' + assert unified.ide_provider == 'claude-code' + + +def test_build_prompt_responses() -> None: + claude = ClaudeCode() + assert claude.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT)) == {} + assert claude.build_hook_response(HookDecision.deny(AiHookEventType.PROMPT, 'no!')) == { + 'decision': 'block', + 'reason': 'no!', + } + + +def test_build_permission_responses() -> None: + claude = ClaudeCode() + allow = claude.build_hook_response(HookDecision.allow(AiHookEventType.FILE_READ)) + assert allow == {'hookSpecificOutput': {'hookEventName': 'PreToolUse', 'permissionDecision': 'allow'}} + + deny = claude.build_hook_response(HookDecision.deny(AiHookEventType.FILE_READ, 'user!', 'agent!')) + assert deny == { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'deny', + 'permissionDecisionReason': 'user!', + } + } + + ask = claude.build_hook_response(HookDecision.ask(AiHookEventType.MCP_EXECUTION, 'u')) + assert ask == { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'ask', + 'permissionDecisionReason': 'u', + } + } + + +# Transcript extraction + + +def test_extract_from_transcript(mocker: MockerFixture) -> None: + """version, model, generation_id from a Claude Code transcript JSONL.""" + transcript_content = ( + b'{"type":"user","version":"2.1.20","uuid":"user-uuid-1","message":{"role":"user","content":"hello"}}\n' + b'{"type":"assistant","message":{"model":"claude-opus-4-5-20251101","role":"assistant",' + b'"content":[{"type":"text","text":"Hi!"}]},"uuid":"assistant-uuid-1"}\n' + b'{"type":"user","version":"2.1.20","uuid":"user-uuid-2","message":{"role":"user","content":"test prompt"}}\n' + ) + mock_path = mocker.patch('cycode.cli.apps.ai_guardrails.ides.claude_code.Path') + mock_path.return_value.exists.return_value = True + mock_path.return_value.open.return_value.__enter__.return_value.seek = mocker.Mock() + mock_path.return_value.open.return_value.__enter__.return_value.tell.return_value = len(transcript_content) + mock_path.return_value.open.return_value.__enter__.return_value.read.return_value = transcript_content + + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test prompt', + 'transcript_path': '/mock/transcript.jsonl', + } + ) + + assert unified.ide_version == '2.1.20' + assert unified.model == 'claude-opus-4-5-20251101' + assert unified.generation_id == 'user-uuid-2' + + +def test_missing_transcript_does_not_break_parsing(mocker: MockerFixture) -> None: + mock_path = mocker.patch('cycode.cli.apps.ai_guardrails.ides.claude_code.Path') + mock_path.return_value.exists.return_value = False + + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test', + 'transcript_path': '/nonexistent/path/transcript.jsonl', + } + ) + + assert unified.ide_version is None + assert unified.model is None + assert unified.generation_id is None + assert unified.conversation_id == 'session-123' + assert unified.prompt == 'test' + + +def test_absent_transcript_path() -> None: + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test', + } + ) + assert unified.ide_version is None + assert unified.model is None + assert unified.generation_id is None + + +# Email extraction from ~/.claude.json + + +def test_email_from_config(mocker: MockerFixture) -> None: + mocker.patch( + 'cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_config', + return_value={'oauthAccount': {'emailAddress': 'user@example.com'}}, + ) + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + } + ) + assert unified.ide_user_email == 'user@example.com' + + +def test_email_none_when_config_missing(mocker: MockerFixture) -> None: + mocker.patch( + 'cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_config', + return_value=None, + ) + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + } + ) + assert unified.ide_user_email is None + + +def test_email_none_when_no_oauth(mocker: MockerFixture) -> None: + mocker.patch( + 'cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_config', + return_value={'someOtherKey': 'value'}, + ) + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + } + ) + assert unified.ide_user_email is None + + +# Session context + + +def test_session_context_no_config() -> None: + with ( + patch('cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_config', return_value=None), + patch('cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_settings', return_value=None), + ): + servers, plugins = ClaudeCode().get_session_context() + assert servers == {} + assert plugins == {} + + +# Claude config parsing (load_claude_config + _email_from_config) + + +def test_load_claude_config_valid(fs: FakeFilesystem) -> None: + config = {'oauthAccount': {'emailAddress': 'user@example.com'}} + config_path = Path.home() / '.claude.json' + fs.create_file(config_path, contents=json.dumps(config)) + + assert load_claude_config(config_path) == config + + +def test_load_claude_config_missing_file(fs: FakeFilesystem) -> None: + fs.create_dir(Path.home()) + assert load_claude_config(Path.home() / '.claude.json') is None + + +def test_load_claude_config_corrupt_file(fs: FakeFilesystem) -> None: + config_path = Path.home() / '.claude.json' + fs.create_file(config_path, contents='not valid json {{{') + + assert load_claude_config(config_path) is None + + +def test_email_from_config_present() -> None: + assert _email_from_config({'oauthAccount': {'emailAddress': 'user@example.com'}}) == 'user@example.com' + + +def test_email_from_config_missing_oauth_account() -> None: + assert _email_from_config({'someOtherKey': 'value'}) is None + + +def test_email_from_config_missing_email_address() -> None: + assert _email_from_config({'oauthAccount': {'someOtherField': 'value'}}) is None diff --git a/tests/cli/commands/ai_guardrails/ides/test_contract.py b/tests/cli/commands/ai_guardrails/ides/test_contract.py new file mode 100644 index 00000000..9714dbfa --- /dev/null +++ b/tests/cli/commands/ai_guardrails/ides/test_contract.py @@ -0,0 +1,141 @@ +"""IDE contract tests, parameterized over the entire IDES registry. + +Every concrete IDE registered in `ides/__init__.py` must satisfy these +assertions. Adding a new IDE without updating these tests means the new +IDE inherits the same baseline guarantees (and fails fast if it doesn't). +""" + +from pathlib import Path + +import pytest + +from cycode.cli.apps.ai_guardrails.ides import IDES +from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType + + +def test_ides_registry_is_non_empty() -> None: + """Sanity check: the refactor isn't useful with zero registered IDEs.""" + assert len(IDES) >= 1 + + +@pytest.fixture(params=sorted(IDES), ids=sorted(IDES)) +def ide(request: pytest.FixtureRequest) -> IDE: + return IDES[request.param] + + +def test_identity_attributes_set(ide: IDE) -> None: + """Every IDE must declare name, display_name, hook_events.""" + assert isinstance(ide.name, str) + assert ide.name + assert isinstance(ide.display_name, str) + assert ide.display_name + assert isinstance(ide.hook_events, list) + assert ide.hook_events + + +def test_registry_key_matches_name(ide: IDE) -> None: + """The registry key must equal the IDE's own `name` attribute.""" + assert IDES[ide.name] is ide + + +def test_settings_path_user_scope(ide: IDE) -> None: + """User scope must return a Path (without requiring a repo_path).""" + path = ide.settings_path('user') + assert isinstance(path, Path) + + +def test_settings_path_repo_scope(ide: IDE, tmp_path: Path) -> None: + """Repo scope path must live under the supplied repo directory.""" + path = ide.settings_path('repo', tmp_path) + assert isinstance(path, Path) + assert str(path).startswith(str(tmp_path)) + + +def test_render_hooks_config_has_hooks_key(ide: IDE) -> None: + """All IDEs share the outer `{"hooks": ...}` wrapper so hooks_manager can merge.""" + rendered = ide.render_hooks_config() + assert isinstance(rendered, dict) + assert 'hooks' in rendered + assert isinstance(rendered['hooks'], dict) + + +def test_render_hooks_config_async_changes_output(ide: IDE) -> None: + """async_mode must influence the rendered output (e.g. & suffix, async flag).""" + assert ide.render_hooks_config(async_mode=False) != ide.render_hooks_config(async_mode=True) + + +def test_matches_payload_rejects_empty(ide: IDE) -> None: + """Empty payloads can't legitimately come from any IDE.""" + assert ide.matches_payload({}) is False + assert ide.matches_payload({'hook_event_name': ''}) is False + + +def test_matches_payload_rejects_unrelated_event_names(ide: IDE) -> None: + """Unknown event names from other IDEs must be ignored to avoid double-processing.""" + assert ide.matches_payload({'hook_event_name': 'completely-fabricated-event'}) is False + + +@pytest.mark.parametrize('event_type', list(AiHookEventType)) +def test_build_hook_response_allow_returns_dict(ide: IDE, event_type: AiHookEventType) -> None: + """ALLOW for every canonical event type yields a serializable dict.""" + response = ide.build_hook_response(HookDecision.allow(event_type)) + assert isinstance(response, dict) + + +@pytest.mark.parametrize('event_type', list(AiHookEventType)) +def test_build_hook_response_deny_carries_message(ide: IDE, event_type: AiHookEventType) -> None: + """DENY must surface the user message somewhere in the response (any key).""" + response = ide.build_hook_response(HookDecision.deny(event_type, 'A unique deny reason', 'agent msg')) + # Search recursively — IDEs use different key names for the message. + assert _contains_value(response, 'A unique deny reason'), response + + +@pytest.mark.parametrize('event_type', [AiHookEventType.FILE_READ, AiHookEventType.MCP_EXECUTION]) +def test_build_hook_response_ask_carries_message(ide: IDE, event_type: AiHookEventType) -> None: + """ASK is meaningful for permission events. Message must propagate.""" + response = ide.build_hook_response(HookDecision.ask(event_type, 'A unique ask reason')) + assert _contains_value(response, 'A unique ask reason'), response + + +def test_build_session_payload_tags_ide(ide: IDE) -> None: + """Session payload must identify the originating IDE.""" + session = ide.build_session_payload({}) + assert session.ide_provider == ide.name + + +def test_get_session_context_returns_pair(ide: IDE) -> None: + """Session context must always be a ``(mcp_servers, plugins)`` 2-tuple of dicts.""" + mcp_servers, plugins = ide.get_session_context() + assert isinstance(mcp_servers, dict) + assert isinstance(plugins, dict) + + +# HookDecision helpers + + +def test_hook_decision_helpers() -> None: + allow = HookDecision.allow(AiHookEventType.PROMPT) + assert allow.action == DecisionAction.ALLOW + assert allow.event_type == AiHookEventType.PROMPT + assert allow.user_message is None + + deny = HookDecision.deny(AiHookEventType.FILE_READ, 'why', 'agent') + assert deny.action == DecisionAction.DENY + assert deny.user_message == 'why' + assert deny.agent_message == 'agent' + + ask = HookDecision.ask(AiHookEventType.MCP_EXECUTION, 'maybe?') + assert ask.action == DecisionAction.ASK + assert ask.user_message == 'maybe?' + + +def _contains_value(obj: object, needle: str) -> bool: + """Recursively search a nested dict/list for a string value.""" + if isinstance(obj, str): + return needle in obj + if isinstance(obj, dict): + return any(_contains_value(v, needle) for v in obj.values()) + if isinstance(obj, list): + return any(_contains_value(v, needle) for v in obj) + return False diff --git a/tests/cli/commands/ai_guardrails/ides/test_cursor.py b/tests/cli/commands/ai_guardrails/ides/test_cursor.py new file mode 100644 index 00000000..bb058f6f --- /dev/null +++ b/tests/cli/commands/ai_guardrails/ides/test_cursor.py @@ -0,0 +1,154 @@ +"""Cursor IDE integration tests (payload parsing, response building, MCP context).""" + +import json +from pathlib import Path +from typing import Any +from unittest.mock import patch + +from cycode.cli.apps.ai_guardrails.ides.base import HookDecision +from cycode.cli.apps.ai_guardrails.ides.cursor import Cursor +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType + + +def test_matches_payload_only_cursor_events() -> None: + cursor = Cursor() + assert cursor.matches_payload({'hook_event_name': 'beforeSubmitPrompt'}) is True + assert cursor.matches_payload({'hook_event_name': 'beforeReadFile'}) is True + assert cursor.matches_payload({'hook_event_name': 'beforeMCPExecution'}) is True + assert cursor.matches_payload({'hook_event_name': 'UserPromptSubmit'}) is False + assert cursor.matches_payload({'hook_event_name': 'PreToolUse'}) is False + + +def test_parse_prompt_payload() -> None: + payload = { + 'hook_event_name': 'beforeSubmitPrompt', + 'conversation_id': 'conv-123', + 'generation_id': 'gen-456', + 'user_email': 'user@example.com', + 'model': 'gpt-4', + 'cursor_version': '0.42.0', + 'prompt': 'Test prompt', + } + unified = Cursor().parse_hook_payload(payload) + + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id == 'conv-123' + assert unified.generation_id == 'gen-456' + assert unified.ide_user_email == 'user@example.com' + assert unified.model == 'gpt-4' + assert unified.ide_provider == 'cursor' + assert unified.ide_version == '0.42.0' + assert unified.prompt == 'Test prompt' + + +def test_parse_file_read_payload() -> None: + unified = Cursor().parse_hook_payload({'hook_event_name': 'beforeReadFile', 'file_path': '/path/to/secret.env'}) + assert unified.event_name == AiHookEventType.FILE_READ + assert unified.file_path == '/path/to/secret.env' + + +def test_parse_mcp_execution_payload() -> None: + args: dict[str, Any] = {'resource_type': 'merge_request', 'parent_id': 'org/repo', 'resource_id': '4'} + unified = Cursor().parse_hook_payload( + { + 'hook_event_name': 'beforeMCPExecution', + 'command': 'GitLab', + 'tool_name': 'discussion_list', + 'arguments': args, + } + ) + + assert unified.event_name == AiHookEventType.MCP_EXECUTION + assert unified.mcp_server_name == 'GitLab' + assert unified.mcp_tool_name == 'discussion_list' + assert unified.mcp_arguments == args + + +def test_parse_alternative_field_names() -> None: + """Cursor's payload has alternative names for some fields.""" + fr = Cursor().parse_hook_payload({'hook_event_name': 'beforeReadFile', 'path': '/alt/path.txt'}) + assert fr.file_path == '/alt/path.txt' + + mcp = Cursor().parse_hook_payload( + { + 'hook_event_name': 'beforeMCPExecution', + 'tool': 'my_tool', + 'tool_input': {'key': 'value'}, + } + ) + assert mcp.mcp_tool_name == 'my_tool' + assert mcp.mcp_arguments == {'key': 'value'} + + +def test_parse_unknown_event_name_falls_through() -> None: + """Unknown event names pass through as the raw string.""" + unified = Cursor().parse_hook_payload({'hook_event_name': 'unknownEvent'}) + assert unified.event_name == 'unknownEvent' + + +def test_parse_empty_payload_defaults() -> None: + unified = Cursor().parse_hook_payload({'hook_event_name': 'beforeSubmitPrompt'}) + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id is None + assert unified.prompt == '' + assert unified.ide_provider == 'cursor' + + +def test_build_prompt_responses() -> None: + cursor = Cursor() + assert cursor.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT)) == {'continue': True} + assert cursor.build_hook_response(HookDecision.deny(AiHookEventType.PROMPT, 'no!')) == { + 'continue': False, + 'user_message': 'no!', + } + + +def test_build_permission_responses() -> None: + cursor = Cursor() + assert cursor.build_hook_response(HookDecision.allow(AiHookEventType.FILE_READ)) == {'permission': 'allow'} + assert cursor.build_hook_response(HookDecision.deny(AiHookEventType.FILE_READ, 'user!', 'agent!')) == { + 'permission': 'deny', + 'user_message': 'user!', + 'agent_message': 'agent!', + } + assert cursor.build_hook_response(HookDecision.ask(AiHookEventType.MCP_EXECUTION, 'u', 'a')) == { + 'permission': 'ask', + 'user_message': 'u', + 'agent_message': 'a', + } + + +def test_session_payload_carries_cursor_fields() -> None: + payload = { + 'conversation_id': 'conv-456', + 'user_email': 'cursor-user@example.com', + 'model': 'gpt-4', + 'cursor_version': '0.42.0', + } + session = Cursor().build_session_payload(payload) + assert session.conversation_id == 'conv-456' + assert session.model == 'gpt-4' + assert session.ide_user_email == 'cursor-user@example.com' + assert session.ide_version == '0.42.0' + assert session.ide_provider == 'cursor' + + +def test_session_context_loads_mcp_servers(tmp_path: Path) -> None: + """Cursor reads MCP servers from ~/.cursor/mcp.json.""" + mcp_servers = {'github': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-github']}} + config_path = tmp_path / 'mcp.json' + config_path.write_text(json.dumps({'mcpServers': mcp_servers})) + + with patch('cycode.cli.apps.ai_guardrails.ides.cursor._load_cursor_mcp_config') as load: + load.return_value = {'mcpServers': mcp_servers} + servers, plugins = Cursor().get_session_context() + + assert servers == mcp_servers + assert plugins == {} + + +def test_session_context_no_config_returns_empty() -> None: + with patch('cycode.cli.apps.ai_guardrails.ides.cursor._load_cursor_mcp_config', return_value=None): + servers, plugins = Cursor().get_session_context() + assert servers == {} + assert plugins == {} diff --git a/tests/cli/commands/ai_guardrails/scan/test_handlers.py b/tests/cli/commands/ai_guardrails/scan/test_handlers.py index 57c25b92..36352a38 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_handlers.py +++ b/tests/cli/commands/ai_guardrails/scan/test_handlers.py @@ -6,13 +6,14 @@ import pytest import typer +from cycode.cli.apps.ai_guardrails.ides.base import DecisionAction, HookDecision from cycode.cli.apps.ai_guardrails.scan.handlers import ( handle_before_mcp_execution, handle_before_read_file, handle_before_submit_prompt, ) from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload -from cycode.cli.apps.ai_guardrails.scan.types import AIHookOutcome, BlockReason +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType, AIHookOutcome, BlockReason @pytest.fixture @@ -30,7 +31,7 @@ def mock_ctx() -> MagicMock: def mock_payload() -> AIHookPayload: """Create a mock AIHookPayload.""" return AIHookPayload( - event_name='prompt', + event_name='Prompt', conversation_id='test-conv-id', generation_id='test-gen-id', ide_user_email='test@example.com', @@ -65,7 +66,7 @@ def test_handle_before_submit_prompt_disabled( result = handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) - assert result == {'continue': True} + assert result == HookDecision.allow(AiHookEventType.PROMPT) mock_ctx.obj['ai_security_client'].create_event.assert_called_once() mock_ctx.obj['ai_security_client'].create_conversation.assert_not_called() @@ -79,11 +80,10 @@ def test_handle_before_submit_prompt_no_secrets( result = handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) - assert result == {'continue': True} + assert result == HookDecision.allow(AiHookEventType.PROMPT) mock_ctx.obj['ai_security_client'].create_event.assert_called_once() mock_ctx.obj['ai_security_client'].create_conversation.assert_not_called() call_args = mock_ctx.obj['ai_security_client'].create_event.call_args - # outcome is arg[2], scan_id and block_reason are kwargs assert call_args.args[2] == AIHookOutcome.ALLOWED assert call_args.kwargs['scan_id'] == 'scan-id-123' assert call_args.kwargs['block_reason'] is None @@ -98,8 +98,9 @@ def test_handle_before_submit_prompt_with_secrets_blocked( result = handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) - assert result['continue'] is False - assert 'Found 1 secret: API key' in result['user_message'] + assert result.action == DecisionAction.DENY + assert result.event_type == AiHookEventType.PROMPT + assert 'Found 1 secret: API key' in result.user_message mock_ctx.obj['ai_security_client'].create_event.assert_called_once() call_args = mock_ctx.obj['ai_security_client'].create_event.call_args assert call_args.args[2] == AIHookOutcome.BLOCKED @@ -116,7 +117,7 @@ def test_handle_before_submit_prompt_with_secrets_warned( result = handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) - assert result == {'continue': True} + assert result == HookDecision.allow(AiHookEventType.PROMPT) mock_ctx.obj['ai_security_client'].create_event.assert_called_once() call_args = mock_ctx.obj['ai_security_client'].create_event.call_args assert call_args.args[2] == AIHookOutcome.WARNED @@ -133,11 +134,9 @@ def test_handle_before_submit_prompt_scan_failure_fail_open( with pytest.raises(RuntimeError): handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) - # Event should be tracked even on exception mock_ctx.obj['ai_security_client'].create_event.assert_called_once() call_args = mock_ctx.obj['ai_security_client'].create_event.call_args assert call_args.args[2] == AIHookOutcome.ALLOWED - # block_reason is set for tracking even when fail_open allows the action assert call_args.kwargs['block_reason'] == BlockReason.SCAN_FAILURE @@ -152,7 +151,6 @@ def test_handle_before_submit_prompt_scan_failure_fail_closed( with pytest.raises(RuntimeError): handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) - # Event should be tracked even on exception mock_ctx.obj['ai_security_client'].create_event.assert_called_once() call_args = mock_ctx.obj['ai_security_client'].create_event.call_args assert call_args.args[2] == AIHookOutcome.BLOCKED @@ -166,14 +164,14 @@ def test_handle_before_read_file_disabled(mock_ctx: MagicMock, default_policy: d """Test that disabled file read scanning allows the file.""" default_policy['file_read']['enabled'] = False payload = AIHookPayload( - event_name='file_read', + event_name='FileRead', ide_provider='cursor', file_path='/path/to/file.txt', ) result = handle_before_read_file(mock_ctx, payload, default_policy) - assert result == {'permission': 'allow'} + assert result == HookDecision.allow(AiHookEventType.FILE_READ) @patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') @@ -183,15 +181,16 @@ def test_handle_before_read_file_sensitive_path( """Test that sensitive path is blocked.""" mock_is_denied.return_value = True payload = AIHookPayload( - event_name='file_read', + event_name='FileRead', ide_provider='cursor', file_path='/path/to/.env', ) result = handle_before_read_file(mock_ctx, payload, default_policy) - assert result['permission'] == 'deny' - assert '.env' in result['user_message'] + assert result.action == DecisionAction.DENY + assert result.event_type == AiHookEventType.FILE_READ + assert '.env' in result.user_message mock_ctx.obj['ai_security_client'].create_event.assert_called_once() call_args = mock_ctx.obj['ai_security_client'].create_event.call_args assert call_args.args[2] == AIHookOutcome.BLOCKED @@ -208,14 +207,14 @@ def test_handle_before_read_file_no_secrets( mock_is_denied.return_value = False mock_scan.return_value = (None, 'scan-id-123') payload = AIHookPayload( - event_name='file_read', + event_name='FileRead', ide_provider='cursor', file_path='/path/to/file.txt', ) result = handle_before_read_file(mock_ctx, payload, default_policy) - assert result == {'permission': 'allow'} + assert result == HookDecision.allow(AiHookEventType.FILE_READ) call_args = mock_ctx.obj['ai_security_client'].create_event.call_args assert call_args.args[2] == AIHookOutcome.ALLOWED assert call_args.kwargs['file_path'] == '/path/to/file.txt' @@ -230,15 +229,16 @@ def test_handle_before_read_file_with_secrets( mock_is_denied.return_value = False mock_scan.return_value = ('Found 1 secret: password', 'scan-id-456') payload = AIHookPayload( - event_name='file_read', + event_name='FileRead', ide_provider='cursor', file_path='/path/to/file.txt', ) result = handle_before_read_file(mock_ctx, payload, default_policy) - assert result['permission'] == 'deny' - assert 'Found 1 secret: password' in result['user_message'] + assert result.action == DecisionAction.DENY + assert result.event_type == AiHookEventType.FILE_READ + assert 'Found 1 secret: password' in result.user_message call_args = mock_ctx.obj['ai_security_client'].create_event.call_args assert call_args.args[2] == AIHookOutcome.BLOCKED assert call_args.kwargs['block_reason'] == BlockReason.SECRETS_IN_FILE @@ -254,14 +254,14 @@ def test_handle_before_read_file_scan_disabled( mock_is_denied.return_value = False default_policy['file_read']['scan_content'] = False payload = AIHookPayload( - event_name='file_read', + event_name='FileRead', ide_provider='cursor', file_path='/path/to/file.txt', ) result = handle_before_read_file(mock_ctx, payload, default_policy) - assert result == {'permission': 'allow'} + assert result == HookDecision.allow(AiHookEventType.FILE_READ) mock_scan.assert_not_called() @@ -275,20 +275,18 @@ def test_handle_before_read_file_sensitive_path_warn_mode_scans_content( mock_scan.return_value = (None, 'scan-id-123') default_policy['mode'] = 'warn' payload = AIHookPayload( - event_name='file_read', + event_name='FileRead', ide_provider='cursor', file_path='/path/to/.env', ) result = handle_before_read_file(mock_ctx, payload, default_policy) - # Content was scanned even though path is sensitive mock_scan.assert_called_once() - # Still warns about sensitive path since no secrets found - assert result['permission'] == 'ask' - assert '.env' in result['user_message'] + assert result.action == DecisionAction.ASK + assert result.event_type == AiHookEventType.FILE_READ + assert '.env' in result.user_message - # Two events: sensitive path warn + content scan result (allowed, no secrets found) assert mock_ctx.obj['ai_security_client'].create_event.call_count == 2 first_event = mock_ctx.obj['ai_security_client'].create_event.call_args_list[0] assert first_event.args[2] == AIHookOutcome.WARNED @@ -308,7 +306,7 @@ def test_handle_before_read_file_sensitive_path_warn_mode_with_secrets( mock_scan.return_value = ('Found 1 secret: API key', 'scan-id-456') default_policy['mode'] = 'warn' payload = AIHookPayload( - event_name='file_read', + event_name='FileRead', ide_provider='cursor', file_path='/path/to/.env', ) @@ -316,10 +314,10 @@ def test_handle_before_read_file_sensitive_path_warn_mode_with_secrets( result = handle_before_read_file(mock_ctx, payload, default_policy) mock_scan.assert_called_once() - assert result['permission'] == 'ask' - assert 'Found 1 secret: API key' in result['user_message'] + assert result.action == DecisionAction.ASK + assert result.event_type == AiHookEventType.FILE_READ + assert 'Found 1 secret: API key' in result.user_message - # Two events: sensitive path warn + secrets warn assert mock_ctx.obj['ai_security_client'].create_event.call_count == 2 first_event = mock_ctx.obj['ai_security_client'].create_event.call_args_list[0] assert first_event.args[2] == AIHookOutcome.WARNED @@ -339,7 +337,7 @@ def test_handle_before_read_file_sensitive_path_scan_disabled_warns( default_policy['mode'] = 'warn' default_policy['file_read']['scan_content'] = False payload = AIHookPayload( - event_name='file_read', + event_name='FileRead', ide_provider='cursor', file_path='/path/to/.env', ) @@ -347,10 +345,10 @@ def test_handle_before_read_file_sensitive_path_scan_disabled_warns( result = handle_before_read_file(mock_ctx, payload, default_policy) mock_scan.assert_not_called() - assert result['permission'] == 'ask' - assert '.env' in result['user_message'] + assert result.action == DecisionAction.ASK + assert result.event_type == AiHookEventType.FILE_READ + assert '.env' in result.user_message - # Single event: sensitive path warn (no separate scan event when scan is disabled) mock_ctx.obj['ai_security_client'].create_event.assert_called_once() call_args = mock_ctx.obj['ai_security_client'].create_event.call_args assert call_args.args[2] == AIHookOutcome.WARNED @@ -375,7 +373,7 @@ def test_handle_before_mcp_execution_disabled(mock_ctx: MagicMock, default_polic """Test that disabled MCP scanning allows the execution.""" default_policy['mcp']['enabled'] = False payload = AIHookPayload( - event_name='mcp_execution', + event_name='McpExecution', ide_provider='cursor', mcp_tool_name='test_tool', mcp_arguments={'arg1': 'value1'}, @@ -383,7 +381,7 @@ def test_handle_before_mcp_execution_disabled(mock_ctx: MagicMock, default_polic result = handle_before_mcp_execution(mock_ctx, payload, default_policy) - assert result == {'permission': 'allow'} + assert result == HookDecision.allow(AiHookEventType.MCP_EXECUTION) @patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') @@ -393,7 +391,7 @@ def test_handle_before_mcp_execution_no_secrets( """Test that MCP execution with no secrets is allowed.""" mock_scan.return_value = (None, 'scan-id-123') payload = AIHookPayload( - event_name='mcp_execution', + event_name='McpExecution', ide_provider='cursor', mcp_tool_name='test_tool', mcp_arguments={'arg1': 'value1'}, @@ -401,7 +399,7 @@ def test_handle_before_mcp_execution_no_secrets( result = handle_before_mcp_execution(mock_ctx, payload, default_policy) - assert result == {'permission': 'allow'} + assert result == HookDecision.allow(AiHookEventType.MCP_EXECUTION) call_args = mock_ctx.obj['ai_security_client'].create_event.call_args assert call_args.args[2] == AIHookOutcome.ALLOWED @@ -413,7 +411,7 @@ def test_handle_before_mcp_execution_with_secrets_blocked( """Test that MCP execution with secrets is blocked.""" mock_scan.return_value = ('Found 1 secret: token', 'scan-id-456') payload = AIHookPayload( - event_name='mcp_execution', + event_name='McpExecution', ide_provider='cursor', mcp_tool_name='test_tool', mcp_arguments={'arg1': 'secret_token_12345'}, @@ -421,8 +419,9 @@ def test_handle_before_mcp_execution_with_secrets_blocked( result = handle_before_mcp_execution(mock_ctx, payload, default_policy) - assert result['permission'] == 'deny' - assert 'Found 1 secret: token' in result['user_message'] + assert result.action == DecisionAction.DENY + assert result.event_type == AiHookEventType.MCP_EXECUTION + assert 'Found 1 secret: token' in result.user_message call_args = mock_ctx.obj['ai_security_client'].create_event.call_args assert call_args.args[2] == AIHookOutcome.BLOCKED assert call_args.kwargs['block_reason'] == BlockReason.SECRETS_IN_MCP_ARGS @@ -436,7 +435,7 @@ def test_handle_before_mcp_execution_with_secrets_warned( mock_scan.return_value = ('Found 1 secret: token', 'scan-id-789') default_policy['mcp']['action'] = 'warn' payload = AIHookPayload( - event_name='mcp_execution', + event_name='McpExecution', ide_provider='cursor', mcp_tool_name='test_tool', mcp_arguments={'arg1': 'secret_token_12345'}, @@ -444,8 +443,9 @@ def test_handle_before_mcp_execution_with_secrets_warned( result = handle_before_mcp_execution(mock_ctx, payload, default_policy) - assert result['permission'] == 'ask' - assert 'Found 1 secret: token' in result['user_message'] + assert result.action == DecisionAction.ASK + assert result.event_type == AiHookEventType.MCP_EXECUTION + assert 'Found 1 secret: token' in result.user_message call_args = mock_ctx.obj['ai_security_client'].create_event.call_args assert call_args.args[2] == AIHookOutcome.WARNED @@ -457,7 +457,7 @@ def test_handle_before_mcp_execution_scan_disabled( """Test that MCP execution is allowed when argument scanning is disabled.""" default_policy['mcp']['scan_arguments'] = False payload = AIHookPayload( - event_name='mcp_execution', + event_name='McpExecution', ide_provider='cursor', mcp_tool_name='test_tool', mcp_arguments={'arg1': 'value1'}, @@ -465,5 +465,5 @@ def test_handle_before_mcp_execution_scan_disabled( result = handle_before_mcp_execution(mock_ctx, payload, default_policy) - assert result == {'permission': 'allow'} + assert result == HookDecision.allow(AiHookEventType.MCP_EXECUTION) mock_scan.assert_not_called() diff --git a/tests/cli/commands/ai_guardrails/scan/test_payload.py b/tests/cli/commands/ai_guardrails/scan/test_payload.py deleted file mode 100644 index 1ef5fad0..00000000 --- a/tests/cli/commands/ai_guardrails/scan/test_payload.py +++ /dev/null @@ -1,432 +0,0 @@ -"""Tests for AI hook payload normalization.""" - -import pytest -from pytest_mock import MockerFixture - -from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload -from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType - - -def test_from_cursor_payload_prompt_event() -> None: - """Test conversion of Cursor beforeSubmitPrompt payload.""" - cursor_payload = { - 'hook_event_name': 'beforeSubmitPrompt', - 'conversation_id': 'conv-123', - 'generation_id': 'gen-456', - 'user_email': 'user@example.com', - 'model': 'gpt-4', - 'cursor_version': '0.42.0', - 'prompt': 'Test prompt', - } - - unified = AIHookPayload.from_cursor_payload(cursor_payload) - - assert unified.event_name == AiHookEventType.PROMPT - assert unified.conversation_id == 'conv-123' - assert unified.generation_id == 'gen-456' - assert unified.ide_user_email == 'user@example.com' - assert unified.model == 'gpt-4' - assert unified.ide_provider == 'cursor' - assert unified.ide_version == '0.42.0' - assert unified.prompt == 'Test prompt' - assert type(unified.ide_provider) is str - - -def test_from_cursor_payload_file_read_event() -> None: - """Test conversion of Cursor beforeReadFile payload.""" - cursor_payload = { - 'hook_event_name': 'beforeReadFile', - 'conversation_id': 'conv-123', - 'file_path': '/path/to/secret.env', - } - - unified = AIHookPayload.from_cursor_payload(cursor_payload) - - assert unified.event_name == AiHookEventType.FILE_READ - assert unified.file_path == '/path/to/secret.env' - assert unified.ide_provider == 'cursor' - - -def test_from_cursor_payload_mcp_execution_event() -> None: - """Test conversion of Cursor beforeMCPExecution payload.""" - cursor_payload = { - 'hook_event_name': 'beforeMCPExecution', - 'conversation_id': 'conv-123', - 'command': 'GitLab', - 'tool_name': 'discussion_list', - 'arguments': {'resource_type': 'merge_request', 'parent_id': 'organization/repo', 'resource_id': '4'}, - } - - unified = AIHookPayload.from_cursor_payload(cursor_payload) - - assert unified.event_name == AiHookEventType.MCP_EXECUTION - assert unified.mcp_server_name == 'GitLab' - assert unified.mcp_tool_name == 'discussion_list' - assert unified.mcp_arguments == { - 'resource_type': 'merge_request', - 'parent_id': 'organization/repo', - 'resource_id': '4', - } - - -def test_from_cursor_payload_with_alternative_field_names() -> None: - """Test that alternative field names are handled (path vs file_path, etc.).""" - cursor_payload = { - 'hook_event_name': 'beforeReadFile', - 'path': '/alternative/path.txt', # Alternative to file_path - } - - unified = AIHookPayload.from_cursor_payload(cursor_payload) - assert unified.file_path == '/alternative/path.txt' - - cursor_payload = { - 'hook_event_name': 'beforeMCPExecution', - 'tool': 'my_tool', # Alternative to tool_name - 'tool_input': {'key': 'value'}, # Alternative to arguments - } - - unified = AIHookPayload.from_cursor_payload(cursor_payload) - assert unified.mcp_tool_name == 'my_tool' - assert unified.mcp_arguments == {'key': 'value'} - - -def test_from_cursor_payload_unknown_event() -> None: - """Test that unknown event names are passed through as-is.""" - cursor_payload = { - 'hook_event_name': 'unknownEvent', - 'conversation_id': 'conv-123', - } - - unified = AIHookPayload.from_cursor_payload(cursor_payload) - # Unknown events fall back to original name - assert unified.event_name == 'unknownEvent' - - -def test_from_payload_cursor() -> None: - """Test from_payload dispatcher with Cursor tool.""" - cursor_payload = { - 'hook_event_name': 'beforeSubmitPrompt', - 'prompt': 'test', - } - - unified = AIHookPayload.from_payload(cursor_payload, tool='cursor') - assert unified.event_name == AiHookEventType.PROMPT - assert unified.ide_provider == 'cursor' - - -def test_from_payload_unsupported_tool() -> None: - """Test from_payload raises ValueError for unsupported tools.""" - payload = {'hook_event_name': 'someEvent'} - - with pytest.raises(ValueError, match='Unsupported IDE/tool: unsupported'): - AIHookPayload.from_payload(payload, tool='unsupported') - - -def test_from_cursor_payload_empty_fields() -> None: - """Test handling of empty/missing fields.""" - cursor_payload = { - 'hook_event_name': 'beforeSubmitPrompt', - # Most fields missing - } - - unified = AIHookPayload.from_cursor_payload(cursor_payload) - - assert unified.event_name == AiHookEventType.PROMPT - assert unified.conversation_id is None - assert unified.prompt == '' # Default to empty string - assert unified.ide_provider == 'cursor' - - -# Claude Code payload tests - - -def test_from_claude_code_payload_prompt_event() -> None: - """Test conversion of Claude Code UserPromptSubmit payload.""" - claude_payload = { - 'hook_event_name': 'UserPromptSubmit', - 'session_id': 'session-123', - 'prompt': 'Test prompt for Claude Code', - } - - unified = AIHookPayload.from_claude_code_payload(claude_payload) - - assert unified.event_name == AiHookEventType.PROMPT - assert unified.conversation_id == 'session-123' - assert unified.ide_provider == 'claude-code' - assert unified.prompt == 'Test prompt for Claude Code' - assert type(unified.ide_provider) is str - - -def test_from_claude_code_payload_file_read_event() -> None: - """Test conversion of Claude Code PreToolUse with Read tool.""" - claude_payload = { - 'hook_event_name': 'PreToolUse', - 'session_id': 'session-456', - 'tool_name': 'Read', - 'tool_input': {'file_path': '/path/to/secret.env'}, - } - - unified = AIHookPayload.from_claude_code_payload(claude_payload) - - assert unified.event_name == AiHookEventType.FILE_READ - assert unified.file_path == '/path/to/secret.env' - assert unified.ide_provider == 'claude-code' - assert unified.mcp_tool_name is None - - -def test_from_claude_code_payload_mcp_execution_event() -> None: - """Test conversion of Claude Code PreToolUse with MCP tool.""" - claude_payload = { - 'hook_event_name': 'PreToolUse', - 'session_id': 'session-789', - 'tool_name': 'mcp__gitlab__discussion_list', - 'tool_input': {'resource_type': 'merge_request', 'parent_id': 'org/repo', 'resource_id': '4'}, - } - - unified = AIHookPayload.from_payload(claude_payload, tool='claude-code') - - assert unified.event_name == AiHookEventType.MCP_EXECUTION - assert unified.mcp_server_name == 'gitlab' - assert unified.mcp_tool_name == 'discussion_list' - assert unified.mcp_arguments == {'resource_type': 'merge_request', 'parent_id': 'org/repo', 'resource_id': '4'} - assert unified.ide_provider == 'claude-code' - - -def test_from_claude_code_payload_empty_fields() -> None: - """Test handling of empty/missing fields for Claude Code.""" - claude_payload = { - 'hook_event_name': 'UserPromptSubmit', - # Most fields missing - } - - unified = AIHookPayload.from_claude_code_payload(claude_payload) - - assert unified.event_name == AiHookEventType.PROMPT - assert unified.conversation_id is None - assert unified.prompt == '' # Default to empty string - assert unified.ide_provider == 'claude-code' - - -# Claude Code transcript extraction tests - - -def test_from_claude_code_payload_extracts_from_transcript(mocker: MockerFixture) -> None: - """Test that version, model, and generation_id are extracted from transcript file.""" - transcript_content = ( - b'{"type":"user","version":"2.1.20","uuid":"user-uuid-1","message":{"role":"user","content":"hello"}}\n' - b'{"type":"assistant","message":{"model":"claude-opus-4-5-20251101","role":"assistant",' - b'"content":[{"type":"text","text":"Hi!"}]},"uuid":"assistant-uuid-1"}\n' - b'{"type":"user","version":"2.1.20","uuid":"user-uuid-2","message":{"role":"user","content":"test prompt"}}\n' - ) - mock_path = mocker.patch('cycode.cli.apps.ai_guardrails.scan.payload.Path') - mock_path.return_value.exists.return_value = True - mock_path.return_value.open.return_value.__enter__.return_value.seek = mocker.Mock() - mock_path.return_value.open.return_value.__enter__.return_value.tell.return_value = len(transcript_content) - mock_path.return_value.open.return_value.__enter__.return_value.read.return_value = transcript_content - - claude_payload = { - 'hook_event_name': 'UserPromptSubmit', - 'session_id': 'session-123', - 'prompt': 'test prompt', - 'transcript_path': '/mock/transcript.jsonl', - } - - unified = AIHookPayload.from_claude_code_payload(claude_payload) - - assert unified.ide_version == '2.1.20' - assert unified.model == 'claude-opus-4-5-20251101' - assert unified.generation_id == 'user-uuid-2' - - -def test_from_claude_code_payload_handles_missing_transcript(mocker: MockerFixture) -> None: - """Test that missing transcript file doesn't break payload parsing.""" - mock_path = mocker.patch('cycode.cli.apps.ai_guardrails.scan.payload.Path') - mock_path.return_value.exists.return_value = False - - claude_payload = { - 'hook_event_name': 'UserPromptSubmit', - 'session_id': 'session-123', - 'prompt': 'test', - 'transcript_path': '/nonexistent/path/transcript.jsonl', - } - - unified = AIHookPayload.from_claude_code_payload(claude_payload) - - assert unified.ide_version is None - assert unified.model is None - assert unified.generation_id is None - assert unified.conversation_id == 'session-123' - assert unified.prompt == 'test' - - -def test_from_claude_code_payload_handles_no_transcript_path() -> None: - """Test that absent transcript_path doesn't break payload parsing.""" - claude_payload = { - 'hook_event_name': 'UserPromptSubmit', - 'session_id': 'session-123', - 'prompt': 'test', - } - - unified = AIHookPayload.from_claude_code_payload(claude_payload) - - assert unified.ide_version is None - assert unified.model is None - assert unified.generation_id is None - - -def test_from_claude_code_payload_extracts_model_from_nested_message(mocker: MockerFixture) -> None: - """Test that model is extracted from nested message.model field.""" - transcript_content = ( - b'{"type":"assistant","message":{"model":"claude-sonnet-4-20250514",' - b'"role":"assistant","content":[]},"uuid":"uuid-1"}\n' - ) - - mock_path = mocker.patch('cycode.cli.apps.ai_guardrails.scan.payload.Path') - mock_path.return_value.exists.return_value = True - mock_path.return_value.open.return_value.__enter__.return_value.seek = mocker.Mock() - mock_path.return_value.open.return_value.__enter__.return_value.tell.return_value = len(transcript_content) - mock_path.return_value.open.return_value.__enter__.return_value.read.return_value = transcript_content - - claude_payload = { - 'hook_event_name': 'UserPromptSubmit', - 'prompt': 'test', - 'transcript_path': '/mock/transcript.jsonl', - } - - unified = AIHookPayload.from_claude_code_payload(claude_payload) - - assert unified.model == 'claude-sonnet-4-20250514' - - -def test_from_claude_code_payload_gets_latest_user_uuid(mocker: MockerFixture) -> None: - """Test that generation_id is the UUID of the latest user message.""" - transcript_content = b"""{"type":"user","uuid":"old-user-uuid","message":{"role":"user","content":"first"}} -{"type":"assistant","uuid":"assistant-uuid","message":{"role":"assistant","content":[]}} -{"type":"user","uuid":"latest-user-uuid","message":{"role":"user","content":"second"}} -{"type":"assistant","uuid":"last-assistant-uuid","message":{"role":"assistant","content":[]}} -""" - mock_path = mocker.patch('cycode.cli.apps.ai_guardrails.scan.payload.Path') - mock_path.return_value.exists.return_value = True - mock_path.return_value.open.return_value.__enter__.return_value.seek = mocker.Mock() - mock_path.return_value.open.return_value.__enter__.return_value.tell.return_value = len(transcript_content) - mock_path.return_value.open.return_value.__enter__.return_value.read.return_value = transcript_content - - claude_payload = { - 'hook_event_name': 'UserPromptSubmit', - 'prompt': 'test', - 'transcript_path': '/mock/transcript.jsonl', - } - - unified = AIHookPayload.from_claude_code_payload(claude_payload) - - assert unified.generation_id == 'latest-user-uuid' - - -# Claude Code email extraction tests - - -def test_from_claude_code_payload_extracts_email_from_config(mocker: MockerFixture) -> None: - """Test that ide_user_email is populated from ~/.claude.json.""" - mocker.patch( - 'cycode.cli.apps.ai_guardrails.scan.payload.load_claude_config', - return_value={'oauthAccount': {'emailAddress': 'user@example.com'}}, - ) - - claude_payload = { - 'hook_event_name': 'UserPromptSubmit', - 'session_id': 'session-123', - 'prompt': 'test', - } - - unified = AIHookPayload.from_claude_code_payload(claude_payload) - assert unified.ide_user_email == 'user@example.com' - - -def test_from_claude_code_payload_email_none_when_config_missing(mocker: MockerFixture) -> None: - """Test that ide_user_email is None when ~/.claude.json is missing.""" - mocker.patch( - 'cycode.cli.apps.ai_guardrails.scan.payload.load_claude_config', - return_value=None, - ) - - claude_payload = { - 'hook_event_name': 'UserPromptSubmit', - 'session_id': 'session-123', - 'prompt': 'test', - } - - unified = AIHookPayload.from_claude_code_payload(claude_payload) - assert unified.ide_user_email is None - - -def test_from_claude_code_payload_email_none_when_no_oauth(mocker: MockerFixture) -> None: - """Test that ide_user_email is None when oauthAccount is missing from config.""" - mocker.patch( - 'cycode.cli.apps.ai_guardrails.scan.payload.load_claude_config', - return_value={'someOtherKey': 'value'}, - ) - - claude_payload = { - 'hook_event_name': 'UserPromptSubmit', - 'session_id': 'session-123', - 'prompt': 'test', - } - - unified = AIHookPayload.from_claude_code_payload(claude_payload) - assert unified.ide_user_email is None - - -# IDE detection tests - - -def test_is_payload_for_ide_claude_code_matches_claude_code() -> None: - """Test that Claude Code events match when expected IDE is claude-code.""" - payload = {'hook_event_name': 'UserPromptSubmit'} - assert AIHookPayload.is_payload_for_ide(payload, 'claude-code') is True - - payload = {'hook_event_name': 'PreToolUse'} - assert AIHookPayload.is_payload_for_ide(payload, 'claude-code') is True - - -def test_is_payload_for_ide_cursor_matches_cursor() -> None: - """Test that Cursor events match when expected IDE is cursor.""" - payload = {'hook_event_name': 'beforeSubmitPrompt'} - assert AIHookPayload.is_payload_for_ide(payload, 'cursor') is True - - payload = {'hook_event_name': 'beforeReadFile'} - assert AIHookPayload.is_payload_for_ide(payload, 'cursor') is True - - payload = {'hook_event_name': 'beforeMCPExecution'} - assert AIHookPayload.is_payload_for_ide(payload, 'cursor') is True - - -def test_is_payload_for_ide_claude_code_does_not_match_cursor() -> None: - """Test that Claude Code events don't match when expected IDE is cursor. - - This prevents double-processing when Cursor reads Claude Code hooks. - """ - payload = {'hook_event_name': 'UserPromptSubmit'} - assert AIHookPayload.is_payload_for_ide(payload, 'cursor') is False - - payload = {'hook_event_name': 'PreToolUse'} - assert AIHookPayload.is_payload_for_ide(payload, 'cursor') is False - - -def test_is_payload_for_ide_cursor_does_not_match_claude_code() -> None: - """Test that Cursor events don't match when expected IDE is claude-code.""" - payload = {'hook_event_name': 'beforeSubmitPrompt'} - assert AIHookPayload.is_payload_for_ide(payload, 'claude-code') is False - - payload = {'hook_event_name': 'beforeReadFile'} - assert AIHookPayload.is_payload_for_ide(payload, 'claude-code') is False - - -def test_is_payload_for_ide_empty_event_name() -> None: - """Test handling of empty or missing hook_event_name.""" - payload = {'hook_event_name': ''} - assert AIHookPayload.is_payload_for_ide(payload, 'cursor') is False - assert AIHookPayload.is_payload_for_ide(payload, 'claude-code') is False - - payload = {} - assert AIHookPayload.is_payload_for_ide(payload, 'cursor') is False - assert AIHookPayload.is_payload_for_ide(payload, 'claude-code') is False diff --git a/tests/cli/commands/ai_guardrails/scan/test_response_builders.py b/tests/cli/commands/ai_guardrails/scan/test_response_builders.py deleted file mode 100644 index 45f80829..00000000 --- a/tests/cli/commands/ai_guardrails/scan/test_response_builders.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Tests for IDE response builders.""" - -import pytest - -from cycode.cli.apps.ai_guardrails.scan.response_builders import ( - ClaudeCodeResponseBuilder, - CursorResponseBuilder, - IDEResponseBuilder, - get_response_builder, -) - - -def test_cursor_response_builder_allow_permission() -> None: - """Test Cursor allow permission response.""" - builder = CursorResponseBuilder() - response = builder.allow_permission() - - assert response == {'permission': 'allow'} - - -def test_cursor_response_builder_deny_permission() -> None: - """Test Cursor deny permission response with messages.""" - builder = CursorResponseBuilder() - response = builder.deny_permission('User message', 'Agent message') - - assert response == { - 'permission': 'deny', - 'user_message': 'User message', - 'agent_message': 'Agent message', - } - - -def test_cursor_response_builder_ask_permission() -> None: - """Test Cursor ask permission response for warnings.""" - builder = CursorResponseBuilder() - response = builder.ask_permission('Warning message', 'Agent warning') - - assert response == { - 'permission': 'ask', - 'user_message': 'Warning message', - 'agent_message': 'Agent warning', - } - - -def test_cursor_response_builder_allow_prompt() -> None: - """Test Cursor allow prompt response.""" - builder = CursorResponseBuilder() - response = builder.allow_prompt() - - assert response == {'continue': True} - - -def test_cursor_response_builder_deny_prompt() -> None: - """Test Cursor deny prompt response with message.""" - builder = CursorResponseBuilder() - response = builder.deny_prompt('Secrets detected') - - assert response == {'continue': False, 'user_message': 'Secrets detected'} - - -def test_get_response_builder_cursor() -> None: - """Test getting Cursor response builder.""" - builder = get_response_builder('cursor') - - assert isinstance(builder, CursorResponseBuilder) - assert isinstance(builder, IDEResponseBuilder) - - -def test_get_response_builder_unsupported() -> None: - """Test that unsupported IDE raises ValueError.""" - with pytest.raises(ValueError, match='Unsupported IDE: unknown'): - get_response_builder('unknown') - - -def test_cursor_response_builder_is_singleton() -> None: - """Test that getting the same builder returns the same instance.""" - builder1 = get_response_builder('cursor') - builder2 = get_response_builder('cursor') - - assert builder1 is builder2 - - -# Claude Code response builder tests - - -def test_claude_code_response_builder_allow_permission() -> None: - """Test Claude Code allow permission response.""" - builder = ClaudeCodeResponseBuilder() - response = builder.allow_permission() - - assert response == { - 'hookSpecificOutput': { - 'hookEventName': 'PreToolUse', - 'permissionDecision': 'allow', - } - } - - -def test_claude_code_response_builder_deny_permission() -> None: - """Test Claude Code deny permission response with messages.""" - builder = ClaudeCodeResponseBuilder() - response = builder.deny_permission('User message', 'Agent message') - - assert response == { - 'hookSpecificOutput': { - 'hookEventName': 'PreToolUse', - 'permissionDecision': 'deny', - 'permissionDecisionReason': 'User message', - } - } - - -def test_claude_code_response_builder_ask_permission() -> None: - """Test Claude Code ask permission response for warnings.""" - builder = ClaudeCodeResponseBuilder() - response = builder.ask_permission('Warning message', 'Agent warning') - - assert response == { - 'hookSpecificOutput': { - 'hookEventName': 'PreToolUse', - 'permissionDecision': 'ask', - 'permissionDecisionReason': 'Warning message', - } - } - - -def test_claude_code_response_builder_allow_prompt() -> None: - """Test Claude Code allow prompt response (empty dict).""" - builder = ClaudeCodeResponseBuilder() - response = builder.allow_prompt() - - assert response == {} - - -def test_claude_code_response_builder_deny_prompt() -> None: - """Test Claude Code deny prompt response with message.""" - builder = ClaudeCodeResponseBuilder() - response = builder.deny_prompt('Secrets detected') - - assert response == {'decision': 'block', 'reason': 'Secrets detected'} - - -def test_get_response_builder_claude_code() -> None: - """Test getting Claude Code response builder.""" - builder = get_response_builder('claude-code') - - assert isinstance(builder, ClaudeCodeResponseBuilder) - assert isinstance(builder, IDEResponseBuilder) diff --git a/tests/cli/commands/ai_guardrails/scan/test_scan_command.py b/tests/cli/commands/ai_guardrails/scan/test_scan_command.py index 4bcb35f2..35f7e4fa 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_scan_command.py +++ b/tests/cli/commands/ai_guardrails/scan/test_scan_command.py @@ -9,7 +9,9 @@ from typer.testing import CliRunner from cycode.cli.apps.ai_guardrails import app as ai_guardrails_app +from cycode.cli.apps.ai_guardrails.ides.base import HookDecision from cycode.cli.apps.ai_guardrails.scan.scan_command import scan_command +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType @pytest.fixture @@ -129,7 +131,7 @@ def test_claude_code_payload_with_claude_code_ide( mocker.patch('sys.stdin', StringIO(json.dumps(payload))) mock_scan_command_deps['load_policy'].return_value = {'fail_open': True} - mock_handler = MagicMock(return_value={'decision': 'allow'}) + mock_handler = MagicMock(return_value=HookDecision.allow(AiHookEventType.PROMPT)) mock_scan_command_deps['get_handler'].return_value = mock_handler scan_command(mock_ctx, ide='claude-code') @@ -146,15 +148,15 @@ class TestDefaultIdeParameterViaCli: def test_scan_command_default_ide_via_cli(self, mocker: MockerFixture) -> None: """Test scan_command works with default --ide when invoked via CLI. - This test catches issues where Typer converts enum defaults to strings - incorrectly (e.g., AIIDEType.CURSOR becomes 'AIIDEType.CURSOR' instead of 'cursor'). + Catches regressions where the default value would no longer match a + registered IDE name (e.g. after renaming `DEFAULT_IDE_NAME`). """ mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command._initialize_clients') mocker.patch( 'cycode.cli.apps.ai_guardrails.scan.scan_command.load_policy', return_value={'fail_open': True}, ) - mock_handler = MagicMock(return_value={'continue': True}) + mock_handler = MagicMock(return_value=HookDecision.allow(AiHookEventType.PROMPT)) mocker.patch( 'cycode.cli.apps.ai_guardrails.scan.scan_command.get_handler_for_event', return_value=mock_handler, diff --git a/tests/cli/commands/ai_guardrails/test_claude_config.py b/tests/cli/commands/ai_guardrails/test_claude_config.py deleted file mode 100644 index 6bbdbcab..00000000 --- a/tests/cli/commands/ai_guardrails/test_claude_config.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Tests for Claude Code config file reader.""" - -import json -from pathlib import Path - -from pyfakefs.fake_filesystem import FakeFilesystem - -from cycode.cli.apps.ai_guardrails.scan.claude_config import get_user_email, load_claude_config - - -def test_load_claude_config_valid(fs: FakeFilesystem) -> None: - """Test loading a valid ~/.claude.json file.""" - config = {'oauthAccount': {'emailAddress': 'user@example.com'}} - config_path = Path.home() / '.claude.json' - fs.create_file(config_path, contents=json.dumps(config)) - - result = load_claude_config(config_path) - assert result == config - - -def test_load_claude_config_missing_file(fs: FakeFilesystem) -> None: - """Test loading when ~/.claude.json does not exist.""" - fs.create_dir(Path.home()) - config_path = Path.home() / '.claude.json' - - result = load_claude_config(config_path) - assert result is None - - -def test_load_claude_config_corrupt_file(fs: FakeFilesystem) -> None: - """Test loading when ~/.claude.json contains invalid JSON.""" - config_path = Path.home() / '.claude.json' - fs.create_file(config_path, contents='not valid json {{{') - - result = load_claude_config(config_path) - assert result is None - - -def test_get_user_email_present() -> None: - """Test extracting email when oauthAccount.emailAddress exists.""" - config = {'oauthAccount': {'emailAddress': 'user@example.com'}} - assert get_user_email(config) == 'user@example.com' - - -def test_get_user_email_missing_oauth_account() -> None: - """Test extracting email when oauthAccount key is missing.""" - config = {'someOtherKey': 'value'} - assert get_user_email(config) is None - - -def test_get_user_email_missing_email_address() -> None: - """Test extracting email when oauthAccount exists but emailAddress is missing.""" - config = {'oauthAccount': {'someOtherField': 'value'}} - assert get_user_email(config) is None diff --git a/tests/cli/commands/ai_guardrails/test_command_utils.py b/tests/cli/commands/ai_guardrails/test_command_utils.py deleted file mode 100644 index 5d8d224b..00000000 --- a/tests/cli/commands/ai_guardrails/test_command_utils.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Tests for AI guardrails command utilities.""" - -import pytest -import typer - -from cycode.cli.apps.ai_guardrails.command_utils import ( - validate_and_parse_ide, - validate_scope, -) -from cycode.cli.apps.ai_guardrails.consts import AIIDEType - - -def test_validate_and_parse_ide_valid() -> None: - """Test parsing valid IDE names.""" - assert validate_and_parse_ide('cursor') == AIIDEType.CURSOR - assert validate_and_parse_ide('CURSOR') == AIIDEType.CURSOR - assert validate_and_parse_ide('CuRsOr') == AIIDEType.CURSOR - assert validate_and_parse_ide('claude-code') == AIIDEType.CLAUDE_CODE - assert validate_and_parse_ide('Claude-Code') == AIIDEType.CLAUDE_CODE - assert validate_and_parse_ide('all') is None - - -def test_validate_and_parse_ide_invalid() -> None: - """Test that invalid IDE raises typer.Exit.""" - with pytest.raises(typer.Exit) as exc_info: - validate_and_parse_ide('invalid_ide') - assert exc_info.value.exit_code == 1 - - -def test_validate_scope_valid_default() -> None: - """Test validating valid scope with default allowed scopes.""" - # Should not raise any exception - validate_scope('user') - validate_scope('repo') - - -def test_validate_scope_invalid_default() -> None: - """Test that invalid scope raises typer.Exit with default allowed scopes.""" - with pytest.raises(typer.Exit) as exc_info: - validate_scope('invalid') - assert exc_info.value.exit_code == 1 - - with pytest.raises(typer.Exit) as exc_info: - validate_scope('all') # 'all' not in default allowed scopes - assert exc_info.value.exit_code == 1 - - -def test_validate_scope_valid_custom() -> None: - """Test validating scope with custom allowed scopes.""" - # Should not raise any exception - validate_scope('user', allowed_scopes=('user', 'repo', 'all')) - validate_scope('repo', allowed_scopes=('user', 'repo', 'all')) - validate_scope('all', allowed_scopes=('user', 'repo', 'all')) - - -def test_validate_scope_invalid_custom() -> None: - """Test that invalid scope raises typer.Exit with custom allowed scopes.""" - with pytest.raises(typer.Exit) as exc_info: - validate_scope('invalid', allowed_scopes=('user', 'repo', 'all')) - assert exc_info.value.exit_code == 1 diff --git a/tests/cli/commands/ai_guardrails/test_hooks_manager.py b/tests/cli/commands/ai_guardrails/test_hooks_manager.py index a5732bca..4ff8d575 100644 --- a/tests/cli/commands/ai_guardrails/test_hooks_manager.py +++ b/tests/cli/commands/ai_guardrails/test_hooks_manager.py @@ -1,4 +1,4 @@ -"""Tests for AI guardrails hooks manager.""" +"""Tests for AI guardrails hooks manager and per-IDE hooks rendering.""" from pathlib import Path @@ -8,27 +8,22 @@ from cycode.cli.apps.ai_guardrails.consts import ( CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND, - AIIDEType, PolicyMode, - get_hooks_config, ) from cycode.cli.apps.ai_guardrails.hooks_manager import create_policy_file, is_cycode_hook_entry +from cycode.cli.apps.ai_guardrails.ides.claude_code import ClaudeCode +from cycode.cli.apps.ai_guardrails.ides.cursor import Cursor def test_is_cycode_hook_entry_cursor_format() -> None: - """Test detecting Cycode hook in Cursor format (flat command).""" - entry = {'command': 'cycode ai-guardrails scan'} - assert is_cycode_hook_entry(entry) is True - - entry = {'command': 'cycode ai-guardrails scan --some-flag'} - assert is_cycode_hook_entry(entry) is True + """Detect Cycode hook in Cursor's flat command format.""" + assert is_cycode_hook_entry({'command': 'cycode ai-guardrails scan'}) is True + assert is_cycode_hook_entry({'command': 'cycode ai-guardrails scan --some-flag'}) is True def test_is_cycode_hook_entry_claude_code_format() -> None: - """Test detecting Cycode hook in Claude Code format (nested).""" - entry = { - 'hooks': [{'type': 'command', 'command': 'cycode ai-guardrails scan --ide claude-code'}], - } + """Detect Cycode hook in Claude Code's nested format.""" + entry = {'hooks': [{'type': 'command', 'command': 'cycode ai-guardrails scan --ide claude-code'}]} assert is_cycode_hook_entry(entry) is True entry = { @@ -39,57 +34,44 @@ def test_is_cycode_hook_entry_claude_code_format() -> None: def test_is_cycode_hook_entry_non_cycode() -> None: - """Test that non-Cycode hooks are not detected.""" - # Cursor format - entry = {'command': 'some-other-command'} - assert is_cycode_hook_entry(entry) is False - - # Claude Code format - entry = { - 'hooks': [{'type': 'command', 'command': 'some-other-command'}], - } - assert is_cycode_hook_entry(entry) is False - - # Empty entry - entry = {} - assert is_cycode_hook_entry(entry) is False + """Non-Cycode hooks must not be detected.""" + assert is_cycode_hook_entry({'command': 'some-other-command'}) is False + assert is_cycode_hook_entry({'hooks': [{'type': 'command', 'command': 'some-other-command'}]}) is False + assert is_cycode_hook_entry({}) is False def test_is_cycode_hook_entry_partial_match() -> None: - """Test partial command match.""" - # Should match if command contains 'cycode ai-guardrails scan' - entry = {'command': '/usr/local/bin/cycode ai-guardrails scan'} - assert is_cycode_hook_entry(entry) is True + """Detection is substring-based: full paths and trailing flags still count.""" + assert is_cycode_hook_entry({'command': '/usr/local/bin/cycode ai-guardrails scan'}) is True + assert is_cycode_hook_entry({'command': 'cycode ai-guardrails scan --verbose'}) is True - entry = {'command': 'cycode ai-guardrails scan --verbose'} - assert is_cycode_hook_entry(entry) is True + +# Per-IDE hook config tests (now exposed via IDE.render_hooks_config) -def test_get_hooks_config_cursor_sync() -> None: - """Test Cursor hooks config in default (sync) mode.""" - config = get_hooks_config(AIIDEType.CURSOR) - hooks = config['hooks'] - scan_hooks = {k: v for k, v in hooks.items() if k != 'sessionStart'} +def test_cursor_render_hooks_sync() -> None: + """Cursor sync hooks: no '&' in scan commands.""" + config = Cursor().render_hooks_config() + scan_hooks = {k: v for k, v in config['hooks'].items() if k != 'sessionStart'} for entries in scan_hooks.values(): for entry in entries: assert entry['command'] == CYCODE_SCAN_PROMPT_COMMAND assert '&' not in entry['command'] -def test_get_hooks_config_cursor_async() -> None: - """Test Cursor hooks config in async mode appends & to command.""" - config = get_hooks_config(AIIDEType.CURSOR, async_mode=True) - hooks = config['hooks'] - scan_hooks = {k: v for k, v in hooks.items() if k != 'sessionStart'} +def test_cursor_render_hooks_async() -> None: + """Cursor async hooks: '&' suffix on scan commands.""" + config = Cursor().render_hooks_config(async_mode=True) + scan_hooks = {k: v for k, v in config['hooks'].items() if k != 'sessionStart'} for entries in scan_hooks.values(): for entry in entries: assert entry['command'].endswith('&') assert CYCODE_SCAN_PROMPT_COMMAND in entry['command'] -def test_get_hooks_config_cursor_session_start() -> None: - """Test Cursor hooks config includes sessionStart with --ide flag.""" - config = get_hooks_config(AIIDEType.CURSOR) +def test_cursor_render_hooks_session_start() -> None: + """Cursor session_start carries the --ide flag explicitly.""" + config = Cursor().render_hooks_config() assert 'sessionStart' in config['hooks'] entries = config['hooks']['sessionStart'] assert len(entries) == 1 @@ -97,9 +79,9 @@ def test_get_hooks_config_cursor_session_start() -> None: assert '--ide cursor' in entries[0]['command'] -def test_get_hooks_config_claude_code_sync() -> None: - """Test Claude Code hooks config in default (sync) mode.""" - config = get_hooks_config(AIIDEType.CLAUDE_CODE) +def test_claude_code_render_hooks_sync() -> None: + """Claude Code sync hooks: no async/timeout fields.""" + config = ClaudeCode().render_hooks_config() scan_events = {k: v for k, v in config['hooks'].items() if k != 'SessionStart'} for event_entries in scan_events.values(): for event_entry in event_entries: @@ -108,9 +90,9 @@ def test_get_hooks_config_claude_code_sync() -> None: assert 'timeout' not in hook -def test_get_hooks_config_claude_code_async() -> None: - """Test Claude Code hooks config in async mode adds async and timeout.""" - config = get_hooks_config(AIIDEType.CLAUDE_CODE, async_mode=True) +def test_claude_code_render_hooks_async() -> None: + """Claude Code async hooks: 'async' flag + timeout.""" + config = ClaudeCode().render_hooks_config(async_mode=True) scan_events = {k: v for k, v in config['hooks'].items() if k != 'SessionStart'} for event_entries in scan_events.values(): for event_entry in event_entries: @@ -118,9 +100,9 @@ def test_get_hooks_config_claude_code_async() -> None: assert hook['async'] is True -def test_get_hooks_config_claude_code_session_start() -> None: - """Test Claude Code hooks config includes SessionStart with --ide flag.""" - config = get_hooks_config(AIIDEType.CLAUDE_CODE) +def test_claude_code_render_hooks_session_start() -> None: + """Claude Code SessionStart carries the --ide flag explicitly.""" + config = ClaudeCode().render_hooks_config() assert 'SessionStart' in config['hooks'] entries = config['hooks']['SessionStart'] assert len(entries) == 1 @@ -128,8 +110,11 @@ def test_get_hooks_config_claude_code_session_start() -> None: assert '--ide claude-code' in entries[0]['hooks'][0]['command'] +# Policy file tests + + def test_create_policy_file_warn(fs: FakeFilesystem) -> None: - """Test creating warn-mode policy file.""" + """Create a warn-mode policy file.""" fs.create_dir(Path.home()) success, message = create_policy_file('user', PolicyMode.WARN) @@ -138,13 +123,11 @@ def test_create_policy_file_warn(fs: FakeFilesystem) -> None: policy_path = Path.home() / '.cycode' / 'ai-guardrails.yaml' assert policy_path.exists() - - policy = yaml.safe_load(policy_path.read_text()) - assert policy['mode'] == 'warn' + assert yaml.safe_load(policy_path.read_text())['mode'] == 'warn' def test_create_policy_file_block(fs: FakeFilesystem) -> None: - """Test creating block-mode policy file.""" + """Create a block-mode policy file.""" fs.create_dir(Path.home()) success, message = create_policy_file('user', PolicyMode.BLOCK) @@ -152,12 +135,11 @@ def test_create_policy_file_block(fs: FakeFilesystem) -> None: assert 'block mode' in message policy_path = Path.home() / '.cycode' / 'ai-guardrails.yaml' - policy = yaml.safe_load(policy_path.read_text()) - assert policy['mode'] == 'block' + assert yaml.safe_load(policy_path.read_text())['mode'] == 'block' def test_create_policy_file_updates_existing(fs: FakeFilesystem) -> None: - """Test that re-running only updates mode and preserves other customizations.""" + """Re-running updates only the mode field and preserves customizations.""" policy_dir = Path.home() / '.cycode' fs.create_dir(policy_dir) policy_path = policy_dir / 'ai-guardrails.yaml' @@ -172,15 +154,13 @@ def test_create_policy_file_updates_existing(fs: FakeFilesystem) -> None: def test_create_policy_file_repo_scope(fs: FakeFilesystem) -> None: - """Test creating policy file in repo scope.""" + """Create a policy file in repo scope.""" repo_path = Path('/my-repo') fs.create_dir(repo_path) - success, message = create_policy_file('repo', PolicyMode.WARN, repo_path=repo_path) + success, _ = create_policy_file('repo', PolicyMode.WARN, repo_path=repo_path) assert success is True policy_path = repo_path / '.cycode' / 'ai-guardrails.yaml' assert policy_path.exists() - - policy = yaml.safe_load(policy_path.read_text()) - assert policy['mode'] == 'warn' + assert yaml.safe_load(policy_path.read_text())['mode'] == 'warn' diff --git a/tests/cli/commands/ai_guardrails/test_session_start_command.py b/tests/cli/commands/ai_guardrails/test_session_start_command.py index 82a13043..0ae57226 100644 --- a/tests/cli/commands/ai_guardrails/test_session_start_command.py +++ b/tests/cli/commands/ai_guardrails/test_session_start_command.py @@ -9,6 +9,8 @@ import typer from cycode.cli.apps.ai_guardrails import session_start_command as _session_start_mod +from cycode.cli.apps.ai_guardrails.ides import claude_code as _claude_mod +from cycode.cli.apps.ai_guardrails.ides import cursor as _cursor_mod from cycode.cli.apps.ai_guardrails.session_start_command import session_start_command @@ -112,8 +114,8 @@ def test_invalid_json_stdin_skips_session_init( # Conversation creation tests -@patch.object(_session_start_mod, 'extract_from_claude_transcript') -@patch.object(_session_start_mod, 'load_claude_config') +@patch.object(_claude_mod, 'extract_from_claude_transcript') +@patch.object(_claude_mod, 'load_claude_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') def test_claude_code_creates_conversation( @@ -176,7 +178,7 @@ def test_cursor_creates_conversation( assert call_payload.ide_provider == 'cursor' -@patch.object(_session_start_mod, 'load_claude_config') +@patch.object(_claude_mod, 'load_claude_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') def test_conversation_creation_failure_non_blocking( @@ -203,8 +205,8 @@ def test_conversation_creation_failure_non_blocking( # MCP server reporting tests -@patch.object(_session_start_mod, 'load_claude_settings') -@patch.object(_session_start_mod, 'load_claude_config') +@patch.object(_claude_mod, 'load_claude_settings') +@patch.object(_claude_mod, 'load_claude_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') def test_claude_code_reports_mcp_servers( @@ -238,8 +240,8 @@ def test_claude_code_reports_mcp_servers( ) -@patch.object(_session_start_mod, 'load_claude_settings') -@patch.object(_session_start_mod, 'load_claude_config') +@patch.object(_claude_mod, 'load_claude_settings') +@patch.object(_claude_mod, 'load_claude_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') def test_claude_code_merges_plugin_mcp_servers_and_metadata( @@ -298,8 +300,8 @@ def test_claude_code_merges_plugin_mcp_servers_and_metadata( ) -@patch.object(_session_start_mod, 'load_claude_settings') -@patch.object(_session_start_mod, 'load_claude_config') +@patch.object(_claude_mod, 'load_claude_settings') +@patch.object(_claude_mod, 'load_claude_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') def test_claude_code_no_mcp_servers_no_plugins_skips_report( @@ -324,7 +326,7 @@ def test_claude_code_no_mcp_servers_no_plugins_skips_report( mock_ai_client.report_session_context.assert_not_called() -@patch.object(_session_start_mod, 'load_cursor_config') +@patch.object(_cursor_mod, '_load_cursor_mcp_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') def test_cursor_reports_mcp_servers( @@ -350,7 +352,7 @@ def test_cursor_reports_mcp_servers( ) -@patch.object(_session_start_mod, 'load_cursor_config') +@patch.object(_cursor_mod, '_load_cursor_mcp_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') def test_cursor_no_mcp_servers_skips_report( From 3d152224d199bd2f8f4ddfda6b877d241afbc3bb Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Wed, 27 May 2026 16:29:26 +0300 Subject: [PATCH 073/123] CM-62984 add codex cli support (#461) Co-authored-by: Claude Opus 4.7 (1M context) --- .../cli/apps/ai_guardrails/hooks_manager.py | 108 ++++-- .../cli/apps/ai_guardrails/ides/__init__.py | 3 +- .../apps/ai_guardrails/ides/_plugin_utils.py | 73 ++++ cycode/cli/apps/ai_guardrails/ides/base.py | 20 ++ .../apps/ai_guardrails/ides/claude_code.py | 84 ++--- cycode/cli/apps/ai_guardrails/ides/codex.py | 310 ++++++++++++++++ cycode/cli/apps/ai_guardrails/ides/cursor.py | 2 +- .../cli/apps/ai_guardrails/scan/handlers.py | 88 +++-- cycode/cli/utils/jwt_utils.py | 8 + poetry.lock | 21 +- pyproject.toml | 2 + .../commands/ai_guardrails/ides/test_codex.py | 335 ++++++++++++++++++ .../ai_guardrails/ides/test_contract.py | 2 +- .../ai_guardrails/test_hooks_manager.py | 111 +++++- 14 files changed, 1058 insertions(+), 109 deletions(-) create mode 100644 cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py create mode 100644 cycode/cli/apps/ai_guardrails/ides/codex.py create mode 100644 tests/cli/commands/ai_guardrails/ides/test_codex.py diff --git a/cycode/cli/apps/ai_guardrails/hooks_manager.py b/cycode/cli/apps/ai_guardrails/hooks_manager.py index 1fe23bb2..192bb9f3 100644 --- a/cycode/cli/apps/ai_guardrails/hooks_manager.py +++ b/cycode/cli/apps/ai_guardrails/hooks_manager.py @@ -28,7 +28,7 @@ def _is_cycode_command(command: str) -> bool: def is_cycode_hook_entry(entry: dict) -> bool: - """Detect Cycode hook entries in both Cursor (flat) and Claude Code (nested) shapes.""" + """True if any hook inside ``entry`` is owned by Cycode.""" command = entry.get('command', '') if _is_cycode_command(command): return True @@ -40,6 +40,31 @@ def is_cycode_hook_entry(entry: dict) -> bool: return False +def _strip_cycode_from_entry(entry: dict) -> Optional[dict]: + """Remove Cycode hooks from ``entry`` and return the remainder. + + Returns ``None`` when nothing useful remains (Cursor-flat Cycode entry, or + every nested hook was Cycode). Non-Cycode hooks co-located in the same + entry are preserved. + """ + # Cursor format: the entry itself IS a single hook command. + if 'command' in entry and 'hooks' not in entry: + return None if _is_cycode_command(entry.get('command', '')) else entry + + # Claude Code / Codex format: nested `hooks` list inside the entry. + nested = entry.get('hooks') + if isinstance(nested, list): + kept = [h for h in nested if not (isinstance(h, dict) and _is_cycode_command(h.get('command', '')))] + if not kept: + return None + if len(kept) == len(nested): + return entry # nothing Cycode-shaped inside; preserve identity + return {**entry, 'hooks': kept} + + # Entry has neither shape we recognize — leave it alone defensively. + return entry + + def _load_hooks_file(hooks_path: Path) -> Optional[dict]: if not hooks_path.exists(): return None @@ -108,50 +133,83 @@ def install_hooks( for event, entries in rendered['hooks'].items(): existing['hooks'].setdefault(event, []) - - # Remove any existing Cycode entries for this event - existing['hooks'][event] = [e for e in existing['hooks'][event] if not is_cycode_hook_entry(e)] - - # Add new Cycode entries + existing['hooks'][event] = [ + stripped for e in existing['hooks'][event] if (stripped := _strip_cycode_from_entry(e)) is not None + ] for entry in entries: existing['hooks'][event].append(entry) - if _save_hooks_file(hooks_path, existing): - return True, f'AI guardrails hooks installed: {hooks_path}' - return False, f'Failed to install hooks to {hooks_path}' + if not _save_hooks_file(hooks_path, existing): + return False, f'Failed to install hooks to {hooks_path}' + message = f'AI guardrails hooks installed: {hooks_path}' -def uninstall_hooks(ide: IDE, scope: str = 'user', repo_path: Optional[Path] = None) -> tuple[bool, str]: - """Remove Cycode AI guardrails hooks for ``ide``.""" - hooks_path = ide.settings_path(scope, repo_path) + # IDE-specific extras (e.g. Codex enables a TOML feature flag). + extra_ok, extra_message = ide.post_install(scope, repo_path) + if not extra_ok: + return False, extra_message + if extra_message: + message = f'{message}\n {extra_message}' - existing = _load_hooks_file(hooks_path) - if existing is None: - return True, f'No hooks file found at {hooks_path}' + return True, message + +def _strip_cycode_entries(existing: dict) -> bool: + """Mutate ``existing`` to drop Cycode hooks (surgically). Return True if anything changed.""" modified = False for event in list(existing.get('hooks', {}).keys()): - original_count = len(existing['hooks'][event]) - existing['hooks'][event] = [e for e in existing['hooks'][event] if not is_cycode_hook_entry(e)] - if len(existing['hooks'][event]) != original_count: - modified = True - if not existing['hooks'][event]: + before = existing['hooks'][event] + after: list = [] + for e in before: + stripped = _strip_cycode_from_entry(e) + if stripped is None: + modified = True + continue + if stripped is not e: + modified = True + after.append(stripped) + if not after: del existing['hooks'][event] + else: + existing['hooks'][event] = after + return modified + +def _persist_uninstall(hooks_path: Path, existing: dict, modified: bool) -> tuple[bool, str]: + """Apply the uninstall result to disk and return ``(success, message)``.""" if not modified: return True, 'No Cycode hooks found to remove' - if not existing.get('hooks'): try: hooks_path.unlink() - return True, f'Removed hooks file: {hooks_path}' except Exception as e: logger.debug('Failed to delete hooks file', exc_info=e) return False, f'Failed to remove hooks file: {hooks_path}' + return True, f'Removed hooks file: {hooks_path}' + if not _save_hooks_file(hooks_path, existing): + return False, f'Failed to update hooks file: {hooks_path}' + return True, f'Cycode hooks removed from: {hooks_path}' + + +def uninstall_hooks(ide: IDE, scope: str = 'user', repo_path: Optional[Path] = None) -> tuple[bool, str]: + """Remove Cycode AI guardrails hooks for ``ide``.""" + hooks_path = ide.settings_path(scope, repo_path) + + existing = _load_hooks_file(hooks_path) + if existing is None: + return True, f'No hooks file found at {hooks_path}' - if _save_hooks_file(hooks_path, existing): - return True, f'Cycode hooks removed from: {hooks_path}' - return False, f'Failed to update hooks file: {hooks_path}' + modified = _strip_cycode_entries(existing) + file_ok, message = _persist_uninstall(hooks_path, existing, modified) + if not file_ok: + return False, message + + extra_ok, extra_message = ide.post_uninstall(scope, repo_path) + if not extra_ok: + return False, extra_message + if extra_message: + message = f'{message}\n {extra_message}' + return True, message def get_hooks_status(ide: IDE, scope: str = 'user', repo_path: Optional[Path] = None) -> dict: diff --git a/cycode/cli/apps/ai_guardrails/ides/__init__.py b/cycode/cli/apps/ai_guardrails/ides/__init__.py index 92859701..e598c5a5 100644 --- a/cycode/cli/apps/ai_guardrails/ides/__init__.py +++ b/cycode/cli/apps/ai_guardrails/ides/__init__.py @@ -9,11 +9,12 @@ from cycode.cli.apps.ai_guardrails.ides.base import IDE from cycode.cli.apps.ai_guardrails.ides.claude_code import ClaudeCode +from cycode.cli.apps.ai_guardrails.ides.codex import Codex from cycode.cli.apps.ai_guardrails.ides.cursor import Cursor # Single source of truth: name → singleton instance. # `--ide` choices and install/uninstall/status iteration both derive from this. -IDES: dict[str, IDE] = {ide.name: ide for ide in (Cursor(), ClaudeCode())} +IDES: dict[str, IDE] = {ide.name: ide for ide in (Cursor(), ClaudeCode(), Codex())} # Default IDE used when `--ide` is omitted. Kept here so the value is colocated # with the registry; no module outside `ides/` needs to know which IDE wins. diff --git a/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py b/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py new file mode 100644 index 00000000..186dd37f --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py @@ -0,0 +1,73 @@ +"""Shared plugin-resolution helpers for IDE integrations. + +Both Claude Code and Codex use the same ``@`` key convention +and emit the same telemetry shape — only the marketplace layout and manifest +location differ. ``walk_enabled_plugins`` is the IDE-agnostic loop; each IDE +supplies the two callables that vary (``locate_dir`` + ``read_plugin``). +""" + +import json +from pathlib import Path +from typing import Any, Callable, Optional + +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails Plugins') + + +def load_plugin_json(path: Path) -> Optional[dict]: + """Load a JSON file inside a plugin directory; None if missing or invalid.""" + if not path.exists(): + return None + try: + return json.loads(path.read_text(encoding='utf-8')) + except Exception as e: + logger.debug('Failed to load plugin file, %s', {'path': str(path)}, exc_info=e) + return None + + +def walk_enabled_plugins( + plugin_entries: dict[str, Any], + is_enabled: Callable[[Any], bool], + locate_dir: Callable[[str, str], Optional[Path]], + read_plugin: Callable[[Path], tuple[dict, dict]], +) -> tuple[dict, dict]: + """Iterate enabled plugins; merge their MCP servers and metadata. + + Args: + plugin_entries: ``{@: settings}`` map from the IDE config. + is_enabled: returns True if ``settings`` indicates the plugin is on + (e.g. ``bool(settings)`` for Claude, ``settings.get('enabled')`` for Codex). + locate_dir: given ``(plugin_name, marketplace)``, returns the plugin's + filesystem path or None if it can't be resolved. + read_plugin: given the plugin path, returns ``(entry_fields, servers)``: + ``entry_fields`` are extra metadata to attach to the inventory entry + (name/version/description/...), ``servers`` are MCP servers contributed. + + Returns ``(merged_mcp_servers, enriched_plugins)``. Plugin keys without + ``@`` (or that fail to resolve to a directory) still appear in the + inventory with just ``{'enabled': True}`` so we don't silently drop them. + """ + merged_mcp: dict = {} + enriched: dict = {} + + for plugin_key, settings in plugin_entries.items(): + if not is_enabled(settings): + continue + + entry: dict = {'enabled': True} + enriched[plugin_key] = entry + + if '@' not in plugin_key: + continue + plugin_name, marketplace = plugin_key.split('@', 1) + + plugin_dir = locate_dir(plugin_name, marketplace) + if plugin_dir is None: + continue + + plugin_fields, servers = read_plugin(plugin_dir) + entry.update(plugin_fields) + merged_mcp.update(servers) + + return merged_mcp, enriched diff --git a/cycode/cli/apps/ai_guardrails/ides/base.py b/cycode/cli/apps/ai_guardrails/ides/base.py index 55d5fb05..92065590 100644 --- a/cycode/cli/apps/ai_guardrails/ides/base.py +++ b/cycode/cli/apps/ai_guardrails/ides/base.py @@ -108,6 +108,26 @@ def render_hooks_config(self, async_mode: bool = False) -> dict: ``hooks_manager`` can treat them uniformly. """ + def post_install(self, scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]: + """Run IDE-specific actions after the hooks file is written. + + Default: no-op success. Override to perform extra setup that doesn't + belong in the hooks file itself — e.g. Codex enables a + ``[features] codex_hooks = true`` flag in its TOML config. + + Returns ``(success, message)``. If ``success`` is False, the overall + install is considered failed. + """ + return True, '' + + def post_uninstall(self, scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]: + """Run IDE-specific cleanup after the hooks file is removed. + + Default: no-op success. Override to undo whatever ``post_install`` + wrote outside the hooks file. + """ + return True, '' + # --- runtime scan --- @abstractmethod diff --git a/cycode/cli/apps/ai_guardrails/ides/claude_code.py b/cycode/cli/apps/ai_guardrails/ides/claude_code.py index 519914b9..4178ec56 100644 --- a/cycode/cli/apps/ai_guardrails/ides/claude_code.py +++ b/cycode/cli/apps/ai_guardrails/ides/claude_code.py @@ -7,6 +7,7 @@ from typing import ClassVar, Optional from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND +from cycode.cli.apps.ai_guardrails.ides._plugin_utils import load_plugin_json, walk_enabled_plugins from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType @@ -127,7 +128,7 @@ def load_claude_config(config_path: Optional[Path] = None) -> Optional[dict]: """Load and parse `~/.claude.json`. Returns None if missing/invalid.""" path = config_path or _CLAUDE_CONFIG_PATH if not path.exists(): - logger.debug('Claude config file not found', extra={'path': str(path)}) + logger.debug('Claude config file not found, %s', {'path': str(path)}) return None try: return json.loads(path.read_text(encoding='utf-8')) @@ -150,7 +151,7 @@ def load_claude_settings(settings_path: Optional[Path] = None) -> Optional[dict] """Load and parse `~/.claude/settings.json`. Returns None if missing/invalid.""" path = settings_path or _CLAUDE_SETTINGS_PATH if not path.exists(): - logger.debug('Claude settings file not found', extra={'path': str(path)}) + logger.debug('Claude settings file not found, %s', {'path': str(path)}) return None try: return json.loads(path.read_text(encoding='utf-8')) @@ -171,69 +172,47 @@ def _resolve_marketplace_path(marketplace: dict) -> Optional[Path]: return path if path.is_dir() else None -def _load_plugin_json_file(plugin_path: Path, relative_path: str) -> Optional[dict]: - """Load and parse a JSON file inside a plugin directory. +def _read_claude_plugin(plugin_dir: Path) -> tuple[dict, dict]: + """Read one Claude Code plugin's manifest + MCP servers. - Returns None if the file is missing, unreadable, or has invalid JSON. + Claude hardcodes the MCP file at ``/.mcp.json`` and always + wraps it as ``{"mcpServers": {...}}``. """ - target = plugin_path / relative_path - if not target.exists(): - return None - try: - return json.loads(target.read_text(encoding='utf-8')) - except Exception as e: - logger.debug('Failed to load plugin file', extra={'path': str(target)}, exc_info=e) - return None + manifest = load_plugin_json(plugin_dir / '.claude-plugin' / 'plugin.json') or {} + entry: dict = {} + for field in ('name', 'version', 'description'): + if field in manifest: + entry[field] = manifest[field] + mcp_config = load_plugin_json(plugin_dir / '.mcp.json') or {} + servers: dict = mcp_config.get('mcpServers') or {} + if servers: + entry['mcp_server_names'] = list(servers.keys()) + return entry, servers -def resolve_plugins(settings: dict) -> tuple[dict, dict]: - """Resolve enabled plugins to their MCP servers and metadata. - Walks ``enabledPlugins`` from claude settings, resolves each plugin's - marketplace directory via ``extraKnownMarketplaces``, and reads: - - ``/.mcp.json`` for MCP servers (merged into a flat dict) - - ``/.claude-plugin/plugin.json`` for metadata (name, version, description) +def resolve_plugins(settings: dict) -> tuple[dict, dict]: + """Walk Claude Code's ``enabledPlugins`` via the shared plugin walker. - Returns ``(merged_mcp_servers, enriched_plugins)``. + Each enabled plugin's marketplace is resolved through + ``extraKnownMarketplaces`` to a directory; the rest of the work + (manifest + ``.mcp.json``) is the shared ``_read_claude_plugin``. """ enabled = settings.get('enabledPlugins') or {} marketplaces = settings.get('extraKnownMarketplaces') or {} - merged_mcp: dict = {} - enriched: dict = {} - for plugin_key, is_enabled in enabled.items(): - if not is_enabled: - continue - - entry: dict = {'enabled': True} - enriched[plugin_key] = entry - - if '@' not in plugin_key: - continue - - _plugin_name, marketplace_name = plugin_key.split('@', 1) + def _locate(_plugin_name: str, marketplace_name: str) -> Optional[Path]: marketplace = marketplaces.get(marketplace_name) if not marketplace: - continue - - plugin_path = _resolve_marketplace_path(marketplace) - if plugin_path is None: - continue - - metadata = _load_plugin_json_file(plugin_path, '.claude-plugin/plugin.json') or {} - for field in ('name', 'version', 'description'): - if field in metadata: - entry[field] = metadata[field] - - mcp_config = _load_plugin_json_file(plugin_path, '.mcp.json') or {} - plugin_server_names = [] - for server_name, server_cfg in (mcp_config.get('mcpServers') or {}).items(): - merged_mcp[server_name] = server_cfg - plugin_server_names.append(server_name) - if plugin_server_names: - entry['mcp_server_names'] = plugin_server_names + return None + return _resolve_marketplace_path(marketplace) - return merged_mcp, enriched + return walk_enabled_plugins( + plugin_entries=enabled, + is_enabled=bool, + locate_dir=_locate, + read_plugin=_read_claude_plugin, + ) # --- IDE integration ---------------------------------------------------------- @@ -260,6 +239,7 @@ def render_hooks_config(self, async_mode: bool = False) -> dict: 'hooks': { 'SessionStart': [ { + 'matcher': 'startup|clear', 'hooks': [{'type': 'command', 'command': _SESSION_START_COMMAND}], } ], diff --git a/cycode/cli/apps/ai_guardrails/ides/codex.py b/cycode/cli/apps/ai_guardrails/ides/codex.py new file mode 100644 index 00000000..f8c9b04d --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/ides/codex.py @@ -0,0 +1,310 @@ +"""Codex CLI IDE integration for AI guardrails.""" + +import json +import os +import sys +from pathlib import Path +from typing import ClassVar, Optional + +import tomli_w + +if sys.version_info >= (3, 11): + import tomllib +else: # pragma: no cover - py<3.11 fallback + import tomli as tomllib + +from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND +from cycode.cli.apps.ai_guardrails.ides._plugin_utils import load_plugin_json, walk_enabled_plugins +from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType +from cycode.cli.utils.jwt_utils import decode_jwt_unverified +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails Codex') + +_CONFIG_DIR_NAME = '.codex' +_HOOKS_FILE_NAME = 'hooks.json' +_CONFIG_TOML_NAME = 'config.toml' +_AUTH_JSON_NAME = 'auth.json' +_CODEX_HOME_ENV_VAR = 'CODEX_HOME' + +_HOOK_EVENTS = ('UserPromptSubmit', 'PreToolUse:mcp') +_CODEX_EVENT_NAMES = frozenset(e.split(':', 1)[0] for e in _HOOK_EVENTS) + +_SCAN_COMMAND = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide codex' +_SESSION_START_COMMAND = f'{CYCODE_SESSION_START_COMMAND} --ide codex' + + +def _codex_home() -> Path: + """Resolve Codex's user-scope home directory. + + Honors ``$CODEX_HOME`` per Codex's documented override; falls back to + ``~/.codex``. + """ + override = os.environ.get(_CODEX_HOME_ENV_VAR) + if override: + return Path(override) + return Path.home() / _CONFIG_DIR_NAME + + +def _codex_config_toml_path(scope: str, repo_path: Optional[Path] = None) -> Path: + """Return the Codex ``config.toml`` path for the given scope.""" + if scope == 'repo' and repo_path: + return repo_path / _CONFIG_DIR_NAME / _CONFIG_TOML_NAME + return _codex_home() / _CONFIG_TOML_NAME + + +def _load_codex_config(config_path: Optional[Path] = None) -> Optional[dict]: + """Load and parse Codex's ``config.toml``. Returns None on missing/invalid.""" + path = config_path or (_codex_home() / _CONFIG_TOML_NAME) + if not path.exists(): + logger.debug('Codex config file not found, %s', {'path': str(path)}) + return None + try: + with path.open('rb') as f: + return tomllib.load(f) + except Exception as e: + logger.debug('Failed to load Codex config file, %s', {'path': str(path)}, exc_info=e) + return None + + +def _email_from_auth(auth_path: Optional[Path] = None) -> Optional[str]: + """Best-effort extraction of the signed-in Codex user's email. + + Reads ``~/.codex/auth.json`` and decodes the JWT in ``tokens.id_token`` + to pull the ``email`` claim. Returns None if auth.json is missing + (``OPENAI_API_KEY``-only setups, OS keychain credentials) or unreadable. + """ + path = auth_path or (_codex_home() / _AUTH_JSON_NAME) + if not path.exists(): + logger.debug('Codex auth file not found, %s', {'path': str(path)}) + return None + try: + auth = json.loads(path.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError) as e: + logger.debug('Failed to load Codex auth file, %s', {'path': str(path)}, exc_info=e) + return None + + token = (auth.get('tokens') or {}).get('id_token') + if not token: + return None + claims = decode_jwt_unverified(token) + if not claims: + return None + return claims.get('email') + + +def _resolve_codex_plugin_dir(plugin_name: str, marketplace: str) -> Optional[Path]: + """Find ``~/.codex/plugins/cache////``. + + The trailing segment is a content hash. If multiple are cached, pick the + most recently modified. + """ + base = _codex_home() / 'plugins' / 'cache' / marketplace / plugin_name + if not base.is_dir(): + return None + candidates = [d for d in base.iterdir() if d.is_dir()] + if not candidates: + return None + return max(candidates, key=lambda d: d.stat().st_mtime) + + +def _read_codex_plugin(plugin_dir: Path) -> tuple[dict, dict]: + """Read one Codex plugin's manifest + MCP servers. + + Codex's manifest references the MCP file via a path string in the + ``mcpServers`` field (default ``./.mcp.json``); the target file is either + a bare ``{name: cfg}`` map or wrapped in ``{"mcpServers": {...}}``. + """ + manifest = load_plugin_json(plugin_dir / '.codex-plugin' / 'plugin.json') + entry: dict = {} + if not manifest: + return entry, {} + + for field in ('name', 'version', 'description'): + if field in manifest: + entry[field] = manifest[field] + + mcp_ref = manifest.get('mcpServers') + if not mcp_ref: + return entry, {} + mcp_doc = load_plugin_json(plugin_dir / mcp_ref) or {} + servers = mcp_doc.get('mcpServers', mcp_doc) + if not isinstance(servers, dict): + servers = {} + if servers: + entry['mcp_server_names'] = list(servers.keys()) + return entry, servers + + +def _resolve_codex_plugins(config: dict) -> tuple[dict, dict]: + """Walk enabled ``[plugins."@"]`` entries.""" + return walk_enabled_plugins( + plugin_entries=config.get('plugins') or {}, + is_enabled=lambda s: isinstance(s, dict) and bool(s.get('enabled')), + locate_dir=_resolve_codex_plugin_dir, + read_plugin=_read_codex_plugin, + ) + + +def _enable_codex_hooks_feature(scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]: + """Set ``[features] hooks = true`` in Codex's ``config.toml``. + + Codex's hook scripts are gated behind this feature flag. We preserve any + existing keys and create the file (+ parent dir) when missing. + """ + config_path = _codex_config_toml_path(scope, repo_path) + + config: dict = {} + if config_path.exists(): + try: + with config_path.open('rb') as f: + config = tomllib.load(f) + except Exception as e: + logger.error('Failed to parse Codex config.toml, %s', {'path': str(config_path)}, exc_info=e) + return False, f'Failed to parse existing Codex config at {config_path}' + + features = config.get('features') + if not isinstance(features, dict): + features = {} + features['hooks'] = True + config['features'] = features + + try: + config_path.parent.mkdir(parents=True, exist_ok=True) + with config_path.open('wb') as f: + tomli_w.dump(config, f) + return True, f'Enabled hooks feature in {config_path}' + except Exception as e: + logger.error('Failed to write Codex config.toml, %s', {'path': str(config_path)}, exc_info=e) + return False, f'Failed to write Codex config at {config_path}' + + +class Codex(IDE): + name: ClassVar[str] = 'codex' + display_name: ClassVar[str] = 'Codex' + hook_events: ClassVar[list[str]] = list(_HOOK_EVENTS) + + def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: + if scope == 'repo' and repo_path: + return repo_path / _CONFIG_DIR_NAME / _HOOKS_FILE_NAME + return _codex_home() / _HOOKS_FILE_NAME + + def render_hooks_config(self, async_mode: bool = False) -> dict: + # Codex's TOML `async: true` flag is unimplemented; shell-background via + # `&` is the working mechanism. SessionStart stays sync so the + # conversation context is registered before any scan hook fires. + bg = ' &' if async_mode else '' + scan_cmd = f'{_SCAN_COMMAND}{bg}' + return { + 'hooks': { + 'SessionStart': [ + { + 'matcher': 'startup|clear', + 'hooks': [{'type': 'command', 'command': _SESSION_START_COMMAND}], + } + ], + 'UserPromptSubmit': [ + { + 'hooks': [{'type': 'command', 'command': scan_cmd}], + } + ], + 'PreToolUse': [ + { + 'matcher': 'mcp__.*', + 'hooks': [{'type': 'command', 'command': scan_cmd}], + }, + ], + }, + } + + def post_install(self, scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]: + return _enable_codex_hooks_feature(scope, repo_path) + + def matches_payload(self, raw_payload: dict) -> bool: + return raw_payload.get('hook_event_name', '') in _CODEX_EVENT_NAMES + + def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: + hook_event_name = raw_payload.get('hook_event_name', '') + tool_name = raw_payload.get('tool_name', '') + tool_input = raw_payload.get('tool_input') + + if hook_event_name == 'UserPromptSubmit': + canonical_event: AiHookEventType | str = AiHookEventType.PROMPT + elif hook_event_name == 'PreToolUse' and tool_name.startswith('mcp__'): + canonical_event = AiHookEventType.MCP_EXECUTION + else: + canonical_event = hook_event_name + + mcp_server_name = None + mcp_tool_name = None + mcp_arguments = None + if tool_name.startswith('mcp__'): + parts = tool_name.split('__') + if len(parts) >= 2: + mcp_server_name = parts[1] + if len(parts) >= 3: + mcp_tool_name = parts[2] + mcp_arguments = tool_input + + return AIHookPayload( + event_name=canonical_event, + conversation_id=raw_payload.get('session_id'), + generation_id=raw_payload.get('turn_id'), + ide_user_email=_email_from_auth(), + model=raw_payload.get('model'), + ide_provider=self.name, + prompt=raw_payload.get('prompt', ''), + mcp_server_name=mcp_server_name, + mcp_tool_name=mcp_tool_name, + mcp_arguments=mcp_arguments, + ) + + def build_hook_response(self, decision: HookDecision) -> dict: + # Codex accepts the same hook response shapes as Claude Code: + # - PROMPT: empty for allow, {"decision": "block", "reason": ...} for deny + # - PreToolUse: hookSpecificOutput.permissionDecision + if decision.event_type == AiHookEventType.PROMPT: + if decision.action == DecisionAction.ALLOW: + return {} + return {'decision': 'block', 'reason': decision.user_message or ''} + + if decision.action == DecisionAction.ALLOW: + return { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'allow', + } + } + return { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': decision.action.value, # 'deny' or 'ask' + 'permissionDecisionReason': decision.user_message or '', + } + } + + def build_session_payload(self, raw_payload: dict) -> AIHookPayload: + return AIHookPayload( + conversation_id=raw_payload.get('session_id'), + ide_user_email=_email_from_auth(), + model=raw_payload.get('model'), + ide_provider=self.name, + ide_version=raw_payload.get('codex_version'), + source=raw_payload.get('source'), + ) + + def get_user_email(self) -> Optional[str]: + return _email_from_auth() + + def get_session_context(self) -> tuple[dict, dict]: + config = _load_codex_config() + if not config: + return {}, {} + # Codex stores MCP servers under `[mcp_servers.]`. Plugin-contributed + # servers (via `[plugins."@"]`) merge on top. + mcp_servers: dict = dict(config.get('mcp_servers') or {}) + plugin_mcp, enriched_plugins = _resolve_codex_plugins(config) + mcp_servers.update(plugin_mcp) + return mcp_servers, enriched_plugins diff --git a/cycode/cli/apps/ai_guardrails/ides/cursor.py b/cycode/cli/apps/ai_guardrails/ides/cursor.py index 4f6be1eb..aa218542 100644 --- a/cycode/cli/apps/ai_guardrails/ides/cursor.py +++ b/cycode/cli/apps/ai_guardrails/ides/cursor.py @@ -43,7 +43,7 @@ def _load_cursor_mcp_config(config_path: Optional[Path] = None) -> Optional[dict """Load and parse `~/.cursor/mcp.json`. Returns None if missing/invalid.""" path = config_path or (Path.home() / '.cursor' / _MCP_CONFIG_FILENAME) if not path.exists(): - logger.debug('Cursor MCP config file not found', extra={'path': str(path)}) + logger.debug('Cursor MCP config file not found, %s', {'path': str(path)}) return None try: return json.loads(path.read_text(encoding='utf-8')) diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index 4a56a179..8b8a2d71 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -10,6 +10,7 @@ import json import os +from dataclasses import dataclass from multiprocessing.pool import ThreadPool from multiprocessing.pool import TimeoutError as PoolTimeoutError from typing import Callable, Optional @@ -178,23 +179,44 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: ) -def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision: - """Scan MCP tool arguments for secrets before execution.""" +@dataclass(frozen=True) +class _ArgScanFeature: + """Configuration for a "scan some text and decide" event. + + MCP execution and command exec share identical scan-and-decide logic; + only the policy key, event type, and user-facing messages differ. + """ + + policy_key: str # 'mcp' or 'command_exec' + scan_key: str # 'scan_arguments' or 'scan_command' + event_type: AiHookEventType + block_reason: BlockReason + deny_message: Callable[[str], str] + deny_agent_message: str + ask_message: Callable[[str], str] + ask_agent_message: str + + +def _handle_arg_scan( + ctx: typer.Context, + payload: AIHookPayload, + policy: dict, + feature: _ArgScanFeature, + scan_text: str, +) -> HookDecision: + """Shared scan + decision flow for MCP_EXECUTION and COMMAND_EXEC events.""" ai_client = ctx.obj['ai_security_client'] - mcp_config = get_policy_value(policy, 'mcp', default={}) - if not get_policy_value(mcp_config, 'enabled', default=True): - ai_client.create_event(payload, AiHookEventType.MCP_EXECUTION, AIHookOutcome.ALLOWED) - return HookDecision.allow(AiHookEventType.MCP_EXECUTION) + feature_config = get_policy_value(policy, feature.policy_key, default={}) + if not get_policy_value(feature_config, 'enabled', default=True): + ai_client.create_event(payload, feature.event_type, AIHookOutcome.ALLOWED) + return HookDecision.allow(feature.event_type) mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK) - tool = payload.mcp_tool_name or 'unknown' - args = payload.mcp_arguments or {} - args_text = args if isinstance(args, str) else json.dumps(args) max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000) timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000) - clipped = truncate_utf8(args_text, max_bytes) - action = get_policy_value(mcp_config, 'action', default=PolicyMode.BLOCK) + clipped = truncate_utf8(scan_text, max_bytes) + action = get_policy_value(feature_config, 'action', default=PolicyMode.BLOCK) scan_id = None block_reason = None @@ -202,26 +224,25 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli error_message = None try: - if get_policy_value(mcp_config, 'scan_arguments', default=True): + if get_policy_value(feature_config, feature.scan_key, default=True): violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms) if violation_summary: - block_reason = BlockReason.SECRETS_IN_MCP_ARGS + block_reason = feature.block_reason if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK: outcome = AIHookOutcome.BLOCKED - user_message = f'Cycode blocked MCP tool call "{tool}". {violation_summary}' return HookDecision.deny( - AiHookEventType.MCP_EXECUTION, - user_message, - 'Do not pass secrets to tools. Use secret references (name/id) instead.', + feature.event_type, + feature.deny_message(violation_summary), + feature.deny_agent_message, ) outcome = AIHookOutcome.WARNED return HookDecision.ask( - AiHookEventType.MCP_EXECUTION, - f'{violation_summary} in MCP tool call "{tool}". Allow execution?', - 'Possible secrets detected in tool arguments; proceed with caution.', + feature.event_type, + feature.ask_message(violation_summary), + feature.ask_agent_message, ) - return HookDecision.allow(AiHookEventType.MCP_EXECUTION) + return HookDecision.allow(feature.event_type) except Exception as e: outcome = ( AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED @@ -232,7 +253,7 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli finally: ai_client.create_event( payload, - AiHookEventType.MCP_EXECUTION, + feature.event_type, outcome, scan_id=scan_id, block_reason=block_reason, @@ -240,6 +261,29 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli ) +def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision: + """Scan MCP tool arguments for secrets before execution.""" + tool = payload.mcp_tool_name or 'unknown' + args = payload.mcp_arguments or {} + args_text = args if isinstance(args, str) else json.dumps(args) + return _handle_arg_scan( + ctx, + payload, + policy, + _ArgScanFeature( + policy_key='mcp', + scan_key='scan_arguments', + event_type=AiHookEventType.MCP_EXECUTION, + block_reason=BlockReason.SECRETS_IN_MCP_ARGS, + deny_message=lambda v: f'Cycode blocked MCP tool call "{tool}". {v}', + deny_agent_message='Do not pass secrets to tools. Use secret references (name/id) instead.', + ask_message=lambda v: f'{v} in MCP tool call "{tool}". Allow execution?', + ask_agent_message='Possible secrets detected in tool arguments; proceed with caution.', + ), + scan_text=args_text, + ) + + def get_handler_for_event(event_type: str) -> Optional[HandlerFn]: """Look up the handler for a canonical event type.""" handlers: dict[str, HandlerFn] = { diff --git a/cycode/cli/utils/jwt_utils.py b/cycode/cli/utils/jwt_utils.py index c87b7c48..21f767d0 100644 --- a/cycode/cli/utils/jwt_utils.py +++ b/cycode/cli/utils/jwt_utils.py @@ -5,6 +5,14 @@ _JWT_PAYLOAD_POSSIBLE_USER_ID_FIELD_NAMES = ('userId', 'internalId', 'token-user-id') +def decode_jwt_unverified(token: str) -> Optional[dict]: + """Return JWT claims without signature verification, or None if the token is unreadable.""" + try: + return jwt.decode(token, options={'verify_signature': False}) + except jwt.PyJWTError: + return None + + def get_user_and_tenant_ids_from_access_token(access_token: str) -> tuple[Optional[str], Optional[str]]: payload = jwt.decode(access_token, options={'verify_signature': False}) diff --git a/poetry.lock b/poetry.lock index 1a6ce6ee..eab3b4da 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.4 and should not be changed by hand. [[package]] name = "altgraph" @@ -339,6 +339,7 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {test = "sys_platform == \"win32\""} [[package]] name = "coverage" @@ -742,7 +743,7 @@ files = [ [package.dependencies] attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.03.6" +jsonschema-specifications = ">=2023.3.6" referencing = ">=0.28.4" rpds-py = ">=0.7.1" @@ -1875,7 +1876,7 @@ version = "2.3.0" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" -groups = ["test"] +groups = ["main", "test"] markers = "python_version < \"3.11\"" files = [ {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, @@ -1922,6 +1923,18 @@ files = [ {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, ] +[[package]] +name = "tomli-w" +version = "1.2.0" +description = "A lil' TOML writer" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90"}, + {file = "tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021"}, +] + [[package]] name = "typer" version = "0.15.4" @@ -2043,4 +2056,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "b67d2f0ceadcf2fbd351b056596da8a04656a3774c2cc26e13cf678b6f31561f" +content-hash = "ac37763cb9b582d1997853c1347edfdb05566fa225adf304685ecce66989fc67" diff --git a/pyproject.toml b/pyproject.toml index c3d10f0a..1b43757b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,8 @@ tenacity = ">=9.0.0,<9.1.0" mcp = { version = ">=1.9.3,<2.0.0", markers = "python_version >= '3.10'" } pydantic = ">=2.11.5,<3.0.0" pathvalidate = ">=3.3.1,<4.0.0" +tomli-w = ">=1.0.0,<2.0.0" +tomli = {version = ">=2.0.0,<3.0.0", python = "<3.11"} [tool.poetry.group.test.dependencies] mock = ">=4.0.3,<4.1.0" diff --git a/tests/cli/commands/ai_guardrails/ides/test_codex.py b/tests/cli/commands/ai_guardrails/ides/test_codex.py new file mode 100644 index 00000000..f71e19a2 --- /dev/null +++ b/tests/cli/commands/ai_guardrails/ides/test_codex.py @@ -0,0 +1,335 @@ +"""Codex CLI IDE integration tests.""" + +import base64 +import json +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest +from pyfakefs.fake_filesystem import FakeFilesystem + +from cycode.cli.apps.ai_guardrails.ides.base import HookDecision +from cycode.cli.apps.ai_guardrails.ides.codex import ( + Codex, + _codex_home, + _email_from_auth, + _enable_codex_hooks_feature, + _load_codex_config, +) +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType + +if sys.version_info >= (3, 11): + import tomllib +else: # pragma: no cover - py<3.11 fallback + import tomli as tomllib + + +# --- payload parsing --------------------------------------------------------- + + +def test_matches_payload_only_codex_events() -> None: + codex = Codex() + assert codex.matches_payload({'hook_event_name': 'UserPromptSubmit'}) is True + assert codex.matches_payload({'hook_event_name': 'PreToolUse'}) is True + assert codex.matches_payload({'hook_event_name': 'beforeSubmitPrompt'}) is False + assert codex.matches_payload({'hook_event_name': 'SessionStart'}) is False + + +def test_parse_prompt_payload() -> None: + unified = Codex().parse_hook_payload( + { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'turn_id': 'turn-456', + 'model': 'gpt-5-codex', + 'prompt': 'Test prompt', + } + ) + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id == 'session-123' + assert unified.generation_id == 'turn-456' + assert unified.model == 'gpt-5-codex' + assert unified.ide_provider == 'codex' + assert unified.prompt == 'Test prompt' + + +def test_parse_mcp_execution_payload() -> None: + args = {'resource_type': 'merge_request', 'resource_id': '4'} + unified = Codex().parse_hook_payload( + { + 'hook_event_name': 'PreToolUse', + 'tool_name': 'mcp__gitlab__discussion_list', + 'tool_input': args, + } + ) + assert unified.event_name == AiHookEventType.MCP_EXECUTION + assert unified.mcp_server_name == 'gitlab' + assert unified.mcp_tool_name == 'discussion_list' + assert unified.mcp_arguments == args + + +def test_parse_unknown_event_falls_through() -> None: + unified = Codex().parse_hook_payload({'hook_event_name': 'Stop'}) + assert unified.event_name == 'Stop' + + +def test_parse_empty_payload_defaults() -> None: + unified = Codex().parse_hook_payload({'hook_event_name': 'UserPromptSubmit'}) + assert unified.event_name == AiHookEventType.PROMPT + assert unified.prompt == '' + assert unified.ide_provider == 'codex' + + +# --- response building ------------------------------------------------------- + + +def test_build_prompt_responses() -> None: + codex = Codex() + assert codex.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT)) == {} + assert codex.build_hook_response(HookDecision.deny(AiHookEventType.PROMPT, 'no!')) == { + 'decision': 'block', + 'reason': 'no!', + } + + +def test_build_mcp_execution_allow_and_deny() -> None: + codex = Codex() + allow = codex.build_hook_response(HookDecision.allow(AiHookEventType.MCP_EXECUTION)) + assert allow == {'hookSpecificOutput': {'hookEventName': 'PreToolUse', 'permissionDecision': 'allow'}} + + deny = codex.build_hook_response(HookDecision.deny(AiHookEventType.MCP_EXECUTION, 'secret in args!')) + assert deny == { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'deny', + 'permissionDecisionReason': 'secret in args!', + } + } + + +def test_build_mcp_execution_ask() -> None: + ask = Codex().build_hook_response(HookDecision.ask(AiHookEventType.MCP_EXECUTION, 'maybe?')) + assert ask == { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'ask', + 'permissionDecisionReason': 'maybe?', + } + } + + +# --- settings paths ---------------------------------------------------------- + + +def test_settings_path_user_scope() -> None: + path = Codex().settings_path('user') + assert path.name == 'hooks.json' + assert path.parent.name == '.codex' + + +def test_settings_path_repo_scope(fs: FakeFilesystem) -> None: + repo = Path('/my-repo') + fs.create_dir(repo) + path = Codex().settings_path('repo', repo) + assert path == repo / '.codex' / 'hooks.json' + + +def test_settings_path_honors_codex_home_env(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + override = '/custom/codex/home' + fs.create_dir(override) + monkeypatch.setenv('CODEX_HOME', override) + assert _codex_home() == Path(override) + assert Codex().settings_path('user') == Path(override) / 'hooks.json' + + +# --- hooks config rendering -------------------------------------------------- + + +def test_render_hooks_session_start_matcher_includes_clear() -> None: + """SessionStart must fire on /clear too (conversation_id rotates).""" + rendered = Codex().render_hooks_config() + assert rendered['hooks']['SessionStart'][0]['matcher'] == 'startup|clear' + assert '--ide codex' in rendered['hooks']['SessionStart'][0]['hooks'][0]['command'] + + +def test_render_hooks_never_emits_async_toml_flags() -> None: + """Codex's TOML `async: true` / `timeout` flags are unimplemented; we must not emit them.""" + for mode in (False, True): + rendered = Codex().render_hooks_config(async_mode=mode) + for entry in rendered['hooks']['PreToolUse']: + for hook in entry['hooks']: + assert 'async' not in hook + assert 'timeout' not in hook + + +def test_render_hooks_async_backgrounds_scan_hooks() -> None: + """In async mode, UserPromptSubmit + PreToolUse scan hooks shell-background.""" + rendered = Codex().render_hooks_config(async_mode=True) + prompt_cmd = rendered['hooks']['UserPromptSubmit'][0]['hooks'][0]['command'] + pretool_cmd = rendered['hooks']['PreToolUse'][0]['hooks'][0]['command'] + assert prompt_cmd.endswith(' &') + assert pretool_cmd.endswith(' &') + + +def test_render_hooks_session_start_always_synchronous() -> None: + """SessionStart registers the conversation context — never backgrounded.""" + for mode in (False, True): + rendered = Codex().render_hooks_config(async_mode=mode) + session_cmd = rendered['hooks']['SessionStart'][0]['hooks'][0]['command'] + assert '&' not in session_cmd + + +def test_render_hooks_pretooluse_matchers_are_mcp_only() -> None: + matchers = [e['matcher'] for e in Codex().render_hooks_config()['hooks']['PreToolUse']] + assert matchers == ['mcp__.*'] + + +# --- post_install: TOML feature flag ---------------------------------------- + + +def test_post_install_creates_config_toml(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + home = '/codex-home' + fs.create_dir(home) + monkeypatch.setenv('CODEX_HOME', home) + + success, message = Codex().post_install('user') + assert success is True + config_path = Path(home) / 'config.toml' + assert config_path.exists() + assert 'config.toml' in message + + with config_path.open('rb') as f: + config = tomllib.load(f) + assert config['features']['hooks'] is True + + +def test_post_install_preserves_existing_keys(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + home = '/codex-home' + fs.create_dir(home) + monkeypatch.setenv('CODEX_HOME', home) + config_path = Path(home) / 'config.toml' + # Pre-existing settings the user cares about + config_path.write_text('model = "gpt-5-codex"\n\n[features]\nother = true\n') + + success, _ = Codex().post_install('user') + assert success is True + + with config_path.open('rb') as f: + config = tomllib.load(f) + assert config['model'] == 'gpt-5-codex' + assert config['features']['other'] is True + assert config['features']['hooks'] is True + + +def test_post_install_repo_scope_writes_to_repo_dir(fs: FakeFilesystem) -> None: + repo = Path('/my-repo') + fs.create_dir(repo) + success, _ = Codex().post_install('repo', repo) + assert success is True + assert (repo / '.codex' / 'config.toml').exists() + + +def test_enable_codex_hooks_feature_fails_on_corrupt_toml(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + home = '/codex-home' + fs.create_dir(home) + monkeypatch.setenv('CODEX_HOME', home) + (Path(home) / 'config.toml').write_text('this is = not [ valid] toml = ') + + success, message = _enable_codex_hooks_feature('user') + assert success is False + assert 'Failed to parse' in message + + +# --- TOML config loading ----------------------------------------------------- + + +def test_load_codex_config_valid(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + home = '/codex-home' + fs.create_dir(home) + monkeypatch.setenv('CODEX_HOME', home) + (Path(home) / 'config.toml').write_text('model = "gpt-5-codex"\n[mcp_servers.linear]\ncommand = "linear-mcp"\n') + + config = _load_codex_config() + assert config is not None + assert config['model'] == 'gpt-5-codex' + assert config['mcp_servers']['linear']['command'] == 'linear-mcp' + + +def test_load_codex_config_missing_file(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + home = '/codex-home' + fs.create_dir(home) + monkeypatch.setenv('CODEX_HOME', home) + assert _load_codex_config() is None + + +def test_load_codex_config_invalid_toml(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + home = '/codex-home' + fs.create_dir(home) + monkeypatch.setenv('CODEX_HOME', home) + (Path(home) / 'config.toml').write_text('this is = not [ valid] toml = ') + assert _load_codex_config() is None + + +# --- JWT email extraction ---------------------------------------------------- + + +def _make_jwt(claims: dict) -> str: + """Build a JWT-shaped token with the given claims (signature ignored).""" + header = base64.urlsafe_b64encode(b'{"alg":"RS256"}').rstrip(b'=').decode() + payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).rstrip(b'=').decode() + return f'{header}.{payload}.signature-not-verified' + + +def test_email_from_auth_returns_email(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + home = '/codex-home' + fs.create_dir(home) + monkeypatch.setenv('CODEX_HOME', home) + token = _make_jwt({'email': 'codex-user@example.com'}) + (Path(home) / 'auth.json').write_text(json.dumps({'tokens': {'id_token': token}})) + + assert _email_from_auth() == 'codex-user@example.com' + + +def test_email_from_auth_missing_file(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + home = '/codex-home' + fs.create_dir(home) + monkeypatch.setenv('CODEX_HOME', home) + assert _email_from_auth() is None + + +def test_email_from_auth_no_id_token(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + home = '/codex-home' + fs.create_dir(home) + monkeypatch.setenv('CODEX_HOME', home) + (Path(home) / 'auth.json').write_text(json.dumps({'tokens': {}})) + assert _email_from_auth() is None + + +def test_email_from_auth_malformed_token(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + home = '/codex-home' + fs.create_dir(home) + monkeypatch.setenv('CODEX_HOME', home) + (Path(home) / 'auth.json').write_text(json.dumps({'tokens': {'id_token': 'not.a.jwt-with-bad-payload!!'}})) + assert _email_from_auth() is None + + +# --- session context -------------------------------------------------------- + + +def test_session_context_reads_mcp_servers() -> None: + mcp = {'linear': {'command': 'linear-mcp'}, 'github': {'command': 'gh-mcp'}} + with patch( + 'cycode.cli.apps.ai_guardrails.ides.codex._load_codex_config', + return_value={'mcp_servers': mcp}, + ): + servers, plugins = Codex().get_session_context() + assert servers == mcp + assert plugins == {} + + +def test_session_context_no_config() -> None: + with patch('cycode.cli.apps.ai_guardrails.ides.codex._load_codex_config', return_value=None): + servers, plugins = Codex().get_session_context() + assert servers == {} + assert plugins == {} diff --git a/tests/cli/commands/ai_guardrails/ides/test_contract.py b/tests/cli/commands/ai_guardrails/ides/test_contract.py index 9714dbfa..7d7a5427 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_contract.py +++ b/tests/cli/commands/ai_guardrails/ides/test_contract.py @@ -61,7 +61,7 @@ def test_render_hooks_config_has_hooks_key(ide: IDE) -> None: def test_render_hooks_config_async_changes_output(ide: IDE) -> None: - """async_mode must influence the rendered output (e.g. & suffix, async flag).""" + """async_mode must influence the rendered output.""" assert ide.render_hooks_config(async_mode=False) != ide.render_hooks_config(async_mode=True) diff --git a/tests/cli/commands/ai_guardrails/test_hooks_manager.py b/tests/cli/commands/ai_guardrails/test_hooks_manager.py index 4ff8d575..f0a0248c 100644 --- a/tests/cli/commands/ai_guardrails/test_hooks_manager.py +++ b/tests/cli/commands/ai_guardrails/test_hooks_manager.py @@ -1,17 +1,27 @@ """Tests for AI guardrails hooks manager and per-IDE hooks rendering.""" from pathlib import Path +from typing import TYPE_CHECKING import yaml from pyfakefs.fake_filesystem import FakeFilesystem +if TYPE_CHECKING: + import pytest + from cycode.cli.apps.ai_guardrails.consts import ( CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND, PolicyMode, ) -from cycode.cli.apps.ai_guardrails.hooks_manager import create_policy_file, is_cycode_hook_entry +from cycode.cli.apps.ai_guardrails.hooks_manager import ( + create_policy_file, + install_hooks, + is_cycode_hook_entry, + uninstall_hooks, +) from cycode.cli.apps.ai_guardrails.ides.claude_code import ClaudeCode +from cycode.cli.apps.ai_guardrails.ides.codex import Codex from cycode.cli.apps.ai_guardrails.ides.cursor import Cursor @@ -101,11 +111,11 @@ def test_claude_code_render_hooks_async() -> None: def test_claude_code_render_hooks_session_start() -> None: - """Claude Code SessionStart carries the --ide flag explicitly.""" + """Claude Code SessionStart fires on startup and /clear.""" config = ClaudeCode().render_hooks_config() - assert 'SessionStart' in config['hooks'] entries = config['hooks']['SessionStart'] assert len(entries) == 1 + assert entries[0]['matcher'] == 'startup|clear' assert CYCODE_SESSION_START_COMMAND in entries[0]['hooks'][0]['command'] assert '--ide claude-code' in entries[0]['hooks'][0]['command'] @@ -153,6 +163,101 @@ def test_create_policy_file_updates_existing(fs: FakeFilesystem) -> None: assert policy['custom_field'] == 'keep_me' +def test_install_preserves_user_hook_colocated_with_cycode( + fs: FakeFilesystem, monkeypatch: 'pytest.MonkeyPatch' +) -> None: + """install must not clobber a user-authored hook that shares + an entry with a Cycode hook. The filter is hook-level, not entry-level. + """ + import json + + repo = Path('/repo') + fs.create_dir(repo) + hooks_path = repo / '.codex' / 'hooks.json' + fs.create_file( + hooks_path, + contents=json.dumps( + { + 'version': 1, + 'hooks': { + 'SessionStart': [ + { + 'matcher': 'startup|clear', + 'hooks': [ + {'type': 'command', 'command': '/usr/local/bin/user-debug.sh SessionStart'}, + {'type': 'command', 'command': 'cycode ai-guardrails session-start --ide codex'}, + ], + } + ], + # Unrelated event with no Cycode hooks at all — must be untouched. + 'PostToolUse': [{'hooks': [{'type': 'command', 'command': '/usr/local/bin/user-postlog.sh'}]}], + }, + } + ), + ) + + # Codex's post_install touches ~/.codex/config.toml (user scope) — keep that off + # the filesystem under test by pinning CODEX_HOME inside the fake FS. + monkeypatch.setenv('CODEX_HOME', '/codex-home') + fs.create_dir('/codex-home') + + success, _ = install_hooks(Codex(), scope='repo', repo_path=repo) + assert success is True + + saved = json.loads(hooks_path.read_text()) + session_start = saved['hooks']['SessionStart'] + # The pre-existing entry should still exist with the user hook preserved, + # and a separate fresh Cycode entry should have been appended. + user_hook_cmd = '/usr/local/bin/user-debug.sh SessionStart' + remaining_user_hooks = [ + h for entry in session_start for h in entry.get('hooks', []) if h.get('command') == user_hook_cmd + ] + assert remaining_user_hooks, 'user hook was clobbered by install' + + # Unrelated event untouched. + assert saved['hooks']['PostToolUse'][0]['hooks'][0]['command'] == '/usr/local/bin/user-postlog.sh' + + +def test_uninstall_preserves_user_hook_colocated_with_cycode( + fs: FakeFilesystem, monkeypatch: 'pytest.MonkeyPatch' +) -> None: + """uninstall must strip only the Cycode hook from a mixed entry.""" + import json + + repo = Path('/repo') + fs.create_dir(repo) + hooks_path = repo / '.codex' / 'hooks.json' + fs.create_file( + hooks_path, + contents=json.dumps( + { + 'version': 1, + 'hooks': { + 'UserPromptSubmit': [ + { + 'hooks': [ + {'type': 'command', 'command': '/usr/local/bin/user-debug.sh UserPromptSubmit'}, + {'type': 'command', 'command': 'cycode ai-guardrails scan --ide codex'}, + ] + } + ] + }, + } + ), + ) + monkeypatch.setenv('CODEX_HOME', '/codex-home') + fs.create_dir('/codex-home') + + success, _ = uninstall_hooks(Codex(), scope='repo', repo_path=repo) + assert success is True + + saved = json.loads(hooks_path.read_text()) + hooks = saved['hooks']['UserPromptSubmit'][0]['hooks'] + commands = [h['command'] for h in hooks] + assert '/usr/local/bin/user-debug.sh UserPromptSubmit' in commands + assert not any('cycode ai-guardrails' in c for c in commands) + + def test_create_policy_file_repo_scope(fs: FakeFilesystem) -> None: """Create a policy file in repo scope.""" repo_path = Path('/my-repo') From 39f70a5077b7d1f7164df2b8ff4ace4d8bb896b4 Mon Sep 17 00:00:00 2001 From: aaron-butler-cy-int Date: Wed, 27 May 2026 11:03:35 -0400 Subject: [PATCH 074/123] CM-63882 - Added scanType validation (#452) Co-authored-by: omerr-cycode --- cycode/cli/apps/scan/scan_command.py | 22 +++++++++++++++++-- tests/cli/commands/scan/test_scan_command.py | 23 ++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 9b2aa280..427f2d78 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -28,17 +28,32 @@ _SECRET_RICH_HELP_PANEL = 'Secret options' +def _single_value_callback(ctx: typer.Context, param: typer.CallbackParam, value: list) -> list: + if len(value) > 1: + values_str = ', '.join(str(v) for v in value) + param_hint = '/'.join(sorted(param.opts, key=len)) + err = typer.BadParameter( + f'Only one value can be specified per command. Got: {values_str}. Run a separate command for each value.', + ctx=ctx, + param_hint=param_hint, + ) + err.exit_code = 1 + raise err + return value + + def scan_command( ctx: typer.Context, scan_type: Annotated[ - ScanTypeOption, + list[ScanTypeOption], typer.Option( '--scan-type', '-t', help='Specify the type of scan you wish to execute.', case_sensitive=False, + callback=_single_value_callback, ), - ] = ScanTypeOption.SECRET, + ] = (ScanTypeOption.SECRET,), soft_fail: Annotated[ bool, typer.Option('--soft-fail', help='Run the scan without failing; always return a non-error status code.') ] = False, @@ -137,6 +152,9 @@ def scan_command( param_hint='--export-file', ) + # _single_value_callback validated exactly one value was provided; unwrap from list + scan_type = scan_type[0] + ctx.obj['show_secret'] = show_secret ctx.obj['soft_fail'] = soft_fail ctx.obj['stop_on_error'] = stop_on_error diff --git a/tests/cli/commands/scan/test_scan_command.py b/tests/cli/commands/scan/test_scan_command.py index de218da5..bb5f363d 100644 --- a/tests/cli/commands/scan/test_scan_command.py +++ b/tests/cli/commands/scan/test_scan_command.py @@ -1,11 +1,19 @@ +import re + import click import pytest import typer +from typer.testing import CliRunner +from cycode.cli.app import app from cycode.cli.apps.scan.scan_command import scan_command_result_callback from cycode.cli.consts import ISSUE_DETECTED_STATUS_CODE, NO_ISSUES_STATUS_CODE, SCAN_ERROR_STATUS_CODE +def _strip_ansi(text: str) -> str: + return re.sub(r'\x1b\[[0-9;]*[mGKHF]', '', text) + + def _make_ctx(**obj_overrides: object) -> click.Context: obj = { 'soft_fail': False, @@ -25,6 +33,21 @@ def _invoke_result_callback(ctx: click.Context) -> int: return exc_info.value.exit_code +class TestScanCommand: + def test_multiple_scan_types_rejected(self) -> None: + result = CliRunner().invoke(app, ['scan', '-t', 'iac', '-t', 'sast', 'path', '.']) + assert result.exit_code == 1 + output = _strip_ansi(result.output) + assert '-t/--scan-type' in output + assert 'iac' in output + assert 'sast' in output + + def test_single_scan_type_accepted(self) -> None: + result = CliRunner().invoke(app, ['scan', '-t', 'iac', '--help']) + assert result.exit_code == 0 + assert 'Error' not in result.output + + class TestScanCommandResultCallback: def test_no_issues_no_errors_exits_zero(self) -> None: assert _invoke_result_callback(_make_ctx()) == NO_ISSUES_STATUS_CODE From af3127cb6be77a3d6d8af36dc05ec291e035fbb3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 16:31:19 +0300 Subject: [PATCH 075/123] Bump svenstaro/upload-release-action from 2.11.4 to 2.11.5 (#428) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build_executable.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index a843dcf5..94274f99 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -313,7 +313,7 @@ jobs: - name: Upload files to release if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish }} - uses: svenstaro/upload-release-action@b98a3b12e86552593f3e4e577ca8a62aa2f3f22b # v2 + uses: svenstaro/upload-release-action@29e53e917877a24fad85510ded594ab3c9ca12de # v2 with: file: dist/* tag: ${{ env.LATEST_TAG }} From 8b798456ec7e1699dbb64c4a4061d8c443934b4c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 07:38:44 +0300 Subject: [PATCH 076/123] Bump docker/build-push-action from 7.1.0 to 7.2.0 (#467) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-image.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index f19b4e2d..412e38b9 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -76,7 +76,7 @@ jobs: - name: Build and push id: docker_build if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') }} - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . platforms: linux/amd64,linux/arm64 @@ -86,7 +86,7 @@ jobs: - name: Verify build id: docker_verify_build if: ${{ github.event_name != 'workflow_dispatch' && !startsWith(github.ref, 'refs/tags/v') }} - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . platforms: linux/amd64,linux/arm64 From 61e5289ea5e2284057921653be752da215c42592 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 07:47:52 +0300 Subject: [PATCH 077/123] Bump cycodelabs/cimon-action from 0.10.1 to 1.0.1 (#466) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build_executable.yml | 2 +- .github/workflows/pre_release.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/ruff.yml | 2 +- .github/workflows/tests.yml | 2 +- .github/workflows/tests_full.yml | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 94274f99..eef17ad3 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Run Cimon if: matrix.os == 'ubuntu-22.04' - uses: cycodelabs/cimon-action@3ca67e875f34772093aa3bf3c185a711720bf5d9 # v0.10.1 + uses: cycodelabs/cimon-action@a0870cc3d9e3bf3cedd28bdb67bf3fd3281e5941 # v1.0.1 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index fd183691..171238cd 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Run Cimon - uses: cycodelabs/cimon-action@3ca67e875f34772093aa3bf3c185a711720bf5d9 # v0.10.1 + uses: cycodelabs/cimon-action@a0870cc3d9e3bf3cedd28bdb67bf3fd3281e5941 # v1.0.1 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index acee4571..9f9d2a73 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Run Cimon - uses: cycodelabs/cimon-action@3ca67e875f34772093aa3bf3c185a711720bf5d9 # v0.10.1 + uses: cycodelabs/cimon-action@a0870cc3d9e3bf3cedd28bdb67bf3fd3281e5941 # v1.0.1 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index fcc7a882..2f0e1253 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Run Cimon - uses: cycodelabs/cimon-action@3ca67e875f34772093aa3bf3c185a711720bf5d9 # v0.10.1 + uses: cycodelabs/cimon-action@a0870cc3d9e3bf3cedd28bdb67bf3fd3281e5941 # v1.0.1 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bd275986..517f10d1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Run Cimon - uses: cycodelabs/cimon-action@3ca67e875f34772093aa3bf3c185a711720bf5d9 # v0.10.1 + uses: cycodelabs/cimon-action@a0870cc3d9e3bf3cedd28bdb67bf3fd3281e5941 # v1.0.1 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index b6fee12e..f2cf8d9e 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Run Cimon if: matrix.os == 'ubuntu-latest' - uses: cycodelabs/cimon-action@3ca67e875f34772093aa3bf3c185a711720bf5d9 # v0.10.1 + uses: cycodelabs/cimon-action@a0870cc3d9e3bf3cedd28bdb67bf3fd3281e5941 # v1.0.1 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} From 41285c086f755fdcd6ec769065f885ef28339c70 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 08:03:17 +0300 Subject: [PATCH 078/123] Bump pyinstaller from 6.19.0 to 6.20.0 (#443) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 41 ++++++++++++++++++++--------------------- pyproject.toml | 2 +- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/poetry.lock b/poetry.lock index eab3b4da..51da19c7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "altgraph" @@ -339,7 +339,6 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {test = "sys_platform == \"win32\""} [[package]] name = "coverage" @@ -743,7 +742,7 @@ files = [ [package.dependencies] attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.3.6" +jsonschema-specifications = ">=2023.03.6" referencing = ">=0.28.4" rpds-py = ">=0.7.1" @@ -1180,25 +1179,25 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyinstaller" -version = "6.19.0" +version = "6.20.0" description = "PyInstaller bundles a Python application and all its dependencies into a single package." optional = false python-versions = "<3.15,>=3.8" groups = ["executable"] markers = "python_version < \"3.15\"" files = [ - {file = "pyinstaller-6.19.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:4190e76b74f0c4b5c5f11ac360928cd2e36ec8e3194d437bf6b8648c7bc0c134"}, - {file = "pyinstaller-6.19.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8bd68abd812d8a6ba33b9f1810e91fee0f325969733721b78151f0065319ca11"}, - {file = "pyinstaller-6.19.0-py3-none-manylinux2014_i686.whl", hash = "sha256:1ec54ef967996ca61dacba676227e2b23219878ccce5ee9d6f3aada7b8ed8abf"}, - {file = "pyinstaller-6.19.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4ab2bb52e58448e14ddf9450601bdedd66800465043501c1d8f1cab87b60b122"}, - {file = "pyinstaller-6.19.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:da6d5c6391ccefe73554b9fa29b86001c8e378e0f20c2a4004f836ba537eff63"}, - {file = "pyinstaller-6.19.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a0fc5f6b3c55aa54353f0c74ffa59b1115433c1850c6f655d62b461a2ed6cbbe"}, - {file = "pyinstaller-6.19.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:e649ba6bd1b0b89b210ad92adb5fbdc8a42dd2c5ca4f72ef3a0bfec83a424b83"}, - {file = "pyinstaller-6.19.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:481a909c8e60c8692fc60fcb1344d984b44b943f8bc9682f2fcdae305ad297e6"}, - {file = "pyinstaller-6.19.0-py3-none-win32.whl", hash = "sha256:3c5c251054fe4cfaa04c34a363dcfbf811545438cb7198304cd444756bc2edd2"}, - {file = "pyinstaller-6.19.0-py3-none-win_amd64.whl", hash = "sha256:b5bb6536c6560330d364d91522250f254b107cf69129d9cbcd0e6727c570be33"}, - {file = "pyinstaller-6.19.0-py3-none-win_arm64.whl", hash = "sha256:c2d5a539b0bfe6159d5522c8c70e1c0e487f22c2badae0f97d45246223b798ea"}, - {file = "pyinstaller-6.19.0.tar.gz", hash = "sha256:ec73aeb8bd9b7f2f1240d328a4542e90b3c6e6fbc106014778431c616592a865"}, + {file = "pyinstaller-6.20.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:bf3be4e1284ee78ddccba5e29f99443a12a7b4673168288ffc4c9d38c6f7b90e"}, + {file = "pyinstaller-6.20.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:72ae9c1fdea134afa791f58bdc9a1934d5c7609753c111e0026bfc272b32b712"}, + {file = "pyinstaller-6.20.0-py3-none-manylinux2014_i686.whl", hash = "sha256:1031bcc307f3fbeffd4e162723e64d46dbf591c82dd0997413afb2a07328b941"}, + {file = "pyinstaller-6.20.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:8df3b3f347659fa2562d8d193a98ad4600133b8b8d07c268df89e4154376750e"}, + {file = "pyinstaller-6.20.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:b0d3cc9dd8120d448459bd3880a12e2f9774c51443af49047801446377999a59"}, + {file = "pyinstaller-6.20.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:03696bb6350177c6bc23bcaf78e71a33c4a89b6754dd90d1be2f318e978c918b"}, + {file = "pyinstaller-6.20.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:6357f1699f6af84f37e7367f031d4f68abdba65543b83990c9e8f5a4cebed0b7"}, + {file = "pyinstaller-6.20.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:0ab39c690abad26ba148e8f664f0478acc82a733997f4f22e757774832802da9"}, + {file = "pyinstaller-6.20.0-py3-none-win32.whl", hash = "sha256:9a7637e8e44b4387b13667fdcaac86ab6b29c446c16d34d8401539b81838759c"}, + {file = "pyinstaller-6.20.0-py3-none-win_amd64.whl", hash = "sha256:d588844e890ee80c4365867f98146636e1849bbca8e4284bbf0c809aff0f161a"}, + {file = "pyinstaller-6.20.0-py3-none-win_arm64.whl", hash = "sha256:bd53282c0a73e5c95573e1ddc8e5d564d4932bec91efbaed4dc5fdff9c2ae7f2"}, + {file = "pyinstaller-6.20.0.tar.gz", hash = "sha256:95c5c7e03d5d61e9dfb8ef259c699cf492bb1041beb6dbe83696608cec07347a"}, ] [package.dependencies] @@ -1207,7 +1206,7 @@ importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""} packaging = ">=22.0" pefile = {version = ">=2022.5.30", markers = "sys_platform == \"win32\""} -pyinstaller-hooks-contrib = ">=2026.0" +pyinstaller-hooks-contrib = ">=2026.4" pywin32-ctypes = {version = ">=0.2.1", markers = "sys_platform == \"win32\""} setuptools = ">=42.0.0" @@ -1217,15 +1216,15 @@ hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"] [[package]] name = "pyinstaller-hooks-contrib" -version = "2026.0" +version = "2026.5" description = "Community maintained hooks for PyInstaller" optional = false python-versions = ">=3.8" groups = ["executable"] markers = "python_version < \"3.15\"" files = [ - {file = "pyinstaller_hooks_contrib-2026.0-py3-none-any.whl", hash = "sha256:0590db8edeba3e6c30c8474937021f5cd39c0602b4d10f74a064c73911efaca5"}, - {file = "pyinstaller_hooks_contrib-2026.0.tar.gz", hash = "sha256:0120893de491a000845470ca9c0b39284731ac6bace26f6849dea9627aaed48e"}, + {file = "pyinstaller_hooks_contrib-2026.5-py3-none-any.whl", hash = "sha256:ea1535783fbdac4626351709e83f3ea80b681d3a4745763ebb407b5e27342eb9"}, + {file = "pyinstaller_hooks_contrib-2026.5.tar.gz", hash = "sha256:f066dfca8f7c45ff6336c9cf9fe25b4e48bfeb322a1aa24faaedfb8a8d1b0b08"}, ] [package.dependencies] @@ -2056,4 +2055,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "ac37763cb9b582d1997853c1347edfdb05566fa225adf304685ecce66989fc67" +content-hash = "36b21102c474b6c7efbf1e70d213c5c6138fc12e28f6c4bde3d253d4f3c73299" diff --git a/pyproject.toml b/pyproject.toml index 1b43757b..fd20bccd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ responses = ">=0.23.1,<0.27.0" pyfakefs = ">=5.7.2,<5.11.0" [tool.poetry.group.executable.dependencies] -pyinstaller = {version=">=6.0.0,<7.0.0", python=">=3.9,<3.15"} +pyinstaller = {version=">=6.20.0,<7.0.0", python=">=3.9,<3.15"} dunamai = ">=1.26.1,<1.27.0" [tool.poetry.group.dev.dependencies] From c33b781fc4f929e6e703e5ebbd2f0a1307871f8a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 08:16:38 +0300 Subject: [PATCH 079/123] Bump actions/upload-artifact from 4.6.2 to 7.0.1 (#449) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build_executable.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index eef17ad3..d3ebb83e 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -265,7 +265,7 @@ jobs: run: echo "ARTIFACT_NAME=$(./process_executable_file.py dist/cycode-cli)" >> $GITHUB_ENV - name: Upload files as artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ env.ARTIFACT_NAME }} path: dist From 22c1f20f222b4cecb00a54a71e49be4a65fc7b3e Mon Sep 17 00:00:00 2001 From: omerr-cycode Date: Wed, 3 Jun 2026 10:34:24 +0300 Subject: [PATCH 080/123] CM-65133 upgrade ruff to 0.15.14 (#462) --- cycode/cli/apps/mcp/mcp_command.py | 7 +- poetry.lock | 1215 ++++++++--------- pyproject.toml | 3 +- .../test_commit_range_documents.py | 32 +- 4 files changed, 613 insertions(+), 644 deletions(-) diff --git a/cycode/cli/apps/mcp/mcp_command.py b/cycode/cli/apps/mcp/mcp_command.py index adfc0a3f..517f514f 100644 --- a/cycode/cli/apps/mcp/mcp_command.py +++ b/cycode/cli/apps/mcp/mcp_command.py @@ -8,6 +8,7 @@ import uuid from typing import Annotated, Any, Optional +import anyio import typer from pathvalidate import sanitize_filepath from pydantic import Field @@ -65,6 +66,7 @@ def _get_current_executable() -> str: return 'cycode' +# ruff: disable[ASYNC109] async def _run_cycode_command(*args: str, timeout: int = _DEFAULT_RUN_COMMAND_TIMEOUT) -> dict[str, Any]: """Run a cycode command asynchronously and return the parsed result. @@ -109,6 +111,9 @@ async def _run_cycode_command(*args: str, timeout: int = _DEFAULT_RUN_COMMAND_TI return {'error': f'Failed to run command: {e!s}'} +# ruff: enable[ASYNC109] + + def _sanitize_file_path(file_path: str) -> str: """Sanitize file path to prevent path traversal and other security issues. @@ -238,7 +243,7 @@ async def _cycode_scan_tool( try: if paths: - missing = [p for p in paths if not os.path.exists(p)] + missing = [p for p in paths if not await anyio.Path(p).exists()] if missing: return json.dumps({'error': f'Paths not found on disk: {missing}'}, indent=2) diff --git a/poetry.lock b/poetry.lock index 51da19c7..0b87b802 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,16 +1,16 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. [[package]] name = "altgraph" -version = "0.17.4" +version = "0.17.5" description = "Python graph (network) package" optional = false python-versions = "*" groups = ["executable"] markers = "python_version < \"3.15\"" files = [ - {file = "altgraph-0.17.4-py2.py3-none-any.whl", hash = "sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff"}, - {file = "altgraph-0.17.4.tar.gz", hash = "sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406"}, + {file = "altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597"}, + {file = "altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7"}, ] [[package]] @@ -27,25 +27,23 @@ files = [ [[package]] name = "anyio" -version = "4.11.0" +version = "4.12.1" description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.9" groups = ["main"] -markers = "python_version >= \"3.10\"" files = [ - {file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"}, - {file = "anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4"}, + {file = "anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"}, + {file = "anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703"}, ] [package.dependencies] exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} idna = ">=2.8" -sniffio = ">=1.1" typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] -trio = ["trio (>=0.31.0)"] +trio = ["trio (>=0.31.0) ; python_version < \"3.10\"", "trio (>=0.32.0) ; python_version >= \"3.10\""] [[package]] name = "arrow" @@ -69,27 +67,27 @@ test = ["dateparser (==1.*)", "pre-commit", "pytest", "pytest-cov", "pytest-mock [[package]] name = "attrs" -version = "25.4.0" +version = "26.1.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.9" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"}, - {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, + {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"}, + {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, ] [[package]] name = "certifi" -version = "2025.10.5" +version = "2026.5.20" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" groups = ["main", "test"] files = [ - {file = "certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de"}, - {file = "certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43"}, + {file = "certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897"}, + {file = "certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d"}, ] [[package]] @@ -192,125 +190,141 @@ pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} [[package]] name = "charset-normalizer" -version = "3.4.4" +version = "3.4.7" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" groups = ["main", "test"] files = [ - {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"}, - {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, - {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"}, + {file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"}, + {file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"}, ] [[package]] @@ -339,6 +353,7 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {test = "sys_platform == \"win32\""} [[package]] name = "coverage" @@ -459,77 +474,70 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cryptography" -version = "46.0.5" +version = "48.0.0" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false -python-versions = "!=3.9.0,!=3.9.1,>=3.8" +python-versions = "!=3.9.0,!=3.9.1,>=3.9" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad"}, - {file = "cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b"}, - {file = "cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b"}, - {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263"}, - {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d"}, - {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed"}, - {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2"}, - {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2"}, - {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0"}, - {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731"}, - {file = "cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82"}, - {file = "cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1"}, - {file = "cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48"}, - {file = "cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4"}, - {file = "cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2"}, - {file = "cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678"}, - {file = "cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87"}, - {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee"}, - {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981"}, - {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9"}, - {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648"}, - {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4"}, - {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0"}, - {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663"}, - {file = "cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826"}, - {file = "cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d"}, - {file = "cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a"}, - {file = "cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4"}, - {file = "cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31"}, - {file = "cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18"}, - {file = "cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235"}, - {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a"}, - {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76"}, - {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614"}, - {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229"}, - {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1"}, - {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d"}, - {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c"}, - {file = "cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4"}, - {file = "cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9"}, - {file = "cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72"}, - {file = "cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595"}, - {file = "cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c"}, - {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a"}, - {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356"}, - {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da"}, - {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257"}, - {file = "cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7"}, - {file = "cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d"}, + {file = "cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5"}, + {file = "cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321"}, + {file = "cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74"}, + {file = "cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4"}, + {file = "cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7"}, + {file = "cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057"}, + {file = "cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae"}, + {file = "cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c"}, + {file = "cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f"}, + {file = "cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12"}, + {file = "cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239"}, + {file = "cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c"}, + {file = "cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4"}, + {file = "cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd"}, + {file = "cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8"}, + {file = "cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855"}, + {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b"}, + {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13"}, + {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb"}, + {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355"}, + {file = "cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a"}, + {file = "cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920"}, ] [package.dependencies] -cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} +cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""} typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""} [package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] -docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] -nox = ["nox[uv] (>=2024.4.15)"] -pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"] -sdist = ["build (>=1.0.0)"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi (>=2024)", "cryptography-vectors (==46.0.5)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] -test-randomorder = ["pytest-randomly"] [[package]] name = "dunamai" @@ -548,16 +556,16 @@ packaging = ">=20.9" [[package]] name = "exceptiongroup" -version = "1.3.0" +version = "1.3.1" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" groups = ["main", "test"] +markers = "python_version < \"3.11\"" files = [ - {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, - {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, ] -markers = {main = "python_version == \"3.10\"", test = "python_version < \"3.11\""} [package.dependencies] typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} @@ -677,30 +685,30 @@ files = [ [[package]] name = "idna" -version = "3.11" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main", "test"] files = [ - {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, - {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] [[package]] name = "importlib-metadata" -version = "8.7.0" +version = "8.7.1" description = "Read metadata from Python packages" optional = false python-versions = ">=3.9" groups = ["executable"] markers = "python_version == \"3.9\"" files = [ - {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, - {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, + {file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"}, + {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"}, ] [package.dependencies] @@ -710,10 +718,10 @@ zipp = ">=3.20" check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] +enabler = ["pytest-enabler (>=3.4)"] perf = ["ipython"] -test = ["flufl.flake8", "importlib_resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] -type = ["pytest-mypy"] +test = ["flufl.flake8", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"] [[package]] name = "iniconfig" @@ -729,22 +737,22 @@ files = [ [[package]] name = "jsonschema" -version = "4.25.1" +version = "4.26.0" description = "An implementation of JSON Schema validation for Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63"}, - {file = "jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85"}, + {file = "jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce"}, + {file = "jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326"}, ] [package.dependencies] attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.03.6" +jsonschema-specifications = ">=2023.3.6" referencing = ">=0.28.4" -rpds-py = ">=0.7.1" +rpds-py = ">=0.25.0" [package.extras] format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] @@ -768,15 +776,15 @@ referencing = ">=0.31.0" [[package]] name = "macholib" -version = "1.16.3" +version = "1.16.4" description = "Mach-O header analysis and editing" optional = false python-versions = "*" groups = ["executable"] markers = "python_version < \"3.15\" and sys_platform == \"darwin\"" files = [ - {file = "macholib-1.16.3-py2.py3-none-any.whl", hash = "sha256:0e315d7583d38b8c77e815b1ecbdbf504a8258d8b3e17b61165c6feb60d18f2c"}, - {file = "macholib-1.16.3.tar.gz", hash = "sha256:07ae9e15e8e4cd9a788013d81f5908b3609aa76f9b1421bae9c4d7606ec86a30"}, + {file = "macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea"}, + {file = "macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362"}, ] [package.dependencies] @@ -829,20 +837,20 @@ tests = ["pytest", "simplejson"] [[package]] name = "mcp" -version = "1.26.0" +version = "1.27.2" description = "Model Context Protocol SDK" optional = false python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca"}, - {file = "mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66"}, + {file = "mcp-1.27.2-py3-none-any.whl", hash = "sha256:d6ff5160c6ca65d93013626efb3fc249de683c30b2d8570755ceddd490344de5"}, + {file = "mcp-1.27.2.tar.gz", hash = "sha256:8e02db104096d1c25b28e64bde29a5c32b31bc241710213e12fd4d84985bdfef"}, ] [package.dependencies] anyio = ">=4.5" -httpx = ">=0.27.1" +httpx = ">=0.27.1,<1.0.0" httpx-sse = ">=0.4" jsonschema = ">=4.20.0" pydantic = ">=2.11.0,<3.0.0" @@ -892,14 +900,14 @@ test = ["pytest (<5.4)", "pytest-cov"] [[package]] name = "packaging" -version = "25.0" +version = "26.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" groups = ["main", "executable", "test"] files = [ - {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, - {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, + {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, + {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, ] [[package]] @@ -975,19 +983,19 @@ files = [ [[package]] name = "pydantic" -version = "2.12.3" +version = "2.13.4" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pydantic-2.12.3-py3-none-any.whl", hash = "sha256:6986454a854bc3bc6e5443e1369e06a3a456af9d339eda45510f517d9ea5c6bf"}, - {file = "pydantic-2.12.3.tar.gz", hash = "sha256:1da1c82b0fc140bb0103bc1441ffe062154c8d38491189751ee00fd8ca65ce74"}, + {file = "pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba"}, + {file = "pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6"}, ] [package.dependencies] annotated-types = ">=0.6.0" -pydantic-core = "2.41.4" +pydantic-core = "2.46.4" typing-extensions = ">=4.14.1" typing-inspection = ">=0.4.2" @@ -997,129 +1005,132 @@ timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows [[package]] name = "pydantic-core" -version = "2.41.4" +version = "2.46.4" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pydantic_core-2.41.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2442d9a4d38f3411f22eb9dd0912b7cbf4b7d5b6c92c4173b75d3e1ccd84e36e"}, - {file = "pydantic_core-2.41.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:30a9876226dda131a741afeab2702e2d127209bde3c65a2b8133f428bc5d006b"}, - {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d55bbac04711e2980645af68b97d445cdbcce70e5216de444a6c4b6943ebcccd"}, - {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e1d778fb7849a42d0ee5927ab0f7453bf9f85eef8887a546ec87db5ddb178945"}, - {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b65077a4693a98b90ec5ad8f203ad65802a1b9b6d4a7e48066925a7e1606706"}, - {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62637c769dee16eddb7686bf421be48dfc2fae93832c25e25bc7242e698361ba"}, - {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dfe3aa529c8f501babf6e502936b9e8d4698502b2cfab41e17a028d91b1ac7b"}, - {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ca2322da745bf2eeb581fc9ea3bbb31147702163ccbcbf12a3bb630e4bf05e1d"}, - {file = "pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e8cd3577c796be7231dcf80badcf2e0835a46665eaafd8ace124d886bab4d700"}, - {file = "pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:1cae8851e174c83633f0833e90636832857297900133705ee158cf79d40f03e6"}, - {file = "pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a26d950449aae348afe1ac8be5525a00ae4235309b729ad4d3399623125b43c9"}, - {file = "pydantic_core-2.41.4-cp310-cp310-win32.whl", hash = "sha256:0cf2a1f599efe57fa0051312774280ee0f650e11152325e41dfd3018ef2c1b57"}, - {file = "pydantic_core-2.41.4-cp310-cp310-win_amd64.whl", hash = "sha256:a8c2e340d7e454dc3340d3d2e8f23558ebe78c98aa8f68851b04dcb7bc37abdc"}, - {file = "pydantic_core-2.41.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:28ff11666443a1a8cf2a044d6a545ebffa8382b5f7973f22c36109205e65dc80"}, - {file = "pydantic_core-2.41.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61760c3925d4633290292bad462e0f737b840508b4f722247d8729684f6539ae"}, - {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eae547b7315d055b0de2ec3965643b0ab82ad0106a7ffd29615ee9f266a02827"}, - {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ef9ee5471edd58d1fcce1c80ffc8783a650e3e3a193fe90d52e43bb4d87bff1f"}, - {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15dd504af121caaf2c95cb90c0ebf71603c53de98305621b94da0f967e572def"}, - {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a926768ea49a8af4d36abd6a8968b8790f7f76dd7cbd5a4c180db2b4ac9a3a2"}, - {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6916b9b7d134bff5440098a4deb80e4cb623e68974a87883299de9124126c2a8"}, - {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5cf90535979089df02e6f17ffd076f07237efa55b7343d98760bde8743c4b265"}, - {file = "pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7533c76fa647fade2d7ec75ac5cc079ab3f34879626dae5689b27790a6cf5a5c"}, - {file = "pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:37e516bca9264cbf29612539801ca3cd5d1be465f940417b002905e6ed79d38a"}, - {file = "pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0c19cb355224037c83642429b8ce261ae108e1c5fbf5c028bac63c77b0f8646e"}, - {file = "pydantic_core-2.41.4-cp311-cp311-win32.whl", hash = "sha256:09c2a60e55b357284b5f31f5ab275ba9f7f70b7525e18a132ec1f9160b4f1f03"}, - {file = "pydantic_core-2.41.4-cp311-cp311-win_amd64.whl", hash = "sha256:711156b6afb5cb1cb7c14a2cc2c4a8b4c717b69046f13c6b332d8a0a8f41ca3e"}, - {file = "pydantic_core-2.41.4-cp311-cp311-win_arm64.whl", hash = "sha256:6cb9cf7e761f4f8a8589a45e49ed3c0d92d1d696a45a6feaee8c904b26efc2db"}, - {file = "pydantic_core-2.41.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ab06d77e053d660a6faaf04894446df7b0a7e7aba70c2797465a0a1af00fc887"}, - {file = "pydantic_core-2.41.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c53ff33e603a9c1179a9364b0a24694f183717b2e0da2b5ad43c316c956901b2"}, - {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:304c54176af2c143bd181d82e77c15c41cbacea8872a2225dd37e6544dce9999"}, - {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:025ba34a4cf4fb32f917d5d188ab5e702223d3ba603be4d8aca2f82bede432a4"}, - {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9f5f30c402ed58f90c70e12eff65547d3ab74685ffe8283c719e6bead8ef53f"}, - {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd96e5d15385d301733113bcaa324c8bcf111275b7675a9c6e88bfb19fc05e3b"}, - {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98f348cbb44fae6e9653c1055db7e29de67ea6a9ca03a5fa2c2e11a47cff0e47"}, - {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec22626a2d14620a83ca583c6f5a4080fa3155282718b6055c2ea48d3ef35970"}, - {file = "pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3a95d4590b1f1a43bf33ca6d647b990a88f4a3824a8c4572c708f0b45a5290ed"}, - {file = "pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:f9672ab4d398e1b602feadcffcdd3af44d5f5e6ddc15bc7d15d376d47e8e19f8"}, - {file = "pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:84d8854db5f55fead3b579f04bda9a36461dab0730c5d570e1526483e7bb8431"}, - {file = "pydantic_core-2.41.4-cp312-cp312-win32.whl", hash = "sha256:9be1c01adb2ecc4e464392c36d17f97e9110fbbc906bcbe1c943b5b87a74aabd"}, - {file = "pydantic_core-2.41.4-cp312-cp312-win_amd64.whl", hash = "sha256:d682cf1d22bab22a5be08539dca3d1593488a99998f9f412137bc323179067ff"}, - {file = "pydantic_core-2.41.4-cp312-cp312-win_arm64.whl", hash = "sha256:833eebfd75a26d17470b58768c1834dfc90141b7afc6eb0429c21fc5a21dcfb8"}, - {file = "pydantic_core-2.41.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:85e050ad9e5f6fe1004eec65c914332e52f429bc0ae12d6fa2092407a462c746"}, - {file = "pydantic_core-2.41.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7393f1d64792763a48924ba31d1e44c2cfbc05e3b1c2c9abb4ceeadd912cced"}, - {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94dab0940b0d1fb28bcab847adf887c66a27a40291eedf0b473be58761c9799a"}, - {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de7c42f897e689ee6f9e93c4bec72b99ae3b32a2ade1c7e4798e690ff5246e02"}, - {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:664b3199193262277b8b3cd1e754fb07f2c6023289c815a1e1e8fb415cb247b1"}, - {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d95b253b88f7d308b1c0b417c4624f44553ba4762816f94e6986819b9c273fb2"}, - {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1351f5bbdbbabc689727cb91649a00cb9ee7203e0a6e54e9f5ba9e22e384b84"}, - {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1affa4798520b148d7182da0615d648e752de4ab1a9566b7471bc803d88a062d"}, - {file = "pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7b74e18052fea4aa8dea2fb7dbc23d15439695da6cbe6cfc1b694af1115df09d"}, - {file = "pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:285b643d75c0e30abda9dc1077395624f314a37e3c09ca402d4015ef5979f1a2"}, - {file = "pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f52679ff4218d713b3b33f88c89ccbf3a5c2c12ba665fb80ccc4192b4608dbab"}, - {file = "pydantic_core-2.41.4-cp313-cp313-win32.whl", hash = "sha256:ecde6dedd6fff127c273c76821bb754d793be1024bc33314a120f83a3c69460c"}, - {file = "pydantic_core-2.41.4-cp313-cp313-win_amd64.whl", hash = "sha256:d081a1f3800f05409ed868ebb2d74ac39dd0c1ff6c035b5162356d76030736d4"}, - {file = "pydantic_core-2.41.4-cp313-cp313-win_arm64.whl", hash = "sha256:f8e49c9c364a7edcbe2a310f12733aad95b022495ef2a8d653f645e5d20c1564"}, - {file = "pydantic_core-2.41.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ed97fd56a561f5eb5706cebe94f1ad7c13b84d98312a05546f2ad036bafe87f4"}, - {file = "pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a870c307bf1ee91fc58a9a61338ff780d01bfae45922624816878dce784095d2"}, - {file = "pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d25e97bc1f5f8f7985bdc2335ef9e73843bb561eb1fa6831fdfc295c1c2061cf"}, - {file = "pydantic_core-2.41.4-cp313-cp313t-win_amd64.whl", hash = "sha256:d405d14bea042f166512add3091c1af40437c2e7f86988f3915fabd27b1e9cd2"}, - {file = "pydantic_core-2.41.4-cp313-cp313t-win_arm64.whl", hash = "sha256:19f3684868309db5263a11bace3c45d93f6f24afa2ffe75a647583df22a2ff89"}, - {file = "pydantic_core-2.41.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1"}, - {file = "pydantic_core-2.41.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac"}, - {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554"}, - {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e"}, - {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616"}, - {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af"}, - {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12"}, - {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d"}, - {file = "pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad"}, - {file = "pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a"}, - {file = "pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025"}, - {file = "pydantic_core-2.41.4-cp314-cp314-win32.whl", hash = "sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e"}, - {file = "pydantic_core-2.41.4-cp314-cp314-win_amd64.whl", hash = "sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894"}, - {file = "pydantic_core-2.41.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d"}, - {file = "pydantic_core-2.41.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da"}, - {file = "pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e"}, - {file = "pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa"}, - {file = "pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d"}, - {file = "pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0"}, - {file = "pydantic_core-2.41.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:646e76293345954acea6966149683047b7b2ace793011922208c8e9da12b0062"}, - {file = "pydantic_core-2.41.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cc8e85a63085a137d286e2791037f5fdfff0aabb8b899483ca9c496dd5797338"}, - {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:692c622c8f859a17c156492783902d8370ac7e121a611bd6fe92cc71acf9ee8d"}, - {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d1e2906efb1031a532600679b424ef1d95d9f9fb507f813951f23320903adbd7"}, - {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e04e2f7f8916ad3ddd417a7abdd295276a0bf216993d9318a5d61cc058209166"}, - {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df649916b81822543d1c8e0e1d079235f68acdc7d270c911e8425045a8cfc57e"}, - {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66c529f862fdba70558061bb936fe00ddbaaa0c647fd26e4a4356ef1d6561891"}, - {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3b4c5a1fd3a311563ed866c2c9b62da06cb6398bee186484ce95c820db71cb"}, - {file = "pydantic_core-2.41.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6e0fc40d84448f941df9b3334c4b78fe42f36e3bf631ad54c3047a0cdddc2514"}, - {file = "pydantic_core-2.41.4-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:44e7625332683b6c1c8b980461475cde9595eff94447500e80716db89b0da005"}, - {file = "pydantic_core-2.41.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:170ee6835f6c71081d031ef1c3b4dc4a12b9efa6a9540f93f95b82f3c7571ae8"}, - {file = "pydantic_core-2.41.4-cp39-cp39-win32.whl", hash = "sha256:3adf61415efa6ce977041ba9745183c0e1f637ca849773afa93833e04b163feb"}, - {file = "pydantic_core-2.41.4-cp39-cp39-win_amd64.whl", hash = "sha256:a238dd3feee263eeaeb7dc44aea4ba1364682c4f9f9467e6af5596ba322c2332"}, - {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:a1b2cfec3879afb742a7b0bcfa53e4f22ba96571c9e54d6a3afe1052d17d843b"}, - {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:d175600d975b7c244af6eb9c9041f10059f20b8bbffec9e33fdd5ee3f67cdc42"}, - {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f184d657fa4947ae5ec9c47bd7e917730fa1cbb78195037e32dcbab50aca5ee"}, - {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed810568aeffed3edc78910af32af911c835cc39ebbfacd1f0ab5dd53028e5c"}, - {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:4f5d640aeebb438517150fdeec097739614421900e4a08db4a3ef38898798537"}, - {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:4a9ab037b71927babc6d9e7fc01aea9e66dc2a4a34dff06ef0724a4049629f94"}, - {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4dab9484ec605c3016df9ad4fd4f9a390bc5d816a3b10c6550f8424bb80b18c"}, - {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8a5028425820731d8c6c098ab642d7b8b999758e24acae03ed38a66eca8335"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:1e5ab4fc177dd41536b3c32b2ea11380dd3d4619a385860621478ac2d25ceb00"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:3d88d0054d3fa11ce936184896bed3c1c5441d6fa483b498fac6a5d0dd6f64a9"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b2a054a8725f05b4b6503357e0ac1c4e8234ad3b0c2ac130d6ffc66f0e170e2"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0d9db5a161c99375a0c68c058e227bee1d89303300802601d76a3d01f74e258"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:6273ea2c8ffdac7b7fda2653c49682db815aebf4a89243a6feccf5e36c18c347"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:4c973add636efc61de22530b2ef83a65f39b6d6f656df97f678720e20de26caa"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b69d1973354758007f46cf2d44a4f3d0933f10b6dc9bf15cf1356e037f6f731a"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:3619320641fd212aaf5997b6ca505e97540b7e16418f4a241f44cdf108ffb50d"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:491535d45cd7ad7e4a2af4a5169b0d07bebf1adfd164b0368da8aa41e19907a5"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:54d86c0cada6aba4ec4c047d0e348cbad7063b87ae0f005d9f8c9ad04d4a92a2"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eca1124aced216b2500dc2609eade086d718e8249cb9696660ab447d50a758bd"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6c9024169becccf0cb470ada03ee578d7348c119a0d42af3dcf9eda96e3a247c"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:26895a4268ae5a2849269f4991cdc97236e4b9c010e51137becf25182daac405"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:ca4df25762cf71308c446e33c9b1fdca2923a3f13de616e2a949f38bf21ff5a8"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:5a28fcedd762349519276c36634e71853b4541079cab4acaaac60c4421827308"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c173ddcd86afd2535e2b695217e82191580663a1d1928239f877f5a1649ef39f"}, - {file = "pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5"}, + {file = "pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4"}, + {file = "pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d"}, + {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4"}, + {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f"}, + {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39"}, + {file = "pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d"}, + {file = "pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf"}, + {file = "pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594"}, + {file = "pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3"}, + {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848"}, + {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3"}, + {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109"}, + {file = "pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda"}, + {file = "pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33"}, + {file = "pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d"}, + {file = "pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2"}, + {file = "pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b"}, + {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458"}, + {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b"}, + {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c"}, + {file = "pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894"}, + {file = "pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89"}, + {file = "pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a"}, + {file = "pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008"}, + {file = "pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e"}, + {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd"}, + {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be"}, + {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d"}, + {file = "pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb"}, + {file = "pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292"}, + {file = "pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d"}, + {file = "pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb"}, + {file = "pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb"}, + {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898"}, + {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e"}, + {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519"}, + {file = "pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4"}, + {file = "pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac"}, + {file = "pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596"}, + {file = "pydantic_core-2.46.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae"}, + {file = "pydantic_core-2.46.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6"}, + {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9"}, + {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29"}, + {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9"}, + {file = "pydantic_core-2.46.4-cp39-cp39-win32.whl", hash = "sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1"}, + {file = "pydantic_core-2.46.4-cp39-cp39-win_amd64.whl", hash = "sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983"}, + {file = "pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1"}, ] [package.dependencies] @@ -1127,15 +1138,15 @@ typing-extensions = ">=4.14.1" [[package]] name = "pydantic-settings" -version = "2.11.0" +version = "2.14.1" description = "Settings management using Pydantic" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c"}, - {file = "pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180"}, + {file = "pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de"}, + {file = "pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa"}, ] [package.dependencies] @@ -1144,7 +1155,7 @@ python-dotenv = ">=0.21.0" typing-inspection = ">=0.4.0" [package.extras] -aws-secrets-manager = ["boto3 (>=1.35.0)", "boto3-stubs[secretsmanager]"] +aws-secrets-manager = ["boto3 (>=1.35.0)", "types-boto3[secretsmanager]"] azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] toml = ["tomli (>=2.0.1)"] @@ -1234,24 +1245,22 @@ setuptools = ">=42.0.0" [[package]] name = "pyjwt" -version = "2.12.0" +version = "2.13.0" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pyjwt-2.12.0-py3-none-any.whl", hash = "sha256:9bb459d1bdd0387967d287f5656bf7ec2b9a26645d1961628cda1764e087fd6e"}, - {file = "pyjwt-2.12.0.tar.gz", hash = "sha256:2f62390b667cd8257de560b850bb5a883102a388829274147f1d724453f8fb02"}, + {file = "pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728"}, + {file = "pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423"}, ] [package.dependencies] cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} +typing_extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} [package.extras] crypto = ["cryptography (>=3.4.0)"] -dev = ["coverage[toml] (==7.10.7)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=8.4.2,<9.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] -docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] -tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"] [[package]] name = "pytest" @@ -1312,15 +1321,15 @@ six = ">=1.5" [[package]] name = "python-dotenv" -version = "1.1.1" +version = "1.2.2" description = "Read key-value pairs from a .env file and set them as environment variables" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc"}, - {file = "python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab"}, + {file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"}, + {file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"}, ] [package.extras] @@ -1328,15 +1337,15 @@ cli = ["click (>=5.0)"] [[package]] name = "python-multipart" -version = "0.0.22" +version = "0.0.30" description = "A streaming multipart parser for Python" optional = false python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155"}, - {file = "python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58"}, + {file = "python_multipart-0.0.30-py3-none-any.whl", hash = "sha256:830964def8c90607ac5daa00514e3987815865713ade8d20febc9177ac0c3c5b"}, + {file = "python_multipart-0.0.30.tar.gz", hash = "sha256:0edfe0475c1f46ddd3ff7785a626f6118af32bdcf359bb21260367313bb32118"}, ] [[package]] @@ -1508,14 +1517,14 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "responses" -version = "0.26.0" +version = "0.26.1" description = "A utility library for mocking out the `requests` Python library." optional = false python-versions = ">=3.8" groups = ["test"] files = [ - {file = "responses-0.26.0-py3-none-any.whl", hash = "sha256:03ec4409088cd5c66b71ecbbbd27fe2c58ddfad801c66203457b3e6a04868c37"}, - {file = "responses-0.26.0.tar.gz", hash = "sha256:c7f6923e6343ef3682816ba421c006626777893cb0d5e1434f674b649bac9eb4"}, + {file = "responses-0.26.1-py3-none-any.whl", hash = "sha256:8aacc4586eb08fb2208ef64a9eb4258d9b0c6e6f4260845f2f018ab847495345"}, + {file = "responses-0.26.1.tar.gz", hash = "sha256:2eb3218553cc8f79b57d257bac23af5e1bf381f5b9390b1767816f0843e01dc2"}, ] [package.dependencies] @@ -1548,219 +1557,179 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "rpds-py" -version = "0.27.1" +version = "0.30.0" description = "Python bindings to Rust's persistent data structures (rpds)" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "rpds_py-0.27.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:68afeec26d42ab3b47e541b272166a0b4400313946871cba3ed3a4fc0cab1cef"}, - {file = "rpds_py-0.27.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74e5b2f7bb6fa38b1b10546d27acbacf2a022a8b5543efb06cfebc72a59c85be"}, - {file = "rpds_py-0.27.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9024de74731df54546fab0bfbcdb49fae19159ecaecfc8f37c18d2c7e2c0bd61"}, - {file = "rpds_py-0.27.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:31d3ebadefcd73b73928ed0b2fd696f7fefda8629229f81929ac9c1854d0cffb"}, - {file = "rpds_py-0.27.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2e7f8f169d775dd9092a1743768d771f1d1300453ddfe6325ae3ab5332b4657"}, - {file = "rpds_py-0.27.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d905d16f77eb6ab2e324e09bfa277b4c8e5e6b8a78a3e7ff8f3cdf773b4c013"}, - {file = "rpds_py-0.27.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50c946f048209e6362e22576baea09193809f87687a95a8db24e5fbdb307b93a"}, - {file = "rpds_py-0.27.1-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:3deab27804d65cd8289eb814c2c0e807c4b9d9916c9225e363cb0cf875eb67c1"}, - {file = "rpds_py-0.27.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8b61097f7488de4be8244c89915da8ed212832ccf1e7c7753a25a394bf9b1f10"}, - {file = "rpds_py-0.27.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8a3f29aba6e2d7d90528d3c792555a93497fe6538aa65eb675b44505be747808"}, - {file = "rpds_py-0.27.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd6cd0485b7d347304067153a6dc1d73f7d4fd995a396ef32a24d24b8ac63ac8"}, - {file = "rpds_py-0.27.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6f4461bf931108c9fa226ffb0e257c1b18dc2d44cd72b125bec50ee0ab1248a9"}, - {file = "rpds_py-0.27.1-cp310-cp310-win32.whl", hash = "sha256:ee5422d7fb21f6a00c1901bf6559c49fee13a5159d0288320737bbf6585bd3e4"}, - {file = "rpds_py-0.27.1-cp310-cp310-win_amd64.whl", hash = "sha256:3e039aabf6d5f83c745d5f9a0a381d031e9ed871967c0a5c38d201aca41f3ba1"}, - {file = "rpds_py-0.27.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:be898f271f851f68b318872ce6ebebbc62f303b654e43bf72683dbdc25b7c881"}, - {file = "rpds_py-0.27.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:62ac3d4e3e07b58ee0ddecd71d6ce3b1637de2d373501412df395a0ec5f9beb5"}, - {file = "rpds_py-0.27.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4708c5c0ceb2d034f9991623631d3d23cb16e65c83736ea020cdbe28d57c0a0e"}, - {file = "rpds_py-0.27.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:abfa1171a9952d2e0002aba2ad3780820b00cc3d9c98c6630f2e93271501f66c"}, - {file = "rpds_py-0.27.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b507d19f817ebaca79574b16eb2ae412e5c0835542c93fe9983f1e432aca195"}, - {file = "rpds_py-0.27.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:168b025f8fd8d8d10957405f3fdcef3dc20f5982d398f90851f4abc58c566c52"}, - {file = "rpds_py-0.27.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb56c6210ef77caa58e16e8c17d35c63fe3f5b60fd9ba9d424470c3400bcf9ed"}, - {file = "rpds_py-0.27.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:d252f2d8ca0195faa707f8eb9368955760880b2b42a8ee16d382bf5dd807f89a"}, - {file = "rpds_py-0.27.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6e5e54da1e74b91dbc7996b56640f79b195d5925c2b78efaa8c5d53e1d88edde"}, - {file = "rpds_py-0.27.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ffce0481cc6e95e5b3f0a47ee17ffbd234399e6d532f394c8dce320c3b089c21"}, - {file = "rpds_py-0.27.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a205fdfe55c90c2cd8e540ca9ceba65cbe6629b443bc05db1f590a3db8189ff9"}, - {file = "rpds_py-0.27.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:689fb5200a749db0415b092972e8eba85847c23885c8543a8b0f5c009b1a5948"}, - {file = "rpds_py-0.27.1-cp311-cp311-win32.whl", hash = "sha256:3182af66048c00a075010bc7f4860f33913528a4b6fc09094a6e7598e462fe39"}, - {file = "rpds_py-0.27.1-cp311-cp311-win_amd64.whl", hash = "sha256:b4938466c6b257b2f5c4ff98acd8128ec36b5059e5c8f8372d79316b1c36bb15"}, - {file = "rpds_py-0.27.1-cp311-cp311-win_arm64.whl", hash = "sha256:2f57af9b4d0793e53266ee4325535a31ba48e2f875da81a9177c9926dfa60746"}, - {file = "rpds_py-0.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ae2775c1973e3c30316892737b91f9283f9908e3cc7625b9331271eaaed7dc90"}, - {file = "rpds_py-0.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2643400120f55c8a96f7c9d858f7be0c88d383cd4653ae2cf0d0c88f668073e5"}, - {file = "rpds_py-0.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16323f674c089b0360674a4abd28d5042947d54ba620f72514d69be4ff64845e"}, - {file = "rpds_py-0.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a1f4814b65eacac94a00fc9a526e3fdafd78e439469644032032d0d63de4881"}, - {file = "rpds_py-0.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ba32c16b064267b22f1850a34051121d423b6f7338a12b9459550eb2096e7ec"}, - {file = "rpds_py-0.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5c20f33fd10485b80f65e800bbe5f6785af510b9f4056c5a3c612ebc83ba6cb"}, - {file = "rpds_py-0.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:466bfe65bd932da36ff279ddd92de56b042f2266d752719beb97b08526268ec5"}, - {file = "rpds_py-0.27.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:41e532bbdcb57c92ba3be62c42e9f096431b4cf478da9bc3bc6ce5c38ab7ba7a"}, - {file = "rpds_py-0.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f149826d742b406579466283769a8ea448eed82a789af0ed17b0cd5770433444"}, - {file = "rpds_py-0.27.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80c60cfb5310677bd67cb1e85a1e8eb52e12529545441b43e6f14d90b878775a"}, - {file = "rpds_py-0.27.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7ee6521b9baf06085f62ba9c7a3e5becffbc32480d2f1b351559c001c38ce4c1"}, - {file = "rpds_py-0.27.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a512c8263249a9d68cac08b05dd59d2b3f2061d99b322813cbcc14c3c7421998"}, - {file = "rpds_py-0.27.1-cp312-cp312-win32.whl", hash = "sha256:819064fa048ba01b6dadc5116f3ac48610435ac9a0058bbde98e569f9e785c39"}, - {file = "rpds_py-0.27.1-cp312-cp312-win_amd64.whl", hash = "sha256:d9199717881f13c32c4046a15f024971a3b78ad4ea029e8da6b86e5aa9cf4594"}, - {file = "rpds_py-0.27.1-cp312-cp312-win_arm64.whl", hash = "sha256:33aa65b97826a0e885ef6e278fbd934e98cdcfed80b63946025f01e2f5b29502"}, - {file = "rpds_py-0.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e4b9fcfbc021633863a37e92571d6f91851fa656f0180246e84cbd8b3f6b329b"}, - {file = "rpds_py-0.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1441811a96eadca93c517d08df75de45e5ffe68aa3089924f963c782c4b898cf"}, - {file = "rpds_py-0.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55266dafa22e672f5a4f65019015f90336ed31c6383bd53f5e7826d21a0e0b83"}, - {file = "rpds_py-0.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d78827d7ac08627ea2c8e02c9e5b41180ea5ea1f747e9db0915e3adf36b62dcf"}, - {file = "rpds_py-0.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae92443798a40a92dc5f0b01d8a7c93adde0c4dc965310a29ae7c64d72b9fad2"}, - {file = "rpds_py-0.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c46c9dd2403b66a2a3b9720ec4b74d4ab49d4fabf9f03dfdce2d42af913fe8d0"}, - {file = "rpds_py-0.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2efe4eb1d01b7f5f1939f4ef30ecea6c6b3521eec451fb93191bf84b2a522418"}, - {file = "rpds_py-0.27.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:15d3b4d83582d10c601f481eca29c3f138d44c92187d197aff663a269197c02d"}, - {file = "rpds_py-0.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4ed2e16abbc982a169d30d1a420274a709949e2cbdef119fe2ec9d870b42f274"}, - {file = "rpds_py-0.27.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a75f305c9b013289121ec0f1181931975df78738cdf650093e6b86d74aa7d8dd"}, - {file = "rpds_py-0.27.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:67ce7620704745881a3d4b0ada80ab4d99df390838839921f99e63c474f82cf2"}, - {file = "rpds_py-0.27.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d992ac10eb86d9b6f369647b6a3f412fc0075cfd5d799530e84d335e440a002"}, - {file = "rpds_py-0.27.1-cp313-cp313-win32.whl", hash = "sha256:4f75e4bd8ab8db624e02c8e2fc4063021b58becdbe6df793a8111d9343aec1e3"}, - {file = "rpds_py-0.27.1-cp313-cp313-win_amd64.whl", hash = "sha256:f9025faafc62ed0b75a53e541895ca272815bec18abe2249ff6501c8f2e12b83"}, - {file = "rpds_py-0.27.1-cp313-cp313-win_arm64.whl", hash = "sha256:ed10dc32829e7d222b7d3b93136d25a406ba9788f6a7ebf6809092da1f4d279d"}, - {file = "rpds_py-0.27.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:92022bbbad0d4426e616815b16bc4127f83c9a74940e1ccf3cfe0b387aba0228"}, - {file = "rpds_py-0.27.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:47162fdab9407ec3f160805ac3e154df042e577dd53341745fc7fb3f625e6d92"}, - {file = "rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb89bec23fddc489e5d78b550a7b773557c9ab58b7946154a10a6f7a214a48b2"}, - {file = "rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e48af21883ded2b3e9eb48cb7880ad8598b31ab752ff3be6457001d78f416723"}, - {file = "rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f5b7bd8e219ed50299e58551a410b64daafb5017d54bbe822e003856f06a802"}, - {file = "rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08f1e20bccf73b08d12d804d6e1c22ca5530e71659e6673bce31a6bb71c1e73f"}, - {file = "rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dc5dceeaefcc96dc192e3a80bbe1d6c410c469e97bdd47494a7d930987f18b2"}, - {file = "rpds_py-0.27.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d76f9cc8665acdc0c9177043746775aa7babbf479b5520b78ae4002d889f5c21"}, - {file = "rpds_py-0.27.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:134fae0e36022edad8290a6661edf40c023562964efea0cc0ec7f5d392d2aaef"}, - {file = "rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb11a4f1b2b63337cfd3b4d110af778a59aae51c81d195768e353d8b52f88081"}, - {file = "rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:13e608ac9f50a0ed4faec0e90ece76ae33b34c0e8656e3dceb9a7db994c692cd"}, - {file = "rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dd2135527aa40f061350c3f8f89da2644de26cd73e4de458e79606384f4f68e7"}, - {file = "rpds_py-0.27.1-cp313-cp313t-win32.whl", hash = "sha256:3020724ade63fe320a972e2ffd93b5623227e684315adce194941167fee02688"}, - {file = "rpds_py-0.27.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8ee50c3e41739886606388ba3ab3ee2aae9f35fb23f833091833255a31740797"}, - {file = "rpds_py-0.27.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:acb9aafccaae278f449d9c713b64a9e68662e7799dbd5859e2c6b3c67b56d334"}, - {file = "rpds_py-0.27.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b7fb801aa7f845ddf601c49630deeeccde7ce10065561d92729bfe81bd21fb33"}, - {file = "rpds_py-0.27.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe0dd05afb46597b9a2e11c351e5e4283c741237e7f617ffb3252780cca9336a"}, - {file = "rpds_py-0.27.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b6dfb0e058adb12d8b1d1b25f686e94ffa65d9995a5157afe99743bf7369d62b"}, - {file = "rpds_py-0.27.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed090ccd235f6fa8bb5861684567f0a83e04f52dfc2e5c05f2e4b1309fcf85e7"}, - {file = "rpds_py-0.27.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf876e79763eecf3e7356f157540d6a093cef395b65514f17a356f62af6cc136"}, - {file = "rpds_py-0.27.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12ed005216a51b1d6e2b02a7bd31885fe317e45897de81d86dcce7d74618ffff"}, - {file = "rpds_py-0.27.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ee4308f409a40e50593c7e3bb8cbe0b4d4c66d1674a316324f0c2f5383b486f9"}, - {file = "rpds_py-0.27.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b08d152555acf1f455154d498ca855618c1378ec810646fcd7c76416ac6dc60"}, - {file = "rpds_py-0.27.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dce51c828941973a5684d458214d3a36fcd28da3e1875d659388f4f9f12cc33e"}, - {file = "rpds_py-0.27.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c1476d6f29eb81aa4151c9a31219b03f1f798dc43d8af1250a870735516a1212"}, - {file = "rpds_py-0.27.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3ce0cac322b0d69b63c9cdb895ee1b65805ec9ffad37639f291dd79467bee675"}, - {file = "rpds_py-0.27.1-cp314-cp314-win32.whl", hash = "sha256:dfbfac137d2a3d0725758cd141f878bf4329ba25e34979797c89474a89a8a3a3"}, - {file = "rpds_py-0.27.1-cp314-cp314-win_amd64.whl", hash = "sha256:a6e57b0abfe7cc513450fcf529eb486b6e4d3f8aee83e92eb5f1ef848218d456"}, - {file = "rpds_py-0.27.1-cp314-cp314-win_arm64.whl", hash = "sha256:faf8d146f3d476abfee026c4ae3bdd9ca14236ae4e4c310cbd1cf75ba33d24a3"}, - {file = "rpds_py-0.27.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ba81d2b56b6d4911ce735aad0a1d4495e808b8ee4dc58715998741a26874e7c2"}, - {file = "rpds_py-0.27.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84f7d509870098de0e864cad0102711c1e24e9b1a50ee713b65928adb22269e4"}, - {file = "rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e960fc78fecd1100539f14132425e1d5fe44ecb9239f8f27f079962021523e"}, - {file = "rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62f85b665cedab1a503747617393573995dac4600ff51869d69ad2f39eb5e817"}, - {file = "rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fed467af29776f6556250c9ed85ea5a4dd121ab56a5f8b206e3e7a4c551e48ec"}, - {file = "rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2729615f9d430af0ae6b36cf042cb55c0936408d543fb691e1a9e36648fd35a"}, - {file = "rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b207d881a9aef7ba753d69c123a35d96ca7cb808056998f6b9e8747321f03b8"}, - {file = "rpds_py-0.27.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:639fd5efec029f99b79ae47e5d7e00ad8a773da899b6309f6786ecaf22948c48"}, - {file = "rpds_py-0.27.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fecc80cb2a90e28af8a9b366edacf33d7a91cbfe4c2c4544ea1246e949cfebeb"}, - {file = "rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42a89282d711711d0a62d6f57d81aa43a1368686c45bc1c46b7f079d55692734"}, - {file = "rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:cf9931f14223de59551ab9d38ed18d92f14f055a5f78c1d8ad6493f735021bbb"}, - {file = "rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f39f58a27cc6e59f432b568ed8429c7e1641324fbe38131de852cd77b2d534b0"}, - {file = "rpds_py-0.27.1-cp314-cp314t-win32.whl", hash = "sha256:d5fa0ee122dc09e23607a28e6d7b150da16c662e66409bbe85230e4c85bb528a"}, - {file = "rpds_py-0.27.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6567d2bb951e21232c2f660c24cf3470bb96de56cdcb3f071a83feeaff8a2772"}, - {file = "rpds_py-0.27.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c918c65ec2e42c2a78d19f18c553d77319119bf43aa9e2edf7fb78d624355527"}, - {file = "rpds_py-0.27.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1fea2b1a922c47c51fd07d656324531adc787e415c8b116530a1d29c0516c62d"}, - {file = "rpds_py-0.27.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbf94c58e8e0cd6b6f38d8de67acae41b3a515c26169366ab58bdca4a6883bb8"}, - {file = "rpds_py-0.27.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c2a8fed130ce946d5c585eddc7c8eeef0051f58ac80a8ee43bd17835c144c2cc"}, - {file = "rpds_py-0.27.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:037a2361db72ee98d829bc2c5b7cc55598ae0a5e0ec1823a56ea99374cfd73c1"}, - {file = "rpds_py-0.27.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5281ed1cc1d49882f9997981c88df1a22e140ab41df19071222f7e5fc4e72125"}, - {file = "rpds_py-0.27.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fd50659a069c15eef8aa3d64bbef0d69fd27bb4a50c9ab4f17f83a16cbf8905"}, - {file = "rpds_py-0.27.1-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:c4b676c4ae3921649a15d28ed10025548e9b561ded473aa413af749503c6737e"}, - {file = "rpds_py-0.27.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:079bc583a26db831a985c5257797b2b5d3affb0386e7ff886256762f82113b5e"}, - {file = "rpds_py-0.27.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4e44099bd522cba71a2c6b97f68e19f40e7d85399de899d66cdb67b32d7cb786"}, - {file = "rpds_py-0.27.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e202e6d4188e53c6661af813b46c37ca2c45e497fc558bacc1a7630ec2695aec"}, - {file = "rpds_py-0.27.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:f41f814b8eaa48768d1bb551591f6ba45f87ac76899453e8ccd41dba1289b04b"}, - {file = "rpds_py-0.27.1-cp39-cp39-win32.whl", hash = "sha256:9e71f5a087ead99563c11fdaceee83ee982fd39cf67601f4fd66cb386336ee52"}, - {file = "rpds_py-0.27.1-cp39-cp39-win_amd64.whl", hash = "sha256:71108900c9c3c8590697244b9519017a400d9ba26a36c48381b3f64743a44aab"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7ba22cb9693df986033b91ae1d7a979bc399237d45fccf875b76f62bb9e52ddf"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5b640501be9288c77738b5492b3fd3abc4ba95c50c2e41273c8a1459f08298d3"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb08b65b93e0c6dd70aac7f7890a9c0938d5ec71d5cb32d45cf844fb8ae47636"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d7ff07d696a7a38152ebdb8212ca9e5baab56656749f3d6004b34ab726b550b8"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fb7c72262deae25366e3b6c0c0ba46007967aea15d1eea746e44ddba8ec58dcc"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b002cab05d6339716b03a4a3a2ce26737f6231d7b523f339fa061d53368c9d8"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23f6b69d1c26c4704fec01311963a41d7de3ee0570a84ebde4d544e5a1859ffc"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:530064db9146b247351f2a0250b8f00b289accea4596a033e94be2389977de71"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b90b0496570bd6b0321724a330d8b545827c4df2034b6ddfc5f5275f55da2ad"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:879b0e14a2da6a1102a3fc8af580fc1ead37e6d6692a781bd8c83da37429b5ab"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:0d807710df3b5faa66c731afa162ea29717ab3be17bdc15f90f2d9f183da4059"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:3adc388fc3afb6540aec081fa59e6e0d3908722771aa1e37ffe22b220a436f0b"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c796c0c1cc68cb08b0284db4229f5af76168172670c74908fdbd4b7d7f515819"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cdfe4bb2f9fe7458b7453ad3c33e726d6d1c7c0a72960bcc23800d77384e42df"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8fabb8fd848a5f75a2324e4a84501ee3a5e3c78d8603f83475441866e60b94a3"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eda8719d598f2f7f3e0f885cba8646644b55a187762bec091fa14a2b819746a9"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c64d07e95606ec402a0a1c511fe003873fa6af630bda59bac77fac8b4318ebc"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93a2ed40de81bcff59aabebb626562d48332f3d028ca2036f1d23cbb52750be4"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:387ce8c44ae94e0ec50532d9cb0edce17311024c9794eb196b90e1058aadeb66"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaf94f812c95b5e60ebaf8bfb1898a7d7cb9c1af5744d4a67fa47796e0465d4e"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:4848ca84d6ded9b58e474dfdbad4b8bfb450344c0551ddc8d958bf4b36aa837c"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2bde09cbcf2248b73c7c323be49b280180ff39fadcfe04e7b6f54a678d02a7cf"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:94c44ee01fd21c9058f124d2d4f0c9dc7634bec93cd4b38eefc385dabe71acbf"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:df8b74962e35c9249425d90144e721eed198e6555a0e22a563d29fe4486b51f6"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:dc23e6820e3b40847e2f4a7726462ba0cf53089512abe9ee16318c366494c17a"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:aa8933159edc50be265ed22b401125c9eebff3171f570258854dbce3ecd55475"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a50431bf02583e21bf273c71b89d710e7a710ad5e39c725b14e685610555926f"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78af06ddc7fe5cc0e967085a9115accee665fb912c22a3f54bad70cc65b05fe6"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:70d0738ef8fee13c003b100c2fbd667ec4f133468109b3472d249231108283a3"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2f6fd8a1cea5bbe599b6e78a6e5ee08db434fc8ffea51ff201c8765679698b3"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8177002868d1426305bb5de1e138161c2ec9eb2d939be38291d7c431c4712df8"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:008b839781d6c9bf3b6a8984d1d8e56f0ec46dc56df61fd669c49b58ae800400"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:a55b9132bb1ade6c734ddd2759c8dc132aa63687d259e725221f106b83a0e485"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a46fdec0083a26415f11d5f236b79fa1291c32aaa4a17684d82f7017a1f818b1"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:8a63b640a7845f2bdd232eb0d0a4a2dd939bcdd6c57e6bb134526487f3160ec5"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:7e32721e5d4922deaaf963469d795d5bde6093207c52fec719bd22e5d1bedbc4"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:2c426b99a068601b5f4623573df7a7c3d72e87533a2dd2253353a03e7502566c"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:4fc9b7fe29478824361ead6e14e4f5aed570d477e06088826537e202d25fe859"}, - {file = "rpds_py-0.27.1.tar.gz", hash = "sha256:26a1c73171d10b7acccbded82bf6a586ab8203601e565badc74bbbf8bc5a10f8"}, + {file = "rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288"}, + {file = "rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139"}, + {file = "rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464"}, + {file = "rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169"}, + {file = "rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425"}, + {file = "rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85"}, + {file = "rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c"}, + {file = "rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825"}, + {file = "rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229"}, + {file = "rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad"}, + {file = "rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394"}, + {file = "rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf"}, + {file = "rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b"}, + {file = "rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e"}, + {file = "rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2"}, + {file = "rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95"}, + {file = "rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d"}, + {file = "rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15"}, + {file = "rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1"}, + {file = "rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a"}, + {file = "rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27"}, + {file = "rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6"}, + {file = "rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d"}, + {file = "rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0"}, + {file = "rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53"}, + {file = "rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed"}, + {file = "rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950"}, + {file = "rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6"}, + {file = "rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb"}, + {file = "rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40"}, + {file = "rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0"}, + {file = "rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e"}, + {file = "rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84"}, ] [[package]] name = "ruff" -version = "0.11.7" +version = "0.15.15" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "ruff-0.11.7-py3-none-linux_armv6l.whl", hash = "sha256:d29e909d9a8d02f928d72ab7837b5cbc450a5bdf578ab9ebee3263d0a525091c"}, - {file = "ruff-0.11.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dd1fb86b168ae349fb01dd497d83537b2c5541fe0626e70c786427dd8363aaee"}, - {file = "ruff-0.11.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d3d7d2e140a6fbbc09033bce65bd7ea29d6a0adeb90b8430262fbacd58c38ada"}, - {file = "ruff-0.11.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4809df77de390a1c2077d9b7945d82f44b95d19ceccf0c287c56e4dc9b91ca64"}, - {file = "ruff-0.11.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f3a0c2e169e6b545f8e2dba185eabbd9db4f08880032e75aa0e285a6d3f48201"}, - {file = "ruff-0.11.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49b888200a320dd96a68e86736cf531d6afba03e4f6cf098401406a257fcf3d6"}, - {file = "ruff-0.11.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2b19cdb9cf7dae00d5ee2e7c013540cdc3b31c4f281f1dacb5a799d610e90db4"}, - {file = "ruff-0.11.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64e0ee994c9e326b43539d133a36a455dbaab477bc84fe7bfbd528abe2f05c1e"}, - {file = "ruff-0.11.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bad82052311479a5865f52c76ecee5d468a58ba44fb23ee15079f17dd4c8fd63"}, - {file = "ruff-0.11.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7940665e74e7b65d427b82bffc1e46710ec7f30d58b4b2d5016e3f0321436502"}, - {file = "ruff-0.11.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:169027e31c52c0e36c44ae9a9c7db35e505fee0b39f8d9fca7274a6305295a92"}, - {file = "ruff-0.11.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:305b93f9798aee582e91e34437810439acb28b5fc1fee6b8205c78c806845a94"}, - {file = "ruff-0.11.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a681db041ef55550c371f9cd52a3cf17a0da4c75d6bd691092dfc38170ebc4b6"}, - {file = "ruff-0.11.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:07f1496ad00a4a139f4de220b0c97da6d4c85e0e4aa9b2624167b7d4d44fd6b6"}, - {file = "ruff-0.11.7-py3-none-win32.whl", hash = "sha256:f25dfb853ad217e6e5f1924ae8a5b3f6709051a13e9dad18690de6c8ff299e26"}, - {file = "ruff-0.11.7-py3-none-win_amd64.whl", hash = "sha256:0a931d85959ceb77e92aea4bbedfded0a31534ce191252721128f77e5ae1f98a"}, - {file = "ruff-0.11.7-py3-none-win_arm64.whl", hash = "sha256:778c1e5d6f9e91034142dfd06110534ca13220bfaad5c3735f6cb844654f6177"}, - {file = "ruff-0.11.7.tar.gz", hash = "sha256:655089ad3224070736dc32844fde783454f8558e71f501cb207485fe4eee23d4"}, + {file = "ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b"}, + {file = "ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e"}, + {file = "ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530"}, + {file = "ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4"}, + {file = "ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f"}, + {file = "ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622"}, + {file = "ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45"}, + {file = "ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627"}, + {file = "ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4"}, + {file = "ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c"}, + {file = "ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd"}, + {file = "ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f"}, + {file = "ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb"}, + {file = "ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a"}, + {file = "ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9"}, + {file = "ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4"}, + {file = "ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7"}, + {file = "ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6"}, ] [[package]] name = "setuptools" -version = "80.9.0" -description = "Easily download, build, install, upgrade, and uninstall Python packages" +version = "82.0.1" +description = "Most extensible Python build backend with support for C/C++ extension modules" optional = false python-versions = ">=3.9" groups = ["executable"] markers = "python_version < \"3.15\"" files = [ - {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, - {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, + {file = "setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb"}, + {file = "setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] -core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.13.0) ; sys_platform != \"cygwin\""] +core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] enabler = ["pytest-enabler (>=2.2)"] test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.18.*)", "pytest-mypy"] [[package]] name = "shellingham" @@ -1788,62 +1757,51 @@ files = [ [[package]] name = "smmap" -version = "5.0.2" +version = "5.0.3" description = "A pure Python implementation of a sliding window memory map manager" optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e"}, - {file = "smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5"}, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -description = "Sniff out which async library your code is running under" -optional = false -python-versions = ">=3.7" -groups = ["main"] -markers = "python_version >= \"3.10\"" -files = [ - {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, - {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, + {file = "smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f"}, + {file = "smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c"}, ] [[package]] name = "sse-starlette" -version = "3.0.2" +version = "3.4.4" description = "SSE plugin for Starlette" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "sse_starlette-3.0.2-py3-none-any.whl", hash = "sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a"}, - {file = "sse_starlette-3.0.2.tar.gz", hash = "sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a"}, + {file = "sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973"}, + {file = "sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0"}, ] [package.dependencies] anyio = ">=4.7.0" +starlette = ">=0.49.1" [package.extras] daphne = ["daphne (>=4.2.0)"] -examples = ["aiosqlite (>=0.21.0)", "fastapi (>=0.115.12)", "sqlalchemy[asyncio] (>=2.0.41)", "starlette (>=0.41.3)", "uvicorn (>=0.34.0)"] +examples = ["fastapi (>=0.115.12)", "pydantic (>=2)", "uvicorn (>=0.34.0)"] +examples-db = ["aiosqlite (>=0.21.0)", "sqlalchemy[asyncio] (>=2.0.41)"] granian = ["granian (>=2.3.1)"] uvicorn = ["uvicorn (>=0.34.0)"] [[package]] name = "starlette" -version = "0.49.1" +version = "1.2.1" description = "The little ASGI library that shines." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "starlette-0.49.1-py3-none-any.whl", hash = "sha256:d92ce9f07e4a3caa3ac13a79523bd18e3bc0042bb8ff2d759a8e7dd0e1859875"}, - {file = "starlette-0.49.1.tar.gz", hash = "sha256:481a43b71e24ed8c43b11ea02f5353d77840e01480881b8cb5a26b8cae64a8cb"}, + {file = "starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89"}, + {file = "starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6"}, ] [package.dependencies] @@ -1871,55 +1829,60 @@ test = ["pytest", "tornado (>=4.5)", "typeguard"] [[package]] name = "tomli" -version = "2.3.0" +version = "2.4.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" groups = ["main", "test"] markers = "python_version < \"3.11\"" files = [ - {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, - {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, - {file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"}, - {file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"}, - {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"}, - {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"}, - {file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"}, - {file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"}, - {file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"}, - {file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"}, - {file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"}, - {file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"}, - {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"}, - {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"}, - {file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"}, - {file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"}, - {file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"}, - {file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"}, - {file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"}, - {file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"}, - {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"}, - {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"}, - {file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"}, - {file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"}, - {file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"}, - {file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"}, - {file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"}, - {file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"}, - {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"}, - {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"}, - {file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"}, - {file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"}, - {file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"}, - {file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"}, - {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"}, - {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"}, - {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"}, - {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"}, - {file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"}, - {file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"}, - {file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"}, - {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, + {file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"}, + {file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc"}, + {file = "tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049"}, + {file = "tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e"}, + {file = "tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1"}, + {file = "tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917"}, + {file = "tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9"}, + {file = "tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5"}, + {file = "tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd"}, + {file = "tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36"}, + {file = "tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba"}, + {file = "tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6"}, + {file = "tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7"}, + {file = "tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f"}, + {file = "tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8"}, + {file = "tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26"}, + {file = "tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396"}, + {file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"}, + {file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"}, ] [[package]] @@ -1982,14 +1945,14 @@ typing-extensions = ">=4.12.0" [[package]] name = "tzdata" -version = "2025.3" +version = "2026.2" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" groups = ["main"] files = [ - {file = "tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1"}, - {file = "tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7"}, + {file = "tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7"}, + {file = "tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10"}, ] [[package]] @@ -2012,15 +1975,15 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "uvicorn" -version = "0.38.0" +version = "0.48.0" description = "The lightning-fast ASGI server." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\" and sys_platform != \"emscripten\"" files = [ - {file = "uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02"}, - {file = "uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d"}, + {file = "uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad"}, + {file = "uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37"}, ] [package.dependencies] @@ -2029,19 +1992,19 @@ h11 = ">=0.8" typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} [package.extras] -standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] +standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.20)", "websockets (>=10.4)"] [[package]] name = "zipp" -version = "3.23.0" +version = "3.23.1" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.9" groups = ["executable"] markers = "python_version == \"3.9\"" files = [ - {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, - {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, + {file = "zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc"}, + {file = "zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110"}, ] [package.extras] @@ -2055,4 +2018,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "36b21102c474b6c7efbf1e70d213c5c6138fc12e28f6c4bde3d253d4f3c73299" +content-hash = "a347b4566b5612c753acadaef737ad7ca594be3402e0118c94bb539ed2062b06" diff --git a/pyproject.toml b/pyproject.toml index fd20bccd..bfb34e90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ pydantic = ">=2.11.5,<3.0.0" pathvalidate = ">=3.3.1,<4.0.0" tomli-w = ">=1.0.0,<2.0.0" tomli = {version = ">=2.0.0,<3.0.0", python = "<3.11"} +anyio = ">=4.0.0, <4.13.0" [tool.poetry.group.test.dependencies] mock = ">=4.0.3,<4.1.0" @@ -65,7 +66,7 @@ pyinstaller = {version=">=6.20.0,<7.0.0", python=">=3.9,<3.15"} dunamai = ">=1.26.1,<1.27.0" [tool.poetry.group.dev.dependencies] -ruff = "0.11.7" +ruff = "0.15.15" [tool.pytest.ini_options] log_cli = true diff --git a/tests/cli/files_collector/test_commit_range_documents.py b/tests/cli/files_collector/test_commit_range_documents.py index 0b96a0e2..999e0e0c 100644 --- a/tests/cli/files_collector/test_commit_range_documents.py +++ b/tests/cli/files_collector/test_commit_range_documents.py @@ -67,7 +67,7 @@ def test_returns_head_when_repository_has_commits(self) -> None: def test_returns_empty_tree_hash_when_repository_has_no_commits(self) -> None: """Test that an empty tree hash is returned when the repository has no commits.""" - with temporary_git_repository() as (temp_dir, repo): + with temporary_git_repository() as (_temp_dir, repo): result = get_safe_head_reference_for_diff(repo) expected_empty_tree_hash = consts.GIT_EMPTY_TREE_OBJECT assert result == expected_empty_tree_hash @@ -343,7 +343,7 @@ def test_diff_with_bare_repository(self) -> None: def test_diff_with_no_paths(self) -> None: """Test behavior when the diff has neither a_path nor b_path.""" - with temporary_git_repository() as (temp_dir, repo): + with temporary_git_repository() as (_temp_dir, repo): class MockDiff: def __init__(self) -> None: @@ -409,7 +409,7 @@ class TestGetDefaultBranchesForMergeBase: def test_environment_variable_override(self) -> None: """Test that the environment variable takes precedence.""" with ( - temporary_git_repository() as (temp_dir, repo), + temporary_git_repository() as (_temp_dir, repo), patch.dict(os.environ, {consts.CYCODE_DEFAULT_BRANCH_ENV_VAR_NAME: 'custom-main'}), ): branches = _get_default_branches_for_merge_base(repo) @@ -418,7 +418,7 @@ def test_environment_variable_override(self) -> None: def test_git_symbolic_ref_success(self) -> None: """Test getting default branch via git symbolic-ref.""" - with temporary_git_repository() as (temp_dir, repo): + with temporary_git_repository() as (_temp_dir, _repo): # Create a mock repo with a git interface that returns origin/main mock_repo = Mock() mock_repo.git.symbolic_ref.return_value = 'refs/remotes/origin/main' @@ -429,7 +429,7 @@ def test_git_symbolic_ref_success(self) -> None: def test_git_symbolic_ref_with_master(self) -> None: """Test getting default branch via git symbolic-ref when it's master.""" - with temporary_git_repository() as (temp_dir, repo): + with temporary_git_repository() as (_temp_dir, _repo): # Create a mock repo with a git interface that returns origin/master mock_repo = Mock() mock_repo.git.symbolic_ref.return_value = 'refs/remotes/origin/master' @@ -440,7 +440,7 @@ def test_git_symbolic_ref_with_master(self) -> None: def test_git_remote_show_fallback(self) -> None: """Test fallback to git remote show when symbolic-ref fails.""" - with temporary_git_repository() as (temp_dir, repo): + with temporary_git_repository() as (_temp_dir, _repo): # Create a mock repo where symbolic-ref fails but the remote show succeeds mock_repo = Mock() mock_repo.git.symbolic_ref.side_effect = Exception('symbolic-ref failed') @@ -459,7 +459,7 @@ def test_git_remote_show_fallback(self) -> None: def test_both_git_methods_fail_fallback_to_hardcoded(self) -> None: """Test fallback to hardcoded branches when both Git methods fail.""" - with temporary_git_repository() as (temp_dir, repo): + with temporary_git_repository() as (_temp_dir, _repo): # Create a mock repo where both Git methods fail mock_repo = Mock() mock_repo.git.symbolic_ref.side_effect = Exception('symbolic-ref failed') @@ -474,7 +474,7 @@ def test_both_git_methods_fail_fallback_to_hardcoded(self) -> None: def test_no_duplicates_in_branch_list(self) -> None: """Test that duplicate branches are not added to the list.""" - with temporary_git_repository() as (temp_dir, repo): + with temporary_git_repository() as (_temp_dir, _repo): # Create a mock repo that returns main (which is also in fallback list) mock_repo = Mock() mock_repo.git.symbolic_ref.return_value = 'refs/remotes/origin/main' @@ -486,7 +486,7 @@ def test_no_duplicates_in_branch_list(self) -> None: def test_env_var_plus_git_detection(self) -> None: """Test combination of environment variable and git detection.""" - with temporary_git_repository() as (temp_dir, repo): + with temporary_git_repository() as (_temp_dir, _repo): mock_repo = Mock() mock_repo.git.symbolic_ref.return_value = 'refs/remotes/origin/develop' @@ -500,7 +500,7 @@ def test_env_var_plus_git_detection(self) -> None: def test_malformed_symbolic_ref_response(self) -> None: """Test handling of malformed symbolic-ref response.""" - with temporary_git_repository() as (temp_dir, repo): + with temporary_git_repository() as (_temp_dir, _repo): # Create a mock repo that returns a malformed response mock_repo = Mock() mock_repo.git.symbolic_ref.return_value = 'malformed-response' @@ -845,7 +845,7 @@ def _make_linear_history(self, repo: Repo, base_dir: str) -> tuple[str, str, str def test_two_dot_linear_history(self) -> None: """For 'A..C', expect (A,C) in linear history.""" with temporary_git_repository() as (temp_dir, repo): - a, b, c = self._make_linear_history(repo, temp_dir) + a, _b, c = self._make_linear_history(repo, temp_dir) parsed_from, parsed_to, separator = parse_commit_range(f'{a}..{c}', temp_dir) assert (parsed_from, parsed_to, separator) == (a, c, '..') @@ -853,7 +853,7 @@ def test_two_dot_linear_history(self) -> None: def test_three_dot_linear_history(self) -> None: """For 'A...C' in linear history, expect (A,C).""" with temporary_git_repository() as (temp_dir, repo): - a, b, c = self._make_linear_history(repo, temp_dir) + a, _b, c = self._make_linear_history(repo, temp_dir) parsed_from, parsed_to, separator = parse_commit_range(f'{a}...{c}', temp_dir) assert (parsed_from, parsed_to, separator) == (a, c, '...') @@ -861,7 +861,7 @@ def test_three_dot_linear_history(self) -> None: def test_open_right_linear_history(self) -> None: """For 'A..', expect (A,HEAD=C).""" with temporary_git_repository() as (temp_dir, repo): - a, b, c = self._make_linear_history(repo, temp_dir) + a, _b, c = self._make_linear_history(repo, temp_dir) parsed_from, parsed_to, separator = parse_commit_range(f'{a}..', temp_dir) assert (parsed_from, parsed_to, separator) == (a, c, '..') @@ -869,7 +869,7 @@ def test_open_right_linear_history(self) -> None: def test_open_left_linear_history(self) -> None: """For '..C' where HEAD==C, expect (HEAD=C,C).""" with temporary_git_repository() as (temp_dir, repo): - a, b, c = self._make_linear_history(repo, temp_dir) + _a, _b, c = self._make_linear_history(repo, temp_dir) parsed_from, parsed_to, separator = parse_commit_range(f'..{c}', temp_dir) assert (parsed_from, parsed_to, separator) == (c, c, '..') @@ -877,7 +877,7 @@ def test_open_left_linear_history(self) -> None: def test_single_commit_spec(self) -> None: """For 'A', expect (A,HEAD=C).""" with temporary_git_repository() as (temp_dir, repo): - a, b, c = self._make_linear_history(repo, temp_dir) + a, _b, c = self._make_linear_history(repo, temp_dir) parsed_from, parsed_to, separator = parse_commit_range(a, temp_dir) assert (parsed_from, parsed_to, separator) == (a, c, '..') @@ -935,7 +935,7 @@ def test_parse_all_for_empty_remote_scenario_with_two_commits(self) -> None: def test_parse_all_with_empty_repository_returns_none(self) -> None: """Test that '--all' returns None when repository has no commits.""" - with temporary_git_repository() as (temp_dir, repo): + with temporary_git_repository() as (temp_dir, _repo): # Empty repository with no commits parsed_from, parsed_to, separator = parse_commit_range('--all', temp_dir) # Should return None, None, None when HEAD doesn't exist From e46504511ed26208417f6e1ddc099bc98159ced3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 10:56:47 +0300 Subject: [PATCH 081/123] Bump pypa/gh-action-pypi-publish from 1.13.0 to 1.14.0 (#468) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pre_release.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 171238cd..0a36cea1 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -74,4 +74,4 @@ jobs: run: poetry build - name: Publish a Python distribution to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f9d2a73..d21a8f8d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,4 +73,4 @@ jobs: run: poetry build - name: Publish a Python distribution to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 From d53991be25f77090c6f913fd448c92e9d0d92759 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:37:13 +0300 Subject: [PATCH 082/123] CM-65507 fire AI Guardrails SessionStart hook on all sources (resume/fork) (#470) --- cycode/cli/apps/ai_guardrails/ides/claude_code.py | 1 - cycode/cli/apps/ai_guardrails/ides/codex.py | 1 - tests/cli/commands/ai_guardrails/ides/test_codex.py | 7 ++++--- tests/cli/commands/ai_guardrails/test_hooks_manager.py | 5 +++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/ides/claude_code.py b/cycode/cli/apps/ai_guardrails/ides/claude_code.py index 4178ec56..a5a9c079 100644 --- a/cycode/cli/apps/ai_guardrails/ides/claude_code.py +++ b/cycode/cli/apps/ai_guardrails/ides/claude_code.py @@ -239,7 +239,6 @@ def render_hooks_config(self, async_mode: bool = False) -> dict: 'hooks': { 'SessionStart': [ { - 'matcher': 'startup|clear', 'hooks': [{'type': 'command', 'command': _SESSION_START_COMMAND}], } ], diff --git a/cycode/cli/apps/ai_guardrails/ides/codex.py b/cycode/cli/apps/ai_guardrails/ides/codex.py index f8c9b04d..8be9f20a 100644 --- a/cycode/cli/apps/ai_guardrails/ides/codex.py +++ b/cycode/cli/apps/ai_guardrails/ides/codex.py @@ -201,7 +201,6 @@ def render_hooks_config(self, async_mode: bool = False) -> dict: 'hooks': { 'SessionStart': [ { - 'matcher': 'startup|clear', 'hooks': [{'type': 'command', 'command': _SESSION_START_COMMAND}], } ], diff --git a/tests/cli/commands/ai_guardrails/ides/test_codex.py b/tests/cli/commands/ai_guardrails/ides/test_codex.py index f71e19a2..5b656364 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_codex.py +++ b/tests/cli/commands/ai_guardrails/ides/test_codex.py @@ -146,10 +146,11 @@ def test_settings_path_honors_codex_home_env(fs: FakeFilesystem, monkeypatch: py # --- hooks config rendering -------------------------------------------------- -def test_render_hooks_session_start_matcher_includes_clear() -> None: - """SessionStart must fire on /clear too (conversation_id rotates).""" +def test_render_hooks_session_start_matches_all_sources() -> None: + """SessionStart must fire on every source (a forked session reports 'resume', + so no matcher is set -> match-all).""" rendered = Codex().render_hooks_config() - assert rendered['hooks']['SessionStart'][0]['matcher'] == 'startup|clear' + assert 'matcher' not in rendered['hooks']['SessionStart'][0] assert '--ide codex' in rendered['hooks']['SessionStart'][0]['hooks'][0]['command'] diff --git a/tests/cli/commands/ai_guardrails/test_hooks_manager.py b/tests/cli/commands/ai_guardrails/test_hooks_manager.py index f0a0248c..2097780a 100644 --- a/tests/cli/commands/ai_guardrails/test_hooks_manager.py +++ b/tests/cli/commands/ai_guardrails/test_hooks_manager.py @@ -111,11 +111,12 @@ def test_claude_code_render_hooks_async() -> None: def test_claude_code_render_hooks_session_start() -> None: - """Claude Code SessionStart fires on startup and /clear.""" + """Claude Code SessionStart fires on every source (a forked session reports + 'resume', so the matcher is empty -> match-all).""" config = ClaudeCode().render_hooks_config() entries = config['hooks']['SessionStart'] assert len(entries) == 1 - assert entries[0]['matcher'] == 'startup|clear' + assert 'matcher' not in entries[0] assert CYCODE_SESSION_START_COMMAND in entries[0]['hooks'][0]['command'] assert '--ide claude-code' in entries[0]['hooks'][0]['command'] From 67aab550d276e2f2041d523f2860b011861c08af Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:21:34 +0300 Subject: [PATCH 083/123] CM-64735 - Reduce sync scan latency (#472) Co-authored-by: Claude Opus 4.7 (1M context) --- cycode/__init__.py | 7 ++ cycode/cli/app.py | 103 ++++++++++++++++++---- cycode/cli/apps/scan/code_scanner.py | 27 +++--- cycode/cli/apps/scan/scan_parameters.py | 2 + cycode/cli/apps/scan/scan_result.py | 4 + cycode/cyclient/base_token_auth_client.py | 41 ++++++--- cycode/cyclient/cycode_client_base.py | 45 +++++----- pyinstaller.spec | 17 ++++ tests/cli/test_app_argv_peek.py | 82 +++++++++++++++++ 9 files changed, 266 insertions(+), 62 deletions(-) create mode 100644 tests/cli/test_app_argv_peek.py diff --git a/cycode/__init__.py b/cycode/__init__.py index 4ce71ef1..63ae25e0 100644 --- a/cycode/__init__.py +++ b/cycode/__init__.py @@ -1 +1,8 @@ +import time as _time + +# Unix-epoch wall clock captured at the earliest possible moment of CLI +# startup. Sent as `scan_parameters.cli_start_time` so the server can compute +# end-to-end scan duration from the moment the user actually triggered it. +_BOOT_WALL: float = _time.time() + __version__ = '0.0.0' # DON'T TOUCH. Placeholder. Will be filled automatically on poetry build from Git Tag diff --git a/cycode/cli/app.py b/cycode/cli/app.py index 103e8b86..82f7f41b 100644 --- a/cycode/cli/app.py +++ b/cycode/cli/app.py @@ -1,3 +1,4 @@ +import importlib import logging import sys from typing import Annotated, Optional @@ -10,12 +11,7 @@ from typer.completion import install_callback, show_callback from cycode import __version__ -from cycode.cli.apps import ai_guardrails, ai_remediation, auth, configure, ignore, report, report_import, scan, status from cycode.cli.apps.api import get_platform_group - -if sys.version_info >= (3, 10): - from cycode.cli.apps import mcp - from cycode.cli.cli_types import OutputTypeOption from cycode.cli.consts import CLI_CONTEXT_SETTINGS from cycode.cli.printers import ConsolePrinter @@ -46,17 +42,88 @@ add_completion=False, # we add it manually to control the rich help panel ) -app.add_typer(ai_guardrails.app) -app.add_typer(ai_remediation.app) -app.add_typer(auth.app) -app.add_typer(configure.app) -app.add_typer(ignore.app) -app.add_typer(report.app) -app.add_typer(report_import.app) -app.add_typer(scan.app) -app.add_typer(status.app) +# Top-level subcommand → module providing its Typer app. Peeking at sys.argv +# lets us import only the invoked subapp on the hot path (e.g. +# `cycode ai-guardrails scan`), skipping ~300ms of unrelated imports. +_SUBAPP_MODULES: dict[str, str] = { + 'ai-guardrails': 'cycode.cli.apps.ai_guardrails', + 'ai-remediation': 'cycode.cli.apps.ai_remediation', + 'auth': 'cycode.cli.apps.auth', + 'configure': 'cycode.cli.apps.configure', + 'ignore': 'cycode.cli.apps.ignore', + 'report': 'cycode.cli.apps.report', + 'import': 'cycode.cli.apps.report_import', + 'scan': 'cycode.cli.apps.scan', + 'status': 'cycode.cli.apps.status', +} if sys.version_info >= (3, 10): - app.add_typer(mcp.app) + _SUBAPP_MODULES['mcp'] = 'cycode.cli.apps.mcp' + +# Aliases: alternate spellings that resolve to a primary subcommand key. +_SUBAPP_ALIASES: dict[str, str] = { + 'ai_remediation': 'ai-remediation', # backward-compat underscore form + 'version': 'status', +} + +# Root-level options that consume a following value; argv-peek must skip past +# both the option and its value when scanning for the first positional arg. +_ROOT_OPTS_WITH_VALUE = frozenset( + { + '--output', + '-o', + '--user-agent', + '--client-secret', + '--client-id', + '--id-token', + '--show-completion', + } +) + + +def _detect_invocation() -> tuple[Optional[str], Optional[str]]: + """Return (top-level-subapp, second-level-subcommand) parsed from sys.argv. + + Both values may be None: when no positional arg matches a known subapp, + or when the user only provided a top-level subcommand. + """ + positionals = [] + args = sys.argv[1:] + i = 0 + while i < len(args): + arg = args[i] + if arg in _ROOT_OPTS_WITH_VALUE: + i += 2 + elif arg.startswith('-'): + # Any flag form: short, long, --key=value, or '--' marker. Skip the token only. + i += 1 + else: + positionals.append(arg) + if len(positionals) >= 2: + break + i += 1 + subapp = positionals[0] if positionals else None + subapp = _SUBAPP_ALIASES.get(subapp, subapp) + if subapp not in _SUBAPP_MODULES: + return None, None + subcommand = positionals[1] if len(positionals) >= 2 else None + return subapp, subcommand + + +# Computed once at import; reused by lazy registration and the version-checker skip. +_INVOKED_SUBAPP, _INVOKED_SUBCOMMAND = _detect_invocation() + + +def _register_subapps(only: Optional[str]) -> None: + if only is not None: + app.add_typer(importlib.import_module(_SUBAPP_MODULES[only]).app) + return + # Cold path (--help, completion, unknown subcommand): load all modules so + # root help lists everything. Deduplicate since aliases share modules. + for module_path in dict.fromkeys(_SUBAPP_MODULES.values()): + app.add_typer(importlib.import_module(module_path).app) + + +_register_subapps(_INVOKED_SUBAPP) # Register the `platform` command group (dynamically built from the OpenAPI spec). # The group itself is constructed cheaply at import time; the spec is only fetched @@ -81,6 +148,12 @@ def _get_group_with_platform(app_typer: typer.Typer) -> click.Group: def check_latest_version_on_close(ctx: typer.Context) -> None: + # Skip on `cycode ai-guardrails scan` — it emits JSON to stdout, so an + # upgrade notice would corrupt the response. Human-driven sibling commands + # (install, uninstall, status, session-start) still get the notice. + if (_INVOKED_SUBAPP, _INVOKED_SUBCOMMAND) == ('ai-guardrails', 'scan'): + return + output = ctx.obj.get('output') # don't print anything if the output is JSON if output == OutputTypeOption.JSON: diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index 072e438e..dc3727e4 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -204,18 +204,21 @@ def _scan_batch_thread_func(batch: list[Document]) -> tuple[str, CliError, Local 'zip_file_size': zip_file_size, }, ) - report_scan_status( - cycode_client, - scan_type, - scan_id, - scan_completed, - relevant_detections_count, - detections_count, - len(batch), - zip_file_size, - command_scan_type, - error_message, - ) + # Sync flows already received the full result inline; only async flows + # need a separate status report to signal polling completion. + if not should_use_sync_flow: + report_scan_status( + cycode_client, + scan_type, + scan_id, + scan_completed, + relevant_detections_count, + detections_count, + len(batch), + zip_file_size, + command_scan_type, + error_message, + ) return scan_id, error, local_scan_result diff --git a/cycode/cli/apps/scan/scan_parameters.py b/cycode/cli/apps/scan/scan_parameters.py index 58754e86..f362d419 100644 --- a/cycode/cli/apps/scan/scan_parameters.py +++ b/cycode/cli/apps/scan/scan_parameters.py @@ -2,6 +2,7 @@ import typer +from cycode import _BOOT_WALL from cycode.cli.apps.scan.remote_url_resolver import get_remote_url_scan_parameter from cycode.cli.utils.scan_utils import generate_unique_scan_id from cycode.logger import get_logger @@ -17,6 +18,7 @@ def _get_default_scan_parameters(ctx: typer.Context) -> dict: 'license_compliance': ctx.obj.get('license-compliance'), 'command_type': ctx.info_name.replace('-', '_'), # save backward compatibility 'aggregation_id': str(generate_unique_scan_id()), + 'cli_start_time': _BOOT_WALL, } diff --git a/cycode/cli/apps/scan/scan_result.py b/cycode/cli/apps/scan/scan_result.py index 13fb8576..9fb1da1d 100644 --- a/cycode/cli/apps/scan/scan_result.py +++ b/cycode/cli/apps/scan/scan_result.py @@ -189,6 +189,10 @@ def enrich_scan_result_with_data_from_detection_rules( for detection in detections_per_file.detections: detection_rule_ids.add(detection.detection_rule_id) + if not detection_rule_ids: + logger.debug('No detections to enrich, skipping detection_rules fetch') + return + detection_rules = cycode_client.get_detection_rules(detection_rule_ids) detection_rules_by_id = {detection_rule.detection_rule_id: detection_rule for detection_rule in detection_rules} diff --git a/cycode/cyclient/base_token_auth_client.py b/cycode/cyclient/base_token_auth_client.py index 3f164836..ec315e7d 100644 --- a/cycode/cyclient/base_token_auth_client.py +++ b/cycode/cyclient/base_token_auth_client.py @@ -24,19 +24,10 @@ def __init__(self, client_id: str) -> None: self.client_id = client_id self._credentials_manager = CredentialsManager() - # load cached access token - access_token, expires_in, creator = self._credentials_manager.get_access_token() - - self._access_token = self._expires_in = None - expected_creator = self._create_jwt_creator() - if creator == expected_creator: - # we must be sure that cached access token is created using the same client id and client secret. - # because client id and client secret could be passed via command, via env vars or via config file. - # we must not use cached access token if client id or client secret was changed. - self._access_token = access_token - self._expires_in = arrow.get(expires_in) if expires_in else None - + self._access_token = None + self._expires_in = None self._lock = Lock() + self._load_token_from_disk() def get_access_token(self) -> str: with self._lock: @@ -51,8 +42,30 @@ def invalidate_access_token(self, in_storage: bool = False) -> None: self._credentials_manager.update_access_token(None, None, None) def refresh_access_token_if_needed(self) -> None: - if self._access_token is None or self._expires_in is None or arrow.utcnow() >= self._expires_in: - self.refresh_access_token() + if self._has_valid_token(): + return + # Re-check disk before doing the network refresh: another client instance + # in this process may have already refreshed and persisted a fresh token. + self._load_token_from_disk() + if self._has_valid_token(): + return + self.refresh_access_token() + + def _has_valid_token(self) -> bool: + return self._access_token is not None and self._expires_in is not None and arrow.utcnow() < self._expires_in + + def _load_token_from_disk(self) -> None: + access_token, expires_in, creator = self._credentials_manager.get_access_token() + expected_creator = self._create_jwt_creator() + # We must be sure that cached access token is created using the same client id and client secret. + # Because client id and client secret could be passed via command, via env vars or via config file. + # We must not use cached access token if client id or client secret was changed. + if creator == expected_creator and access_token: + self._access_token = access_token + self._expires_in = arrow.get(expires_in) if expires_in else None + else: + self._access_token = None + self._expires_in = None def refresh_access_token(self) -> None: auth_response = self._request_new_access_token() diff --git a/cycode/cyclient/cycode_client_base.py b/cycode/cyclient/cycode_client_base.py index 1aae7bcb..bde0e880 100644 --- a/cycode/cyclient/cycode_client_base.py +++ b/cycode/cyclient/cycode_client_base.py @@ -1,3 +1,4 @@ +import functools import os import platform import ssl @@ -39,16 +40,29 @@ def cert_verify(self, *args, **kwargs) -> None: conn.ca_certs = None +@functools.cache +def _get_session() -> requests.Session: + """Process-wide Session so TCP+TLS connections are reused across all API calls.""" + session = requests.Session() + # On Windows without an explicit CA bundle env var, fall back to the system + # trust store via a custom SSL context. + if platform.system() == 'Windows' and not ( + os.environ.get('REQUESTS_CA_BUNDLE') or os.environ.get('CURL_CA_BUNDLE') + ): + session.mount('https://', SystemStorageSslContext()) + return session + + def _get_request_function() -> Callable: - if os.environ.get('REQUESTS_CA_BUNDLE') or os.environ.get('CURL_CA_BUNDLE'): - return requests.request + return _get_session().request - if platform.system() != 'Windows': - return requests.request - session = requests.Session() - session.mount('https://', SystemStorageSslContext()) - return session.request +def _log_response(response: Response, url: str, hide_response_content_log: bool) -> None: + content = 'HIDDEN' if hide_response_content_log else response.text + logger.debug( + 'Receiving response, %s', + {'status_code': response.status_code, 'url': url, 'content': content}, + ) _REQUEST_ERRORS_TO_RETRY = ( @@ -182,12 +196,7 @@ def _send_multipart( response = _get_request_function()( method='post', url=url, data=tracker, headers=headers, timeout=self.timeout ) - - content = 'HIDDEN' if hide_response_content_log else response.text - logger.debug( - 'Receiving response, %s', - {'status_code': response.status_code, 'url': url, 'content': content}, - ) + _log_response(response, url, hide_response_content_log) response.raise_for_status() return response @@ -231,14 +240,8 @@ def _execute( try: headers = self.get_request_headers(headers, without_auth=without_auth) - request = _get_request_function() - response = request(method=method, url=url, timeout=timeout, headers=headers, **kwargs) - - content = 'HIDDEN' if hide_response_content_log else response.text - logger.debug( - 'Receiving response, %s', - {'status_code': response.status_code, 'url': url, 'content': content}, - ) + response = _get_request_function()(method=method, url=url, timeout=timeout, headers=headers, **kwargs) + _log_response(response, url, hide_response_content_log) response.raise_for_status() return response diff --git a/pyinstaller.spec b/pyinstaller.spec index c577c547..d93766a8 100644 --- a/pyinstaller.spec +++ b/pyinstaller.spec @@ -21,9 +21,26 @@ CLI_VERSION = _dunamai.get_version('cycode', first_choice=_dunamai.Version.from_ with open(_INIT_FILE_PATH, 'w', encoding='UTF-8') as file: file.write(prev_content.replace(VERSION_PLACEHOLDER, CLI_VERSION)) +# Top-level subapp modules are loaded lazily via importlib.import_module() in +# cycode/cli/app.py to keep startup fast on hot paths (e.g. ai-guardrails scan). +# PyInstaller's static analyzer can't see those imports, so list them explicitly. +_hiddenimports = [ + 'cycode.cli.apps.ai_guardrails', + 'cycode.cli.apps.ai_remediation', + 'cycode.cli.apps.auth', + 'cycode.cli.apps.configure', + 'cycode.cli.apps.ignore', + 'cycode.cli.apps.report', + 'cycode.cli.apps.report_import', + 'cycode.cli.apps.scan', + 'cycode.cli.apps.status', + 'cycode.cli.apps.mcp', +] + a = Analysis( scripts=['cycode/cli/main.py'], excludes=['tests', 'setuptools', 'pkg_resources'], + hiddenimports=_hiddenimports, ) exe_args = [PYZ(a.pure), a.scripts, a.binaries, a.datas] diff --git a/tests/cli/test_app_argv_peek.py b/tests/cli/test_app_argv_peek.py new file mode 100644 index 00000000..bd4c61a8 --- /dev/null +++ b/tests/cli/test_app_argv_peek.py @@ -0,0 +1,82 @@ +"""Tests for the argv-peek lazy subapp registration in cycode/cli/app.py. + +The argv-peek picks the invoked subapp from sys.argv before Typer dispatches, +so it has to walk argv itself — skipping flags and (importantly) the values +those flags consume. The `_ROOT_OPTS_WITH_VALUE` set lists every root-level +flag that consumes a following positional token. If a maintainer adds a new +value-taking option to `app_callback` and forgets to register it here, the +argv-peek will silently fall back to the cold path (loading every subapp). +The test below catches that drift by comparing the hand-maintained set +against what Click's introspection sees on the built command. +""" + +from typing import Optional +from unittest.mock import patch + +import click +import pytest +import typer.main + +from cycode.cli.app import _ROOT_OPTS_WITH_VALUE, _detect_invocation, app + + +def test_root_opts_with_value_matches_click_introspection() -> None: + """Every root option that takes a value must be registered in _ROOT_OPTS_WITH_VALUE.""" + cmd = typer.main.get_command(app) + expected = { + opt + for param in cmd.params + if isinstance(param, click.Option) and not param.is_flag + for opt in param.opts + if opt.startswith('-') + } + assert frozenset(expected) == _ROOT_OPTS_WITH_VALUE, ( + f'_ROOT_OPTS_WITH_VALUE is out of sync with app_callback.\n' + f' Missing: {sorted(expected - _ROOT_OPTS_WITH_VALUE)}\n' + f' Extra: {sorted(_ROOT_OPTS_WITH_VALUE - expected)}\n' + f'Update _ROOT_OPTS_WITH_VALUE in cycode/cli/app.py.' + ) + + +@pytest.mark.parametrize( + 'argv', + [ + ['cycode', 'ai-guardrails', 'scan'], + ['cycode', '-v', 'ai-guardrails', 'scan'], + ['cycode', '--verbose', 'ai-guardrails', 'scan'], + ['cycode', '--output', 'json', 'ai-guardrails', 'scan'], + ['cycode', '-o', 'json', 'ai-guardrails', 'scan'], + ['cycode', '--user-agent', '{"app_name":"x"}', 'ai-guardrails', 'scan'], + ['cycode', '--client-secret', 'secret-val', 'ai-guardrails', 'scan'], + ['cycode', '--client-id', 'client-val', 'ai-guardrails', 'scan'], + ['cycode', '--id-token', 'token-val', 'ai-guardrails', 'scan'], + ['cycode', '--show-completion', 'bash', 'ai-guardrails', 'scan'], + # --key=value form is one token; argv-peek should treat it as a flag + ['cycode', '--output=json', 'ai-guardrails', 'scan'], + # multiple value-taking options stacked + ['cycode', '-v', '--output', 'json', '--client-id', 'foo', 'ai-guardrails', 'scan'], + ], +) +def test_detect_invocation_finds_subcommand_past_flags(argv: list[str]) -> None: + with patch('sys.argv', argv): + assert _detect_invocation() == ('ai-guardrails', 'scan') + + +@pytest.mark.parametrize( + ('argv', 'expected'), + [ + # No positional args → no match + (['cycode'], (None, None)), + (['cycode', '-v'], (None, None)), + # Unknown subapp → no match (graceful: app.py falls back to cold path) + (['cycode', 'not-a-real-subapp'], (None, None)), + # Known subapp, no subcommand + (['cycode', 'scan'], ('scan', None)), + # Alias resolution + (['cycode', 'ai_remediation'], ('ai-remediation', None)), + (['cycode', 'version'], ('status', None)), + ], +) +def test_detect_invocation_edge_cases(argv: list[str], expected: tuple[Optional[str], Optional[str]]) -> None: + with patch('sys.argv', argv): + assert _detect_invocation() == expected From d651b3ca6ab71994b989d89b2649b15f68c08988 Mon Sep 17 00:00:00 2001 From: RoniCycode <142726722+RoniCycode@users.noreply.github.com> Date: Sun, 14 Jun 2026 12:48:49 +0300 Subject: [PATCH 084/123] CM-65100-add-file-support-codex-and-claude (#463) Co-authored-by: Claude Opus 4.8 (1M context) --- .../apps/ai_guardrails/ides/_plugin_utils.py | 32 +++++++---- cycode/cli/apps/ai_guardrails/ides/base.py | 14 +++-- .../apps/ai_guardrails/ides/claude_code.py | 25 +++++---- cycode/cli/apps/ai_guardrails/ides/codex.py | 30 ++++++---- cycode/cli/apps/ai_guardrails/ides/cursor.py | 17 ++++-- .../ai_guardrails/session_start_command.py | 20 ++++++- cycode/cyclient/ai_security_manager_client.py | 12 +++- .../ai_guardrails/ides/test_claude_code.py | 42 +++++++++++++- .../commands/ai_guardrails/ides/test_codex.py | 56 +++++++++++++++++-- .../ai_guardrails/ides/test_contract.py | 9 ++- .../ai_guardrails/ides/test_cursor.py | 17 +++--- .../test_session_start_command.py | 38 ++++++++++--- 12 files changed, 239 insertions(+), 73 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py b/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py index 186dd37f..124fdc99 100644 --- a/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py +++ b/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py @@ -26,13 +26,26 @@ def load_plugin_json(path: Path) -> Optional[dict]: return None +def build_global_config_file(path: Path, mcp_servers: Optional[dict]) -> Optional[dict]: + """Wrap a global (non-plugin) MCP config into the session-context file shape. + + Returns ``{"path": , "content": <{"mcpServers": ...} JSON>}`` when + there are servers, else ``None``. ``content`` is normalized to the canonical + ``{"mcpServers": {...}}`` shape, dropping everything else in the source file. + """ + servers = mcp_servers or {} + if not servers: + return None + return {'path': str(path), 'content': json.dumps({'mcpServers': servers})} + + def walk_enabled_plugins( plugin_entries: dict[str, Any], is_enabled: Callable[[Any], bool], locate_dir: Callable[[str, str], Optional[Path]], read_plugin: Callable[[Path], tuple[dict, dict]], -) -> tuple[dict, dict]: - """Iterate enabled plugins; merge their MCP servers and metadata. +) -> dict: + """Iterate enabled plugins and build their inventory metadata. Args: plugin_entries: ``{@: settings}`` map from the IDE config. @@ -42,13 +55,13 @@ def walk_enabled_plugins( filesystem path or None if it can't be resolved. read_plugin: given the plugin path, returns ``(entry_fields, servers)``: ``entry_fields`` are extra metadata to attach to the inventory entry - (name/version/description/...), ``servers`` are MCP servers contributed. + (name/version/description/...); ``servers`` are the plugin's MCP + servers, which ``read_plugin`` uses to derive that metadata. - Returns ``(merged_mcp_servers, enriched_plugins)``. Plugin keys without - ``@`` (or that fail to resolve to a directory) still appear in the - inventory with just ``{'enabled': True}`` so we don't silently drop them. + Returns ``enriched_plugins``. Plugin keys without ``@`` (or that fail to + resolve to a directory) still appear in the inventory with just + ``{'enabled': True}`` so we don't silently drop them. """ - merged_mcp: dict = {} enriched: dict = {} for plugin_key, settings in plugin_entries.items(): @@ -66,8 +79,7 @@ def walk_enabled_plugins( if plugin_dir is None: continue - plugin_fields, servers = read_plugin(plugin_dir) + plugin_fields, _ = read_plugin(plugin_dir) entry.update(plugin_fields) - merged_mcp.update(servers) - return merged_mcp, enriched + return enriched diff --git a/cycode/cli/apps/ai_guardrails/ides/base.py b/cycode/cli/apps/ai_guardrails/ides/base.py index 92065590..28971db9 100644 --- a/cycode/cli/apps/ai_guardrails/ides/base.py +++ b/cycode/cli/apps/ai_guardrails/ides/base.py @@ -167,10 +167,16 @@ def get_user_email(self) -> Optional[str]: """ return None - def get_session_context(self) -> tuple[dict, dict]: - """Return ``(mcp_servers, enabled_plugins)`` for session-context reporting. + def get_session_context(self) -> tuple[Optional[dict], dict]: + """Return ``(global_config_file, enabled_plugins)`` for session-context reporting. - Default: empty dicts (no plugin system, no discoverable MCP config). + ``global_config_file`` is the IDE's global (non-plugin) MCP config as + ``{"path": , "content": }``, + or ``None`` when there is no global MCP config. ``enabled_plugins`` maps each + enabled plugin key to its metadata (including its own ``mcp_config_file`` + content and ``mcp_config_file_path``). + + Default: ``(None, {})`` (no plugin system, no discoverable MCP config). Override to surface MCP/plugin inventory. """ - return {}, {} + return None, {} diff --git a/cycode/cli/apps/ai_guardrails/ides/claude_code.py b/cycode/cli/apps/ai_guardrails/ides/claude_code.py index a5a9c079..06989d87 100644 --- a/cycode/cli/apps/ai_guardrails/ides/claude_code.py +++ b/cycode/cli/apps/ai_guardrails/ides/claude_code.py @@ -7,7 +7,11 @@ from typing import ClassVar, Optional from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND -from cycode.cli.apps.ai_guardrails.ides._plugin_utils import load_plugin_json, walk_enabled_plugins +from cycode.cli.apps.ai_guardrails.ides._plugin_utils import ( + build_global_config_file, + load_plugin_json, + walk_enabled_plugins, +) from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType @@ -184,14 +188,17 @@ def _read_claude_plugin(plugin_dir: Path) -> tuple[dict, dict]: if field in manifest: entry[field] = manifest[field] - mcp_config = load_plugin_json(plugin_dir / '.mcp.json') or {} + mcp_config_path = plugin_dir / '.mcp.json' + mcp_config = load_plugin_json(mcp_config_path) or {} servers: dict = mcp_config.get('mcpServers') or {} if servers: entry['mcp_server_names'] = list(servers.keys()) + entry['mcp_config_file_path'] = str(mcp_config_path) + entry['mcp_config_file'] = json.dumps(mcp_config) return entry, servers -def resolve_plugins(settings: dict) -> tuple[dict, dict]: +def resolve_plugins(settings: dict) -> dict: """Walk Claude Code's ``enabledPlugins`` via the shared plugin walker. Each enabled plugin's marketplace is resolved through @@ -354,15 +361,11 @@ def get_user_email(self) -> Optional[str]: config = load_claude_config() return _email_from_config(config) if config else None - def get_session_context(self) -> tuple[dict, dict]: + def get_session_context(self) -> tuple[Optional[dict], dict]: config = load_claude_config() - mcp_servers: dict = dict(get_mcp_servers(config) or {}) if config else {} + global_config_file = build_global_config_file(_CLAUDE_CONFIG_PATH, get_mcp_servers(config)) if config else None settings = load_claude_settings() - if settings: - plugin_mcp, enriched_plugins = resolve_plugins(settings) - mcp_servers.update(plugin_mcp) - else: - enriched_plugins = {} + enriched_plugins = resolve_plugins(settings) if settings else {} - return mcp_servers, enriched_plugins + return global_config_file, enriched_plugins diff --git a/cycode/cli/apps/ai_guardrails/ides/codex.py b/cycode/cli/apps/ai_guardrails/ides/codex.py index 8be9f20a..2bfd70dc 100644 --- a/cycode/cli/apps/ai_guardrails/ides/codex.py +++ b/cycode/cli/apps/ai_guardrails/ides/codex.py @@ -14,7 +14,11 @@ import tomli as tomllib from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND -from cycode.cli.apps.ai_guardrails.ides._plugin_utils import load_plugin_json, walk_enabled_plugins +from cycode.cli.apps.ai_guardrails.ides._plugin_utils import ( + build_global_config_file, + load_plugin_json, + walk_enabled_plugins, +) from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType @@ -129,16 +133,19 @@ def _read_codex_plugin(plugin_dir: Path) -> tuple[dict, dict]: mcp_ref = manifest.get('mcpServers') if not mcp_ref: return entry, {} - mcp_doc = load_plugin_json(plugin_dir / mcp_ref) or {} + mcp_config_path = plugin_dir / mcp_ref + mcp_doc = load_plugin_json(mcp_config_path) or {} servers = mcp_doc.get('mcpServers', mcp_doc) if not isinstance(servers, dict): servers = {} if servers: entry['mcp_server_names'] = list(servers.keys()) + entry['mcp_config_file_path'] = str(mcp_config_path) + entry['mcp_config_file'] = json.dumps(mcp_doc) return entry, servers -def _resolve_codex_plugins(config: dict) -> tuple[dict, dict]: +def _resolve_codex_plugins(config: dict) -> dict: """Walk enabled ``[plugins."@"]`` entries.""" return walk_enabled_plugins( plugin_entries=config.get('plugins') or {}, @@ -297,13 +304,14 @@ def build_session_payload(self, raw_payload: dict) -> AIHookPayload: def get_user_email(self) -> Optional[str]: return _email_from_auth() - def get_session_context(self) -> tuple[dict, dict]: + def get_session_context(self) -> tuple[Optional[dict], dict]: config = _load_codex_config() if not config: - return {}, {} - # Codex stores MCP servers under `[mcp_servers.]`. Plugin-contributed - # servers (via `[plugins."@"]`) merge on top. - mcp_servers: dict = dict(config.get('mcp_servers') or {}) - plugin_mcp, enriched_plugins = _resolve_codex_plugins(config) - mcp_servers.update(plugin_mcp) - return mcp_servers, enriched_plugins + return None, {} + # Codex stores MCP servers under `[mcp_servers.]`; the global config + # file becomes its own session-context file. Plugins (via + # `[plugins."@"]`) carry their own config files. + config_path = _codex_config_toml_path('user') + global_config_file = build_global_config_file(config_path, config.get('mcp_servers')) + enriched_plugins = _resolve_codex_plugins(config) + return global_config_file, enriched_plugins diff --git a/cycode/cli/apps/ai_guardrails/ides/cursor.py b/cycode/cli/apps/ai_guardrails/ides/cursor.py index aa218542..950e15bb 100644 --- a/cycode/cli/apps/ai_guardrails/ides/cursor.py +++ b/cycode/cli/apps/ai_guardrails/ides/cursor.py @@ -6,6 +6,7 @@ from typing import ClassVar, Optional from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND +from cycode.cli.apps.ai_guardrails.ides._plugin_utils import build_global_config_file from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType @@ -39,9 +40,14 @@ def _user_hooks_dir() -> Path: return Path.home() / '.config' / 'Cursor' +def _cursor_mcp_config_path() -> Path: + """User-scope Cursor MCP config path (``~/.cursor/mcp.json``, all platforms).""" + return Path.home() / '.cursor' / _MCP_CONFIG_FILENAME + + def _load_cursor_mcp_config(config_path: Optional[Path] = None) -> Optional[dict]: """Load and parse `~/.cursor/mcp.json`. Returns None if missing/invalid.""" - path = config_path or (Path.home() / '.cursor' / _MCP_CONFIG_FILENAME) + path = config_path or _cursor_mcp_config_path() if not path.exists(): logger.debug('Cursor MCP config file not found, %s', {'path': str(path)}) return None @@ -113,7 +119,10 @@ def build_session_payload(self, raw_payload: dict) -> AIHookPayload: ide_version=raw_payload.get('cursor_version'), ) - def get_session_context(self) -> tuple[dict, dict]: + def get_session_context(self) -> tuple[Optional[dict], dict]: config = _load_cursor_mcp_config() - mcp_servers = dict((config or {}).get('mcpServers') or {}) if config else {} - return mcp_servers, {} + if not config: + return None, {} + config_path = _cursor_mcp_config_path() + global_config_file = build_global_config_file(config_path, config.get('mcpServers')) + return global_config_file, {} diff --git a/cycode/cli/apps/ai_guardrails/session_start_command.py b/cycode/cli/apps/ai_guardrails/session_start_command.py index cda53c62..3a20b2c8 100644 --- a/cycode/cli/apps/ai_guardrails/session_start_command.py +++ b/cycode/cli/apps/ai_guardrails/session_start_command.py @@ -1,5 +1,8 @@ """Handle AI guardrails session start: auth, conversation creation, session context.""" +import os +import platform +import socket import sys from typing import TYPE_CHECKING, Annotated, Optional @@ -20,14 +23,25 @@ logger = get_logger('AI Guardrails') +def _get_logged_in_user() -> Optional[str]: + """Best-effort OS account name (whoami). None if it can't be resolved.""" + try: + return os.getlogin() + except Exception: + return None + + def _report_session_context(ai_client: 'AISecurityManagerClient', ide: IDE, user_email: Optional[str]) -> None: """Report IDE session context to the AI security manager. Never raises.""" try: - mcp_servers, enabled_plugins = ide.get_session_context() - if not mcp_servers and not enabled_plugins: + global_config_file, enabled_plugins = ide.get_session_context() + if not global_config_file and not enabled_plugins: return ai_client.report_session_context( - mcp_servers=mcp_servers, + hostname=socket.gethostname(), + platform=platform.system(), + logged_in_user=_get_logged_in_user(), + global_config_file=global_config_file, enabled_plugins=enabled_plugins, user_email=user_email, ) diff --git a/cycode/cyclient/ai_security_manager_client.py b/cycode/cyclient/ai_security_manager_client.py index f9b7b124..19955410 100644 --- a/cycode/cyclient/ai_security_manager_client.py +++ b/cycode/cyclient/ai_security_manager_client.py @@ -93,15 +93,21 @@ def create_event( def report_session_context( self, - mcp_servers: Optional[dict] = None, + hostname: Optional[str] = None, + platform: Optional[str] = None, + logged_in_user: Optional[str] = None, + global_config_file: Optional[dict] = None, enabled_plugins: Optional[dict] = None, user_email: Optional[str] = None, ) -> None: """Report session context to the backend.""" body: dict = { - 'mcp_servers': mcp_servers, - 'enabled_plugins': enabled_plugins, + 'hostname': hostname, + 'platform': platform, + 'logged_in_user': logged_in_user, 'user_email': user_email, + 'global_config_file': global_config_file, + 'enabled_plugins': enabled_plugins, } try: diff --git a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py index f997abe3..d1e28c0b 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py +++ b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py @@ -8,7 +8,12 @@ from pytest_mock import MockerFixture from cycode.cli.apps.ai_guardrails.ides.base import HookDecision -from cycode.cli.apps.ai_guardrails.ides.claude_code import ClaudeCode, _email_from_config, load_claude_config +from cycode.cli.apps.ai_guardrails.ides.claude_code import ( + ClaudeCode, + _email_from_config, + _read_claude_plugin, + load_claude_config, +) from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType @@ -214,6 +219,37 @@ def test_email_none_when_no_oauth(mocker: MockerFixture) -> None: assert unified.ide_user_email is None +# _read_claude_plugin + + +def test_read_claude_plugin_includes_mcp_config_file(tmp_path: Path) -> None: + mcp_content = {'mcpServers': {'aspire': {'command': 'aspire', 'args': ['mcp', 'start']}}} + (tmp_path / '.mcp.json').write_text(json.dumps(mcp_content)) + + entry, servers = _read_claude_plugin(tmp_path) + + assert 'mcp_config_file' in entry + assert json.loads(entry['mcp_config_file']) == mcp_content + assert entry['mcp_config_file_path'] == str(tmp_path / '.mcp.json') + assert servers == mcp_content['mcpServers'] + + +def test_read_claude_plugin_no_mcp_config_file_when_no_servers(tmp_path: Path) -> None: + (tmp_path / '.mcp.json').write_text(json.dumps({'mcpServers': {}})) + + entry, servers = _read_claude_plugin(tmp_path) + + assert 'mcp_config_file' not in entry + assert servers == {} + + +def test_read_claude_plugin_no_mcp_config_file_when_missing(tmp_path: Path) -> None: + entry, servers = _read_claude_plugin(tmp_path) + + assert 'mcp_config_file' not in entry + assert servers == {} + + # Session context @@ -222,8 +258,8 @@ def test_session_context_no_config() -> None: patch('cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_config', return_value=None), patch('cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_settings', return_value=None), ): - servers, plugins = ClaudeCode().get_session_context() - assert servers == {} + global_config_file, plugins = ClaudeCode().get_session_context() + assert global_config_file is None assert plugins == {} diff --git a/tests/cli/commands/ai_guardrails/ides/test_codex.py b/tests/cli/commands/ai_guardrails/ides/test_codex.py index 5b656364..285b889c 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_codex.py +++ b/tests/cli/commands/ai_guardrails/ides/test_codex.py @@ -16,6 +16,7 @@ _email_from_auth, _enable_codex_hooks_feature, _load_codex_config, + _read_codex_plugin, ) from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType @@ -324,13 +325,60 @@ def test_session_context_reads_mcp_servers() -> None: 'cycode.cli.apps.ai_guardrails.ides.codex._load_codex_config', return_value={'mcp_servers': mcp}, ): - servers, plugins = Codex().get_session_context() - assert servers == mcp + global_config_file, plugins = Codex().get_session_context() + assert global_config_file is not None + assert global_config_file['path'].endswith('config.toml') + assert global_config_file['content'] == json.dumps({'mcpServers': mcp}) assert plugins == {} def test_session_context_no_config() -> None: with patch('cycode.cli.apps.ai_guardrails.ides.codex._load_codex_config', return_value=None): - servers, plugins = Codex().get_session_context() - assert servers == {} + global_config_file, plugins = Codex().get_session_context() + assert global_config_file is None assert plugins == {} + + +def _write_codex_plugin(plugin_dir: Path, mcp_doc: dict) -> None: + """Lay out a Codex plugin: manifest referencing .mcp.json + the MCP file itself.""" + (plugin_dir / '.codex-plugin').mkdir(parents=True, exist_ok=True) + (plugin_dir / '.codex-plugin' / 'plugin.json').write_text(json.dumps({'name': 'demo', 'mcpServers': '.mcp.json'})) + (plugin_dir / '.mcp.json').write_text(json.dumps(mcp_doc)) + + +def test_read_codex_plugin_includes_mcp_config_file(tmp_path: Path) -> None: + mcp_content = {'mcpServers': {'dummy-server': {'command': 'dummy-command', 'args': ['serve']}}} + _write_codex_plugin(tmp_path, mcp_content) + + entry, servers = _read_codex_plugin(tmp_path) + + assert json.loads(entry['mcp_config_file']) == mcp_content + assert entry['mcp_config_file_path'] == str(tmp_path / '.mcp.json') + assert servers == mcp_content['mcpServers'] + + +def test_read_codex_plugin_mcp_config_file_bare_map(tmp_path: Path) -> None: + # Codex MCP files may be a bare {name: cfg} map with no mcpServers wrapper. + mcp_content = {'dummy-server': {'command': 'dummy-command'}} + _write_codex_plugin(tmp_path, mcp_content) + + entry, servers = _read_codex_plugin(tmp_path) + + assert json.loads(entry['mcp_config_file']) == mcp_content + assert servers == mcp_content + + +def test_read_codex_plugin_no_mcp_config_file_when_no_servers(tmp_path: Path) -> None: + _write_codex_plugin(tmp_path, {'mcpServers': {}}) + + entry, servers = _read_codex_plugin(tmp_path) + + assert 'mcp_config_file' not in entry + assert servers == {} + + +def test_read_codex_plugin_no_mcp_config_file_when_no_manifest(tmp_path: Path) -> None: + entry, servers = _read_codex_plugin(tmp_path) + + assert 'mcp_config_file' not in entry + assert servers == {} diff --git a/tests/cli/commands/ai_guardrails/ides/test_contract.py b/tests/cli/commands/ai_guardrails/ides/test_contract.py index 7d7a5427..3bdbc19d 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_contract.py +++ b/tests/cli/commands/ai_guardrails/ides/test_contract.py @@ -105,9 +105,12 @@ def test_build_session_payload_tags_ide(ide: IDE) -> None: def test_get_session_context_returns_pair(ide: IDE) -> None: - """Session context must always be a ``(mcp_servers, plugins)`` 2-tuple of dicts.""" - mcp_servers, plugins = ide.get_session_context() - assert isinstance(mcp_servers, dict) + """Session context must be a ``(global_config_file, plugins)`` pair. + + ``global_config_file`` is ``None`` or a ``{"path", "content"}`` dict; ``plugins`` is a dict. + """ + global_config_file, plugins = ide.get_session_context() + assert global_config_file is None or isinstance(global_config_file, dict) assert isinstance(plugins, dict) diff --git a/tests/cli/commands/ai_guardrails/ides/test_cursor.py b/tests/cli/commands/ai_guardrails/ides/test_cursor.py index bb058f6f..4d082d3a 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_cursor.py +++ b/tests/cli/commands/ai_guardrails/ides/test_cursor.py @@ -133,22 +133,23 @@ def test_session_payload_carries_cursor_fields() -> None: assert session.ide_provider == 'cursor' -def test_session_context_loads_mcp_servers(tmp_path: Path) -> None: - """Cursor reads MCP servers from ~/.cursor/mcp.json.""" +def test_session_context_loads_mcp_servers() -> None: + """Cursor wraps ~/.cursor/mcp.json into a global_config_file.""" mcp_servers = {'github': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-github']}} - config_path = tmp_path / 'mcp.json' - config_path.write_text(json.dumps({'mcpServers': mcp_servers})) with patch('cycode.cli.apps.ai_guardrails.ides.cursor._load_cursor_mcp_config') as load: load.return_value = {'mcpServers': mcp_servers} - servers, plugins = Cursor().get_session_context() + global_config_file, plugins = Cursor().get_session_context() - assert servers == mcp_servers + assert global_config_file == { + 'path': str(Path.home() / '.cursor' / 'mcp.json'), + 'content': json.dumps({'mcpServers': mcp_servers}), + } assert plugins == {} def test_session_context_no_config_returns_empty() -> None: with patch('cycode.cli.apps.ai_guardrails.ides.cursor._load_cursor_mcp_config', return_value=None): - servers, plugins = Cursor().get_session_context() - assert servers == {} + global_config_file, plugins = Cursor().get_session_context() + assert global_config_file is None assert plugins == {} diff --git a/tests/cli/commands/ai_guardrails/test_session_start_command.py b/tests/cli/commands/ai_guardrails/test_session_start_command.py index 0ae57226..d048c8b3 100644 --- a/tests/cli/commands/ai_guardrails/test_session_start_command.py +++ b/tests/cli/commands/ai_guardrails/test_session_start_command.py @@ -3,7 +3,7 @@ import json from io import StringIO from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import ANY, MagicMock, patch import pytest import typer @@ -234,7 +234,13 @@ def test_claude_code_reports_mcp_servers( session_start_command(mock_ctx, ide='claude-code') mock_ai_client.report_session_context.assert_called_once_with( - mcp_servers=mcp_servers, + hostname=ANY, + platform=ANY, + logged_in_user=ANY, + global_config_file={ + 'path': str(_claude_mod._CLAUDE_CONFIG_PATH), + 'content': json.dumps({'mcpServers': mcp_servers}), + }, enabled_plugins={'cycode-dev@cycode-marketplace': {'enabled': True}}, user_email='test@test.com', ) @@ -244,7 +250,7 @@ def test_claude_code_reports_mcp_servers( @patch.object(_claude_mod, 'load_claude_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') -def test_claude_code_merges_plugin_mcp_servers_and_metadata( +def test_claude_code_reports_global_file_and_plugin_metadata( mock_get_auth: MagicMock, mock_get_client: MagicMock, mock_load_config: MagicMock, @@ -252,8 +258,8 @@ def test_claude_code_merges_plugin_mcp_servers_and_metadata( mock_ctx: MagicMock, tmp_path: Path, ) -> None: - """Plugin MCP servers from /.mcp.json should merge into mcp_servers, - and plugin metadata from .claude-plugin/plugin.json should enrich enabled_plugins.""" + """The global config file carries only the global MCP servers; the plugin's own + .mcp.json content + path + metadata enrich enabled_plugins (no merge into the global).""" mock_get_auth.return_value = MagicMock() mock_ai_client = MagicMock() mock_get_client.return_value = mock_ai_client @@ -282,10 +288,14 @@ def test_claude_code_merges_plugin_mcp_servers_and_metadata( with patch('sys.stdin', new=StringIO(json.dumps(payload))): session_start_command(mock_ctx, ide='claude-code') + plugin_mcp = {'mcpServers': {'aspire': {'command': 'aspire', 'args': ['mcp', 'start']}}} mock_ai_client.report_session_context.assert_called_once_with( - mcp_servers={ - 'gitlab': {'command': 'npx'}, - 'aspire': {'command': 'aspire', 'args': ['mcp', 'start']}, + hostname=ANY, + platform=ANY, + logged_in_user=ANY, + global_config_file={ + 'path': str(_claude_mod._CLAUDE_CONFIG_PATH), + 'content': json.dumps({'mcpServers': user_mcp_servers}), }, enabled_plugins={ 'cycode-dev@cycode-marketplace': { @@ -294,6 +304,8 @@ def test_claude_code_merges_plugin_mcp_servers_and_metadata( 'version': '1.0.28', 'description': 'Shared skills', 'mcp_server_names': ['aspire'], + 'mcp_config_file_path': str(plugin_dir / '.mcp.json'), + 'mcp_config_file': json.dumps(plugin_mcp), } }, user_email=None, @@ -348,7 +360,15 @@ def test_cursor_reports_mcp_servers( session_start_command(mock_ctx, ide='cursor') mock_ai_client.report_session_context.assert_called_once_with( - mcp_servers=mcp_servers, enabled_plugins={}, user_email=None + hostname=ANY, + platform=ANY, + logged_in_user=ANY, + global_config_file={ + 'path': str(Path.home() / '.cursor' / 'mcp.json'), + 'content': json.dumps({'mcpServers': mcp_servers}), + }, + enabled_plugins={}, + user_email=None, ) From 1c6435ed5ec35355542e11433c7eedfc7353b50c Mon Sep 17 00:00:00 2001 From: Mateusz Sterczewski Date: Tue, 16 Jun 2026 10:44:48 +0200 Subject: [PATCH 085/123] CM-65436: add SAST fallback ignore-extensions list (#473) Co-authored-by: Claude Sonnet 4.6 --- cycode/cli/consts.py | 24 +++++++++++++++++++++ cycode/cli/files_collector/file_excluder.py | 8 +++++++ 2 files changed, 32 insertions(+) diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 52a6827d..9ef19eb2 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -53,6 +53,30 @@ '.iso', ) +# Fallback block-list used for SAST only when the server does not return scannable extensions +# (e.g. when the customer has custom rules, any text file is scannable). These are non-source +# data formats that can slip past binary detection (the EICAR test file and ClamAV signature +# databases are plain ASCII) and may be quarantined by object-storage antivirus after upload. +SAST_SCAN_FILE_EXTENSIONS_TO_IGNORE = ( + '.bin', + '.cvd', + '.cld', + '.cud', + '.hdb', + '.hsb', + '.mdb', + '.msb', + '.ndb', + '.ndu', + '.ldb', + '.ldu', + '.idb', + '.fp', + '.sfp', + '.ign', + '.ign2', +) + SCA_CONFIGURATION_SCAN_SUPPORTED_FILES = ( # keep in lowercase 'cargo.lock', 'cargo.toml', diff --git a/cycode/cli/files_collector/file_excluder.py b/cycode/cli/files_collector/file_excluder.py index 11fd3410..fc61f0e2 100644 --- a/cycode/cli/files_collector/file_excluder.py +++ b/cycode/cli/files_collector/file_excluder.py @@ -63,7 +63,10 @@ def __init__(self) -> None: } self._non_scannable_extensions: dict[str, tuple[str, ...]] = { consts.SECRET_SCAN_TYPE: consts.SECRET_SCAN_FILE_EXTENSIONS_TO_IGNORE, + consts.SAST_SCAN_TYPE: consts.SAST_SCAN_FILE_EXTENSIONS_TO_IGNORE, } + # Tracks scan types for which the SAST fallback log has already been emitted (log once, not per file) + self._logged_sast_fallback = False def apply_scan_config(self, scan_type: str, scan_config: 'models.ScanConfiguration') -> None: if scan_config.scannable_extensions: @@ -86,6 +89,11 @@ def _is_file_extension_supported(self, scan_type: str, filename: str) -> bool: non_scannable_extensions = self._non_scannable_extensions.get(scan_type) if non_scannable_extensions: + # For SAST, reaching the block-list means the server returned no scannable extensions + # (e.g. custom rules, or no remote config). Log once so this is diagnosable. + if scan_type == consts.SAST_SCAN_TYPE and not self._logged_sast_fallback: + self._logged_sast_fallback = True + logger.debug('No scannable extensions provided for SAST; falling back to the built-in ignore list') return not filename.endswith(non_scannable_extensions) return True From d7f407681db37d8dae59a27a14229cd16e2a10bf Mon Sep 17 00:00:00 2001 From: Ilia Shkolyar <60312091+ilia-cy@users.noreply.github.com> Date: Sun, 21 Jun 2026 15:45:57 +0300 Subject: [PATCH 086/123] CM-67318: Warn when env vars override configured credentials (#474) Co-authored-by: Claude Opus 4.8 --- .../cli/apps/configure/configure_command.py | 18 +++++++++++- cycode/cli/apps/configure/messages.py | 20 +++++++++---- tests/cli/commands/configure/test_messages.py | 28 +++++++++++++++++++ 3 files changed, 59 insertions(+), 7 deletions(-) create mode 100644 tests/cli/commands/configure/test_messages.py diff --git a/cycode/cli/apps/configure/configure_command.py b/cycode/cli/apps/configure/configure_command.py index 1811271c..3c2f269b 100644 --- a/cycode/cli/apps/configure/configure_command.py +++ b/cycode/cli/apps/configure/configure_command.py @@ -1,7 +1,12 @@ from typing import Optional from cycode.cli.apps.configure.consts import CONFIGURATION_MANAGER, CREDENTIALS_MANAGER -from cycode.cli.apps.configure.messages import get_credentials_update_result_message, get_urls_update_result_message +from cycode.cli.apps.configure.messages import ( + get_credentials_environment_variables_override_warning, + get_credentials_update_result_message, + get_urls_environment_variables_override_warning, + get_urls_update_result_message, +) from cycode.cli.apps.configure.prompts import ( get_api_url_input, get_app_url_input, @@ -73,3 +78,14 @@ def configure_command() -> None: console.print(get_urls_update_result_message()) if credentials_updated or oidc_credentials_updated: console.print(get_credentials_update_result_message()) + + # Warn about environment variables that override the configured file values, regardless of whether anything was + # updated. The env vars take precedence on every subsequent call, so configuring the file alone has no effect while + # they are set. + urls_override_warning = get_urls_environment_variables_override_warning() + if urls_override_warning: + console.print(f'[yellow]Warning:[/] {urls_override_warning}') + + credentials_override_warning = get_credentials_environment_variables_override_warning() + if credentials_override_warning: + console.print(f'[yellow]Warning:[/] {credentials_override_warning}') diff --git a/cycode/cli/apps/configure/messages.py b/cycode/cli/apps/configure/messages.py index 36ce807b..f008f09d 100644 --- a/cycode/cli/apps/configure/messages.py +++ b/cycode/cli/apps/configure/messages.py @@ -1,3 +1,5 @@ +from typing import Optional + from cycode.cli.apps.configure.consts import ( CONFIGURATION_MANAGER, CREDENTIALS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE, @@ -14,11 +16,14 @@ def _are_credentials_exist_in_environment_variables() -> bool: def get_credentials_update_result_message() -> str: - success_message = CREDENTIALS_UPDATED_SUCCESSFULLY_MESSAGE.format(filename=CREDENTIALS_MANAGER.get_filename()) + return CREDENTIALS_UPDATED_SUCCESSFULLY_MESSAGE.format(filename=CREDENTIALS_MANAGER.get_filename()) + + +def get_credentials_environment_variables_override_warning() -> Optional[str]: if _are_credentials_exist_in_environment_variables(): - return f'{success_message}. {CREDENTIALS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE}' + return CREDENTIALS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE - return success_message + return None def _are_urls_exist_in_environment_variables() -> bool: @@ -28,10 +33,13 @@ def _are_urls_exist_in_environment_variables() -> bool: def get_urls_update_result_message() -> str: - success_message = URLS_UPDATED_SUCCESSFULLY_MESSAGE.format( + return URLS_UPDATED_SUCCESSFULLY_MESSAGE.format( filename=CONFIGURATION_MANAGER.global_config_file_manager.get_filename() ) + + +def get_urls_environment_variables_override_warning() -> Optional[str]: if _are_urls_exist_in_environment_variables(): - return f'{success_message}. {URLS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE}' + return URLS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE - return success_message + return None diff --git a/tests/cli/commands/configure/test_messages.py b/tests/cli/commands/configure/test_messages.py new file mode 100644 index 00000000..9eb0190a --- /dev/null +++ b/tests/cli/commands/configure/test_messages.py @@ -0,0 +1,28 @@ +from typing import TYPE_CHECKING + +from cycode.cli.apps.configure import messages +from cycode.cli.config import CYCODE_CLIENT_ID_ENV_VAR_NAME, CYCODE_CLIENT_SECRET_ENV_VAR_NAME + +if TYPE_CHECKING: + import pytest + + +def test_credentials_override_warning_absent_when_no_env_vars(monkeypatch: 'pytest.MonkeyPatch') -> None: + monkeypatch.delenv(CYCODE_CLIENT_ID_ENV_VAR_NAME, raising=False) + monkeypatch.delenv(CYCODE_CLIENT_SECRET_ENV_VAR_NAME, raising=False) + + assert messages.get_credentials_environment_variables_override_warning() is None + + +def test_credentials_override_warning_present_when_only_client_id_set(monkeypatch: 'pytest.MonkeyPatch') -> None: + monkeypatch.setenv(CYCODE_CLIENT_ID_ENV_VAR_NAME, 'env-client-id') + monkeypatch.delenv(CYCODE_CLIENT_SECRET_ENV_VAR_NAME, raising=False) + + assert messages.get_credentials_environment_variables_override_warning() is not None + + +def test_credentials_success_message_does_not_embed_override_warning(monkeypatch: 'pytest.MonkeyPatch') -> None: + monkeypatch.setenv(CYCODE_CLIENT_ID_ENV_VAR_NAME, 'env-client-id') + monkeypatch.setenv(CYCODE_CLIENT_SECRET_ENV_VAR_NAME, 'env-client-secret') + + assert 'environment variables' not in messages.get_credentials_update_result_message() From 9f34d7f616de50ddb80fea185cd6e9b4ba46f1b6 Mon Sep 17 00:00:00 2001 From: RoniCycode <142726722+RoniCycode@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:10:29 +0300 Subject: [PATCH 087/123] CM-67459: Enrich session context payload (#477) --- .../ai_guardrails/session_start_command.py | 26 ++-- cycode/cli/utils/host_info.py | 127 ++++++++++++++++++ cycode/cyclient/ai_security_manager_client.py | 12 +- .../test_session_start_command.py | 18 ++- 4 files changed, 159 insertions(+), 24 deletions(-) create mode 100644 cycode/cli/utils/host_info.py diff --git a/cycode/cli/apps/ai_guardrails/session_start_command.py b/cycode/cli/apps/ai_guardrails/session_start_command.py index 3a20b2c8..5d491f10 100644 --- a/cycode/cli/apps/ai_guardrails/session_start_command.py +++ b/cycode/cli/apps/ai_guardrails/session_start_command.py @@ -1,8 +1,5 @@ """Handle AI guardrails session start: auth, conversation creation, session context.""" -import os -import platform -import socket import sys from typing import TYPE_CHECKING, Annotated, Optional @@ -15,6 +12,13 @@ from cycode.cli.apps.auth.auth_manager import AuthManager from cycode.cli.exceptions.handle_auth_errors import handle_auth_exception from cycode.cli.utils.get_api_client import get_ai_security_manager_client +from cycode.cli.utils.host_info import ( + get_hostname, + get_last_login_user, + get_os_version, + get_platform_name, + get_serial_number, +) from cycode.logger import get_logger if TYPE_CHECKING: @@ -23,14 +27,6 @@ logger = get_logger('AI Guardrails') -def _get_logged_in_user() -> Optional[str]: - """Best-effort OS account name (whoami). None if it can't be resolved.""" - try: - return os.getlogin() - except Exception: - return None - - def _report_session_context(ai_client: 'AISecurityManagerClient', ide: IDE, user_email: Optional[str]) -> None: """Report IDE session context to the AI security manager. Never raises.""" try: @@ -38,9 +34,11 @@ def _report_session_context(ai_client: 'AISecurityManagerClient', ide: IDE, user if not global_config_file and not enabled_plugins: return ai_client.report_session_context( - hostname=socket.gethostname(), - platform=platform.system(), - logged_in_user=_get_logged_in_user(), + hostname=get_hostname(), + platform_name=get_platform_name(), + os_version=get_os_version(), + serial_number=get_serial_number(), + last_login_user=get_last_login_user(), global_config_file=global_config_file, enabled_plugins=enabled_plugins, user_email=user_email, diff --git a/cycode/cli/utils/host_info.py b/cycode/cli/utils/host_info.py new file mode 100644 index 00000000..23737b7a --- /dev/null +++ b/cycode/cli/utils/host_info.py @@ -0,0 +1,127 @@ +import getpass +import platform +import re +import socket +import subprocess +from typing import Optional + +from cycode.logger import get_logger + +logger = get_logger('HOST INFO') + +_SUBPROCESS_TIMEOUT_SEC = 5 + +_PLATFORM_NAMES = {'Darwin': 'macOS', 'Windows': 'Windows', 'Linux': 'Linux'} + + +def _run(command: list, timeout: int = _SUBPROCESS_TIMEOUT_SEC) -> Optional[str]: + """Run a command and return its stripped stdout. Never raises; returns None on any error.""" + try: + result = subprocess.run(command, capture_output=True, text=True, timeout=timeout) # noqa: S603 + return result.stdout.strip() or None + except Exception as e: + logger.debug('Failed to run command %s', command, exc_info=e) + return None + + +def _read_text_file(path: str) -> Optional[str]: + """Read and strip a text file. Never raises; returns None if it can't be read.""" + try: + with open(path) as text_file: + return text_file.read().strip() or None + except OSError: + return None + + +def get_hostname() -> Optional[str]: + try: + return socket.gethostname() or None + except Exception as e: + logger.debug('Failed to resolve hostname', exc_info=e) + return None + + +def get_platform_name() -> Optional[str]: + try: + system = platform.system() + return _PLATFORM_NAMES.get(system, system or None) + except Exception as e: + logger.debug('Failed to resolve platform name', exc_info=e) + return None + + +def get_os_version() -> Optional[str]: + try: + system = platform.system() + if system == 'Darwin': + return platform.mac_ver()[0] or None + if system == 'Windows': + return platform.win32_ver()[1] or platform.version() or None + if system == 'Linux': + return _get_linux_os_version() + return platform.release() or None + except Exception as e: + logger.debug('Failed to resolve OS version', exc_info=e) + return None + + +def _get_linux_os_version() -> Optional[str]: + freedesktop_os_release = getattr(platform, 'freedesktop_os_release', None) # Python 3.10+ + if freedesktop_os_release is not None: + try: + version_id = freedesktop_os_release().get('VERSION_ID') + if version_id: + return version_id + except OSError: + pass + + os_release = _read_text_file('/etc/os-release') # Python 3.9 fallback: parse manually + if os_release: + for line in os_release.splitlines(): + if line.startswith('VERSION_ID='): + return line.split('=', 1)[1].strip().strip('"') or None + + return platform.release() or None + + +def get_last_login_user() -> Optional[str]: + try: + return getpass.getuser() or None + except Exception as e: + logger.debug('Failed to resolve last login user', exc_info=e) + return None + + +def get_serial_number() -> Optional[str]: + try: + system = platform.system() + if system == 'Darwin': + return _get_macos_serial_number() + if system == 'Windows': + return _get_windows_serial_number() + except Exception as e: + logger.debug('Failed to resolve serial number', exc_info=e) + return None + + +def _get_macos_serial_number() -> Optional[str]: + output = _run(['ioreg', '-c', 'IOPlatformExpertDevice', '-d', '2']) + if not output: + return None + match = re.search(r'"IOPlatformSerialNumber"\s*=\s*"([^"]+)"', output) + return match.group(1) if match else None + + +def _get_windows_serial_number() -> Optional[str]: + import pythoncom # from pywin32 + import win32com.client # from pywin32 + + pythoncom.CoInitialize() + try: + wmi_service = win32com.client.GetObject('winmgmts:') + for bios in wmi_service.InstancesOf('Win32_BIOS'): + serial = bios.SerialNumber + return serial.strip() if serial else None + finally: + pythoncom.CoUninitialize() + return None diff --git a/cycode/cyclient/ai_security_manager_client.py b/cycode/cyclient/ai_security_manager_client.py index 19955410..a4f9bd76 100644 --- a/cycode/cyclient/ai_security_manager_client.py +++ b/cycode/cyclient/ai_security_manager_client.py @@ -94,8 +94,10 @@ def create_event( def report_session_context( self, hostname: Optional[str] = None, - platform: Optional[str] = None, - logged_in_user: Optional[str] = None, + platform_name: Optional[str] = None, + os_version: Optional[str] = None, + serial_number: Optional[str] = None, + last_login_user: Optional[str] = None, global_config_file: Optional[dict] = None, enabled_plugins: Optional[dict] = None, user_email: Optional[str] = None, @@ -103,8 +105,10 @@ def report_session_context( """Report session context to the backend.""" body: dict = { 'hostname': hostname, - 'platform': platform, - 'logged_in_user': logged_in_user, + 'platform_name': platform_name, + 'os_version': os_version, + 'serial_number': serial_number, + 'last_login_user': last_login_user, 'user_email': user_email, 'global_config_file': global_config_file, 'enabled_plugins': enabled_plugins, diff --git a/tests/cli/commands/ai_guardrails/test_session_start_command.py b/tests/cli/commands/ai_guardrails/test_session_start_command.py index d048c8b3..ed6708ce 100644 --- a/tests/cli/commands/ai_guardrails/test_session_start_command.py +++ b/tests/cli/commands/ai_guardrails/test_session_start_command.py @@ -235,8 +235,10 @@ def test_claude_code_reports_mcp_servers( mock_ai_client.report_session_context.assert_called_once_with( hostname=ANY, - platform=ANY, - logged_in_user=ANY, + platform_name=ANY, + os_version=ANY, + serial_number=ANY, + last_login_user=ANY, global_config_file={ 'path': str(_claude_mod._CLAUDE_CONFIG_PATH), 'content': json.dumps({'mcpServers': mcp_servers}), @@ -291,8 +293,10 @@ def test_claude_code_reports_global_file_and_plugin_metadata( plugin_mcp = {'mcpServers': {'aspire': {'command': 'aspire', 'args': ['mcp', 'start']}}} mock_ai_client.report_session_context.assert_called_once_with( hostname=ANY, - platform=ANY, - logged_in_user=ANY, + platform_name=ANY, + os_version=ANY, + serial_number=ANY, + last_login_user=ANY, global_config_file={ 'path': str(_claude_mod._CLAUDE_CONFIG_PATH), 'content': json.dumps({'mcpServers': user_mcp_servers}), @@ -361,8 +365,10 @@ def test_cursor_reports_mcp_servers( mock_ai_client.report_session_context.assert_called_once_with( hostname=ANY, - platform=ANY, - logged_in_user=ANY, + platform_name=ANY, + os_version=ANY, + serial_number=ANY, + last_login_user=ANY, global_config_file={ 'path': str(Path.home() / '.cursor' / 'mcp.json'), 'content': json.dumps({'mcpServers': mcp_servers}), From 641747d5803c411a10bad96a7263494c97bc6dd9 Mon Sep 17 00:00:00 2001 From: Arad Traub Date: Mon, 29 Jun 2026 16:25:20 +0300 Subject: [PATCH 088/123] CM-67195: Add Bun package manager support to SCA local scans (#478) Co-authored-by: Claude Opus 4.8 (1M context) --- cycode/cli/consts.py | 2 + .../sca/npm/restore_bun_dependencies.py | 113 ++++++++++ .../sca/npm/restore_npm_dependencies.py | 11 +- .../files_collector/sca/sca_file_collector.py | 4 +- .../sca/npm/test_restore_bun_dependencies.py | 205 ++++++++++++++++++ .../sca/npm/test_restore_npm_dependencies.py | 9 + 6 files changed, 342 insertions(+), 2 deletions(-) create mode 100644 cycode/cli/files_collector/sca/npm/restore_bun_dependencies.py create mode 100644 tests/cli/files_collector/sca/npm/test_restore_bun_dependencies.py diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 9ef19eb2..a134f3f4 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -102,6 +102,7 @@ 'deno.lock', 'deno.json', 'pnpm-lock.yaml', + 'bun.lock', 'npm-shrinkwrap.json', 'packages.config', 'project.assets.json', @@ -165,6 +166,7 @@ 'npm-shrinkwrap.json', '.npmrc', 'pnpm-lock.yaml', + 'bun.lock', 'deno.lock', 'deno.json', ], diff --git a/cycode/cli/files_collector/sca/npm/restore_bun_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_bun_dependencies.py new file mode 100644 index 00000000..2bf0d647 --- /dev/null +++ b/cycode/cli/files_collector/sca/npm/restore_bun_dependencies.py @@ -0,0 +1,113 @@ +import json +import re +from pathlib import Path +from typing import Optional + +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content +from cycode.cli.utils.shell_executor import shell +from cycode.logger import get_logger + +logger = get_logger('Bun Restore Dependencies') + +BUN_MANIFEST_FILE_NAME = 'package.json' +BUN_LOCK_FILE_NAME = 'bun.lock' + +# Only Bun >=1.2 produces the text-based `bun.lock` lockfile that we parse. +# Older Bun versions emit a binary `bun.lockb`, which is not supported. +MINIMUM_BUN_VERSION = (1, 2) +BUN_VERSION_COMMAND = ['bun', '--version'] + + +def _indicates_bun(package_json_content: Optional[str]) -> bool: + """Return True if package.json content signals that this project uses Bun.""" + if not package_json_content: + return False + try: + data = json.loads(package_json_content) + except (json.JSONDecodeError, ValueError): + return False + + package_manager = data.get('packageManager', '') + if isinstance(package_manager, str) and package_manager.startswith('bun'): + return True + + engines = data.get('engines', {}) + return isinstance(engines, dict) and 'bun' in engines + + +def _parse_bun_version(raw_version: Optional[str]) -> Optional[tuple[int, int]]: + """Parse the (major, minor) version from `bun --version` output (e.g. '1.2.3').""" + if not raw_version: + return None + match = re.match(r'(\d+)\.(\d+)', raw_version.strip()) + if not match: + return None + return int(match.group(1)), int(match.group(2)) + + +class RestoreBunDependencies(BaseRestoreDependencies): + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + if Path(document.path).name != BUN_MANIFEST_FILE_NAME: + return False + + manifest_dir = self.get_manifest_dir(document) + if manifest_dir and (Path(manifest_dir) / BUN_LOCK_FILE_NAME).is_file(): + return True + + return _indicates_bun(document.content) + + def _is_supported_bun_version(self) -> bool: + """Verify that the installed Bun is >=1.2, which is required to generate a text bun.lock.""" + raw_version = shell(command=BUN_VERSION_COMMAND, timeout=self.command_timeout, silent_exc_info=True) + version = _parse_bun_version(raw_version) + minimum = '.'.join(str(part) for part in MINIMUM_BUN_VERSION) + if version is None: + logger.warning( + 'Could not determine Bun version; Bun %s+ is required to restore Bun dependencies, %s', + minimum, + {'raw_version': raw_version}, + ) + return False + if version < MINIMUM_BUN_VERSION: + logger.warning( + 'Unsupported Bun version; Bun %s+ is required to restore Bun dependencies, %s', + minimum, + {'detected_version': '.'.join(str(part) for part in version)}, + ) + return False + return True + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + lockfile_path = Path(manifest_dir) / BUN_LOCK_FILE_NAME if manifest_dir else None + + if lockfile_path and lockfile_path.is_file(): + # Lockfile already exists — read it directly without running bun. + # A text bun.lock only exists when generated by Bun >=1.2, so no version check is needed here. + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, BUN_LOCK_FILE_NAME) + logger.debug('Using existing bun.lock, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + # Lockfile absent — must generate it via `bun install`. This requires Bun >=1.2, + # otherwise an older Bun would emit a binary bun.lockb that we cannot parse. + if not self._is_supported_bun_version(): + return None + + return super().try_restore_dependencies(document) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [['bun', 'install', '--ignore-scripts']] + + def get_lock_file_name(self) -> str: + return BUN_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [BUN_LOCK_FILE_NAME] diff --git a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py index d07bc4a5..9416f58c 100644 --- a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py +++ b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py @@ -11,7 +11,7 @@ NPM_MANIFEST_FILE_NAME = 'package.json' NPM_LOCK_FILE_NAME = 'package-lock.json' # These lockfiles indicate another package manager owns the project — NPM should not run -_ALTERNATIVE_LOCK_FILES = ('yarn.lock', 'pnpm-lock.yaml', 'deno.lock') +_ALTERNATIVE_LOCK_FILES = ('yarn.lock', 'pnpm-lock.yaml', 'deno.lock', 'bun.lock') class RestoreNpmDependencies(BaseRestoreDependencies): @@ -23,6 +23,15 @@ def is_project(self, document: Document) -> bool: Yarn and pnpm projects are handled by their dedicated handlers, which run before this one in the handler list. This handler is the npm fallback. + + NOTE: this guard only excludes a project when an alternative lockfile is *physically + present on disk*. It does not inspect the `packageManager`/`engines` signal in + package.json. So a project that declares e.g. `packageManager: "bun@..."` (or pnpm) + but has no lockfile yet is claimed by BOTH the dedicated handler and this npm fallback, + and both restores run. This is pre-existing behavior shared by pnpm/yarn/bun and is + accepted for now (a real Bun/pnpm project ships a lockfile, so npm correctly skips). + If this ever needs tightening, also skip here when package.json declares a non-npm + packageManager/engines signal. """ if Path(document.path).name != NPM_MANIFEST_FILE_NAME: return False diff --git a/cycode/cli/files_collector/sca/sca_file_collector.py b/cycode/cli/files_collector/sca/sca_file_collector.py index b57061b0..6bcfd494 100644 --- a/cycode/cli/files_collector/sca/sca_file_collector.py +++ b/cycode/cli/files_collector/sca/sca_file_collector.py @@ -10,6 +10,7 @@ from cycode.cli.files_collector.sca.go.restore_go_dependencies import RestoreGoDependencies from cycode.cli.files_collector.sca.maven.restore_gradle_dependencies import RestoreGradleDependencies from cycode.cli.files_collector.sca.maven.restore_maven_dependencies import RestoreMavenDependencies +from cycode.cli.files_collector.sca.npm.restore_bun_dependencies import RestoreBunDependencies from cycode.cli.files_collector.sca.npm.restore_deno_dependencies import RestoreDenoDependencies from cycode.cli.files_collector.sca.npm.restore_npm_dependencies import RestoreNpmDependencies from cycode.cli.files_collector.sca.npm.restore_pnpm_dependencies import RestorePnpmDependencies @@ -157,8 +158,9 @@ def _get_restore_handlers(ctx: typer.Context, is_git_diff: bool) -> list[BaseRes RestoreNugetDependencies(ctx, is_git_diff, build_dep_tree_timeout), RestoreYarnDependencies(ctx, is_git_diff, build_dep_tree_timeout), RestorePnpmDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestoreBunDependencies(ctx, is_git_diff, build_dep_tree_timeout), RestoreDenoDependencies(ctx, is_git_diff, build_dep_tree_timeout), - RestoreNpmDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be after Yarn & Pnpm for fallback + RestoreNpmDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be after Yarn, Pnpm & Bun for fallback RestoreRubyDependencies(ctx, is_git_diff, build_dep_tree_timeout), RestoreUvDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be before Poetry for pyproject.toml RestorePoetryDependencies(ctx, is_git_diff, build_dep_tree_timeout), diff --git a/tests/cli/files_collector/sca/npm/test_restore_bun_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_bun_dependencies.py new file mode 100644 index 00000000..17f189df --- /dev/null +++ b/tests/cli/files_collector/sca/npm/test_restore_bun_dependencies.py @@ -0,0 +1,205 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.npm.restore_bun_dependencies import ( + BUN_LOCK_FILE_NAME, + RestoreBunDependencies, + _parse_bun_version, +) +from cycode.cli.models import Document + +_BUN_MODULE = 'cycode.cli.files_collector.sca.npm.restore_bun_dependencies' + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_bun(mock_ctx: typer.Context) -> RestoreBunDependencies: + return RestoreBunDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_package_json_with_bun_lock_matches(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'bun.lock').write_text('{"lockfileVersion": 1}\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_bun.is_project(doc) is True + + def test_package_json_with_package_manager_bun_matches(self, restore_bun: RestoreBunDependencies) -> None: + content = '{"name": "test", "packageManager": "bun@1.1.0"}' + doc = Document('package.json', content) + assert restore_bun.is_project(doc) is True + + def test_package_json_with_engines_bun_matches(self, restore_bun: RestoreBunDependencies) -> None: + content = '{"name": "test", "engines": {"bun": ">=1"}}' + doc = Document('package.json', content) + assert restore_bun.is_project(doc) is True + + def test_package_json_with_no_bun_signal_does_not_match( + self, restore_bun: RestoreBunDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_bun.is_project(doc) is False + + def test_package_json_with_yarn_lock_does_not_match( + self, restore_bun: RestoreBunDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'yarn.lock').write_text('# yarn lockfile v1\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_bun.is_project(doc) is False + + def test_tsconfig_json_does_not_match(self, restore_bun: RestoreBunDependencies) -> None: + doc = Document('tsconfig.json', '{"compilerOptions": {}}') + assert restore_bun.is_project(doc) is False + + def test_package_manager_yarn_does_not_match(self, restore_bun: RestoreBunDependencies) -> None: + content = '{"name": "test", "packageManager": "yarn@4.0.0"}' + doc = Document('package.json', content) + assert restore_bun.is_project(doc) is False + + def test_invalid_json_content_does_not_match(self, restore_bun: RestoreBunDependencies) -> None: + doc = Document('package.json', 'not valid json') + assert restore_bun.is_project(doc) is False + + +class TestTryRestoreDependencies: + def test_existing_bun_lock_returned_directly(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None: + bun_lock_content = '{"lockfileVersion": 1, "packages": {"package": ["package@1.0.0", "", {}, ""]}}\n' + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'bun.lock').write_text(bun_lock_content) + + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + result = restore_bun.try_restore_dependencies(doc) + + assert result is not None + assert BUN_LOCK_FILE_NAME in result.path + assert result.content == bun_lock_content + + def test_get_lock_file_name(self, restore_bun: RestoreBunDependencies) -> None: + assert restore_bun.get_lock_file_name() == BUN_LOCK_FILE_NAME + + def test_get_commands_returns_bun_install(self, restore_bun: RestoreBunDependencies) -> None: + commands = restore_bun.get_commands('/path/to/package.json') + assert commands == [['bun', 'install', '--ignore-scripts']] + + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestParseBunVersion: + def test_parses_full_semver(self) -> None: + assert _parse_bun_version('1.2.3') == (1, 2) + + def test_parses_with_surrounding_whitespace(self) -> None: + assert _parse_bun_version(' 1.2.0\n') == (1, 2) + + def test_none_input_returns_none(self) -> None: + assert _parse_bun_version(None) is None + + def test_non_version_string_returns_none(self) -> None: + assert _parse_bun_version('not-a-version') is None + + +class TestBunVersionGate: + def test_supported_version_proceeds_to_restore(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None: + content = '{"name": "test", "packageManager": "bun@1.2.0"}' + (tmp_path / 'package.json').write_text(content) + doc = Document(str(tmp_path / 'package.json'), content, absolute_path=str(tmp_path / 'package.json')) + + with ( + patch(f'{_BUN_MODULE}.shell', return_value='1.2.5'), + patch.object( + restore_bun.__class__.__bases__[0], 'try_restore_dependencies', return_value=None + ) as mock_super, + ): + restore_bun.try_restore_dependencies(doc) + mock_super.assert_called_once_with(doc) + + def test_old_version_skips_restore(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None: + content = '{"name": "test", "packageManager": "bun@1.1.0"}' + (tmp_path / 'package.json').write_text(content) + doc = Document(str(tmp_path / 'package.json'), content, absolute_path=str(tmp_path / 'package.json')) + + with ( + patch(f'{_BUN_MODULE}.shell', return_value='1.1.38'), + patch.object(restore_bun.__class__.__bases__[0], 'try_restore_dependencies') as mock_super, + ): + result = restore_bun.try_restore_dependencies(doc) + assert result is None + mock_super.assert_not_called() + + def test_missing_bun_skips_restore(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None: + content = '{"name": "test", "packageManager": "bun@1.2.0"}' + (tmp_path / 'package.json').write_text(content) + doc = Document(str(tmp_path / 'package.json'), content, absolute_path=str(tmp_path / 'package.json')) + + with ( + patch(f'{_BUN_MODULE}.shell', return_value=None), + patch.object(restore_bun.__class__.__bases__[0], 'try_restore_dependencies') as mock_super, + ): + result = restore_bun.try_restore_dependencies(doc) + assert result is None + mock_super.assert_not_called() + + def test_existing_lockfile_skips_version_check(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'bun.lock').write_text('{"lockfileVersion": 1}\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + + with patch(f'{_BUN_MODULE}.shell') as mock_shell: + result = restore_bun.try_restore_dependencies(doc) + assert result is not None + mock_shell.assert_not_called() + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_bun: RestoreBunDependencies, tmp_path: Path + ) -> None: + # bun: no pre-existing bun.lock but package.json indicates bun (supported version installed) + content = '{"name": "test", "packageManager": "bun@1.2.0"}' + (tmp_path / 'package.json').write_text(content) + doc = Document(str(tmp_path / 'package.json'), content, absolute_path=str(tmp_path / 'package.json')) + lock_path = tmp_path / BUN_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('{"lockfileVersion": 1}\n') + return 'output' + + with ( + patch(f'{_BUN_MODULE}.shell', return_value='1.2.5'), + patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect), + ): + result = restore_bun.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{BUN_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None: + lock_content = '{"lockfileVersion": 1, "packages": {"pkg": ["pkg@1.0.0", "", {}, ""]}}\n' + (tmp_path / 'package.json').write_text('{"name": "test"}') + lock_path = tmp_path / BUN_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + + result = restore_bun.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {BUN_LOCK_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py index c418b659..95f94da0 100644 --- a/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py +++ b/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py @@ -49,6 +49,15 @@ def test_package_json_with_pnpm_lock_does_not_match( doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) assert restore_npm.is_project(doc) is False + def test_package_json_with_bun_lock_does_not_match( + self, restore_npm: RestoreNpmDependencies, tmp_path: Path + ) -> None: + """Bun projects are handled by RestoreBunDependencies — NPM should not claim them.""" + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'bun.lock').write_text('{"lockfileVersion": 1}\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_npm.is_project(doc) is False + def test_tsconfig_json_does_not_match(self, restore_npm: RestoreNpmDependencies) -> None: doc = Document('tsconfig.json', '{}') assert restore_npm.is_project(doc) is False From 7f44bc4b0651e4984a4c605d9e19c20e33c7f955 Mon Sep 17 00:00:00 2001 From: omer-roth Date: Sun, 5 Jul 2026 11:52:12 +0300 Subject: [PATCH 089/123] CM-68136 added package cooldown (#483) --- .github/dependabot.yml | 4 ++++ poetry.toml | 2 ++ 2 files changed, 6 insertions(+) create mode 100644 poetry.toml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0b845d3b..e46640bc 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,8 +4,12 @@ updates: directory: "/" schedule: interval: "monthly" + cooldown: + default-days: 10 - package-ecosystem: "pip" directory: "/" schedule: interval: "monthly" + cooldown: + default-days: 10 diff --git a/poetry.toml b/poetry.toml new file mode 100644 index 00000000..9f0eee61 --- /dev/null +++ b/poetry.toml @@ -0,0 +1,2 @@ +[solver] +min-release-age = 7 # Requires Poetry >= 2.4.0; silently ignored by older versions From ac3b09e4d6fd128e259617194c9fd6a7a18a8034 Mon Sep 17 00:00:00 2001 From: omer-roth Date: Mon, 6 Jul 2026 11:10:22 +0300 Subject: [PATCH 090/123] CM-68185 update workflow cache settings (#489) --- .github/workflows/build_executable.yml | 3 ++- CODEOWNERS | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index d3ebb83e..31127510 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -62,6 +62,7 @@ jobs: echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV - name: Set up Python 3.13 + id: setup-python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.13' @@ -71,7 +72,7 @@ jobs: uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.local - key: poetry-${{ matrix.os }}-2 # increment to reset cache + key: poetry-${{ matrix.os }}-${{ steps.setup-python.outputs.python-version }}-2 # increment to reset cache - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' diff --git a/CODEOWNERS b/CODEOWNERS index e59df91b..9a3abb16 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1 +1 @@ -* @avishaiamiel @omerr-cycode +* @avishaiamiel @omer-roth From a11ea08e4280fc591fa539514bd363ca8a2eaea7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:07:41 +0000 Subject: [PATCH 091/123] Bump ruff from 0.15.15 to 0.15.20 (#487) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 40 ++++++++++++++++++++-------------------- pyproject.toml | 2 +- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/poetry.lock b/poetry.lock index 0b87b802..cf6fa0dc 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1683,30 +1683,30 @@ files = [ [[package]] name = "ruff" -version = "0.15.15" +version = "0.15.20" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b"}, - {file = "ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e"}, - {file = "ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530"}, - {file = "ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4"}, - {file = "ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f"}, - {file = "ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622"}, - {file = "ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45"}, - {file = "ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627"}, - {file = "ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4"}, - {file = "ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c"}, - {file = "ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd"}, - {file = "ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f"}, - {file = "ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb"}, - {file = "ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a"}, - {file = "ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9"}, - {file = "ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4"}, - {file = "ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7"}, - {file = "ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6"}, + {file = "ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078"}, + {file = "ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b"}, + {file = "ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487"}, + {file = "ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3"}, + {file = "ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053"}, + {file = "ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4"}, + {file = "ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460"}, + {file = "ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21"}, + {file = "ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415"}, + {file = "ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca"}, + {file = "ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566"}, ] [[package]] @@ -2018,4 +2018,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "a347b4566b5612c753acadaef737ad7ca594be3402e0118c94bb539ed2062b06" +content-hash = "6e4f71b3a516dae60c71976f1c83fcb826b2225a9ddde328e9f80434a002c08e" diff --git a/pyproject.toml b/pyproject.toml index bfb34e90..1a510ac5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,7 @@ pyinstaller = {version=">=6.20.0,<7.0.0", python=">=3.9,<3.15"} dunamai = ">=1.26.1,<1.27.0" [tool.poetry.group.dev.dependencies] -ruff = "0.15.15" +ruff = "0.15.20" [tool.pytest.ini_options] log_cli = true From 4c6e81cadef2c99da9410154d23f943615bbd795 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:17:46 +0000 Subject: [PATCH 092/123] Bump actions/setup-python from 6.2.0 to 6.3.0 (#480) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build_executable.yml | 2 +- .github/workflows/docker-image.yml | 2 +- .github/workflows/pre_release.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/ruff.yml | 2 +- .github/workflows/tests.yml | 2 +- .github/workflows/tests_full.yml | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 31127510..dd579290 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -63,7 +63,7 @@ jobs: - name: Set up Python 3.13 id: setup-python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.13' diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 412e38b9..fe5ae20e 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -31,7 +31,7 @@ jobs: git checkout ${{ steps.latest_tag.outputs.LATEST_TAG }} - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.9' diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 0a36cea1..9f8b99f5 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -33,7 +33,7 @@ jobs: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.9' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d21a8f8d..f9ac7cb2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,7 @@ jobs: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.9' diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 2f0e1253..2eb5ecb5 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -24,7 +24,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: 3.9 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 517f10d1..b5ac4874 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,7 +26,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.9' diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index f2cf8d9e..d3ccc364 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -41,7 +41,7 @@ jobs: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: ${{ matrix.python-version }} From ce210dfe40c15362fe01bfb03e4ad3062bb8a737 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:25:10 +0000 Subject: [PATCH 093/123] Bump actions/cache from 5.0.5 to 6.1.0 (#479) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build_executable.yml | 2 +- .github/workflows/docker-image.yml | 2 +- .github/workflows/pre_release.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/ruff.yml | 2 +- .github/workflows/tests.yml | 2 +- .github/workflows/tests_full.yml | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index dd579290..41523a1a 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -69,7 +69,7 @@ jobs: - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.local key: poetry-${{ matrix.os }}-${{ steps.setup-python.outputs.python-version }}-2 # increment to reset cache diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index fe5ae20e..95f4f3a9 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -37,7 +37,7 @@ jobs: - name: Load cached Poetry setup id: cached_poetry - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 9f8b99f5..837fe0b8 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -39,7 +39,7 @@ jobs: - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f9ac7cb2..d2ddc2c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,7 +38,7 @@ jobs: - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 2eb5ecb5..6272e060 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -30,7 +30,7 @@ jobs: - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b5ac4874..a4019536 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -32,7 +32,7 @@ jobs: - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index d3ccc364..4a6a1946 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -47,7 +47,7 @@ jobs: - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.local key: poetry-${{ matrix.os }}-${{ matrix.python-version }}-3 # increment to reset cache From 9e3da7108ebf2c9b684ae8af31e26d494f01b004 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:33:04 +0300 Subject: [PATCH 094/123] Bump docker/setup-qemu-action from 4.0.0 to 4.1.0 (#484) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-image.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 95f4f3a9..a692d4e1 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -61,7 +61,7 @@ jobs: echo "CLI_VERSION=$(poetry version --short)" >> $GITHUB_OUTPUT - name: Set up QEMU - uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 + uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 From 6c8a96db15a72649a89b34e0f55948a5554dafe1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:35:46 +0000 Subject: [PATCH 095/123] Bump mcp from 1.27.2 to 1.28.1 (#486) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 25 +++++++++++++++++-------- pyproject.toml | 2 +- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/poetry.lock b/poetry.lock index cf6fa0dc..1616e71a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -837,15 +837,15 @@ tests = ["pytest", "simplejson"] [[package]] name = "mcp" -version = "1.27.2" +version = "1.28.1" description = "Model Context Protocol SDK" optional = false python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "mcp-1.27.2-py3-none-any.whl", hash = "sha256:d6ff5160c6ca65d93013626efb3fc249de683c30b2d8570755ceddd490344de5"}, - {file = "mcp-1.27.2.tar.gz", hash = "sha256:8e02db104096d1c25b28e64bde29a5c32b31bc241710213e12fd4d84985bdfef"}, + {file = "mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df"}, + {file = "mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683"}, ] [package.dependencies] @@ -853,13 +853,22 @@ anyio = ">=4.5" httpx = ">=0.27.1,<1.0.0" httpx-sse = ">=0.4" jsonschema = ">=4.20.0" -pydantic = ">=2.11.0,<3.0.0" +pydantic = [ + {version = ">=2.11.0,<3.0.0", markers = "python_version < \"3.14\""}, + {version = ">=2.12.0,<3.0.0", markers = "python_version >= \"3.14\""}, +] pydantic-settings = ">=2.5.2" pyjwt = {version = ">=2.10.1", extras = ["crypto"]} python-multipart = ">=0.0.9" -pywin32 = {version = ">=310", markers = "sys_platform == \"win32\""} +pywin32 = [ + {version = ">=310", markers = "sys_platform == \"win32\" and python_version < \"3.14\""}, + {version = ">=311", markers = "sys_platform == \"win32\" and python_version >= \"3.14\""}, +] sse-starlette = ">=1.6.1" -starlette = ">=0.27" +starlette = [ + {version = ">=0.27", markers = "python_version < \"3.14\""}, + {version = ">=0.48.0", markers = "python_version >= \"3.14\""}, +] typing-extensions = ">=4.9.0" typing-inspection = ">=0.4.1" uvicorn = {version = ">=0.31.1", markers = "sys_platform != \"emscripten\""} @@ -1355,7 +1364,7 @@ description = "Python for Window Extensions" optional = false python-versions = "*" groups = ["main"] -markers = "python_version >= \"3.10\" and sys_platform == \"win32\"" +markers = "sys_platform == \"win32\" and python_version >= \"3.10\"" files = [ {file = "pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3"}, {file = "pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b"}, @@ -2018,4 +2027,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "6e4f71b3a516dae60c71976f1c83fcb826b2225a9ddde328e9f80434a002c08e" +content-hash = "6645eb474429e1c04c9cfc2225af5c227380c601c2b4ed1e309ff34bade63dd8" diff --git a/pyproject.toml b/pyproject.toml index 1a510ac5..afb4ddc3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ rich = ">=13.9.4, <14" patch-ng = "1.19.1" typer = "^0.15.3" tenacity = ">=9.0.0,<9.1.0" -mcp = { version = ">=1.9.3,<2.0.0", markers = "python_version >= '3.10'" } +mcp = { version = ">=1.28.1,<2.0.0", markers = "python_version >= '3.10'" } pydantic = ">=2.11.5,<3.0.0" pathvalidate = ">=3.3.1,<4.0.0" tomli-w = ">=1.0.0,<2.0.0" From a4b4957c9150528110dd0700055cd799e7fc37b4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:39:14 +0300 Subject: [PATCH 096/123] Bump mock from 4.0.3 to 5.2.0 (#485) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 10 +++++----- pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/poetry.lock b/poetry.lock index 1616e71a..3a39aaa5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -892,20 +892,20 @@ files = [ [[package]] name = "mock" -version = "4.0.3" +version = "5.2.0" description = "Rolling backport of unittest.mock for all Pythons" optional = false python-versions = ">=3.6" groups = ["test"] files = [ - {file = "mock-4.0.3-py3-none-any.whl", hash = "sha256:122fcb64ee37cfad5b3f48d7a7d51875d7031aaf3d8be7c42e2bee25044eee62"}, - {file = "mock-4.0.3.tar.gz", hash = "sha256:7d3fbbde18228f4ff2f1f119a45cdffa458b4c0dee32eb4d2bb2f82554bac7bc"}, + {file = "mock-5.2.0-py3-none-any.whl", hash = "sha256:7ba87f72ca0e915175596069dbbcc7c75af7b5e9b9bc107ad6349ede0819982f"}, + {file = "mock-5.2.0.tar.gz", hash = "sha256:4e460e818629b4b173f32d08bf30d3af8123afbb8e04bb5707a1fd4799e503f0"}, ] [package.extras] build = ["blurb", "twine", "wheel"] docs = ["sphinx"] -test = ["pytest (<5.4)", "pytest-cov"] +test = ["pytest", "pytest-cov"] [[package]] name = "packaging" @@ -2027,4 +2027,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "6645eb474429e1c04c9cfc2225af5c227380c601c2b4ed1e309ff34bade63dd8" +content-hash = "09f70b525d7ba0c84e1a209e4ec1359d52a5e7901869c2b738abf5236f812594" diff --git a/pyproject.toml b/pyproject.toml index afb4ddc3..57e59c30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ tomli = {version = ">=2.0.0,<3.0.0", python = "<3.11"} anyio = ">=4.0.0, <4.13.0" [tool.poetry.group.test.dependencies] -mock = ">=4.0.3,<4.1.0" +mock = ">=5.2.0,<5.3.0" pytest = ">=7.3.1,<8.5.0" pytest-mock = ">=3.10.0,<3.11.0" coverage = ">=7.2.3,<7.11.0" From c7dcb13eb59f9dd577f37f41471f85265e5d772e Mon Sep 17 00:00:00 2001 From: Ilia Shkolyar <60312091+ilia-cy@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:44:36 +0300 Subject: [PATCH 097/123] CM-67391: Use S3 presigned upload for secret CLI scans (#476) Co-authored-by: Claude Opus 4.8 (1M context) --- cycode/cli/apps/scan/code_scanner.py | 11 ++- cycode/cli/apps/scan/commit_range_scanner.py | 7 +- cycode/cli/consts.py | 3 +- tests/cli/commands/scan/test_code_scanner.py | 77 ++++++++++++++++++- .../scan/test_commit_range_scanner.py | 49 ++++++++++++ .../cyclient/mocked_responses/scan_client.py | 48 +++++++++++- 6 files changed, 187 insertions(+), 8 deletions(-) create mode 100644 tests/cli/commands/scan/test_commit_range_scanner.py diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index dc3727e4..667138fa 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -279,7 +279,10 @@ def scan_documents( scan_batch_thread_func = _get_scan_documents_thread_func(ctx, is_git_diff, is_commit_range, scan_parameters) - if should_use_presigned_upload(scan_type): + # Presigned single-file upload is async-only; a --sync scan must stay on the batched inline path + # so it never builds one oversized zip to POST synchronously. + should_use_sync_flow = _should_use_sync_flow(ctx.info_name, scan_type, ctx.obj['sync']) + if should_use_presigned_upload(scan_type) and not should_use_sync_flow: errors, local_scan_results = _run_presigned_upload_scan( scan_batch_thread_func, scan_type, documents_to_scan, progress_bar, printer ) @@ -388,7 +391,11 @@ def _perform_scan( is_commit_range, on_upload_progress, ) - except requests.exceptions.RequestException: + except ( + requests.exceptions.RequestException, + custom_exceptions.RequestError, + custom_exceptions.SlowUploadConnectionError, + ): logger.warning('Direct upload to object storage failed. Falling back to upload via Cycode API. ') return _perform_scan_async( diff --git a/cycode/cli/apps/scan/commit_range_scanner.py b/cycode/cli/apps/scan/commit_range_scanner.py index 9691be6e..298af34c 100644 --- a/cycode/cli/apps/scan/commit_range_scanner.py +++ b/cycode/cli/apps/scan/commit_range_scanner.py @@ -19,6 +19,7 @@ print_local_scan_results, ) from cycode.cli.config import configuration_manager +from cycode.cli.exceptions import custom_exceptions from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception from cycode.cli.files_collector.commit_range_documents import ( collect_commit_range_diff_documents, @@ -162,7 +163,11 @@ def _scan_commit_range_documents( scan_parameters, timeout, ) - except requests.exceptions.RequestException: + except ( + requests.exceptions.RequestException, + custom_exceptions.RequestError, + custom_exceptions.SlowUploadConnectionError, + ): logger.warning('Direct upload to object storage failed. Falling back to upload via Cycode API. ') scan_result = _perform_commit_range_scan_async( cycode_client, diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index a134f3f4..37ef2298 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -222,12 +222,13 @@ FILE_MAX_SIZE_LIMIT_IN_BYTES = 5000000 PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES = 5 * 1024 * 1024 * 1024 # 5 GB (S3 presigned POST limit) -PRESIGNED_UPLOAD_SCAN_TYPES = {SAST_SCAN_TYPE} +PRESIGNED_UPLOAD_SCAN_TYPES = {SAST_SCAN_TYPE, SECRET_SCAN_TYPE} DEFAULT_ZIP_MAX_SIZE_LIMIT_IN_BYTES = 20 * 1024 * 1024 ZIP_MAX_SIZE_LIMIT_IN_BYTES = { SCA_SCAN_TYPE: 200 * 1024 * 1024, SAST_SCAN_TYPE: PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES, + SECRET_SCAN_TYPE: PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES, } # scan in batches diff --git a/tests/cli/commands/scan/test_code_scanner.py b/tests/cli/commands/scan/test_code_scanner.py index 8a9f60b7..8b4a30b3 100644 --- a/tests/cli/commands/scan/test_code_scanner.py +++ b/tests/cli/commands/scan/test_code_scanner.py @@ -2,8 +2,11 @@ from os.path import normpath from unittest.mock import MagicMock, Mock, patch +import pytest + from cycode.cli import consts -from cycode.cli.apps.scan.code_scanner import scan_disk_files +from cycode.cli.apps.scan.code_scanner import _perform_scan, scan_disk_files, scan_documents +from cycode.cli.exceptions import custom_exceptions from cycode.cli.files_collector.file_excluder import _is_file_relevant_for_sca_scan from cycode.cli.files_collector.path_documents import _generate_document from cycode.cli.models import Document @@ -162,3 +165,75 @@ def test_entrypoint_cycode_not_added_for_single_file( assert len(entrypoint_docs) == 0 # Verify only the original documents are present assert len(documents_passed) == len(mock_documents) + + +@pytest.mark.parametrize( + ('scan_type', 'command_scan_type', 'sync_option', 'expect_presigned'), + [ + # SAST keeps uploading directly to S3 via a presigned URL (regression guard for the new sync gate). + (consts.SAST_SCAN_TYPE, 'path', False, True), + # Async secret scans now upload as a single file directly to S3 via a presigned URL. + (consts.SECRET_SCAN_TYPE, 'path', False, True), + # A --sync secret scan must stay on the batched inline path and never build one giant zip. + (consts.SECRET_SCAN_TYPE, 'path', True, False), + ], +) +@patch('cycode.cli.apps.scan.code_scanner.print_local_scan_results') +@patch('cycode.cli.apps.scan.code_scanner.set_issue_detected_by_scan_results') +@patch('cycode.cli.apps.scan.code_scanner.try_set_aggregation_report_url_if_needed') +@patch('cycode.cli.apps.scan.code_scanner.run_parallel_batched_scan') +@patch('cycode.cli.apps.scan.code_scanner._run_presigned_upload_scan') +def test_scan_documents_routes_upload_by_scan_type_and_sync( + mock_presigned_upload: Mock, + mock_batched_scan: Mock, + mock_aggregation: Mock, + mock_set_issue: Mock, + mock_print: Mock, + scan_type: str, + command_scan_type: str, + sync_option: bool, + expect_presigned: bool, +) -> None: + mock_presigned_upload.return_value = ([], []) + mock_batched_scan.return_value = ([], []) + + mock_ctx = MagicMock() + mock_ctx.info_name = command_scan_type + mock_ctx.obj = { + 'scan_type': scan_type, + 'progress_bar': MagicMock(), + 'console_printer': MagicMock(), + 'client': MagicMock(), + 'severity_threshold': None, + 'sync': sync_option, + } + documents = [Document('/repo/file.py', 'content', is_git_diff_format=False)] + + scan_documents(mock_ctx, documents, {}) + + assert mock_presigned_upload.called is expect_presigned + assert mock_batched_scan.called is (not expect_presigned) + + +@patch('cycode.cli.apps.scan.code_scanner._perform_scan_async') +@patch('cycode.cli.apps.scan.code_scanner._perform_scan_v4_async') +def test_perform_scan_falls_back_to_api_when_presigned_upload_raises_wrapped_error( + mock_v4_async: Mock, mock_async: Mock +) -> None: + # RequestConnectionError is a CycodeError, not a requests.RequestException — the fallback must still catch it. + mock_v4_async.side_effect = custom_exceptions.RequestConnectionError + fallback_result = object() + mock_async.return_value = fallback_result + + result = _perform_scan( + cycode_client=MagicMock(), + zipped_documents=MagicMock(), + scan_type=consts.SAST_SCAN_TYPE, + is_git_diff=False, + is_commit_range=False, + scan_parameters={}, + ) + + assert result is fallback_result + mock_v4_async.assert_called_once() + mock_async.assert_called_once() diff --git a/tests/cli/commands/scan/test_commit_range_scanner.py b/tests/cli/commands/scan/test_commit_range_scanner.py new file mode 100644 index 00000000..a4a6c58b --- /dev/null +++ b/tests/cli/commands/scan/test_commit_range_scanner.py @@ -0,0 +1,49 @@ +from unittest.mock import MagicMock, Mock, patch + +from cycode.cli import consts +from cycode.cli.apps.scan.commit_range_scanner import _scan_commit_range_documents +from cycode.cli.exceptions import custom_exceptions +from cycode.cli.models import Document + + +@patch('cycode.cli.apps.scan.commit_range_scanner.report_scan_status') +@patch('cycode.cli.apps.scan.commit_range_scanner.handle_scan_exception') +@patch('cycode.cli.apps.scan.commit_range_scanner.print_local_scan_results') +@patch('cycode.cli.apps.scan.commit_range_scanner.set_issue_detected_by_scan_results') +@patch('cycode.cli.apps.scan.commit_range_scanner.create_local_scan_result') +@patch('cycode.cli.apps.scan.commit_range_scanner.enrich_scan_result_with_data_from_detection_rules') +@patch('cycode.cli.apps.scan.commit_range_scanner.zip_documents') +@patch('cycode.cli.apps.scan.commit_range_scanner._perform_commit_range_scan_async') +@patch('cycode.cli.apps.scan.commit_range_scanner._perform_commit_range_scan_v4_async') +def test_commit_range_scan_falls_back_to_api_when_presigned_upload_raises_wrapped_error( + mock_v4_async: Mock, + mock_async: Mock, + mock_zip: Mock, + mock_enrich: Mock, + mock_create_result: Mock, + mock_set_issue: Mock, + mock_print: Mock, + mock_handle_exception: Mock, + mock_report_status: Mock, +) -> None: + # SlowUploadConnectionError is a CycodeError, not a requests.RequestException — the presigned + # commit-range fallback must still catch it and retry via the Cycode API. + mock_v4_async.side_effect = custom_exceptions.SlowUploadConnectionError + fallback_result = MagicMock() + mock_async.return_value = fallback_result + + mock_ctx = MagicMock() + mock_ctx.info_name = 'commit_history' + mock_ctx.obj = { + 'client': MagicMock(), + 'scan_type': consts.SECRET_SCAN_TYPE, + 'severity_threshold': None, + 'progress_bar': MagicMock(), + } + documents = [Document('/repo/file.py', 'content', is_git_diff_format=False)] + + _scan_commit_range_documents(mock_ctx, documents, []) + + mock_v4_async.assert_called_once() + mock_async.assert_called_once() + mock_handle_exception.assert_not_called() diff --git a/tests/cyclient/mocked_responses/scan_client.py b/tests/cyclient/mocked_responses/scan_client.py index c37c1d8a..b2be7ab5 100644 --- a/tests/cyclient/mocked_responses/scan_client.py +++ b/tests/cyclient/mocked_responses/scan_client.py @@ -5,6 +5,7 @@ import responses +from cycode.cli.utils.scan_utils import should_use_presigned_upload from cycode.cyclient.scan_client import ScanClient from tests.conftest import MOCKED_RESPONSES_PATH @@ -128,6 +129,38 @@ def get_scan_configuration_response(url: str) -> responses.Response: return responses.Response(method=responses.GET, url=url, json=json_response, status=200) +_PRESIGNED_UPLOAD_URL = 'https://cycode-tests.s3.amazonaws.com/presigned-upload' + + +def get_upload_link_url(scan_type: str, scan_client: ScanClient) -> str: + api_url = scan_client.scan_cycode_client.api_url + async_scan_type = scan_client.scan_config.get_async_scan_type(scan_type) + service_url = f'{scan_client.get_scan_service_v4_url_path(scan_type)}/{async_scan_type}/upload-link' + return f'{api_url}/{service_url}' + + +def get_upload_link_response(url: str) -> responses.Response: + json_response = {'upload_id': str(uuid4()), 'url': _PRESIGNED_UPLOAD_URL, 'presigned_post_fields': {}} + return responses.Response(method=responses.GET, url=url, json=json_response, status=200) + + +def get_presigned_upload_response() -> responses.Response: + return responses.Response(method=responses.POST, url=_PRESIGNED_UPLOAD_URL, status=204) + + +def get_scan_from_upload_id_url(scan_type: str, scan_client: ScanClient) -> str: + api_url = scan_client.scan_cycode_client.api_url + async_scan_type = scan_client.scan_config.get_async_scan_type(scan_type) + service_url = f'{scan_client.get_scan_service_v4_url_path(scan_type)}/{async_scan_type}/repository' + return f'{api_url}/{service_url}' + + +def get_scan_from_upload_id_response(url: str, scan_id: Optional[UUID] = None) -> responses.Response: + if not scan_id: + scan_id = uuid4() + return responses.Response(method=responses.POST, url=url, json={'scan_id': str(scan_id)}, status=200) + + def mock_remote_config_responses(responses_module: responses, scan_type: str, scan_client: ScanClient) -> None: responses_module.add(get_scan_configuration_response(get_scan_configuration_url(scan_type, scan_client))) @@ -136,9 +169,18 @@ def mock_scan_async_responses( responses_module: responses, scan_type: str, scan_client: ScanClient, scan_id: UUID, zip_content_path: Path ) -> None: mock_remote_config_responses(responses_module, scan_type, scan_client) - responses_module.add( - get_zipped_file_scan_async_response(get_zipped_file_scan_async_url(scan_type, scan_client), scan_id) - ) + + if should_use_presigned_upload(scan_type): + responses_module.add(get_upload_link_response(get_upload_link_url(scan_type, scan_client))) + responses_module.add(get_presigned_upload_response()) + responses_module.add( + get_scan_from_upload_id_response(get_scan_from_upload_id_url(scan_type, scan_client), scan_id) + ) + else: + responses_module.add( + get_zipped_file_scan_async_response(get_zipped_file_scan_async_url(scan_type, scan_client), scan_id) + ) + responses_module.add(get_scan_details_response(get_scan_details_url(scan_type, scan_id, scan_client), scan_id)) responses_module.add(get_detection_rules_response(get_detection_rules_url(scan_client))) responses_module.add(get_scan_detections_response(get_scan_detections_url(scan_client), scan_id, zip_content_path)) From 2ce15d4befcbcd16fe0388b036aa2d09ae3f2d46 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 08:50:09 +0300 Subject: [PATCH 098/123] Bump snok/install-poetry from 1.4.1 to 1.4.2 (#469) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build_executable.yml | 2 +- .github/workflows/docker-image.yml | 2 +- .github/workflows/pre_release.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/ruff.yml | 2 +- .github/workflows/tests.yml | 2 +- .github/workflows/tests_full.yml | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 41523a1a..a9e5eddc 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -76,7 +76,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 + uses: snok/install-poetry@a783c322200f0519c7926aa6faa857c4e23e9263 # v1.4.2 with: version: 2.2.1 diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index a692d4e1..1266dcbf 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -44,7 +44,7 @@ jobs: - name: Setup Poetry if: steps.cached_poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 + uses: snok/install-poetry@a783c322200f0519c7926aa6faa857c4e23e9263 # v1.4.2 with: version: 2.2.1 diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 837fe0b8..2dee645b 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -46,7 +46,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 + uses: snok/install-poetry@a783c322200f0519c7926aa6faa857c4e23e9263 # v1.4.2 with: version: 2.2.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d2ddc2c7..1bc04888 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,7 +45,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 + uses: snok/install-poetry@a783c322200f0519c7926aa6faa857c4e23e9263 # v1.4.2 with: version: 2.2.1 diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 6272e060..2c1cd8c6 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -37,7 +37,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 + uses: snok/install-poetry@a783c322200f0519c7926aa6faa857c4e23e9263 # v1.4.2 with: version: 2.2.1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a4019536..a9f2bc1c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -39,7 +39,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 + uses: snok/install-poetry@a783c322200f0519c7926aa6faa857c4e23e9263 # v1.4.2 with: version: 2.2.1 diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index 4a6a1946..b4eb3e48 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -54,7 +54,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 + uses: snok/install-poetry@a783c322200f0519c7926aa6faa857c4e23e9263 # v1.4.2 with: version: 2.2.1 From 2615e0259676f4c6c456675d32a8ad17d7491204 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:40:06 +0300 Subject: [PATCH 099/123] CM-68232: read a machine-wide AI Guardrails policy path (#490) Co-authored-by: Claude Opus 4.8 (1M context) --- cycode/cli/apps/ai_guardrails/scan/policy.py | 24 +++++++-- .../ai_guardrails/scan/test_policy.py | 53 +++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/scan/policy.py b/cycode/cli/apps/ai_guardrails/scan/policy.py index f40d77c0..96c45574 100644 --- a/cycode/cli/apps/ai_guardrails/scan/policy.py +++ b/cycode/cli/apps/ai_guardrails/scan/policy.py @@ -3,11 +3,14 @@ Policies are loaded and merged in order (later overrides earlier): 1. Built-in defaults (consts.DEFAULT_POLICY) -2. User-level config (~/.cycode/ai-guardrails.yaml) -3. Repo-level config (/.cycode/ai-guardrails.yaml) +2. Machine-wide config (admin/MDM-provisioned; see get_machine_policy_path) +3. User-level config (~/.cycode/ai-guardrails.yaml) +4. Repo-level config (/.cycode/ai-guardrails.yaml) """ import json +import os +import sys from pathlib import Path from typing import Any, Optional @@ -16,6 +19,16 @@ from cycode.cli.apps.ai_guardrails.scan.consts import DEFAULT_POLICY, POLICY_FILE_NAME +def get_machine_policy_path() -> Path: + """Machine-wide (admin/MDM-provisioned) policy path, by platform.""" + if sys.platform == 'darwin': + return Path('/Library/Application Support/Cycode') / POLICY_FILE_NAME + if sys.platform == 'win32': + program_data = os.environ.get('PROGRAMDATA', 'C:\\ProgramData') + return Path(program_data) / 'Cycode' / POLICY_FILE_NAME + return Path('/etc/cycode') / POLICY_FILE_NAME + + def deep_merge(base: dict, override: dict) -> dict: """Deep merge two dictionaries, with override taking precedence.""" result = base.copy() @@ -61,7 +74,7 @@ def load_policy(workspace_root: Optional[str] = None) -> dict: """ Load policy by merging configs in order of precedence. - Merge order: defaults <- user config <- repo config + Merge order: defaults <- machine <- user config <- repo config Args: workspace_root: Workspace root path for repo-level config lookup. @@ -69,6 +82,11 @@ def load_policy(workspace_root: Optional[str] = None) -> dict: # Start with defaults policy = load_defaults() + # Merge machine-wide config (admin/MDM-provisioned) - overrides defaults, below user/repo. + machine_config = load_yaml_file(get_machine_policy_path()) + if machine_config: + policy = deep_merge(policy, machine_config) + # Merge user-level config (if exists) user_policy_path = Path.home() / '.cycode' / POLICY_FILE_NAME user_config = load_yaml_file(user_policy_path) diff --git a/tests/cli/commands/ai_guardrails/scan/test_policy.py b/tests/cli/commands/ai_guardrails/scan/test_policy.py index bbe884b0..a378ad1c 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_policy.py +++ b/tests/cli/commands/ai_guardrails/scan/test_policy.py @@ -4,10 +4,12 @@ from typing import Optional from unittest.mock import MagicMock, patch +import pytest from pyfakefs.fake_filesystem import FakeFilesystem from cycode.cli.apps.ai_guardrails.scan.policy import ( deep_merge, + get_machine_policy_path, get_policy_value, load_defaults, load_policy, @@ -197,3 +199,54 @@ def test_load_policy_none_workspace_root(mock_load: MagicMock) -> None: # Should only load defaults (no repo config) assert 'mode' in policy + + +def test_get_machine_policy_path_per_os(monkeypatch: pytest.MonkeyPatch) -> None: + """Test the per-OS machine policy locations.""" + with patch('sys.platform', 'darwin'): + assert get_machine_policy_path() == Path('/Library/Application Support/Cycode') / 'ai-guardrails.yaml' + + with patch('sys.platform', 'linux'): + assert get_machine_policy_path() == Path('/etc/cycode') / 'ai-guardrails.yaml' + + with patch('sys.platform', 'win32'): + monkeypatch.setenv('PROGRAMDATA', 'C:\\ProgramData') + assert get_machine_policy_path() == Path('C:\\ProgramData') / 'Cycode' / 'ai-guardrails.yaml' + + +@patch('pathlib.Path.home') +@patch('cycode.cli.apps.ai_guardrails.scan.policy.get_machine_policy_path') +def test_load_policy_with_machine_config( + mock_machine_path: MagicMock, mock_home: MagicMock, fs: FakeFilesystem +) -> None: + """Test that the machine-wide config overrides defaults.""" + mock_home.return_value = Path('/home/testuser') + machine_path = Path('/machine/ai-guardrails.yaml') + mock_machine_path.return_value = machine_path + fs.create_file(str(machine_path), contents='mode: warn\n') + + policy = load_policy() + + # Machine config overrides the built-in default (block); other keys inherit from defaults. + assert policy['mode'] == 'warn' + assert policy['fail_open'] is True + + +@patch('pathlib.Path.home') +@patch('cycode.cli.apps.ai_guardrails.scan.policy.get_machine_policy_path') +def test_load_policy_precedence_defaults_machine_user_repo( + mock_machine_path: MagicMock, mock_home: MagicMock, fs: FakeFilesystem +) -> None: + """Test full precedence: defaults < machine < user < repo.""" + mock_home.return_value = Path('/home/testuser') + machine_path = Path('/machine/ai-guardrails.yaml') + mock_machine_path.return_value = machine_path + fs.create_file(str(machine_path), contents='mode: warn\nfail_open: false\n') + fs.create_file('/home/testuser/.cycode/ai-guardrails.yaml', contents='fail_open: true\n') + fs.create_file('/fake/repo/.cycode/ai-guardrails.yaml', contents='mode: block\n') + + policy = load_policy('/fake/repo') + + # repo overrides machine's mode; user overrides machine's fail_open. + assert policy['mode'] == 'block' + assert policy['fail_open'] is True From 23158f506dccfb9a30bac58830887b4aac7ff7a2 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:46:30 +0300 Subject: [PATCH 100/123] CM-68330: fix hook payload handling on Cursor for Windows (#491) Co-authored-by: Claude Opus 4.8 (1M context) --- .../apps/ai_guardrails/scan/scan_command.py | 8 ++--- cycode/cli/apps/ai_guardrails/scan/utils.py | 17 ++++++++++ .../ai_guardrails/session_start_command.py | 4 +-- .../ai_guardrails/scan/test_scan_command.py | 24 ++++++++++++++ .../commands/ai_guardrails/scan/test_utils.py | 31 +++++++++++++++++++ 5 files changed, 78 insertions(+), 6 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/scan/scan_command.py b/cycode/cli/apps/ai_guardrails/scan/scan_command.py index bd31d33e..e6f8b977 100644 --- a/cycode/cli/apps/ai_guardrails/scan/scan_command.py +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -7,7 +7,6 @@ ``HookDecision``); ``IDE.build_hook_response`` is the per-IDE translation step. """ -import sys from typing import Annotated, Optional, Union import click @@ -18,7 +17,7 @@ from cycode.cli.apps.ai_guardrails.scan.handlers import get_handler_for_event from cycode.cli.apps.ai_guardrails.scan.policy import load_policy from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType -from cycode.cli.apps.ai_guardrails.scan.utils import output_json, safe_json_parse +from cycode.cli.apps.ai_guardrails.scan.utils import output_json, read_stdin_text, safe_json_parse from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError from cycode.cli.utils.get_api_client import get_ai_security_manager_client, get_scan_cycode_client from cycode.logger import get_logger @@ -91,7 +90,7 @@ def scan_command( """ ide_integration = get_ide(ide) - stdin_data = sys.stdin.read().strip() + stdin_data = read_stdin_text().strip() payload = safe_json_parse(stdin_data) if not payload: @@ -113,7 +112,8 @@ def scan_command( event_name = unified_payload.event_name logger.debug('Processing AI guardrails hook', extra={'event_name': event_name, 'ide': ide_integration.name}) - workspace_roots = payload.get('workspace_roots', ['.']) + # `or` (not a .get default) - Cursor sends workspace_roots=[] when no folder is open. + workspace_roots = payload.get('workspace_roots') or ['.'] policy = load_policy(workspace_roots[0]) try: diff --git a/cycode/cli/apps/ai_guardrails/scan/utils.py b/cycode/cli/apps/ai_guardrails/scan/utils.py index e14c1c02..6223c925 100644 --- a/cycode/cli/apps/ai_guardrails/scan/utils.py +++ b/cycode/cli/apps/ai_guardrails/scan/utils.py @@ -6,11 +6,28 @@ import json import os +import sys from pathlib import Path from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value +def read_stdin_text() -> str: + """Read the hook payload from stdin as UTF-8 text. + + Reads bytes and decodes with utf-8-sig: hook payloads are UTF-8 JSON, but on Windows + Python decodes piped stdin with the ANSI code page (mojibake for non-ASCII prompts), + and Cursor on Windows prefixes the payload with a UTF-8 BOM - the -sig codec strips it. + """ + buffer = getattr(sys.stdin, 'buffer', None) + if buffer is not None: + return buffer.read().decode('utf-8-sig', errors='replace') + # No .buffer (tests mocking sys.stdin with StringIO, exotic streams) - text-mode fallback. + # lstrip the BOM here too: an already-decoded stream leaves it as U+FEFF, which json.loads + # rejects (and .strip() doesn't remove - it is not whitespace). + return sys.stdin.read().lstrip('\ufeff') + + def safe_json_parse(s: str) -> dict: """Parse JSON string, returning empty dict on failure.""" try: diff --git a/cycode/cli/apps/ai_guardrails/session_start_command.py b/cycode/cli/apps/ai_guardrails/session_start_command.py index 5d491f10..c7164c79 100644 --- a/cycode/cli/apps/ai_guardrails/session_start_command.py +++ b/cycode/cli/apps/ai_guardrails/session_start_command.py @@ -7,7 +7,7 @@ from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, get_ide from cycode.cli.apps.ai_guardrails.ides.base import IDE -from cycode.cli.apps.ai_guardrails.scan.utils import safe_json_parse +from cycode.cli.apps.ai_guardrails.scan.utils import read_stdin_text, safe_json_parse from cycode.cli.apps.auth.auth_common import get_authorization_info from cycode.cli.apps.auth.auth_manager import AuthManager from cycode.cli.exceptions.handle_auth_errors import handle_auth_exception @@ -78,7 +78,7 @@ def session_start_command( logger.debug('No stdin payload (TTY), skipping session initialization') return - stdin_data = sys.stdin.read().strip() + stdin_data = read_stdin_text().strip() payload = safe_json_parse(stdin_data) if not payload: logger.debug('Empty or invalid stdin payload, skipping session initialization') diff --git a/tests/cli/commands/ai_guardrails/scan/test_scan_command.py b/tests/cli/commands/ai_guardrails/scan/test_scan_command.py index 35f7e4fa..b7d7734a 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_scan_command.py +++ b/tests/cli/commands/ai_guardrails/scan/test_scan_command.py @@ -141,6 +141,30 @@ def test_claude_code_payload_with_claude_code_ide( mock_scan_command_deps['get_handler'].assert_called_once() mock_handler.assert_called_once() + def test_empty_workspace_roots_falls_back_to_cwd( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Cursor sends workspace_roots=[] when no folder is open - must not crash.""" + payload = { + 'hook_event_name': 'beforeSubmitPrompt', + 'conversation_id': 'conv-123', + 'prompt': 'test', + 'workspace_roots': [], + } + mocker.patch('sys.stdin', StringIO(json.dumps(payload))) + + mock_scan_command_deps['load_policy'].return_value = {'fail_open': True} + mock_handler = MagicMock(return_value=HookDecision.allow(AiHookEventType.PROMPT)) + mock_scan_command_deps['get_handler'].return_value = mock_handler + + scan_command(mock_ctx, ide='cursor') + + mock_scan_command_deps['load_policy'].assert_called_once_with('.') + mock_handler.assert_called_once() + class TestDefaultIdeParameterViaCli: """Tests that verify default IDE parameter works correctly via CLI invocation.""" diff --git a/tests/cli/commands/ai_guardrails/scan/test_utils.py b/tests/cli/commands/ai_guardrails/scan/test_utils.py index ce84c609..46ae195d 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_utils.py +++ b/tests/cli/commands/ai_guardrails/scan/test_utils.py @@ -1,12 +1,43 @@ """Tests for AI guardrails utility functions.""" +import io +from unittest.mock import patch + from cycode.cli.apps.ai_guardrails.scan.utils import ( is_denied_path, matches_glob, normalize_path, + read_stdin_text, + safe_json_parse, ) +def test_read_stdin_text_decodes_bom_and_utf8() -> None: + """utf-8-sig byte decode strips the BOM Cursor sends on Windows and avoids ANSI mojibake.""" + raw = '\ufeff{"prompt": "café"}'.encode() # utf-8 with BOM, multi-byte non-ASCII content + fake_stdin = io.TextIOWrapper(io.BytesIO(raw), encoding='utf-8') + + with patch('sys.stdin', fake_stdin): + text = read_stdin_text() + + assert safe_json_parse(text)['prompt'] == 'café' + + +def test_read_stdin_text_falls_back_without_buffer() -> None: + """Streams without .buffer (e.g. StringIO in tests) fall back to a text-mode read, BOM-stripped.""" + with patch('sys.stdin', io.StringIO('{"a": 1}')): + assert read_stdin_text() == '{"a": 1}' + + with patch('sys.stdin', io.StringIO('\ufeff{"a": 1}')): + assert read_stdin_text() == '{"a": 1}' + + +def test_safe_json_parse_invalid_and_empty() -> None: + """Invalid JSON and empty inputs return an empty dict.""" + assert safe_json_parse('not valid json {') == {} + assert safe_json_parse('') == {} + + def test_normalize_path_rejects_escape() -> None: """Test that paths attempting to escape are rejected.""" path = '../../../etc/passwd' From 1342addaab9ae33c29f989aae0af3e6ab6922630 Mon Sep 17 00:00:00 2001 From: omer-roth Date: Thu, 9 Jul 2026 14:44:11 +0300 Subject: [PATCH 101/123] CM-68462 added support for gradlew (#492) --- .../sca/maven/restore_gradle_dependencies.py | 22 +++++-- .../maven/test_restore_gradle_dependencies.py | 58 +++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py index d2687bf6..ea91a8de 100644 --- a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py @@ -1,4 +1,5 @@ import os +import platform import re from typing import Optional @@ -12,19 +13,32 @@ BUILD_GRADLE_FILE_NAME = 'build.gradle' BUILD_GRADLE_KTS_FILE_NAME = 'build.gradle.kts' BUILD_GRADLE_DEP_TREE_FILE_NAME = 'gradle-dependencies-generated.txt' -BUILD_GRADLE_ALL_PROJECTS_COMMAND = ['gradle', 'projects'] ALL_PROJECTS_REGEX = r"[+-]{3} Project '(.*?)'" +GRADLE_EXECUTABLE = 'gradle' +GRADLEW_FILE_NAME = 'gradlew' +GRADLEW_BAT_FILE_NAME = 'gradlew.bat' + class RestoreGradleDependencies(BaseRestoreDependencies): def __init__( self, ctx: typer.Context, is_git_diff: bool, command_timeout: int, projects: Optional[set[str]] = None ) -> None: super().__init__(ctx, is_git_diff, command_timeout, create_output_file_manually=True) + self.gradle_executable = self._resolve_gradle_executable() if projects is None: projects = set() self.projects = self.get_all_projects() if self.is_gradle_sub_projects() else projects + def _resolve_gradle_executable(self) -> str: + scan_root = get_path_from_context(self.ctx) + if scan_root: + wrapper_name = GRADLEW_BAT_FILE_NAME if platform.system() == 'Windows' else GRADLEW_FILE_NAME + wrapper_path = os.path.join(scan_root, wrapper_name) + if os.path.isfile(wrapper_path): + return wrapper_path + return GRADLE_EXECUTABLE + def is_gradle_sub_projects(self) -> bool: return self.ctx.obj.get('gradle_all_sub_projects', False) @@ -35,7 +49,7 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: return ( self.get_commands_for_sub_projects(manifest_file_path) if self.is_gradle_sub_projects() - else [['gradle', 'dependencies', '-b', manifest_file_path, '-q', '--console', 'plain']] + else [[self.gradle_executable, 'dependencies', '-b', manifest_file_path, '-q', '--console', 'plain']] ) def get_lock_file_name(self) -> str: @@ -49,7 +63,7 @@ def get_working_directory(self, document: Document) -> Optional[str]: def get_all_projects(self) -> set[str]: output = shell( - command=BUILD_GRADLE_ALL_PROJECTS_COMMAND, + command=[self.gradle_executable, 'projects'], timeout=self.command_timeout, working_directory=get_path_from_context(self.ctx), ) @@ -62,7 +76,7 @@ def get_commands_for_sub_projects(self, manifest_file_path: str) -> list[list[st project_name = os.path.basename(os.path.dirname(manifest_file_path)) project_name = f':{project_name}' return ( - [['gradle', f'{project_name}:dependencies', '-q', '--console', 'plain']] + [[self.gradle_executable, f'{project_name}:dependencies', '-q', '--console', 'plain']] if project_name in self.projects else [] ) diff --git a/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py b/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py index 72ca8a7d..43d34e30 100644 --- a/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py +++ b/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py @@ -9,11 +9,15 @@ BUILD_GRADLE_DEP_TREE_FILE_NAME, BUILD_GRADLE_FILE_NAME, BUILD_GRADLE_KTS_FILE_NAME, + GRADLE_EXECUTABLE, + GRADLEW_BAT_FILE_NAME, + GRADLEW_FILE_NAME, RestoreGradleDependencies, ) from cycode.cli.models import Document _BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' +_GRADLE_MODULE = 'cycode.cli.files_collector.sca.maven.restore_gradle_dependencies' @pytest.fixture @@ -47,6 +51,60 @@ def test_settings_gradle_does_not_match(self, restore_gradle: RestoreGradleDepen assert restore_gradle.is_project(doc) is False +class TestResolveGradleExecutable: + def test_falls_back_to_gradle_when_no_wrapper(self, restore_gradle: RestoreGradleDependencies) -> None: + assert restore_gradle.gradle_executable == GRADLE_EXECUTABLE + + def test_prefers_gradlew_wrapper_on_posix(self, mock_ctx: typer.Context, tmp_path: Path) -> None: + wrapper = tmp_path / GRADLEW_FILE_NAME + wrapper.write_text('#!/bin/sh\n') + with patch(f'{_GRADLE_MODULE}.platform.system', return_value='Linux'): + restore = RestoreGradleDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + assert restore.gradle_executable == str(wrapper) + + def test_prefers_gradlew_bat_on_windows(self, mock_ctx: typer.Context, tmp_path: Path) -> None: + wrapper = tmp_path / GRADLEW_BAT_FILE_NAME + wrapper.write_text('@echo off\n') + with patch(f'{_GRADLE_MODULE}.platform.system', return_value='Windows'): + restore = RestoreGradleDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + assert restore.gradle_executable == str(wrapper) + + def test_posix_ignores_bat_wrapper(self, mock_ctx: typer.Context, tmp_path: Path) -> None: + (tmp_path / GRADLEW_BAT_FILE_NAME).write_text('@echo off\n') + with patch(f'{_GRADLE_MODULE}.platform.system', return_value='Linux'): + restore = RestoreGradleDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + assert restore.gradle_executable == GRADLE_EXECUTABLE + + def test_wrapper_is_threaded_into_get_commands(self, mock_ctx: typer.Context, tmp_path: Path) -> None: + wrapper = tmp_path / GRADLEW_FILE_NAME + wrapper.write_text('#!/bin/sh\n') + with patch(f'{_GRADLE_MODULE}.platform.system', return_value='Linux'): + restore = RestoreGradleDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + commands = restore.get_commands(str(tmp_path / BUILD_GRADLE_FILE_NAME)) + assert commands == [ + [str(wrapper), 'dependencies', '-b', str(tmp_path / BUILD_GRADLE_FILE_NAME), '-q', '--console', 'plain'] + ] + + def test_wrapper_is_threaded_into_sub_project_commands(self, tmp_path: Path) -> None: + wrapper = tmp_path / GRADLEW_FILE_NAME + wrapper.write_text('#!/bin/sh\n') + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False, 'gradle_all_sub_projects': True} + ctx.params = {'path': str(tmp_path)} + module_project = tmp_path / 'module-a' + module_project.mkdir() + manifest = module_project / BUILD_GRADLE_FILE_NAME + + with ( + patch(f'{_GRADLE_MODULE}.platform.system', return_value='Linux'), + patch.object(RestoreGradleDependencies, 'get_all_projects', return_value={':module-a'}), + ): + restore = RestoreGradleDependencies(ctx, is_git_diff=False, command_timeout=30) + + commands = restore.get_commands_for_sub_projects(str(manifest)) + assert commands == [[str(wrapper), ':module-a:dependencies', '-q', '--console', 'plain']] + + class TestCleanup: def test_generated_dep_tree_file_is_deleted_after_restore( self, restore_gradle: RestoreGradleDependencies, tmp_path: Path From 49c9e404e247c3f5a00388a80800809ae0cf3736 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:32:56 +0300 Subject: [PATCH 102/123] CM-68342: sweep all IDEs' session context and dedup reports (#493) Co-authored-by: Claude Fable 5 --- .../cli/apps/ai_guardrails/ides/__init__.py | 19 ++ .../apps/ai_guardrails/ides/_plugin_utils.py | 16 + .../apps/ai_guardrails/ides/claude_code.py | 26 +- cycode/cli/apps/ai_guardrails/ides/codex.py | 17 +- .../ai_guardrails/session_start_command.py | 101 ++++-- cycode/cyclient/ai_security_manager_client.py | 10 +- .../ai_guardrails/ides/test_claude_code.py | 55 +++- .../commands/ai_guardrails/ides/test_codex.py | 5 +- .../test_session_start_command.py | 304 +++++++++++++----- 9 files changed, 419 insertions(+), 134 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/ides/__init__.py b/cycode/cli/apps/ai_guardrails/ides/__init__.py index e598c5a5..127431ef 100644 --- a/cycode/cli/apps/ai_guardrails/ides/__init__.py +++ b/cycode/cli/apps/ai_guardrails/ides/__init__.py @@ -34,6 +34,25 @@ def get_ide(name: str) -> IDE: return ide +def collect_all_session_contexts() -> tuple[dict[str, dict], dict]: + """Sweep every registered IDE's session context, regardless of which IDE triggered the hook. + + Returns ``(config_files_by_ide, plugins)``: the global MCP config file of each IDE that has + one (keyed by IDE name), and the enabled plugins merged across IDEs (first registered IDE + wins on a duplicate plugin key - plugins are IDE-agnostic marketplace artifacts). + """ + config_files_by_ide: dict[str, dict] = {} + plugins: dict = {} + for ide in IDES.values(): + global_config_file, enabled_plugins = ide.get_session_context() + if global_config_file: + config_files_by_ide[ide.name] = global_config_file + for plugin_key, plugin in (enabled_plugins or {}).items(): + plugins.setdefault(plugin_key, plugin) + + return config_files_by_ide, plugins + + def resolve_ides(name: str) -> list[IDE]: """Resolve an ``--ide`` argument to one or all IDE instances. diff --git a/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py b/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py index 124fdc99..6f7d8917 100644 --- a/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py +++ b/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py @@ -15,6 +15,22 @@ logger = get_logger('AI Guardrails Plugins') +def resolve_cached_plugin_dir(cache_root: Path, marketplace: str, plugin_name: str) -> Optional[Path]: + """Find ``////``. + + Both Claude Code and Codex cache installed plugin content in this layout (the trailing + segment is a version for Claude, a content hash for Codex). If multiple are cached, pick + the most recently modified (name as a deterministic tie-breaker). + """ + base = cache_root / marketplace / plugin_name + if not base.is_dir(): + return None + candidates = [d for d in base.iterdir() if d.is_dir()] + if not candidates: + return None + return max(candidates, key=lambda d: (d.stat().st_mtime, d.name)) + + def load_plugin_json(path: Path) -> Optional[dict]: """Load a JSON file inside a plugin directory; None if missing or invalid.""" if not path.exists(): diff --git a/cycode/cli/apps/ai_guardrails/ides/claude_code.py b/cycode/cli/apps/ai_guardrails/ides/claude_code.py index 06989d87..17e7563d 100644 --- a/cycode/cli/apps/ai_guardrails/ides/claude_code.py +++ b/cycode/cli/apps/ai_guardrails/ides/claude_code.py @@ -10,6 +10,7 @@ from cycode.cli.apps.ai_guardrails.ides._plugin_utils import ( build_global_config_file, load_plugin_json, + resolve_cached_plugin_dir, walk_enabled_plugins, ) from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision @@ -164,6 +165,11 @@ def load_claude_settings(settings_path: Optional[Path] = None) -> Optional[dict] return None +def _plugins_cache_dir() -> Path: + """Claude Code's local plugin content cache: ``~/.claude/plugins/cache////``.""" + return Path.home() / '.claude' / 'plugins' / 'cache' + + def _resolve_marketplace_path(marketplace: dict) -> Optional[Path]: """Resolve filesystem path for a directory-type marketplace.""" source = marketplace.get('source', {}) @@ -194,25 +200,29 @@ def _read_claude_plugin(plugin_dir: Path) -> tuple[dict, dict]: if servers: entry['mcp_server_names'] = list(servers.keys()) entry['mcp_config_file_path'] = str(mcp_config_path) - entry['mcp_config_file'] = json.dumps(mcp_config) + entry['mcp_config_file'] = json.dumps({'mcpServers': servers}) return entry, servers def resolve_plugins(settings: dict) -> dict: """Walk Claude Code's ``enabledPlugins`` via the shared plugin walker. - Each enabled plugin's marketplace is resolved through - ``extraKnownMarketplaces`` to a directory; the rest of the work - (manifest + ``.mcp.json``) is the shared ``_read_claude_plugin``. + Directory-type marketplaces resolve through ``extraKnownMarketplaces``; all + other source types (git, github, ...) resolve through the local plugin cache. + The rest of the work (manifest + ``.mcp.json``) is the shared ``_read_claude_plugin``. """ enabled = settings.get('enabledPlugins') or {} marketplaces = settings.get('extraKnownMarketplaces') or {} - def _locate(_plugin_name: str, marketplace_name: str) -> Optional[Path]: + def _locate(plugin_name: str, marketplace_name: str) -> Optional[Path]: + # Directory-type marketplaces point straight at the plugin source; every other source + # type (git, github, ...) is cloned into the local plugin cache. marketplace = marketplaces.get(marketplace_name) - if not marketplace: - return None - return _resolve_marketplace_path(marketplace) + if marketplace: + marketplace_path = _resolve_marketplace_path(marketplace) + if marketplace_path is not None: + return marketplace_path + return resolve_cached_plugin_dir(_plugins_cache_dir(), marketplace_name, plugin_name) return walk_enabled_plugins( plugin_entries=enabled, diff --git a/cycode/cli/apps/ai_guardrails/ides/codex.py b/cycode/cli/apps/ai_guardrails/ides/codex.py index 2bfd70dc..e8049621 100644 --- a/cycode/cli/apps/ai_guardrails/ides/codex.py +++ b/cycode/cli/apps/ai_guardrails/ides/codex.py @@ -17,6 +17,7 @@ from cycode.cli.apps.ai_guardrails.ides._plugin_utils import ( build_global_config_file, load_plugin_json, + resolve_cached_plugin_dir, walk_enabled_plugins, ) from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision @@ -100,18 +101,8 @@ def _email_from_auth(auth_path: Optional[Path] = None) -> Optional[str]: def _resolve_codex_plugin_dir(plugin_name: str, marketplace: str) -> Optional[Path]: - """Find ``~/.codex/plugins/cache////``. - - The trailing segment is a content hash. If multiple are cached, pick the - most recently modified. - """ - base = _codex_home() / 'plugins' / 'cache' / marketplace / plugin_name - if not base.is_dir(): - return None - candidates = [d for d in base.iterdir() if d.is_dir()] - if not candidates: - return None - return max(candidates, key=lambda d: d.stat().st_mtime) + """Find ``~/.codex/plugins/cache////``.""" + return resolve_cached_plugin_dir(_codex_home() / 'plugins' / 'cache', marketplace, plugin_name) def _read_codex_plugin(plugin_dir: Path) -> tuple[dict, dict]: @@ -141,7 +132,7 @@ def _read_codex_plugin(plugin_dir: Path) -> tuple[dict, dict]: if servers: entry['mcp_server_names'] = list(servers.keys()) entry['mcp_config_file_path'] = str(mcp_config_path) - entry['mcp_config_file'] = json.dumps(mcp_doc) + entry['mcp_config_file'] = json.dumps({'mcpServers': servers}) return entry, servers diff --git a/cycode/cli/apps/ai_guardrails/session_start_command.py b/cycode/cli/apps/ai_guardrails/session_start_command.py index c7164c79..bebd421d 100644 --- a/cycode/cli/apps/ai_guardrails/session_start_command.py +++ b/cycode/cli/apps/ai_guardrails/session_start_command.py @@ -1,12 +1,15 @@ """Handle AI guardrails session start: auth, conversation creation, session context.""" +import hashlib +import json import sys +import time +from pathlib import Path from typing import TYPE_CHECKING, Annotated, Optional import typer -from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, get_ide -from cycode.cli.apps.ai_guardrails.ides.base import IDE +from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, collect_all_session_contexts, get_ide from cycode.cli.apps.ai_guardrails.scan.utils import read_stdin_text, safe_json_parse from cycode.cli.apps.auth.auth_common import get_authorization_info from cycode.cli.apps.auth.auth_manager import AuthManager @@ -26,23 +29,76 @@ logger = get_logger('AI Guardrails') +_SESSION_CONTEXT_CACHE_FILE = '.session-context-cache' +_SESSION_CONTEXT_TTL_SECONDS = 7 * 24 * 60 * 60 -def _report_session_context(ai_client: 'AISecurityManagerClient', ide: IDE, user_email: Optional[str]) -> None: - """Report IDE session context to the AI security manager. Never raises.""" + +def _session_context_cache_path() -> Path: + return Path.home() / '.cycode' / _SESSION_CONTEXT_CACHE_FILE + + +def _session_context_digest(report: dict) -> str: + """Deterministic hash of the outgoing payload (not the raw config files, which churn).""" + canonical = json.dumps(report, sort_keys=True, separators=(',', ':'), default=str) + return hashlib.sha256(canonical.encode('utf-8')).hexdigest() + + +def _should_skip_report(digest: str, tenant_id: Optional[str]) -> bool: + """Skip when the same payload was already sent for this tenant and the TTL hasn't expired.""" try: - global_config_file, enabled_plugins = ide.get_session_context() - if not global_config_file and not enabled_plugins: - return - ai_client.report_session_context( - hostname=get_hostname(), - platform_name=get_platform_name(), - os_version=get_os_version(), - serial_number=get_serial_number(), - last_login_user=get_last_login_user(), - global_config_file=global_config_file, - enabled_plugins=enabled_plugins, - user_email=user_email, + cache = json.loads(_session_context_cache_path().read_text(encoding='utf-8')) + return ( + cache.get('hash') == digest + and cache.get('tenant_id') == tenant_id + and time.time() - float(cache.get('sent_at', 0)) < _SESSION_CONTEXT_TTL_SECONDS + ) + except Exception: + # Missing/corrupt cache reads as a miss - over-sending is harmless + return False + + +def _save_report_cache(digest: str, tenant_id: Optional[str]) -> None: + try: + cache_path = _session_context_cache_path() + cache_path.parent.mkdir(parents=True, exist_ok=True) + cache_path.write_text( + json.dumps({'hash': digest, 'tenant_id': tenant_id, 'sent_at': time.time()}), encoding='utf-8' ) + except Exception as e: + logger.debug('Failed to write session context cache', exc_info=e) + + +def _report_session_context( + ai_client: 'AISecurityManagerClient', + user_email: Optional[str], + tenant_id: Optional[str], +) -> None: + """Report the device + cross-IDE session context to the AI security manager. Never raises. + + The device context is always reported. MCP configs are collected from every registered IDE, + not just the triggering one. Unchanged payloads are skipped via a hash cache until the TTL expires. + """ + try: + config_files_by_ide, enabled_plugins = collect_all_session_contexts() + report = { + 'hostname': get_hostname(), + 'platform_name': get_platform_name(), + 'os_version': get_os_version(), + 'serial_number': get_serial_number(), + 'last_login_user': get_last_login_user(), + # Sorted by path so the digest is stable regardless of IDE registry order. + 'config_files': sorted(config_files_by_ide.values(), key=lambda f: f['path']), + 'enabled_plugins': enabled_plugins, + 'user_email': user_email, + } + + digest = _session_context_digest(report) + if _should_skip_report(digest, tenant_id): + logger.debug('Session context unchanged; skipping report') + return + + if ai_client.report_session_context(**report): + _save_report_cache(digest, tenant_id) except Exception as e: logger.debug('Failed to report session context', exc_info=e) @@ -61,7 +117,7 @@ def session_start_command( """Handle session start: ensure auth, create conversation, report session context.""" ide_integration = get_ide(ide) - # Step 1: Ensure authentication + # Ensure authentication auth_info = get_authorization_info(ctx) if auth_info is None: logger.debug('Not authenticated, starting authentication') @@ -70,10 +126,11 @@ def session_start_command( except Exception as err: handle_auth_exception(ctx, err) return + auth_info = get_authorization_info(ctx) else: logger.debug('Already authenticated') - # Step 2: Read stdin payload (backward compat: old hooks pipe no stdin) + # Read stdin payload (backward compat: old hooks pipe no stdin) if sys.stdin.isatty(): logger.debug('No stdin payload (TTY), skipping session initialization') return @@ -84,7 +141,7 @@ def session_start_command( logger.debug('Empty or invalid stdin payload, skipping session initialization') return - # Step 3: Build session payload + initialize API client + # Build session payload + initialize API client session_payload = ide_integration.build_session_payload(payload) try: @@ -93,11 +150,11 @@ def session_start_command( logger.debug('Failed to initialize AI security client', exc_info=e) return - # Step 4: Create conversation + # Create conversation try: ai_client.create_conversation(session_payload) except Exception as e: logger.debug('Failed to create conversation during session start', exc_info=e) - # Step 5: Report session context (MCP servers, enabled plugins) - _report_session_context(ai_client, ide_integration, session_payload.ide_user_email) + # Report session context (device + cross-IDE MCP servers and plugins) + _report_session_context(ai_client, session_payload.ide_user_email, auth_info.tenant_id) diff --git a/cycode/cyclient/ai_security_manager_client.py b/cycode/cyclient/ai_security_manager_client.py index a4f9bd76..5dee7f2c 100644 --- a/cycode/cyclient/ai_security_manager_client.py +++ b/cycode/cyclient/ai_security_manager_client.py @@ -98,11 +98,11 @@ def report_session_context( os_version: Optional[str] = None, serial_number: Optional[str] = None, last_login_user: Optional[str] = None, - global_config_file: Optional[dict] = None, + config_files: Optional[list[dict]] = None, enabled_plugins: Optional[dict] = None, user_email: Optional[str] = None, - ) -> None: - """Report session context to the backend.""" + ) -> bool: + """Report session context to the backend. Returns whether the report was accepted.""" body: dict = { 'hostname': hostname, 'platform_name': platform_name, @@ -110,12 +110,14 @@ def report_session_context( 'serial_number': serial_number, 'last_login_user': last_login_user, 'user_email': user_email, - 'global_config_file': global_config_file, + 'config_files': config_files, 'enabled_plugins': enabled_plugins, } try: self.client.post(self._build_endpoint_path(self._SESSION_CONTEXT_PATH), body=body) + return True except Exception as e: logger.debug('Failed to report session context', exc_info=e) # Don't fail the session if reporting fails + return False diff --git a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py index d1e28c0b..dbaca44f 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py +++ b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py @@ -13,6 +13,7 @@ _email_from_config, _read_claude_plugin, load_claude_config, + resolve_plugins, ) from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType @@ -222,34 +223,68 @@ def test_email_none_when_no_oauth(mocker: MockerFixture) -> None: # _read_claude_plugin -def test_read_claude_plugin_includes_mcp_config_file(tmp_path: Path) -> None: - mcp_content = {'mcpServers': {'aspire': {'command': 'aspire', 'args': ['mcp', 'start']}}} - (tmp_path / '.mcp.json').write_text(json.dumps(mcp_content)) +def test_read_claude_plugin_includes_mcp_config_file(fs: FakeFilesystem) -> None: + plugin_dir = Path('/dummy/plugin') + mcp_content = {'mcpServers': {'dummy-server': {'command': 'dummy-command', 'args': ['serve']}}} + fs.create_file(plugin_dir / '.mcp.json', contents=json.dumps(mcp_content)) - entry, servers = _read_claude_plugin(tmp_path) + entry, servers = _read_claude_plugin(plugin_dir) assert 'mcp_config_file' in entry assert json.loads(entry['mcp_config_file']) == mcp_content - assert entry['mcp_config_file_path'] == str(tmp_path / '.mcp.json') + assert entry['mcp_config_file_path'] == str(plugin_dir / '.mcp.json') assert servers == mcp_content['mcpServers'] -def test_read_claude_plugin_no_mcp_config_file_when_no_servers(tmp_path: Path) -> None: - (tmp_path / '.mcp.json').write_text(json.dumps({'mcpServers': {}})) +def test_read_claude_plugin_no_mcp_config_file_when_no_servers(fs: FakeFilesystem) -> None: + plugin_dir = Path('/dummy/plugin') + fs.create_file(plugin_dir / '.mcp.json', contents=json.dumps({'mcpServers': {}})) - entry, servers = _read_claude_plugin(tmp_path) + entry, servers = _read_claude_plugin(plugin_dir) assert 'mcp_config_file' not in entry assert servers == {} -def test_read_claude_plugin_no_mcp_config_file_when_missing(tmp_path: Path) -> None: - entry, servers = _read_claude_plugin(tmp_path) +def test_read_claude_plugin_no_mcp_config_file_when_missing(fs: FakeFilesystem) -> None: + plugin_dir = Path('/dummy/plugin') + fs.create_dir(plugin_dir) + + entry, servers = _read_claude_plugin(plugin_dir) assert 'mcp_config_file' not in entry assert servers == {} +# resolve_plugins + + +def test_resolve_plugins_git_marketplace_resolves_from_cache(fs: FakeFilesystem) -> None: + """Non-directory marketplaces (git/github) resolve through ~/.claude/plugins/cache.""" + plugin_dir = Path.home() / '.claude' / 'plugins' / 'cache' / 'dummy-marketplace' / 'dummy-plugin' / '1.0.1' + fs.create_file( + plugin_dir / '.claude-plugin' / 'plugin.json', + contents=json.dumps({'name': 'dummy-plugin', 'version': '1.0.1'}), + ) + fs.create_file( + plugin_dir / '.mcp.json', + contents=json.dumps({'mcpServers': {'dummy-server': {'command': 'dummy-command'}}}), + ) + + settings = { + 'enabledPlugins': {'dummy-plugin@dummy-marketplace': True}, + 'extraKnownMarketplaces': { + 'dummy-marketplace': {'source': {'source': 'git', 'url': 'git@example.com:dummy/dummy-marketplace.git'}} + }, + } + plugins = resolve_plugins(settings) + + entry = plugins['dummy-plugin@dummy-marketplace'] + assert entry['version'] == '1.0.1' + assert entry['mcp_server_names'] == ['dummy-server'] + assert entry['mcp_config_file_path'] == str(plugin_dir / '.mcp.json') + + # Session context diff --git a/tests/cli/commands/ai_guardrails/ides/test_codex.py b/tests/cli/commands/ai_guardrails/ides/test_codex.py index 285b889c..682739e6 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_codex.py +++ b/tests/cli/commands/ai_guardrails/ides/test_codex.py @@ -358,13 +358,14 @@ def test_read_codex_plugin_includes_mcp_config_file(tmp_path: Path) -> None: def test_read_codex_plugin_mcp_config_file_bare_map(tmp_path: Path) -> None: - # Codex MCP files may be a bare {name: cfg} map with no mcpServers wrapper. + # Codex MCP files may be a bare {name: cfg} map with no mcpServers wrapper; the serialized + # session-context content is normalized to the canonical wrapped shape. mcp_content = {'dummy-server': {'command': 'dummy-command'}} _write_codex_plugin(tmp_path, mcp_content) entry, servers = _read_codex_plugin(tmp_path) - assert json.loads(entry['mcp_config_file']) == mcp_content + assert json.loads(entry['mcp_config_file']) == {'mcpServers': mcp_content} assert servers == mcp_content diff --git a/tests/cli/commands/ai_guardrails/test_session_start_command.py b/tests/cli/commands/ai_guardrails/test_session_start_command.py index ed6708ce..6beaa615 100644 --- a/tests/cli/commands/ai_guardrails/test_session_start_command.py +++ b/tests/cli/commands/ai_guardrails/test_session_start_command.py @@ -9,7 +9,9 @@ import typer from cycode.cli.apps.ai_guardrails import session_start_command as _session_start_mod +from cycode.cli.apps.ai_guardrails.ides import IDES, collect_all_session_contexts from cycode.cli.apps.ai_guardrails.ides import claude_code as _claude_mod +from cycode.cli.apps.ai_guardrails.ides import codex as _codex_mod from cycode.cli.apps.ai_guardrails.ides import cursor as _cursor_mod from cycode.cli.apps.ai_guardrails.session_start_command import session_start_command @@ -22,6 +24,12 @@ def mock_ctx() -> MagicMock: return ctx +@pytest.fixture(autouse=True) +def _isolated_session_context_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Keep the dedup cache away from the real ~/.cycode in every test.""" + monkeypatch.setattr(_session_start_mod, '_session_context_cache_path', lambda: tmp_path / '.session-context-cache') + + # Auth tests @@ -202,52 +210,83 @@ def test_conversation_creation_failure_non_blocking( # Should not raise -# MCP server reporting tests +# Session context reporting tests -@patch.object(_claude_mod, 'load_claude_settings') -@patch.object(_claude_mod, 'load_claude_config') +@patch.object(_claude_mod, 'load_claude_config', return_value={}) +@patch.object(_session_start_mod, 'collect_all_session_contexts') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') -def test_claude_code_reports_mcp_servers( +def test_reports_cross_ide_session_context( mock_get_auth: MagicMock, mock_get_client: MagicMock, + mock_collect: MagicMock, mock_load_config: MagicMock, - mock_load_settings: MagicMock, mock_ctx: MagicMock, ) -> None: - """Claude Code should report MCP servers from ~/.claude.json and enriched plugins.""" - mock_get_auth.return_value = MagicMock() + """All registered IDEs' configs go into config_files.""" + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') mock_ai_client = MagicMock() mock_get_client.return_value = mock_ai_client - mcp_servers = { - 'gitlab': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-gitlab']}, - 'filesystem': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-filesystem']}, - } - mock_load_config.return_value = {'oauthAccount': {'emailAddress': 'test@test.com'}, 'mcpServers': mcp_servers} - # Marketplace won't resolve (no extraKnownMarketplaces) so plugin gets {"enabled": True} only. - mock_load_settings.return_value = {'enabledPlugins': {'cycode-dev@cycode-marketplace': True}} + cursor_file = {'path': '/home/u/.cursor/mcp.json', 'content': '{"mcpServers": {}}'} + claude_file = {'path': '/home/u/.claude.json', 'content': '{"mcpServers": {}}'} + plugins = {'dummy-plugin@dummy-marketplace': {'enabled': True}} + mock_collect.return_value = ({'cursor': cursor_file, 'claude-code': claude_file}, plugins) payload = {'session_id': 'session-123'} with patch('sys.stdin', new=StringIO(json.dumps(payload))): session_start_command(mock_ctx, ide='claude-code') + # config_files is sorted by path for a stable digest. mock_ai_client.report_session_context.assert_called_once_with( hostname=ANY, platform_name=ANY, os_version=ANY, serial_number=ANY, last_login_user=ANY, - global_config_file={ - 'path': str(_claude_mod._CLAUDE_CONFIG_PATH), - 'content': json.dumps({'mcpServers': mcp_servers}), - }, - enabled_plugins={'cycode-dev@cycode-marketplace': {'enabled': True}}, - user_email='test@test.com', + config_files=[claude_file, cursor_file], + enabled_plugins=plugins, + user_email=None, ) +@patch.object(_claude_mod, 'load_claude_config', return_value={}) +@patch.object(_session_start_mod, 'collect_all_session_contexts') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_no_mcp_anywhere_still_reports_device( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_collect: MagicMock, + mock_load_config: MagicMock, + mock_ctx: MagicMock, +) -> None: + """A machine with no MCP configs or plugins must still report its device context.""" + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + mock_collect.return_value = ({}, {}) + + payload = {'session_id': 'session-123'} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='claude-code') + + mock_ai_client.report_session_context.assert_called_once_with( + hostname=ANY, + platform_name=ANY, + os_version=ANY, + serial_number=ANY, + last_login_user=ANY, + config_files=[], + enabled_plugins={}, + user_email=None, + ) + + +@patch.object(_codex_mod, '_load_codex_config') +@patch.object(_cursor_mod, '_load_cursor_mcp_config') @patch.object(_claude_mod, 'load_claude_settings') @patch.object(_claude_mod, 'load_claude_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @@ -257,32 +296,36 @@ def test_claude_code_reports_global_file_and_plugin_metadata( mock_get_client: MagicMock, mock_load_config: MagicMock, mock_load_settings: MagicMock, + mock_load_cursor: MagicMock, + mock_load_codex: MagicMock, mock_ctx: MagicMock, tmp_path: Path, ) -> None: """The global config file carries only the global MCP servers; the plugin's own .mcp.json content + path + metadata enrich enabled_plugins (no merge into the global).""" - mock_get_auth.return_value = MagicMock() + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') mock_ai_client = MagicMock() mock_get_client.return_value = mock_ai_client + mock_load_cursor.return_value = None + mock_load_codex.return_value = None # Set up a fake plugin directory on disk. - plugin_dir = tmp_path / 'ai-prompts' + plugin_dir = tmp_path / 'dummy-plugin' plugin_dir.mkdir() (plugin_dir / '.mcp.json').write_text( - json.dumps({'mcpServers': {'aspire': {'command': 'aspire', 'args': ['mcp', 'start']}}}) + json.dumps({'mcpServers': {'dummy-server': {'command': 'dummy-command', 'args': ['serve']}}}) ) claude_plugin_dir = plugin_dir / '.claude-plugin' claude_plugin_dir.mkdir() (claude_plugin_dir / 'plugin.json').write_text( - json.dumps({'name': 'cycode-dev', 'version': '1.0.28', 'description': 'Shared skills'}) + json.dumps({'name': 'dummy-plugin', 'version': '1.0.28', 'description': 'Dummy plugin'}) ) - user_mcp_servers = {'gitlab': {'command': 'npx'}} + user_mcp_servers = {'dummy-global': {'command': 'dummy-command'}} mock_load_config.return_value = {'mcpServers': user_mcp_servers} mock_load_settings.return_value = { - 'enabledPlugins': {'cycode-dev@cycode-marketplace': True}, - 'extraKnownMarketplaces': {'cycode-marketplace': {'source': {'source': 'directory', 'path': str(plugin_dir)}}}, + 'enabledPlugins': {'dummy-plugin@dummy-marketplace': True}, + 'extraKnownMarketplaces': {'dummy-marketplace': {'source': {'source': 'directory', 'path': str(plugin_dir)}}}, } payload = {'session_id': 'session-123'} @@ -290,24 +333,25 @@ def test_claude_code_reports_global_file_and_plugin_metadata( with patch('sys.stdin', new=StringIO(json.dumps(payload))): session_start_command(mock_ctx, ide='claude-code') - plugin_mcp = {'mcpServers': {'aspire': {'command': 'aspire', 'args': ['mcp', 'start']}}} + plugin_mcp = {'mcpServers': {'dummy-server': {'command': 'dummy-command', 'args': ['serve']}}} + claude_file = { + 'path': str(_claude_mod._CLAUDE_CONFIG_PATH), + 'content': json.dumps({'mcpServers': user_mcp_servers}), + } mock_ai_client.report_session_context.assert_called_once_with( hostname=ANY, platform_name=ANY, os_version=ANY, serial_number=ANY, last_login_user=ANY, - global_config_file={ - 'path': str(_claude_mod._CLAUDE_CONFIG_PATH), - 'content': json.dumps({'mcpServers': user_mcp_servers}), - }, + config_files=[claude_file], enabled_plugins={ - 'cycode-dev@cycode-marketplace': { + 'dummy-plugin@dummy-marketplace': { 'enabled': True, - 'name': 'cycode-dev', + 'name': 'dummy-plugin', 'version': '1.0.28', - 'description': 'Shared skills', - 'mcp_server_names': ['aspire'], + 'description': 'Dummy plugin', + 'mcp_server_names': ['dummy-server'], 'mcp_config_file_path': str(plugin_dir / '.mcp.json'), 'mcp_config_file': json.dumps(plugin_mcp), } @@ -316,89 +360,199 @@ def test_claude_code_reports_global_file_and_plugin_metadata( ) +@patch.object(_codex_mod, '_load_codex_config') @patch.object(_claude_mod, 'load_claude_settings') @patch.object(_claude_mod, 'load_claude_config') +@patch.object(_cursor_mod, '_load_cursor_mcp_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') -def test_claude_code_no_mcp_servers_no_plugins_skips_report( +def test_cursor_trigger_sweeps_other_ides( mock_get_auth: MagicMock, mock_get_client: MagicMock, + mock_load_cursor: MagicMock, mock_load_config: MagicMock, mock_load_settings: MagicMock, + mock_load_codex: MagicMock, mock_ctx: MagicMock, ) -> None: - """When no mcpServers and no plugins, report_session_context should not be called.""" - mock_get_auth.return_value = MagicMock() + """A Cursor-triggered session start also reports Claude's config via config_files.""" + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') mock_ai_client = MagicMock() mock_get_client.return_value = mock_ai_client - mock_load_config.return_value = {'oauthAccount': {'emailAddress': 'test@test.com'}} + cursor_servers = {'github': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-github']}} + claude_servers = {'gitlab': {'command': 'npx'}} + mock_load_cursor.return_value = {'mcpServers': cursor_servers} + mock_load_config.return_value = {'mcpServers': claude_servers} mock_load_settings.return_value = None + mock_load_codex.return_value = None - payload = {'session_id': 'session-123'} + payload = {'conversation_id': 'conv-456', 'model': 'gpt-4'} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='cursor') + cursor_file = { + 'path': str(Path.home() / '.cursor' / 'mcp.json'), + 'content': json.dumps({'mcpServers': cursor_servers}), + } + claude_file = { + 'path': str(_claude_mod._CLAUDE_CONFIG_PATH), + 'content': json.dumps({'mcpServers': claude_servers}), + } + # config_files is sorted by path for a stable digest (~/.claude.json < ~/.cursor/mcp.json). + mock_ai_client.report_session_context.assert_called_once_with( + hostname=ANY, + platform_name=ANY, + os_version=ANY, + serial_number=ANY, + last_login_user=ANY, + config_files=[claude_file, cursor_file], + enabled_plugins={}, + user_email=None, + ) + + +# Dedup cache tests + + +def _run_session_start(mock_ctx: MagicMock, payload: dict) -> None: with patch('sys.stdin', new=StringIO(json.dumps(payload))): session_start_command(mock_ctx, ide='claude-code') - mock_ai_client.report_session_context.assert_not_called() +@patch.object(_session_start_mod, 'collect_all_session_contexts') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_unchanged_context_skips_second_report( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_collect: MagicMock, + mock_ctx: MagicMock, +) -> None: + """An identical payload within the TTL is sent once; the second session start skips it.""" + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + mock_collect.return_value = ({'cursor': {'path': '/p', 'content': 'c'}}, {}) + + _run_session_start(mock_ctx, {'session_id': 'session-1'}) + _run_session_start(mock_ctx, {'session_id': 'session-2'}) -@patch.object(_cursor_mod, '_load_cursor_mcp_config') + mock_ai_client.report_session_context.assert_called_once() + + +@patch.object(_session_start_mod, 'collect_all_session_contexts') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') -def test_cursor_reports_mcp_servers( +def test_changed_context_resends( mock_get_auth: MagicMock, mock_get_client: MagicMock, - mock_load_cursor: MagicMock, + mock_collect: MagicMock, mock_ctx: MagicMock, ) -> None: - """Cursor should report MCP servers from ~/.cursor/mcp.json.""" - mock_get_auth.return_value = MagicMock() + """A change in the collected inventory busts the cache immediately.""" + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') mock_ai_client = MagicMock() mock_get_client.return_value = mock_ai_client - mcp_servers = {'github': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-github']}} - mock_load_cursor.return_value = {'mcpServers': mcp_servers} - payload = {'conversation_id': 'conv-456', 'model': 'gpt-4'} + mock_collect.return_value = ({'cursor': {'path': '/p', 'content': 'c1'}}, {}) + _run_session_start(mock_ctx, {'session_id': 'session-1'}) - with patch('sys.stdin', new=StringIO(json.dumps(payload))): - session_start_command(mock_ctx, ide='cursor') + mock_collect.return_value = ({'cursor': {'path': '/p', 'content': 'c2'}}, {}) + _run_session_start(mock_ctx, {'session_id': 'session-2'}) - mock_ai_client.report_session_context.assert_called_once_with( - hostname=ANY, - platform_name=ANY, - os_version=ANY, - serial_number=ANY, - last_login_user=ANY, - global_config_file={ - 'path': str(Path.home() / '.cursor' / 'mcp.json'), - 'content': json.dumps({'mcpServers': mcp_servers}), - }, - enabled_plugins={}, - user_email=None, - ) + assert mock_ai_client.report_session_context.call_count == 2 -@patch.object(_cursor_mod, '_load_cursor_mcp_config') +@patch.object(_session_start_mod, 'collect_all_session_contexts') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') -def test_cursor_no_mcp_servers_skips_report( +def test_tenant_change_resends( mock_get_auth: MagicMock, mock_get_client: MagicMock, - mock_load_cursor: MagicMock, + mock_collect: MagicMock, mock_ctx: MagicMock, ) -> None: - """Cursor with no MCP config file should skip report_session_context.""" - mock_get_auth.return_value = MagicMock() + """Re-authenticating against a different tenant must re-send the same inventory.""" mock_ai_client = MagicMock() mock_get_client.return_value = mock_ai_client - mock_load_cursor.return_value = None + mock_collect.return_value = ({'cursor': {'path': '/p', 'content': 'c'}}, {}) - payload = {'conversation_id': 'conv-456', 'model': 'gpt-4'} + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') + _run_session_start(mock_ctx, {'session_id': 'session-1'}) - with patch('sys.stdin', new=StringIO(json.dumps(payload))): - session_start_command(mock_ctx, ide='cursor') + mock_get_auth.return_value = MagicMock(tenant_id='tenant-2') + _run_session_start(mock_ctx, {'session_id': 'session-2'}) + + assert mock_ai_client.report_session_context.call_count == 2 + + +@patch.object(_session_start_mod, 'collect_all_session_contexts') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_failed_report_is_not_cached( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_collect: MagicMock, + mock_ctx: MagicMock, +) -> None: + """A failed send must not populate the cache - the next session retries.""" + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') + mock_ai_client = MagicMock() + mock_ai_client.report_session_context.return_value = False + mock_get_client.return_value = mock_ai_client + mock_collect.return_value = ({'cursor': {'path': '/p', 'content': 'c'}}, {}) + + _run_session_start(mock_ctx, {'session_id': 'session-1'}) + _run_session_start(mock_ctx, {'session_id': 'session-2'}) + + assert mock_ai_client.report_session_context.call_count == 2 + + +@patch.object(_session_start_mod, 'collect_all_session_contexts') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_expired_ttl_resends( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_collect: MagicMock, + mock_ctx: MagicMock, +) -> None: + """After the TTL, an unchanged payload is re-sent (self-healing / liveness heartbeat).""" + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + mock_collect.return_value = ({'cursor': {'path': '/p', 'content': 'c'}}, {}) + + _run_session_start(mock_ctx, {'session_id': 'session-1'}) + + # Age the cache entry past the TTL. + cache_path = _session_start_mod._session_context_cache_path() + cache = json.loads(cache_path.read_text(encoding='utf-8')) + cache['sent_at'] = cache['sent_at'] - _session_start_mod._SESSION_CONTEXT_TTL_SECONDS - 1 + cache_path.write_text(json.dumps(cache), encoding='utf-8') + + _run_session_start(mock_ctx, {'session_id': 'session-2'}) + + assert mock_ai_client.report_session_context.call_count == 2 + + +# Cross-IDE sweep tests + + +def test_collect_all_session_contexts_merges_plugins_first_wins() -> None: + """A plugin key present in two IDEs keeps the first registered IDE's entry.""" + claude_plugin = {'enabled': True, 'version': '1.0.0'} + codex_plugin = {'enabled': True, 'version': '2.0.0'} + + with ( + patch.object(IDES['cursor'], 'get_session_context', return_value=(None, {})), + patch.object(IDES['claude-code'], 'get_session_context', return_value=(None, {'plug@m': claude_plugin})), + patch.object(IDES['codex'], 'get_session_context', return_value=(None, {'plug@m': codex_plugin})), + ): + _, plugins = collect_all_session_contexts() - mock_ai_client.report_session_context.assert_not_called() + assert plugins == {'plug@m': claude_plugin} @patch.object(_session_start_mod, 'handle_auth_exception') From 8be08edc66d053fc345685f19617c1bcc2949ec5 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:36:49 +0300 Subject: [PATCH 103/123] CM-68554: Build and sign Windows onedir executable (#494) Co-authored-by: Claude Fable 5 --- .github/workflows/build_executable.yml | 38 ++++++++++++++++++++++---- process_executable_file.py | 13 +++++---- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index a9e5eddc..2b81f2cc 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -26,8 +26,6 @@ jobs: exclude: - os: ubuntu-22.04 mode: onedir - - os: windows-2022 - mode: onedir runs-on: ${{ matrix.os }} @@ -249,18 +247,46 @@ jobs: C:\Windows\System32\certutil.exe -csp "DigiCert Signing Manager KSP" -key -user smctl windows certsync --keypair-alias=%SM_KEYPAIR_ALIAS% - :: sign executable - signtool.exe sign /sha1 %SM_CODE_SIGNING_CERT_SHA1_HASH% /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 ".\dist\cycode-cli.exe" + :: sign executable (in onedir mode the exe lives inside the collected directory) + set "EXE_PATH=.\dist\cycode-cli.exe" + if "${{ matrix.mode }}"=="onedir" set "EXE_PATH=.\dist\cycode-cli\cycode-cli.exe" + signtool.exe sign /sha1 %SM_CODE_SIGNING_CERT_SHA1_HASH% /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 "%EXE_PATH%" + + - name: Sign unsigned onedir binaries (Windows) + if: runner.os == 'Windows' && matrix.mode == 'onedir' + shell: powershell + env: + SM_HOST: ${{ secrets.SM_HOST }} + SM_API_KEY: ${{ secrets.SM_API_KEY }} + SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }} + SM_CODE_SIGNING_CERT_SHA1_HASH: ${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }} + run: | + # Vendor binaries (PSF-signed stdlib .pyds, python3xx.dll, Microsoft VC runtime) already + # carry valid signatures; re-signing would replace them with ours. Sign only the unsigned + # ones (PyInstaller-generated and third-party wheel binaries). + $files = Get-ChildItem -Path dist\cycode-cli\_internal -Recurse -Include *.dll,*.pyd,*.exe | + Where-Object { (Get-AuthenticodeSignature $_.FullName).Status -eq 'NotSigned' } + if (-not $files) { + Write-Host 'No unsigned binaries found' + exit 0 + } + Write-Host "Signing $($files.Count) unsigned binaries:" + $files.FullName | Write-Host + signtool.exe sign /sha1 $env:SM_CODE_SIGNING_CERT_SHA1_HASH /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 @($files.FullName) + exit $LASTEXITCODE - name: Test Windows signed executable if: runner.os == 'Windows' shell: cmd run: | + set "EXE_PATH=.\dist\cycode-cli.exe" + if "${{ matrix.mode }}"=="onedir" set "EXE_PATH=.\dist\cycode-cli\cycode-cli.exe" + :: call executable and expect correct output - .\dist\cycode-cli.exe status + "%EXE_PATH%" status :: verify signature - signtool.exe verify /v /pa ".\dist\cycode-cli.exe" + signtool.exe verify /v /pa "%EXE_PATH%" - name: Prepare files for artifact and release (rename and calculate sha256) run: echo "ARTIFACT_NAME=$(./process_executable_file.py dist/cycode-cli)" >> $GITHUB_ENV diff --git a/process_executable_file.py b/process_executable_file.py index 36d6d0d6..19cfbb44 100755 --- a/process_executable_file.py +++ b/process_executable_file.py @@ -22,7 +22,7 @@ _OS_TO_CLI_DIST_TEMPLATE = { 'darwin': Template('cycode-mac$suffix$ext'), 'linux': Template('cycode-linux$suffix$ext'), - 'windows': Template('cycode-win$suffix.exe$ext'), + 'windows': Template('cycode-win$suffix$ext'), } _WINDOWS = 'windows' _WINDOWS_EXECUTABLE_SUFFIX = '.exe' @@ -87,8 +87,7 @@ def get_cli_file_name(suffix: str = '', ext: str = '') -> str: if os_name not in _OS_TO_CLI_DIST_TEMPLATE: raise Exception(f'Unsupported OS: {os_name}') - template = _OS_TO_CLI_DIST_TEMPLATE[os_name] - return template.substitute(suffix=suffix, ext=ext) + return _OS_TO_CLI_DIST_TEMPLATE[os_name].substitute(suffix=suffix, ext=ext) def get_cli_file_suffix(is_onedir: bool) -> str: @@ -117,7 +116,9 @@ def write_hashes_db_to_file(hashes: DirHashes, output_path: str) -> None: def get_cli_filename(is_onedir: bool) -> str: - return get_cli_file_name(get_cli_file_suffix(is_onedir)) + # onedir is distributed as an archive of a directory, so only onefile carries .exe + ext = _WINDOWS_EXECUTABLE_SUFFIX if get_os_name() == _WINDOWS and not is_onedir else '' + return get_cli_file_name(suffix=get_cli_file_suffix(is_onedir), ext=ext) def get_cli_path(output_path: Path, is_onedir: bool) -> str: @@ -125,7 +126,7 @@ def get_cli_path(output_path: Path, is_onedir: bool) -> str: def get_cli_hash_filename(is_onedir: bool) -> str: - return get_cli_file_name(suffix=get_cli_file_suffix(is_onedir), ext=_HASH_FILE_EXT) + return get_cli_filename(is_onedir) + _HASH_FILE_EXT def get_cli_hash_path(output_path: Path, is_onedir: bool) -> str: @@ -133,7 +134,7 @@ def get_cli_hash_path(output_path: Path, is_onedir: bool) -> str: def get_cli_archive_filename(is_onedir: bool) -> str: - return get_cli_file_name(suffix=get_cli_file_suffix(is_onedir)) + return get_cli_filename(is_onedir) def get_cli_archive_path(output_path: Path, is_onedir: bool) -> str: From 815461f84a4755315110253ce977e920294c5490 Mon Sep 17 00:00:00 2001 From: omer-roth Date: Wed, 15 Jul 2026 21:17:27 +0300 Subject: [PATCH 104/123] CM-68872: gate secret-scan async (presigned) flow behind CYCODE_SECRET_SCAN_ASYNC (#496) Co-authored-by: Claude Opus 4.8 --- cycode/cli/consts.py | 6 ++- cycode/cli/files_collector/zip_documents.py | 6 ++- cycode/cli/utils/scan_utils.py | 7 ++- cycode/config.py | 9 +++- tests/cli/commands/scan/test_code_scanner.py | 19 +++++--- .../scan/test_commit_range_scanner.py | 5 ++ tests/cli/utils/__init__.py | 0 tests/cli/utils/test_scan_utils.py | 46 +++++++++++++++++++ 8 files changed, 87 insertions(+), 11 deletions(-) create mode 100644 tests/cli/utils/__init__.py create mode 100644 tests/cli/utils/test_scan_utils.py diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 37ef2298..58924c2d 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -222,13 +222,15 @@ FILE_MAX_SIZE_LIMIT_IN_BYTES = 5000000 PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES = 5 * 1024 * 1024 * 1024 # 5 GB (S3 presigned POST limit) -PRESIGNED_UPLOAD_SCAN_TYPES = {SAST_SCAN_TYPE, SECRET_SCAN_TYPE} +PRESIGNED_UPLOAD_SCAN_TYPES = {SAST_SCAN_TYPE} +# Secret scans use the previous (batched / API-upload) flow by default. The presigned S3 async flow is +# opt-in via the SECRET_SCAN_ASYNC_ENV_VAR_NAME env var; see should_use_presigned_upload. +SECRET_SCAN_ASYNC_ENV_VAR_NAME = 'CYCODE_SECRET_SCAN_ASYNC' DEFAULT_ZIP_MAX_SIZE_LIMIT_IN_BYTES = 20 * 1024 * 1024 ZIP_MAX_SIZE_LIMIT_IN_BYTES = { SCA_SCAN_TYPE: 200 * 1024 * 1024, SAST_SCAN_TYPE: PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES, - SECRET_SCAN_TYPE: PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES, } # scan in batches diff --git a/cycode/cli/files_collector/zip_documents.py b/cycode/cli/files_collector/zip_documents.py index 7927bdc6..4f66429f 100644 --- a/cycode/cli/files_collector/zip_documents.py +++ b/cycode/cli/files_collector/zip_documents.py @@ -6,13 +6,17 @@ from cycode.cli.exceptions import custom_exceptions from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip from cycode.cli.models import Document +from cycode.cli.utils.scan_utils import should_use_presigned_upload from cycode.logger import get_logger logger = get_logger('ZIP') def _validate_zip_file_size(scan_type: str, zip_file_size: int) -> None: - max_size_limit = consts.ZIP_MAX_SIZE_LIMIT_IN_BYTES.get(scan_type, consts.DEFAULT_ZIP_MAX_SIZE_LIMIT_IN_BYTES) + if should_use_presigned_upload(scan_type): + max_size_limit = consts.PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES + else: + max_size_limit = consts.ZIP_MAX_SIZE_LIMIT_IN_BYTES.get(scan_type, consts.DEFAULT_ZIP_MAX_SIZE_LIMIT_IN_BYTES) if zip_file_size > max_size_limit: raise custom_exceptions.ZipTooLargeError(max_size_limit) diff --git a/cycode/cli/utils/scan_utils.py b/cycode/cli/utils/scan_utils.py index 819a4116..21131343 100644 --- a/cycode/cli/utils/scan_utils.py +++ b/cycode/cli/utils/scan_utils.py @@ -7,6 +7,7 @@ from cycode.cli import consts from cycode.cli.cli_types import SeverityOption +from cycode.config import parse_bool if TYPE_CHECKING: from cycode.cli.models import LocalScanResult @@ -33,7 +34,11 @@ def is_cycodeignore_allowed_by_scan_config(ctx: typer.Context) -> bool: def should_use_presigned_upload(scan_type: str) -> bool: - return scan_type in consts.PRESIGNED_UPLOAD_SCAN_TYPES + if scan_type in consts.PRESIGNED_UPLOAD_SCAN_TYPES: + return True + if scan_type == consts.SECRET_SCAN_TYPE: + return parse_bool(os.getenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME)) + return False def generate_unique_scan_id() -> UUID: diff --git a/cycode/config.py b/cycode/config.py index f4306b31..b17dadaa 100644 --- a/cycode/config.py +++ b/cycode/config.py @@ -19,11 +19,18 @@ def get_val_as_string(key: str) -> str: return configuration.get(key) +_TRUTHY_STRING_VALUES = {'true', '1', 'yes', 'y', 'on', 'enabled'} + + +def parse_bool(value: Optional[str]) -> bool: + return value is not None and value.lower() in _TRUTHY_STRING_VALUES + + def get_val_as_bool(key: str, default: bool = False) -> bool: if key not in configuration: return default - return configuration[key].lower() in {'true', '1', 'yes', 'y', 'on', 'enabled'} + return parse_bool(configuration[key]) def get_val_as_int(key: str) -> Optional[int]: diff --git a/tests/cli/commands/scan/test_code_scanner.py b/tests/cli/commands/scan/test_code_scanner.py index 8b4a30b3..11d0fd46 100644 --- a/tests/cli/commands/scan/test_code_scanner.py +++ b/tests/cli/commands/scan/test_code_scanner.py @@ -168,14 +168,16 @@ def test_entrypoint_cycode_not_added_for_single_file( @pytest.mark.parametrize( - ('scan_type', 'command_scan_type', 'sync_option', 'expect_presigned'), + ('scan_type', 'command_scan_type', 'sync_option', 'secret_async_env', 'expect_presigned'), [ # SAST keeps uploading directly to S3 via a presigned URL (regression guard for the new sync gate). - (consts.SAST_SCAN_TYPE, 'path', False, True), - # Async secret scans now upload as a single file directly to S3 via a presigned URL. - (consts.SECRET_SCAN_TYPE, 'path', False, True), - # A --sync secret scan must stay on the batched inline path and never build one giant zip. - (consts.SECRET_SCAN_TYPE, 'path', True, False), + (consts.SAST_SCAN_TYPE, 'path', False, False, True), + # Secret scans use the previous batched flow by default (presigned async is opt-in). + (consts.SECRET_SCAN_TYPE, 'path', False, False, False), + # With CYCODE_SECRET_SCAN_ASYNC enabled, secret scans upload as a single file directly to S3. + (consts.SECRET_SCAN_TYPE, 'path', False, True, True), + # A --sync secret scan must stay on the batched inline path even when async is enabled. + (consts.SECRET_SCAN_TYPE, 'path', True, True, False), ], ) @patch('cycode.cli.apps.scan.code_scanner.print_local_scan_results') @@ -192,8 +194,13 @@ def test_scan_documents_routes_upload_by_scan_type_and_sync( scan_type: str, command_scan_type: str, sync_option: bool, + secret_async_env: bool, expect_presigned: bool, + monkeypatch: pytest.MonkeyPatch, ) -> None: + if secret_async_env: + monkeypatch.setenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME, 'true') + mock_presigned_upload.return_value = ([], []) mock_batched_scan.return_value = ([], []) diff --git a/tests/cli/commands/scan/test_commit_range_scanner.py b/tests/cli/commands/scan/test_commit_range_scanner.py index a4a6c58b..8b8cc54b 100644 --- a/tests/cli/commands/scan/test_commit_range_scanner.py +++ b/tests/cli/commands/scan/test_commit_range_scanner.py @@ -1,5 +1,7 @@ from unittest.mock import MagicMock, Mock, patch +import pytest + from cycode.cli import consts from cycode.cli.apps.scan.commit_range_scanner import _scan_commit_range_documents from cycode.cli.exceptions import custom_exceptions @@ -25,7 +27,10 @@ def test_commit_range_scan_falls_back_to_api_when_presigned_upload_raises_wrappe mock_print: Mock, mock_handle_exception: Mock, mock_report_status: Mock, + monkeypatch: pytest.MonkeyPatch, ) -> None: + # Secret uses the presigned flow only when async is opted in. + monkeypatch.setenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME, 'true') # SlowUploadConnectionError is a CycodeError, not a requests.RequestException — the presigned # commit-range fallback must still catch it and retry via the Cycode API. mock_v4_async.side_effect = custom_exceptions.SlowUploadConnectionError diff --git a/tests/cli/utils/__init__.py b/tests/cli/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/utils/test_scan_utils.py b/tests/cli/utils/test_scan_utils.py new file mode 100644 index 00000000..d2bf0438 --- /dev/null +++ b/tests/cli/utils/test_scan_utils.py @@ -0,0 +1,46 @@ +import pytest + +from cycode.cli import consts +from cycode.cli.exceptions import custom_exceptions +from cycode.cli.files_collector.zip_documents import _validate_zip_file_size +from cycode.cli.utils.scan_utils import should_use_presigned_upload + + +def test_sast_always_uses_presigned_upload() -> None: + assert should_use_presigned_upload(consts.SAST_SCAN_TYPE) is True + + +def test_secret_does_not_use_presigned_upload_by_default(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME, raising=False) + assert should_use_presigned_upload(consts.SECRET_SCAN_TYPE) is False + + +@pytest.mark.parametrize('env_value', ['true', 'True', '1', 'yes', 'y', 'on', 'enabled']) +def test_secret_uses_presigned_upload_when_env_enabled(monkeypatch: pytest.MonkeyPatch, env_value: str) -> None: + monkeypatch.setenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME, env_value) + assert should_use_presigned_upload(consts.SECRET_SCAN_TYPE) is True + + +@pytest.mark.parametrize('env_value', ['false', '0', 'no', '', 'off']) +def test_secret_ignores_non_truthy_env_values(monkeypatch: pytest.MonkeyPatch, env_value: str) -> None: + monkeypatch.setenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME, env_value) + assert should_use_presigned_upload(consts.SECRET_SCAN_TYPE) is False + + +def test_sca_never_uses_presigned_upload() -> None: + assert should_use_presigned_upload(consts.SCA_SCAN_TYPE) is False + + +def test_secret_zip_size_limit_uses_default_by_default(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME, raising=False) + # A zip just above the default 20 MB limit must be rejected on the previous (batched) flow. + with pytest.raises(custom_exceptions.ZipTooLargeError): + _validate_zip_file_size(consts.SECRET_SCAN_TYPE, consts.DEFAULT_ZIP_MAX_SIZE_LIMIT_IN_BYTES + 1) + + +def test_secret_zip_size_limit_uses_presigned_limit_when_env_enabled(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME, 'true') + # The same zip fits under the 5 GB presigned limit when async is enabled. + _validate_zip_file_size(consts.SECRET_SCAN_TYPE, consts.DEFAULT_ZIP_MAX_SIZE_LIMIT_IN_BYTES + 1) + with pytest.raises(custom_exceptions.ZipTooLargeError): + _validate_zip_file_size(consts.SECRET_SCAN_TYPE, consts.PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES + 1) From a57ba409816bfb73666e4001ae891724fb9b4aeb Mon Sep 17 00:00:00 2001 From: omer-roth Date: Thu, 16 Jul 2026 12:24:25 +0300 Subject: [PATCH 105/123] =?UTF-8?q?Revert=20"CM-68872:=20gate=20secret-sca?= =?UTF-8?q?n=20async=20(presigned)=20flow=20behind=20CYCO=E2=80=A6=20(#497?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cycode/cli/consts.py | 6 +-- cycode/cli/files_collector/zip_documents.py | 6 +-- cycode/cli/utils/scan_utils.py | 7 +-- cycode/config.py | 9 +--- tests/cli/commands/scan/test_code_scanner.py | 19 +++----- .../scan/test_commit_range_scanner.py | 5 -- tests/cli/utils/__init__.py | 0 tests/cli/utils/test_scan_utils.py | 46 ------------------- 8 files changed, 11 insertions(+), 87 deletions(-) delete mode 100644 tests/cli/utils/__init__.py delete mode 100644 tests/cli/utils/test_scan_utils.py diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 58924c2d..37ef2298 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -222,15 +222,13 @@ FILE_MAX_SIZE_LIMIT_IN_BYTES = 5000000 PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES = 5 * 1024 * 1024 * 1024 # 5 GB (S3 presigned POST limit) -PRESIGNED_UPLOAD_SCAN_TYPES = {SAST_SCAN_TYPE} -# Secret scans use the previous (batched / API-upload) flow by default. The presigned S3 async flow is -# opt-in via the SECRET_SCAN_ASYNC_ENV_VAR_NAME env var; see should_use_presigned_upload. -SECRET_SCAN_ASYNC_ENV_VAR_NAME = 'CYCODE_SECRET_SCAN_ASYNC' +PRESIGNED_UPLOAD_SCAN_TYPES = {SAST_SCAN_TYPE, SECRET_SCAN_TYPE} DEFAULT_ZIP_MAX_SIZE_LIMIT_IN_BYTES = 20 * 1024 * 1024 ZIP_MAX_SIZE_LIMIT_IN_BYTES = { SCA_SCAN_TYPE: 200 * 1024 * 1024, SAST_SCAN_TYPE: PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES, + SECRET_SCAN_TYPE: PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES, } # scan in batches diff --git a/cycode/cli/files_collector/zip_documents.py b/cycode/cli/files_collector/zip_documents.py index 4f66429f..7927bdc6 100644 --- a/cycode/cli/files_collector/zip_documents.py +++ b/cycode/cli/files_collector/zip_documents.py @@ -6,17 +6,13 @@ from cycode.cli.exceptions import custom_exceptions from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip from cycode.cli.models import Document -from cycode.cli.utils.scan_utils import should_use_presigned_upload from cycode.logger import get_logger logger = get_logger('ZIP') def _validate_zip_file_size(scan_type: str, zip_file_size: int) -> None: - if should_use_presigned_upload(scan_type): - max_size_limit = consts.PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES - else: - max_size_limit = consts.ZIP_MAX_SIZE_LIMIT_IN_BYTES.get(scan_type, consts.DEFAULT_ZIP_MAX_SIZE_LIMIT_IN_BYTES) + max_size_limit = consts.ZIP_MAX_SIZE_LIMIT_IN_BYTES.get(scan_type, consts.DEFAULT_ZIP_MAX_SIZE_LIMIT_IN_BYTES) if zip_file_size > max_size_limit: raise custom_exceptions.ZipTooLargeError(max_size_limit) diff --git a/cycode/cli/utils/scan_utils.py b/cycode/cli/utils/scan_utils.py index 21131343..819a4116 100644 --- a/cycode/cli/utils/scan_utils.py +++ b/cycode/cli/utils/scan_utils.py @@ -7,7 +7,6 @@ from cycode.cli import consts from cycode.cli.cli_types import SeverityOption -from cycode.config import parse_bool if TYPE_CHECKING: from cycode.cli.models import LocalScanResult @@ -34,11 +33,7 @@ def is_cycodeignore_allowed_by_scan_config(ctx: typer.Context) -> bool: def should_use_presigned_upload(scan_type: str) -> bool: - if scan_type in consts.PRESIGNED_UPLOAD_SCAN_TYPES: - return True - if scan_type == consts.SECRET_SCAN_TYPE: - return parse_bool(os.getenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME)) - return False + return scan_type in consts.PRESIGNED_UPLOAD_SCAN_TYPES def generate_unique_scan_id() -> UUID: diff --git a/cycode/config.py b/cycode/config.py index b17dadaa..f4306b31 100644 --- a/cycode/config.py +++ b/cycode/config.py @@ -19,18 +19,11 @@ def get_val_as_string(key: str) -> str: return configuration.get(key) -_TRUTHY_STRING_VALUES = {'true', '1', 'yes', 'y', 'on', 'enabled'} - - -def parse_bool(value: Optional[str]) -> bool: - return value is not None and value.lower() in _TRUTHY_STRING_VALUES - - def get_val_as_bool(key: str, default: bool = False) -> bool: if key not in configuration: return default - return parse_bool(configuration[key]) + return configuration[key].lower() in {'true', '1', 'yes', 'y', 'on', 'enabled'} def get_val_as_int(key: str) -> Optional[int]: diff --git a/tests/cli/commands/scan/test_code_scanner.py b/tests/cli/commands/scan/test_code_scanner.py index 11d0fd46..8b4a30b3 100644 --- a/tests/cli/commands/scan/test_code_scanner.py +++ b/tests/cli/commands/scan/test_code_scanner.py @@ -168,16 +168,14 @@ def test_entrypoint_cycode_not_added_for_single_file( @pytest.mark.parametrize( - ('scan_type', 'command_scan_type', 'sync_option', 'secret_async_env', 'expect_presigned'), + ('scan_type', 'command_scan_type', 'sync_option', 'expect_presigned'), [ # SAST keeps uploading directly to S3 via a presigned URL (regression guard for the new sync gate). - (consts.SAST_SCAN_TYPE, 'path', False, False, True), - # Secret scans use the previous batched flow by default (presigned async is opt-in). - (consts.SECRET_SCAN_TYPE, 'path', False, False, False), - # With CYCODE_SECRET_SCAN_ASYNC enabled, secret scans upload as a single file directly to S3. - (consts.SECRET_SCAN_TYPE, 'path', False, True, True), - # A --sync secret scan must stay on the batched inline path even when async is enabled. - (consts.SECRET_SCAN_TYPE, 'path', True, True, False), + (consts.SAST_SCAN_TYPE, 'path', False, True), + # Async secret scans now upload as a single file directly to S3 via a presigned URL. + (consts.SECRET_SCAN_TYPE, 'path', False, True), + # A --sync secret scan must stay on the batched inline path and never build one giant zip. + (consts.SECRET_SCAN_TYPE, 'path', True, False), ], ) @patch('cycode.cli.apps.scan.code_scanner.print_local_scan_results') @@ -194,13 +192,8 @@ def test_scan_documents_routes_upload_by_scan_type_and_sync( scan_type: str, command_scan_type: str, sync_option: bool, - secret_async_env: bool, expect_presigned: bool, - monkeypatch: pytest.MonkeyPatch, ) -> None: - if secret_async_env: - monkeypatch.setenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME, 'true') - mock_presigned_upload.return_value = ([], []) mock_batched_scan.return_value = ([], []) diff --git a/tests/cli/commands/scan/test_commit_range_scanner.py b/tests/cli/commands/scan/test_commit_range_scanner.py index 8b8cc54b..a4a6c58b 100644 --- a/tests/cli/commands/scan/test_commit_range_scanner.py +++ b/tests/cli/commands/scan/test_commit_range_scanner.py @@ -1,7 +1,5 @@ from unittest.mock import MagicMock, Mock, patch -import pytest - from cycode.cli import consts from cycode.cli.apps.scan.commit_range_scanner import _scan_commit_range_documents from cycode.cli.exceptions import custom_exceptions @@ -27,10 +25,7 @@ def test_commit_range_scan_falls_back_to_api_when_presigned_upload_raises_wrappe mock_print: Mock, mock_handle_exception: Mock, mock_report_status: Mock, - monkeypatch: pytest.MonkeyPatch, ) -> None: - # Secret uses the presigned flow only when async is opted in. - monkeypatch.setenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME, 'true') # SlowUploadConnectionError is a CycodeError, not a requests.RequestException — the presigned # commit-range fallback must still catch it and retry via the Cycode API. mock_v4_async.side_effect = custom_exceptions.SlowUploadConnectionError diff --git a/tests/cli/utils/__init__.py b/tests/cli/utils/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/cli/utils/test_scan_utils.py b/tests/cli/utils/test_scan_utils.py deleted file mode 100644 index d2bf0438..00000000 --- a/tests/cli/utils/test_scan_utils.py +++ /dev/null @@ -1,46 +0,0 @@ -import pytest - -from cycode.cli import consts -from cycode.cli.exceptions import custom_exceptions -from cycode.cli.files_collector.zip_documents import _validate_zip_file_size -from cycode.cli.utils.scan_utils import should_use_presigned_upload - - -def test_sast_always_uses_presigned_upload() -> None: - assert should_use_presigned_upload(consts.SAST_SCAN_TYPE) is True - - -def test_secret_does_not_use_presigned_upload_by_default(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME, raising=False) - assert should_use_presigned_upload(consts.SECRET_SCAN_TYPE) is False - - -@pytest.mark.parametrize('env_value', ['true', 'True', '1', 'yes', 'y', 'on', 'enabled']) -def test_secret_uses_presigned_upload_when_env_enabled(monkeypatch: pytest.MonkeyPatch, env_value: str) -> None: - monkeypatch.setenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME, env_value) - assert should_use_presigned_upload(consts.SECRET_SCAN_TYPE) is True - - -@pytest.mark.parametrize('env_value', ['false', '0', 'no', '', 'off']) -def test_secret_ignores_non_truthy_env_values(monkeypatch: pytest.MonkeyPatch, env_value: str) -> None: - monkeypatch.setenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME, env_value) - assert should_use_presigned_upload(consts.SECRET_SCAN_TYPE) is False - - -def test_sca_never_uses_presigned_upload() -> None: - assert should_use_presigned_upload(consts.SCA_SCAN_TYPE) is False - - -def test_secret_zip_size_limit_uses_default_by_default(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME, raising=False) - # A zip just above the default 20 MB limit must be rejected on the previous (batched) flow. - with pytest.raises(custom_exceptions.ZipTooLargeError): - _validate_zip_file_size(consts.SECRET_SCAN_TYPE, consts.DEFAULT_ZIP_MAX_SIZE_LIMIT_IN_BYTES + 1) - - -def test_secret_zip_size_limit_uses_presigned_limit_when_env_enabled(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME, 'true') - # The same zip fits under the 5 GB presigned limit when async is enabled. - _validate_zip_file_size(consts.SECRET_SCAN_TYPE, consts.DEFAULT_ZIP_MAX_SIZE_LIMIT_IN_BYTES + 1) - with pytest.raises(custom_exceptions.ZipTooLargeError): - _validate_zip_file_size(consts.SECRET_SCAN_TYPE, consts.PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES + 1) From 2d823528f7faa0314eae110d7b48e998c957cd54 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:42:13 +0300 Subject: [PATCH 106/123] CM-64462: add GitHub Copilot (VS Code) support to AI guardrails (#498) Co-authored-by: Claude Fable 5 --- .../cli/apps/ai_guardrails/hooks_manager.py | 17 +- .../cli/apps/ai_guardrails/ides/__init__.py | 3 +- cycode/cli/apps/ai_guardrails/ides/base.py | 19 + .../apps/ai_guardrails/ides/claude_code.py | 6 +- cycode/cli/apps/ai_guardrails/ides/codex.py | 9 +- cycode/cli/apps/ai_guardrails/ides/copilot.py | 436 ++++++++++++++++++ cycode/cli/apps/ai_guardrails/ides/cursor.py | 4 +- .../apps/ai_guardrails/scan/scan_command.py | 27 +- .../ai_guardrails/ides/test_claude_code.py | 32 +- .../commands/ai_guardrails/ides/test_codex.py | 16 +- .../ai_guardrails/ides/test_contract.py | 10 +- .../ai_guardrails/ides/test_copilot.py | 416 +++++++++++++++++ .../ai_guardrails/scan/test_scan_command.py | 64 ++- .../ai_guardrails/test_hooks_manager.py | 56 ++- .../test_session_start_command.py | 16 +- 15 files changed, 1094 insertions(+), 37 deletions(-) create mode 100644 cycode/cli/apps/ai_guardrails/ides/copilot.py create mode 100644 tests/cli/commands/ai_guardrails/ides/test_copilot.py diff --git a/cycode/cli/apps/ai_guardrails/hooks_manager.py b/cycode/cli/apps/ai_guardrails/hooks_manager.py index 192bb9f3..b7e55b86 100644 --- a/cycode/cli/apps/ai_guardrails/hooks_manager.py +++ b/cycode/cli/apps/ai_guardrails/hooks_manager.py @@ -22,15 +22,22 @@ _CYCODE_COMMAND_MARKERS = ('cycode ai-guardrails',) +# Command-carrying fields of a flat hook entry. Copilot entries use per-OS +# `bash`/`powershell` fields instead of `command`. +_COMMAND_FIELDS = ('command', 'bash', 'powershell') + def _is_cycode_command(command: str) -> bool: return any(marker in command for marker in _CYCODE_COMMAND_MARKERS) +def _has_cycode_command_field(entry: dict) -> bool: + return any(_is_cycode_command(entry.get(field, '')) for field in _COMMAND_FIELDS) + + def is_cycode_hook_entry(entry: dict) -> bool: """True if any hook inside ``entry`` is owned by Cycode.""" - command = entry.get('command', '') - if _is_cycode_command(command): + if _has_cycode_command_field(entry): return True for hook in entry.get('hooks', []): @@ -47,9 +54,9 @@ def _strip_cycode_from_entry(entry: dict) -> Optional[dict]: every nested hook was Cycode). Non-Cycode hooks co-located in the same entry are preserved. """ - # Cursor format: the entry itself IS a single hook command. - if 'command' in entry and 'hooks' not in entry: - return None if _is_cycode_command(entry.get('command', '')) else entry + # Cursor/Copilot format: the entry itself IS a single hook command. + if 'hooks' not in entry and any(field in entry for field in _COMMAND_FIELDS): + return None if _has_cycode_command_field(entry) else entry # Claude Code / Codex format: nested `hooks` list inside the entry. nested = entry.get('hooks') diff --git a/cycode/cli/apps/ai_guardrails/ides/__init__.py b/cycode/cli/apps/ai_guardrails/ides/__init__.py index 127431ef..396074da 100644 --- a/cycode/cli/apps/ai_guardrails/ides/__init__.py +++ b/cycode/cli/apps/ai_guardrails/ides/__init__.py @@ -10,11 +10,12 @@ from cycode.cli.apps.ai_guardrails.ides.base import IDE from cycode.cli.apps.ai_guardrails.ides.claude_code import ClaudeCode from cycode.cli.apps.ai_guardrails.ides.codex import Codex +from cycode.cli.apps.ai_guardrails.ides.copilot import Copilot from cycode.cli.apps.ai_guardrails.ides.cursor import Cursor # Single source of truth: name → singleton instance. # `--ide` choices and install/uninstall/status iteration both derive from this. -IDES: dict[str, IDE] = {ide.name: ide for ide in (Cursor(), ClaudeCode(), Codex())} +IDES: dict[str, IDE] = {ide.name: ide for ide in (Cursor(), ClaudeCode(), Codex(), Copilot())} # Default IDE used when `--ide` is omitted. Kept here so the value is colocated # with the registry; no module outside `ides/` needs to know which IDE wins. diff --git a/cycode/cli/apps/ai_guardrails/ides/base.py b/cycode/cli/apps/ai_guardrails/ides/base.py index 28971db9..84e4315f 100644 --- a/cycode/cli/apps/ai_guardrails/ides/base.py +++ b/cycode/cli/apps/ai_guardrails/ides/base.py @@ -14,6 +14,7 @@ JSON response shape that the IDE expects on stdout. """ +import platform from abc import ABC, abstractmethod from dataclasses import dataclass from enum import Enum @@ -24,6 +25,24 @@ from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType +def shell_background_suffix(async_mode: bool) -> str: + """`' &'` when backgrounding is requested and the platform's shell supports it. + + Only valid for hooks whose runner is stdin-safe under backgrounding (zsh keeps + a backgrounded command's stdin; verified for Cursor/Codex). bash/sh reattach it + to /dev/null, silently emptying the payload — hooks that run under bash (e.g. + Copilot's `bash` field) must add an explicit `<&0` redirect instead. + + Windows gets no suffix: depending on the IDE, hooks may run under cmd (where a + trailing `&` is a no-op separator) or Windows PowerShell (where it's a parse + error that would fail the hook). Until the CLI can self-detach in report mode, + Windows hooks run synchronously. + """ + if not async_mode or platform.system() == 'Windows': + return '' + return ' &' + + class DecisionAction(str, Enum): """Canonical decision action returned by event handlers.""" diff --git a/cycode/cli/apps/ai_guardrails/ides/claude_code.py b/cycode/cli/apps/ai_guardrails/ides/claude_code.py index 17e7563d..f48794ef 100644 --- a/cycode/cli/apps/ai_guardrails/ides/claude_code.py +++ b/cycode/cli/apps/ai_guardrails/ides/claude_code.py @@ -278,7 +278,11 @@ def render_hooks_config(self, async_mode: bool = False) -> dict: } def matches_payload(self, raw_payload: dict) -> bool: - return raw_payload.get('hook_event_name', '') in _CLAUDE_CODE_EVENT_NAMES + # transcript_path is a documented Claude Code common field, present on every + # hook event. VS Code Copilot emits near-identical payloads (same event names, + # snake_case fields) without it — requiring it keeps those from being + # processed as Claude Code events. + return raw_payload.get('hook_event_name', '') in _CLAUDE_CODE_EVENT_NAMES and 'transcript_path' in raw_payload def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: hook_event_name = raw_payload.get('hook_event_name', '') diff --git a/cycode/cli/apps/ai_guardrails/ides/codex.py b/cycode/cli/apps/ai_guardrails/ides/codex.py index e8049621..c9e48393 100644 --- a/cycode/cli/apps/ai_guardrails/ides/codex.py +++ b/cycode/cli/apps/ai_guardrails/ides/codex.py @@ -20,7 +20,7 @@ resolve_cached_plugin_dir, walk_enabled_plugins, ) -from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision +from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision, shell_background_suffix from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType from cycode.cli.utils.jwt_utils import decode_jwt_unverified @@ -191,10 +191,9 @@ def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: def render_hooks_config(self, async_mode: bool = False) -> dict: # Codex's TOML `async: true` flag is unimplemented; shell-background via - # `&` is the working mechanism. SessionStart stays sync so the - # conversation context is registered before any scan hook fires. - bg = ' &' if async_mode else '' - scan_cmd = f'{_SCAN_COMMAND}{bg}' + # `&` is the working mechanism (unix only). SessionStart stays sync so + # the conversation context is registered before any scan hook fires. + scan_cmd = f'{_SCAN_COMMAND}{shell_background_suffix(async_mode)}' return { 'hooks': { 'SessionStart': [ diff --git a/cycode/cli/apps/ai_guardrails/ides/copilot.py b/cycode/cli/apps/ai_guardrails/ides/copilot.py new file mode 100644 index 00000000..12ef8f89 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/ides/copilot.py @@ -0,0 +1,436 @@ +"""GitHub Copilot (VS Code extension) integration for AI guardrails. + +Hooks are installed in Copilot's native format to ``~/.copilot/hooks/cycode.json`` +(user scope) or ``/.github/hooks/cycode.json`` (repo scope). Both locations +are also read by Copilot CLI and the Copilot cloud coding agent, but only the +VS Code payload dialect is parsed here — CLI payloads (camelCase, no event name) +are rejected by ``matches_payload`` and fall through to the allow-and-skip path. + +VS Code sends Claude-style payloads (``hook_event_name``, ``tool_name``, +``tool_input``) with structural differences that ``matches_payload`` keys on: +a top-level ISO ``timestamp`` and no ``transcript_path``. Copilot hooks have no +matchers, so ``preToolUse`` fires for every tool; tools we don't scan pass +through as raw event names, which match no handler and allow immediately. +""" + +import json +import os +import platform +import re +from collections.abc import Iterable +from pathlib import Path +from typing import ClassVar, Optional, Union +from urllib.parse import urlparse +from urllib.request import url2pathname + +from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND +from cycode.cli.apps.ai_guardrails.ides._plugin_utils import ( + build_global_config_file, + load_plugin_json, + walk_enabled_plugins, +) +from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails Copilot') + +# Payload dialect (VS Code sends Claude-style PascalCase event names). +_COPILOT_SCAN_EVENT_NAMES = frozenset({'UserPromptSubmit', 'PreToolUse'}) +_READ_FILE_TOOL = 'read_file' +# VS Code names MCP tools `mcp__` (single underscores). +_MCP_TOOL_PREFIX = 'mcp_' + +# Hooks-file dialect (Copilot-native camelCase event names). +_HOOK_EVENTS = ['userPromptSubmitted', 'preToolUse'] + +_COPILOT_HOME_ENV_VAR = 'COPILOT_HOME' +_HOOKS_FILE_NAME = 'cycode.json' +_REPO_HOOKS_SUBDIR = Path('.github') / 'hooks' +_HOOK_TIMEOUT_SEC = 20 +_MCP_CONFIG_FILENAME = 'mcp.json' + +# Plugin sources. CLI installs register in ~/.copilot/config.json and auto-surface +# in VS Code; VS Code UI installs register in ~/.vscode/agent-plugins/installed.json; +# local-directory plugins are declared via the chat.pluginLocations setting. +_VSCODE_PLUGINS_REGISTRY_NAME = 'installed.json' +_PLUGIN_LOCATIONS_SETTING = 'chat.pluginLocations' +_LOCAL_PLUGINS_MARKETPLACE = 'local' + +# Manifest locations in VS Code's documented detection order. Plugins may ship +# several manifest dialects at once — first hit wins, matching VS Code's probing. +_PLUGIN_MANIFEST_LOCATIONS = ( + Path('.plugin') / 'plugin.json', + Path('plugin.json'), + Path('.github') / 'plugin' / 'plugin.json', + Path('.claude-plugin') / 'plugin.json', +) + +# --event is ignored by the VS Code payload parsing (the payload self-describes) +# but Copilot CLI payloads carry no event name at all — baking the flag in now +# means CLI support won't require customers to re-install hooks. Values use the +# payload-dialect spelling so a future CLI path can inject them straight into +# hook_event_name and reuse the existing parsing. +_SCAN_PROMPT_COMMAND = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide copilot --event UserPromptSubmit' +_SCAN_TOOL_COMMAND = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide copilot --event PreToolUse' +_SESSION_START_COMMAND = f'{CYCODE_SESSION_START_COMMAND} --ide copilot' + + +def _copilot_home() -> Path: + """Resolve Copilot's user-scope home directory (honors ``$COPILOT_HOME``).""" + override = os.environ.get(_COPILOT_HOME_ENV_VAR) + if override: + return Path(override) + return Path.home() / '.copilot' + + +def _vscode_agent_plugins_dir() -> Path: + # Resolved at call time (not a module-level Path constant): on py<=3.10 a Path + # instance binds its filesystem accessor at creation, which breaks fake-fs tests + # and ignores home changes. + return Path.home() / '.vscode' / 'agent-plugins' + + +def _vscode_user_dir() -> Path: + """Per-platform VS Code user settings directory.""" + if platform.system() == 'Darwin': + return Path.home() / 'Library' / 'Application Support' / 'Code' / 'User' + if platform.system() == 'Windows': + return Path.home() / 'AppData' / 'Roaming' / 'Code' / 'User' + return Path.home() / '.config' / 'Code' / 'User' + + +def _vscode_mcp_config_path() -> Path: + return _vscode_user_dir() / _MCP_CONFIG_FILENAME + + +def _load_vscode_mcp_config(config_path: Optional[Path] = None) -> Optional[dict]: + """Load and parse VS Code's user-level ``mcp.json``. Returns None if missing/invalid.""" + path = config_path or _vscode_mcp_config_path() + if not path.exists(): + logger.debug('VS Code MCP config file not found, %s', {'path': str(path)}) + return None + try: + return json.loads(path.read_text(encoding='utf-8')) + except Exception as e: + logger.debug('Failed to load VS Code MCP config file', exc_info=e) + return None + + +def _load_jsonc(path: Path) -> Optional[dict]: + """Parse a JSON file tolerating //-comment lines (Copilot's config.json ships + with a comment header; VS Code's settings.json is JSONC). + + Best-effort: JSONC constructs beyond full-line comments (trailing commas, + inline comments) read as a missing file. + """ + if not path.exists(): + logger.debug('Config file not found, %s', {'path': str(path)}) + return None + try: + text = path.read_text(encoding='utf-8') + stripped = '\n'.join(line for line in text.splitlines() if not line.lstrip().startswith('//')) + return json.loads(stripped) + except Exception as e: + logger.debug('Failed to load config file, %s', {'path': str(path)}, exc_info=e) + return None + + +# --- plugins inventory ---------------------------------------------------------- + + +def _read_copilot_plugin(plugin_dir: Path) -> tuple[dict, dict]: + """Read one Copilot plugin's manifest + MCP servers. + + The manifest's ``mcpServers`` field, when present, is a path string to the MCP + file; otherwise the root ``.mcp.json`` convention applies (same as Claude + plugins). Both forms exist in marketplace plugins. + """ + manifest: dict = {} + for location in _PLUGIN_MANIFEST_LOCATIONS: + manifest = load_plugin_json(plugin_dir / location) or {} + if manifest: + break + + entry: dict = {} + for field in ('name', 'version', 'description'): + if field in manifest: + entry[field] = manifest[field] + + mcp_ref = manifest.get('mcpServers') + mcp_config_path = plugin_dir / mcp_ref if isinstance(mcp_ref, str) else plugin_dir / '.mcp.json' + mcp_doc = load_plugin_json(mcp_config_path) or {} + servers = mcp_doc.get('mcpServers') + if not isinstance(servers, dict): + servers = {} + if servers: + entry['mcp_server_names'] = list(servers.keys()) + entry['mcp_config_file_path'] = str(mcp_config_path) + entry['mcp_config_file'] = json.dumps({'mcpServers': servers}) + return entry, servers + + +def _walk_registry_plugins(entries: dict[str, dict], dirs: dict[str, Path], is_enabled: bool = True) -> dict: + """Walk plugins whose directories are known up front (registry-provided).""" + return walk_enabled_plugins( + plugin_entries=entries, + is_enabled=lambda p: p.get('enabled', True) if is_enabled else True, + locate_dir=lambda name, marketplace: dirs.get(f'{name}@{marketplace}'), + read_plugin=_read_copilot_plugin, + ) + + +def _cli_registry_plugins() -> dict: + """Plugins installed via Copilot CLI: ``~/.copilot/config.json`` → ``installedPlugins``.""" + config = _load_jsonc(_copilot_home() / 'config.json') or {} + entries: dict[str, dict] = {} + dirs: dict[str, Path] = {} + for plugin in config.get('installedPlugins') or []: + if not isinstance(plugin, dict) or not plugin.get('name'): + continue + key = f'{plugin["name"]}@{plugin.get("marketplace", "")}' + entries[key] = plugin + if plugin.get('cache_path'): + dirs[key] = Path(plugin['cache_path']) + return _walk_registry_plugins(entries, dirs) + + +def _vscode_registry_plugins() -> dict: + """Plugins installed via the VS Code UI (@agentPlugins): ``~/.vscode/agent-plugins/installed.json``. + + Registry-driven only — the directory also holds marketplace clones that are + not installed. ``pluginUri`` is the authoritative location (the registry's + ``marketplace`` label is unreliable); presence in the registry means enabled. + """ + registry = load_plugin_json(_vscode_agent_plugins_dir() / _VSCODE_PLUGINS_REGISTRY_NAME) or {} + entries: dict[str, dict] = {} + dirs: dict[str, Path] = {} + for plugin in registry.get('installed') or []: + if not isinstance(plugin, dict) or not plugin.get('name'): + continue + key = f'{plugin["name"]}@{plugin.get("marketplace", "")}' + entries[key] = plugin + uri = plugin.get('pluginUri', '') + if uri.startswith('file://'): + # url2pathname unquotes and handles Windows drive-letter URIs (file:///C:/...). + dirs[key] = Path(url2pathname(urlparse(uri).path)) + return _walk_registry_plugins(entries, dirs, is_enabled=False) + + +def _local_dir_plugins() -> dict: + """Local-directory plugins declared via the ``chat.pluginLocations`` setting.""" + settings = _load_jsonc(_vscode_user_dir() / 'settings.json') or {} + locations = settings.get(_PLUGIN_LOCATIONS_SETTING) + if not isinstance(locations, dict): + return {} + entries: dict[str, bool] = {} + dirs: dict[str, Path] = {} + for raw_path, enabled in locations.items(): + path = Path(raw_path).expanduser() + key = f'{path.name}@{_LOCAL_PLUGINS_MARKETPLACE}' + entries[key] = bool(enabled) + dirs[key] = path + return walk_enabled_plugins( + plugin_entries=entries, + is_enabled=bool, + locate_dir=lambda name, marketplace: dirs.get(f'{name}@{marketplace}'), + read_plugin=_read_copilot_plugin, + ) + + +def _collect_installed_plugins() -> dict: + """Merge the three plugin sources (first source wins on a duplicate key).""" + plugins: dict = {} + for source in (_cli_registry_plugins, _vscode_registry_plugins, _local_dir_plugins): + for key, entry in source().items(): + plugins.setdefault(key, entry) + return plugins + + +# --- MCP tool-name splitting ------------------------------------------------------ + + +def _known_mcp_server_names() -> list[str]: + """Config-declared MCP server names: user-level ``mcp.json`` + plugin configs. + + Best-effort inventory: servers contributed by extensions, ``chat.mcp.discovery`` + imports, dev containers, or non-default profiles are not discoverable from disk. + """ + config = _load_vscode_mcp_config() + servers = (config or {}).get('servers') + names = list(servers.keys()) if isinstance(servers, dict) else [] + for plugin in _collect_installed_plugins().values(): + names.extend(plugin.get('mcp_server_names') or []) + return names + + +def _server_name_variants(server_name: str) -> set[str]: + """Normalized forms a config name may take inside a VS Code tool-name prefix. + + The prefix derives from the server's self-reported handshake name, which often + resembles the config name modulo case and separators (a server configured as + ``dummy-tracker`` self-reporting ``DummyTracker`` yields prefix ``dummytracker``). + """ + lowered = server_name.lower() + underscored = re.sub(r'[^a-z0-9]+', '_', lowered).strip('_') + collapsed = re.sub(r'[^a-z0-9]', '', lowered) + return {v for v in (server_name, underscored, collapsed) if v} + + +def split_mcp_tool_name(tool_name: str, server_names: Iterable[str]) -> tuple[Optional[str], Optional[str]]: + """Split ``mcp__`` into ``(server, tool)``. + + The ```` part is VS Code's sanitized (and possibly truncated) form of + the server's SELF-REPORTED handshake name, not the config key — so matching + against known config names (and their normalized variants) is best-effort. + When nothing matches, return the unsplit remainder as the tool rather than + fabricating a server from a guessed split. + """ + rest = tool_name[len(_MCP_TOOL_PREFIX) :] + + best_server = None + best_variant_len = -1 + for server in server_names: + for variant in _server_name_variants(server): + if (rest == variant or rest.startswith(f'{variant}_')) and len(variant) > best_variant_len: + best_server = server + best_variant_len = len(variant) + if best_server is not None: + return best_server, rest[best_variant_len + 1 :] or None + + return None, rest or None + + +class Copilot(IDE): + name: ClassVar[str] = 'copilot' + display_name: ClassVar[str] = 'GitHub Copilot' + hook_events: ClassVar[list[str]] = list(_HOOK_EVENTS) + + def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: + # Dedicated Cycode-owned file (Copilot reads every *.json in the hooks + # dir), unlike the shared settings files of other IDEs. + if scope == 'repo' and repo_path: + return repo_path / _REPO_HOOKS_SUBDIR / _HOOKS_FILE_NAME + return _copilot_home() / 'hooks' / _HOOKS_FILE_NAME + + def render_hooks_config(self, async_mode: bool = False) -> dict: + def entry(command: str) -> dict: + if async_mode: + # Copilot has no async hook flag; background via shell on unix. The + # explicit <&0 keeps the payload flowing: a bare `cmd &` gets its stdin + # reattached to /dev/null by the shell (job control is off in hooks). + # Windows PowerShell has no trailing-& operator, so it stays sync. + return { + 'type': 'command', + 'bash': f'{command} <&0 &', + 'powershell': command, + 'timeoutSec': _HOOK_TIMEOUT_SEC, + } + # Single cross-platform `command` field, copied to both shells by Copilot. + return {'type': 'command', 'command': command, 'timeoutSec': _HOOK_TIMEOUT_SEC} + + return { + 'version': 1, + 'hooks': { + 'sessionStart': [ + {'type': 'command', 'command': _SESSION_START_COMMAND, 'timeoutSec': _HOOK_TIMEOUT_SEC} + ], + 'userPromptSubmitted': [entry(_SCAN_PROMPT_COMMAND)], + 'preToolUse': [entry(_SCAN_TOOL_COMMAND)], + }, + } + + def matches_payload(self, raw_payload: dict) -> bool: + # Structural discrimination, no magic strings: VS Code Copilot events carry + # a top-level ISO timestamp and no transcript_path; real Claude Code events + # always carry transcript_path; Copilot CLI payloads have no hook_event_name. + return ( + raw_payload.get('hook_event_name', '') in _COPILOT_SCAN_EVENT_NAMES + and 'timestamp' in raw_payload + and 'transcript_path' not in raw_payload + ) + + def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: + hook_event_name = raw_payload.get('hook_event_name', '') + tool_name = raw_payload.get('tool_name', '') + tool_input = raw_payload.get('tool_input') + + if hook_event_name == 'UserPromptSubmit': + canonical_event: Union[AiHookEventType, str] = AiHookEventType.PROMPT + elif hook_event_name == 'PreToolUse' and tool_name == _READ_FILE_TOOL: + canonical_event = AiHookEventType.FILE_READ + elif hook_event_name == 'PreToolUse' and tool_name.startswith(_MCP_TOOL_PREFIX): + canonical_event = AiHookEventType.MCP_EXECUTION + else: + # No matchers in Copilot hooks: preToolUse fires for every tool. Pass + # the raw tool name through — it matches no handler, so scan_command + # answers with a neutral allow before any policy/network work. + canonical_event = tool_name or hook_event_name + + file_path = None + if canonical_event == AiHookEventType.FILE_READ and isinstance(tool_input, dict): + file_path = tool_input.get('filePath') + + mcp_server_name = None + mcp_tool_name = None + mcp_arguments = None + if canonical_event == AiHookEventType.MCP_EXECUTION: + mcp_server_name, mcp_tool_name = split_mcp_tool_name(tool_name, _known_mcp_server_names()) + mcp_arguments = tool_input if isinstance(tool_input, dict) else None + + return AIHookPayload( + event_name=canonical_event, + conversation_id=raw_payload.get('session_id'), + ide_provider=self.name, + prompt=raw_payload.get('prompt', ''), + file_path=file_path, + mcp_server_name=mcp_server_name, + mcp_tool_name=mcp_tool_name, + mcp_arguments=mcp_arguments, + ) + + def build_hook_response(self, decision: HookDecision) -> dict: + if decision.action == DecisionAction.ALLOW: + # Neutral allow: {} means "no objection", leaving VS Code's own + # permission flow intact. An explicit permissionDecision "allow" would + # pre-approve the tool past the user's confirmation prompts — and with + # no matchers that would cover every tool, not just scanned ones. + return {} + + if decision.event_type == AiHookEventType.PROMPT: + reason = decision.user_message or '' + # decision/reason is what VS Code acts on; continue/stopReason/systemMessage + # are the generic top-level fields — the combo is what was verified live. + return { + 'decision': 'block', + 'reason': reason, + 'continue': False, + 'stopReason': reason, + 'systemMessage': reason, + } + + return { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': decision.action.value, # 'deny' or 'ask' + 'permissionDecisionReason': decision.user_message or '', + } + } + + def build_session_payload(self, raw_payload: dict) -> AIHookPayload: + return AIHookPayload( + conversation_id=raw_payload.get('session_id'), + model=raw_payload.get('model'), + ide_provider=self.name, + source=raw_payload.get('source'), + ) + + def get_session_context(self) -> tuple[Optional[dict], dict]: + # VS Code's mcp.json uses `servers` as its top-level key; normalized to the + # canonical mcpServers shape by build_global_config_file. + config = _load_vscode_mcp_config() + global_config_file = ( + build_global_config_file(_vscode_mcp_config_path(), config.get('servers')) if config else None + ) + return global_config_file, _collect_installed_plugins() diff --git a/cycode/cli/apps/ai_guardrails/ides/cursor.py b/cycode/cli/apps/ai_guardrails/ides/cursor.py index 950e15bb..01c65edb 100644 --- a/cycode/cli/apps/ai_guardrails/ides/cursor.py +++ b/cycode/cli/apps/ai_guardrails/ides/cursor.py @@ -7,7 +7,7 @@ from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND from cycode.cli.apps.ai_guardrails.ides._plugin_utils import build_global_config_file -from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision +from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision, shell_background_suffix from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType from cycode.logger import get_logger @@ -69,7 +69,7 @@ def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: return _user_hooks_dir() / _HOOKS_FILE_NAME def render_hooks_config(self, async_mode: bool = False) -> dict: - command = f'{_SCAN_COMMAND} &' if async_mode else _SCAN_COMMAND + command = f'{_SCAN_COMMAND}{shell_background_suffix(async_mode)}' hooks = {event: [{'command': command}] for event in self.hook_events} hooks['sessionStart'] = [{'command': _SESSION_START_COMMAND}] return {'version': 1, 'hooks': hooks} diff --git a/cycode/cli/apps/ai_guardrails/scan/scan_command.py b/cycode/cli/apps/ai_guardrails/scan/scan_command.py index e6f8b977..cad92263 100644 --- a/cycode/cli/apps/ai_guardrails/scan/scan_command.py +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -82,6 +82,14 @@ def scan_command( hidden=True, ), ] = DEFAULT_IDE_NAME, + event: Annotated[ + Optional[str], + typer.Option( + '--event', + help='Hook event that triggered the scan, for IDEs whose payloads omit it (e.g. Copilot CLI).', + hidden=True, + ), + ] = None, ) -> None: """Scan content from AI IDE hooks for secrets. @@ -110,7 +118,18 @@ def scan_command( unified_payload = ide_integration.parse_hook_payload(payload) event_name = unified_payload.event_name - logger.debug('Processing AI guardrails hook', extra={'event_name': event_name, 'ide': ide_integration.name}) + logger.debug( + 'Processing AI guardrails hook', + extra={'event_name': event_name, 'ide': ide_integration.name, 'cli_event_hint': event}, + ) + + # Resolved before any policy/client work: Copilot hooks have no matchers, so + # every tool call arrives here and unmatched tools must exit fast. + handler = get_handler_for_event(event_name) + if handler is None: + logger.debug('Unknown hook event, allowing by default', extra={'event_name': event_name}) + output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT))) + return # `or` (not a .get default) - Cursor sends workspace_roots=[] when no folder is open. workspace_roots = payload.get('workspace_roots') or ['.'] @@ -119,12 +138,6 @@ def scan_command( try: _initialize_clients(ctx) - handler = get_handler_for_event(event_name) - if handler is None: - logger.debug('Unknown hook event, allowing by default', extra={'event_name': event_name}) - output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT))) - return - decision = handler(ctx, unified_payload, policy) logger.debug('Hook handler completed', extra={'event_name': event_name, 'action': decision.action.value}) output_json(ide_integration.build_hook_response(decision)) diff --git a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py index dbaca44f..60fb331e 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py +++ b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py @@ -20,10 +20,34 @@ def test_matches_payload_only_claude_events() -> None: claude = ClaudeCode() - assert claude.matches_payload({'hook_event_name': 'UserPromptSubmit'}) is True - assert claude.matches_payload({'hook_event_name': 'PreToolUse'}) is True - assert claude.matches_payload({'hook_event_name': 'beforeSubmitPrompt'}) is False - assert claude.matches_payload({'hook_event_name': 'beforeReadFile'}) is False + transcript = {'transcript_path': '/home/user/.claude/projects/transcript.jsonl'} + assert claude.matches_payload({'hook_event_name': 'UserPromptSubmit', **transcript}) is True + assert claude.matches_payload({'hook_event_name': 'PreToolUse', **transcript}) is True + assert claude.matches_payload({'hook_event_name': 'beforeSubmitPrompt', **transcript}) is False + assert claude.matches_payload({'hook_event_name': 'beforeReadFile', **transcript}) is False + + +def test_matches_payload_rejects_vscode_copilot_payloads() -> None: + """VS Code Copilot sends the same event names in the same snake_case dialect, + but never a transcript_path — those events must not be claimed as Claude Code.""" + claude = ClaudeCode() + assert ( + claude.matches_payload( + { + 'timestamp': '2026-07-14T13:33:24.387Z', + 'hook_event_name': 'PreToolUse', + 'session_id': '43cbad91-ea8b-4d4a-9acc-56561421c5d2', + 'tool_name': 'read_file', + 'tool_input': {'filePath': '/Users/user/.gitconfig'}, + 'tool_use_id': 'call_KuiUJvNJ06uHlIdwKy16G9W6__vscode-1784034535752', + } + ) + is False + ) + assert ( + claude.matches_payload({'timestamp': '2026-07-14T13:32:46.517Z', 'hook_event_name': 'UserPromptSubmit'}) + is False + ) def test_parse_prompt_payload() -> None: diff --git a/tests/cli/commands/ai_guardrails/ides/test_codex.py b/tests/cli/commands/ai_guardrails/ides/test_codex.py index 682739e6..33137311 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_codex.py +++ b/tests/cli/commands/ai_guardrails/ides/test_codex.py @@ -8,6 +8,7 @@ import pytest from pyfakefs.fake_filesystem import FakeFilesystem +from pytest_mock import MockerFixture from cycode.cli.apps.ai_guardrails.ides.base import HookDecision from cycode.cli.apps.ai_guardrails.ides.codex import ( @@ -165,8 +166,9 @@ def test_render_hooks_never_emits_async_toml_flags() -> None: assert 'timeout' not in hook -def test_render_hooks_async_backgrounds_scan_hooks() -> None: - """In async mode, UserPromptSubmit + PreToolUse scan hooks shell-background.""" +def test_render_hooks_async_backgrounds_scan_hooks(mocker: MockerFixture) -> None: + """In async mode, UserPromptSubmit + PreToolUse scan hooks shell-background (unix).""" + mocker.patch('platform.system', return_value='Linux') rendered = Codex().render_hooks_config(async_mode=True) prompt_cmd = rendered['hooks']['UserPromptSubmit'][0]['hooks'][0]['command'] pretool_cmd = rendered['hooks']['PreToolUse'][0]['hooks'][0]['command'] @@ -174,6 +176,16 @@ def test_render_hooks_async_backgrounds_scan_hooks() -> None: assert pretool_cmd.endswith(' &') +def test_render_hooks_async_windows_stays_sync(mocker: MockerFixture) -> None: + """No '&' on Windows - nothing there detaches safely, so hooks run sync.""" + mocker.patch('platform.system', return_value='Windows') + rendered = Codex().render_hooks_config(async_mode=True) + prompt_cmd = rendered['hooks']['UserPromptSubmit'][0]['hooks'][0]['command'] + pretool_cmd = rendered['hooks']['PreToolUse'][0]['hooks'][0]['command'] + assert '&' not in prompt_cmd + assert '&' not in pretool_cmd + + def test_render_hooks_session_start_always_synchronous() -> None: """SessionStart registers the conversation context — never backgrounded.""" for mode in (False, True): diff --git a/tests/cli/commands/ai_guardrails/ides/test_contract.py b/tests/cli/commands/ai_guardrails/ides/test_contract.py index 3bdbc19d..7d7ab773 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_contract.py +++ b/tests/cli/commands/ai_guardrails/ides/test_contract.py @@ -8,6 +8,7 @@ from pathlib import Path import pytest +from pytest_mock import MockerFixture from cycode.cli.apps.ai_guardrails.ides import IDES from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision @@ -60,8 +61,13 @@ def test_render_hooks_config_has_hooks_key(ide: IDE) -> None: assert isinstance(rendered['hooks'], dict) -def test_render_hooks_config_async_changes_output(ide: IDE) -> None: - """async_mode must influence the rendered output.""" +def test_render_hooks_config_async_changes_output(ide: IDE, mocker: MockerFixture) -> None: + """async_mode must influence the rendered output. + + Pinned to a unix platform: IDEs that background via a shell `&` render + identical sync/async configs on Windows, where no safe suffix exists. + """ + mocker.patch('platform.system', return_value='Linux') assert ide.render_hooks_config(async_mode=False) != ide.render_hooks_config(async_mode=True) diff --git a/tests/cli/commands/ai_guardrails/ides/test_copilot.py b/tests/cli/commands/ai_guardrails/ides/test_copilot.py new file mode 100644 index 00000000..8141f74f --- /dev/null +++ b/tests/cli/commands/ai_guardrails/ides/test_copilot.py @@ -0,0 +1,416 @@ +"""GitHub Copilot (VS Code) IDE integration tests. + +Payload fixtures mirror real events captured from VS Code (built-in Copilot +Chat 0.56.0) and Copilot CLI, with identifying values swapped for dummies. +""" + +import json +import os +from pathlib import Path +from typing import Optional + +from pyfakefs.fake_filesystem import FakeFilesystem +from pytest_mock import MockerFixture + +from cycode.cli.apps.ai_guardrails.ides.base import HookDecision +from cycode.cli.apps.ai_guardrails.ides.copilot import ( + Copilot, + _vscode_mcp_config_path, + split_mcp_tool_name, +) +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType + +_VSCODE_PROMPT_PAYLOAD = { + 'timestamp': '2026-07-14T13:32:46.517Z', + 'hook_event_name': 'UserPromptSubmit', + 'session_id': '43cbad91-ea8b-4d4a-9acc-56561421c5d2', + 'prompt': 'test prompt', +} + +_VSCODE_READ_FILE_PAYLOAD = { + 'timestamp': '2026-07-14T13:35:08.758Z', + 'hook_event_name': 'PreToolUse', + 'session_id': '43cbad91-ea8b-4d4a-9acc-56561421c5d2', + 'tool_name': 'read_file', + 'tool_input': {'filePath': '/Users/user/.gitconfig', 'startLine': 1, 'endLine': 200}, + 'tool_use_id': 'call_dummyDummyDummyDummy__vscode-1784034535752', +} + +_VSCODE_MCP_PAYLOAD = { + 'timestamp': '2026-07-14T14:03:57.337Z', + 'hook_event_name': 'PreToolUse', + 'session_id': '43cbad91-ea8b-4d4a-9acc-56561421c5d2', + 'tool_name': 'mcp_gitlab_get_user', + 'tool_input': {'user_id': 'dummy-user'}, + 'tool_use_id': 'call_dummyDummyDummyDummy__vscode-1784034535755', +} + +_VSCODE_SESSION_START_PAYLOAD = { + 'timestamp': '2026-07-14T13:32:46.474Z', + 'hook_event_name': 'SessionStart', + 'session_id': '43cbad91-ea8b-4d4a-9acc-56561421c5d2', + 'source': 'new', + 'model': 'auto', +} + +# Copilot CLI dialect: camelCase, epoch-ms timestamp, no event name, stringified args. +_COPILOT_CLI_TOOL_PAYLOAD = { + 'sessionId': '826a14c1-cfb5-4946-9618-8b0bb7060466', + 'timestamp': 1784038775604, + 'cwd': '/Users/user', + 'toolName': 'view', + 'toolArgs': '{"path": "/Users/user/.zshrc"}', +} + +_CLAUDE_CODE_PAYLOAD = { + 'session_id': 'session-123', + 'transcript_path': '/home/user/.claude/projects/transcript.jsonl', + 'cwd': '/Users/user/project', + 'hook_event_name': 'PreToolUse', + 'tool_name': 'Read', + 'tool_input': {'file_path': '/Users/user/.gitconfig'}, + 'tool_use_id': 'toolu_dummyDummyDummyDummy', +} + + +# --- matches_payload ------------------------------------------------------------ + + +def test_matches_payload_accepts_vscode_events() -> None: + copilot = Copilot() + assert copilot.matches_payload(_VSCODE_PROMPT_PAYLOAD) is True + assert copilot.matches_payload(_VSCODE_READ_FILE_PAYLOAD) is True + assert copilot.matches_payload(_VSCODE_MCP_PAYLOAD) is True + + +def test_matches_payload_rejects_claude_code_payloads() -> None: + # Same event names and dialect, but Claude Code always carries transcript_path. + assert Copilot().matches_payload(_CLAUDE_CODE_PAYLOAD) is False + + +def test_matches_payload_rejects_copilot_cli_payloads() -> None: + # CLI dialect is unsupported until its own parsing lands - must skip fail-open. + assert Copilot().matches_payload(_COPILOT_CLI_TOOL_PAYLOAD) is False + + +def test_matches_payload_rejects_cursor_payloads() -> None: + assert Copilot().matches_payload({'hook_event_name': 'beforeSubmitPrompt', 'prompt': 'test'}) is False + + +def test_matches_payload_requires_timestamp() -> None: + payload = {k: v for k, v in _VSCODE_PROMPT_PAYLOAD.items() if k != 'timestamp'} + assert Copilot().matches_payload(payload) is False + + +# --- parse_hook_payload --------------------------------------------------------- + + +def test_parse_prompt_payload() -> None: + unified = Copilot().parse_hook_payload(_VSCODE_PROMPT_PAYLOAD) + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id == '43cbad91-ea8b-4d4a-9acc-56561421c5d2' + assert unified.ide_provider == 'copilot' + assert unified.prompt == 'test prompt' + + +def test_parse_read_file_payload() -> None: + unified = Copilot().parse_hook_payload(_VSCODE_READ_FILE_PAYLOAD) + assert unified.event_name == AiHookEventType.FILE_READ + assert unified.file_path == '/Users/user/.gitconfig' + assert unified.mcp_tool_name is None + + +def test_parse_mcp_payload_without_known_servers_reports_raw(fs: FakeFilesystem) -> None: + # No known servers on disk - honest fallback: no fabricated server, the full + # unsplit remainder as the tool. + unified = Copilot().parse_hook_payload(_VSCODE_MCP_PAYLOAD) + assert unified.event_name == AiHookEventType.MCP_EXECUTION + assert unified.mcp_server_name is None + assert unified.mcp_tool_name == 'gitlab_get_user' + assert unified.mcp_arguments == {'user_id': 'dummy-user'} + + +def test_parse_mcp_payload_with_known_server_containing_underscores(fs: FakeFilesystem) -> None: + fs.create_file( + _vscode_mcp_config_path(), + contents=json.dumps({'servers': {'gitlab_selfhosted': {'command': 'dummy-mcp'}}}), + ) + payload = {**_VSCODE_MCP_PAYLOAD, 'tool_name': 'mcp_gitlab_selfhosted_get_user'} + unified = Copilot().parse_hook_payload(payload) + assert unified.mcp_server_name == 'gitlab_selfhosted' + assert unified.mcp_tool_name == 'get_user' + + +def test_parse_unmatched_tool_passes_raw_tool_name_through() -> None: + # No matchers in Copilot hooks: unscanned tools must map to an event that + # matches no handler so scan_command answers with a neutral allow. + payload = {**_VSCODE_READ_FILE_PAYLOAD, 'tool_name': 'list_dir', 'tool_input': {'path': '/Users/user'}} + unified = Copilot().parse_hook_payload(payload) + assert unified.event_name == 'list_dir' + assert unified.file_path is None + assert unified.mcp_server_name is None + + +# --- split_mcp_tool_name -------------------------------------------------------- + + +def test_split_mcp_tool_name_prefers_longest_known_server() -> None: + servers = ['gitlab', 'gitlab_selfhosted'] + assert split_mcp_tool_name('mcp_gitlab_selfhosted_get_user', servers) == ('gitlab_selfhosted', 'get_user') + + +def test_split_mcp_tool_name_matches_normalized_config_name() -> None: + # The wire prefix is the sanitized SELF-REPORTED server name (`DummyTracker` -> + # `dummytracker`), which resembles the config name modulo separators. + assert split_mcp_tool_name('mcp_dummytracker_fetch_api', ['dummy-tracker']) == ('dummy-tracker', 'fetch_api') + + +def test_split_mcp_tool_name_unknown_server_reports_raw() -> None: + # Self-reported names can diverge entirely from config names (e.g. a server + # configured as `dummy-plugin` self-reporting `Vendor.DummyApp.Hybrid` yields + # a sanitized+truncated prefix like `vendor_du`) - never guess a split. + assert split_mcp_tool_name('mcp_vendor_du_search_instructions', ['dummy-plugin']) == ( + None, + 'vendor_du_search_instructions', + ) + + +def test_split_mcp_tool_name_server_only() -> None: + assert split_mcp_tool_name('mcp_gitlab', ['gitlab']) == ('gitlab', None) + + +# --- build_hook_response -------------------------------------------------------- + + +def test_allow_is_neutral_for_every_event_type() -> None: + """Allow must be {} - an explicit permissionDecision "allow" would pre-approve + tools past VS Code's own permission prompts (and with no matchers, that would + cover every tool, not just scanned ones).""" + copilot = Copilot() + for event_type in AiHookEventType: + assert copilot.build_hook_response(HookDecision.allow(event_type)) == {} + + +def test_deny_prompt_response_shape() -> None: + response = Copilot().build_hook_response(HookDecision.deny(AiHookEventType.PROMPT, 'Secrets detected')) + assert response['decision'] == 'block' + assert response['reason'] == 'Secrets detected' + assert response['continue'] is False + assert response['stopReason'] == 'Secrets detected' + + +def test_deny_tool_response_shape() -> None: + response = Copilot().build_hook_response(HookDecision.deny(AiHookEventType.FILE_READ, 'Sensitive file')) + assert response == { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'deny', + 'permissionDecisionReason': 'Sensitive file', + } + } + + +def test_ask_mcp_response_shape() -> None: + response = Copilot().build_hook_response(HookDecision.ask(AiHookEventType.MCP_EXECUTION, 'Allow execution?')) + assert response['hookSpecificOutput']['permissionDecision'] == 'ask' + assert response['hookSpecificOutput']['permissionDecisionReason'] == 'Allow execution?' + + +# --- render_hooks_config / settings_path ---------------------------------------- + + +def test_render_hooks_config_sync_uses_cross_platform_command() -> None: + rendered = Copilot().render_hooks_config() + assert rendered['version'] == 1 + + prompt_entry = rendered['hooks']['userPromptSubmitted'][0] + assert prompt_entry['command'] == 'cycode ai-guardrails scan --ide copilot --event UserPromptSubmit' + assert 'bash' not in prompt_entry + + tool_entry = rendered['hooks']['preToolUse'][0] + assert tool_entry['command'] == 'cycode ai-guardrails scan --ide copilot --event PreToolUse' + + session_entry = rendered['hooks']['sessionStart'][0] + assert session_entry['command'] == 'cycode ai-guardrails session-start --ide copilot' + + +def test_render_hooks_config_async_backgrounds_on_unix() -> None: + rendered = Copilot().render_hooks_config(async_mode=True) + tool_entry = rendered['hooks']['preToolUse'][0] + assert tool_entry['bash'].endswith('&') + assert not tool_entry['powershell'].endswith('&') + assert 'command' not in tool_entry + + +def test_settings_path_user_scope() -> None: + path = Copilot().settings_path('user') + assert path == Path.home() / '.copilot' / 'hooks' / 'cycode.json' + + +def test_settings_path_honors_copilot_home(mocker: MockerFixture) -> None: + mocker.patch.dict(os.environ, {'COPILOT_HOME': '/custom/copilot-home'}) + path = Copilot().settings_path('user') + assert path == Path('/custom/copilot-home') / 'hooks' / 'cycode.json' + + +def test_settings_path_repo_scope(tmp_path: Path) -> None: + path = Copilot().settings_path('repo', tmp_path) + assert path == tmp_path / '.github' / 'hooks' / 'cycode.json' + + +# --- session payload / context -------------------------------------------------- + + +def test_build_session_payload() -> None: + session = Copilot().build_session_payload(_VSCODE_SESSION_START_PAYLOAD) + assert session.ide_provider == 'copilot' + assert session.conversation_id == '43cbad91-ea8b-4d4a-9acc-56561421c5d2' + assert session.model == 'auto' + assert session.source == 'new' + + +def test_get_session_context_normalizes_servers_key(fs: FakeFilesystem) -> None: + config_path = _vscode_mcp_config_path() + fs.create_file( + config_path, + contents=json.dumps({'servers': {'gitlab': {'type': 'stdio', 'command': 'dummy-mcp'}}}), + ) + + global_config_file, plugins = Copilot().get_session_context() + + assert global_config_file is not None + assert global_config_file['path'] == str(config_path) + # VS Code's `servers` key is normalized to the canonical mcpServers shape. + assert json.loads(global_config_file['content']) == { + 'mcpServers': {'gitlab': {'type': 'stdio', 'command': 'dummy-mcp'}} + } + assert plugins == {} + + +def test_get_session_context_without_config(fs: FakeFilesystem) -> None: + assert Copilot().get_session_context() == (None, {}) + + +# --- plugins inventory ----------------------------------------------------------- + + +def _create_plugin_on_disk( + fs: FakeFilesystem, + plugin_dir: Path, + manifest_location: str = '.github/plugin/plugin.json', + manifest_extra: Optional[dict] = None, + mcp_file: str = '.mcp.json', + server_name: str = 'dummy-server', +) -> None: + manifest = {'name': plugin_dir.name, 'version': '1.0.0', 'description': 'Dummy plugin', **(manifest_extra or {})} + fs.create_file(plugin_dir / manifest_location, contents=json.dumps(manifest)) + fs.create_file( + plugin_dir / mcp_file, + contents=json.dumps({'mcpServers': {server_name: {'command': 'dummy-mcp'}}}), + ) + + +def test_cli_registry_plugins(fs: FakeFilesystem) -> None: + """CLI-installed plugins: comment-headed config.json registry, manifest with an + mcpServers path-ref.""" + plugin_dir = Path.home() / '.copilot' / 'installed-plugins' / 'dummy-marketplace' / 'dummy-plugin' + _create_plugin_on_disk(fs, plugin_dir, manifest_extra={'mcpServers': './.mcp.json'}) + fs.create_file( + Path.home() / '.copilot' / 'config.json', + contents='// This file is managed automatically.\n' + + json.dumps( + { + 'installedPlugins': [ + { + 'name': 'dummy-plugin', + 'marketplace': 'dummy-marketplace', + 'version': '1.0.0', + 'cache_path': str(plugin_dir), + 'enabled': True, + }, + { + 'name': 'disabled-plugin', + 'marketplace': 'dummy-marketplace', + 'cache_path': str(plugin_dir), + 'enabled': False, + }, + ] + } + ), + ) + + _, plugins = Copilot().get_session_context() + + assert set(plugins) == {'dummy-plugin@dummy-marketplace'} + entry = plugins['dummy-plugin@dummy-marketplace'] + assert entry['enabled'] is True + assert entry['version'] == '1.0.0' + assert entry['mcp_server_names'] == ['dummy-server'] + assert json.loads(entry['mcp_config_file']) == {'mcpServers': {'dummy-server': {'command': 'dummy-mcp'}}} + + +def test_vscode_registry_plugins(fs: FakeFilesystem) -> None: + """VS Code UI-installed plugins: installed.json registry with file:// pluginUri, + root .mcp.json convention without a manifest mcpServers field.""" + plugin_dir = ( + Path.home() / '.vscode' / 'agent-plugins' / 'github.com' / 'dummy-org' / 'repo' / 'plugins' / 'dummy-plugin' + ) + _create_plugin_on_disk(fs, plugin_dir, manifest_location='.claude-plugin/plugin.json') + fs.create_file( + Path.home() / '.vscode' / 'agent-plugins' / 'installed.json', + contents=json.dumps( + { + 'version': 1, + 'installed': [ + {'pluginUri': plugin_dir.as_uri(), 'marketplace': 'dummy-marketplace', 'name': 'dummy-plugin'} + ], + } + ), + ) + + _, plugins = Copilot().get_session_context() + + assert set(plugins) == {'dummy-plugin@dummy-marketplace'} + assert plugins['dummy-plugin@dummy-marketplace']['mcp_server_names'] == ['dummy-server'] + + +def test_local_dir_plugins_from_plugin_locations_setting(fs: FakeFilesystem) -> None: + """Local-directory plugins declared via chat.pluginLocations (JSONC settings).""" + enabled_dir = Path('/plugins/local-plugin') + disabled_dir = Path('/plugins/disabled-plugin') + _create_plugin_on_disk(fs, enabled_dir, manifest_location='plugin.json') + _create_plugin_on_disk(fs, disabled_dir, manifest_location='plugin.json') + # json.dumps escapes Windows path separators; the comment line exercises JSONC handling. + settings = json.dumps({'chat.pluginLocations': {str(enabled_dir): True, str(disabled_dir): False}}) + fs.create_file( + _vscode_mcp_config_path().parent / 'settings.json', + contents=f'// user settings\n{settings}', + ) + + _, plugins = Copilot().get_session_context() + + assert set(plugins) == {'local-plugin@local'} + assert plugins['local-plugin@local']['mcp_server_names'] == ['dummy-server'] + + +def test_parse_mcp_payload_matches_plugin_server_via_normalized_name(fs: FakeFilesystem) -> None: + """End-to-end split: a plugin-declared server named dummy-tracker attributes the + wire tool mcp_dummytracker_fetch_api (prefix = sanitized self-reported name).""" + plugin_dir = Path.home() / '.copilot' / 'installed-plugins' / 'dummy-marketplace' / 'dummy-tracker' + _create_plugin_on_disk(fs, plugin_dir, server_name='dummy-tracker') + fs.create_file( + Path.home() / '.copilot' / 'config.json', + contents=json.dumps( + { + 'installedPlugins': [ + {'name': 'dummy-tracker', 'marketplace': 'dummy-marketplace', 'cache_path': str(plugin_dir)} + ] + } + ), + ) + + payload = {**_VSCODE_MCP_PAYLOAD, 'tool_name': 'mcp_dummytracker_fetch_api', 'tool_input': {'key': 'payments'}} + unified = Copilot().parse_hook_payload(payload) + + assert unified.mcp_server_name == 'dummy-tracker' + assert unified.mcp_tool_name == 'fetch_api' diff --git a/tests/cli/commands/ai_guardrails/scan/test_scan_command.py b/tests/cli/commands/ai_guardrails/scan/test_scan_command.py index b7d7734a..349a1ee3 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_scan_command.py +++ b/tests/cli/commands/ai_guardrails/scan/test_scan_command.py @@ -127,7 +127,12 @@ def test_claude_code_payload_with_claude_code_ide( mock_scan_command_deps: dict[str, MagicMock], ) -> None: """Test Claude Code payload is processed when --ide claude-code is specified.""" - payload = {'hook_event_name': 'UserPromptSubmit', 'session_id': 'session-123', 'prompt': 'test'} + payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test', + 'transcript_path': '/home/user/.claude/projects/transcript.jsonl', + } mocker.patch('sys.stdin', StringIO(json.dumps(payload))) mock_scan_command_deps['load_policy'].return_value = {'fail_open': True} @@ -166,6 +171,63 @@ def test_empty_workspace_roots_falls_back_to_cwd( mock_handler.assert_called_once() +class TestCopilotPayloadRouting: + """Copilot-specific routing through scan_command.""" + + def test_unmatched_tool_allows_without_policy_or_clients( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Copilot hooks have no matchers - tools we don't scan must skip fast. + + The handler lookup runs before load_policy/_initialize_clients, so an + unmatched tool costs neither file I/O nor network setup. + """ + payload = { + 'timestamp': '2026-07-14T13:33:24.387Z', + 'hook_event_name': 'PreToolUse', + 'session_id': 'session-123', + 'tool_name': 'list_dir', + 'tool_input': {'path': '/Users/user'}, + 'tool_use_id': 'call_abc__vscode-1', + } + mocker.patch('sys.stdin', StringIO(json.dumps(payload))) + mock_scan_command_deps['get_handler'].return_value = None + + scan_command(mock_ctx, ide='copilot') + + mock_scan_command_deps['get_handler'].assert_called_once_with('list_dir') + mock_scan_command_deps['load_policy'].assert_not_called() + mock_scan_command_deps['initialize_clients'].assert_not_called() + assert json.loads(capsys.readouterr().out) == {} + + def test_copilot_cli_payload_skipped( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Copilot CLI shares the hooks file but speaks camelCase without an event + name - until its dialect is supported, its events skip fail-open.""" + payload = { + 'sessionId': '826a14c1-cfb5-4946-9618-8b0bb7060466', + 'timestamp': 1784038775604, + 'cwd': '/Users/user', + 'toolName': 'view', + 'toolArgs': '{"path": "/Users/user/file"}', + } + mocker.patch('sys.stdin', StringIO(json.dumps(payload))) + + scan_command(mock_ctx, ide='copilot') + + _assert_no_api_calls(mock_scan_command_deps) + assert json.loads(capsys.readouterr().out) == {} + + class TestDefaultIdeParameterViaCli: """Tests that verify default IDE parameter works correctly via CLI invocation.""" diff --git a/tests/cli/commands/ai_guardrails/test_hooks_manager.py b/tests/cli/commands/ai_guardrails/test_hooks_manager.py index 2097780a..cf478fcc 100644 --- a/tests/cli/commands/ai_guardrails/test_hooks_manager.py +++ b/tests/cli/commands/ai_guardrails/test_hooks_manager.py @@ -1,5 +1,6 @@ """Tests for AI guardrails hooks manager and per-IDE hooks rendering.""" +import json from pathlib import Path from typing import TYPE_CHECKING @@ -8,6 +9,7 @@ if TYPE_CHECKING: import pytest + from pytest_mock import MockerFixture from cycode.cli.apps.ai_guardrails.consts import ( CYCODE_SCAN_PROMPT_COMMAND, @@ -22,6 +24,7 @@ ) from cycode.cli.apps.ai_guardrails.ides.claude_code import ClaudeCode from cycode.cli.apps.ai_guardrails.ides.codex import Codex +from cycode.cli.apps.ai_guardrails.ides.copilot import Copilot from cycode.cli.apps.ai_guardrails.ides.cursor import Cursor @@ -43,6 +46,13 @@ def test_is_cycode_hook_entry_claude_code_format() -> None: assert is_cycode_hook_entry(entry) is True +def test_is_cycode_hook_entry_copilot_shell_fields() -> None: + # Copilot async entries carry per-OS bash/powershell fields instead of `command`. + assert is_cycode_hook_entry({'type': 'command', 'bash': 'cycode ai-guardrails scan --ide copilot &'}) is True + assert is_cycode_hook_entry({'type': 'command', 'powershell': 'cycode ai-guardrails scan --ide copilot'}) is True + assert is_cycode_hook_entry({'type': 'command', 'bash': '/usr/local/bin/user-hook.sh'}) is False + + def test_is_cycode_hook_entry_non_cycode() -> None: """Non-Cycode hooks must not be detected.""" assert is_cycode_hook_entry({'command': 'some-other-command'}) is False @@ -69,8 +79,9 @@ def test_cursor_render_hooks_sync() -> None: assert '&' not in entry['command'] -def test_cursor_render_hooks_async() -> None: - """Cursor async hooks: '&' suffix on scan commands.""" +def test_cursor_render_hooks_async(mocker: 'MockerFixture') -> None: + """Cursor async hooks: '&' suffix on scan commands (unix).""" + mocker.patch('platform.system', return_value='Linux') config = Cursor().render_hooks_config(async_mode=True) scan_hooks = {k: v for k, v in config['hooks'].items() if k != 'sessionStart'} for entries in scan_hooks.values(): @@ -79,6 +90,17 @@ def test_cursor_render_hooks_async() -> None: assert CYCODE_SCAN_PROMPT_COMMAND in entry['command'] +def test_cursor_render_hooks_async_windows_stays_sync(mocker: 'MockerFixture') -> None: + """No '&' on Windows: cmd treats it as a no-op separator and Windows + PowerShell rejects it outright - either way nothing detaches.""" + mocker.patch('platform.system', return_value='Windows') + config = Cursor().render_hooks_config(async_mode=True) + scan_hooks = {k: v for k, v in config['hooks'].items() if k != 'sessionStart'} + for entries in scan_hooks.values(): + for entry in entries: + assert '&' not in entry['command'] + + def test_cursor_render_hooks_session_start() -> None: """Cursor session_start carries the --ide flag explicitly.""" config = Cursor().render_hooks_config() @@ -170,8 +192,6 @@ def test_install_preserves_user_hook_colocated_with_cycode( """install must not clobber a user-authored hook that shares an entry with a Cycode hook. The filter is hook-level, not entry-level. """ - import json - repo = Path('/repo') fs.create_dir(repo) hooks_path = repo / '.codex' / 'hooks.json' @@ -223,8 +243,6 @@ def test_uninstall_preserves_user_hook_colocated_with_cycode( fs: FakeFilesystem, monkeypatch: 'pytest.MonkeyPatch' ) -> None: """uninstall must strip only the Cycode hook from a mixed entry.""" - import json - repo = Path('/repo') fs.create_dir(repo) hooks_path = repo / '.codex' / 'hooks.json' @@ -259,6 +277,32 @@ def test_uninstall_preserves_user_hook_colocated_with_cycode( assert not any('cycode ai-guardrails' in c for c in commands) +def test_copilot_dedicated_file_install_uninstall_lifecycle(fs: FakeFilesystem) -> None: + """Copilot uses a dedicated Cycode-owned file: install creates it from + scratch, reinstall is idempotent, uninstall removes the file entirely.""" + copilot = Copilot() + hooks_path = copilot.settings_path('user') + + success, _ = install_hooks(copilot) + assert success is True + saved = json.loads(hooks_path.read_text()) + assert saved['version'] == 1 + assert set(saved['hooks']) == {'sessionStart', 'userPromptSubmitted', 'preToolUse'} + assert all(len(entries) == 1 for entries in saved['hooks'].values()) + + # Reinstall (also flipping mode) must replace, not duplicate. + success, _ = install_hooks(copilot, report_mode=True) + assert success is True + saved = json.loads(hooks_path.read_text()) + assert all(len(entries) == 1 for entries in saved['hooks'].values()) + assert saved['hooks']['preToolUse'][0]['bash'].endswith('&') + + # Uninstall deletes the emptied dedicated file rather than leaving a husk. + success, _ = uninstall_hooks(copilot) + assert success is True + assert not hooks_path.exists() + + def test_create_policy_file_repo_scope(fs: FakeFilesystem) -> None: """Create a policy file in repo scope.""" repo_path = Path('/my-repo') diff --git a/tests/cli/commands/ai_guardrails/test_session_start_command.py b/tests/cli/commands/ai_guardrails/test_session_start_command.py index 6beaa615..eaec8531 100644 --- a/tests/cli/commands/ai_guardrails/test_session_start_command.py +++ b/tests/cli/commands/ai_guardrails/test_session_start_command.py @@ -12,6 +12,7 @@ from cycode.cli.apps.ai_guardrails.ides import IDES, collect_all_session_contexts from cycode.cli.apps.ai_guardrails.ides import claude_code as _claude_mod from cycode.cli.apps.ai_guardrails.ides import codex as _codex_mod +from cycode.cli.apps.ai_guardrails.ides import copilot as _copilot_mod from cycode.cli.apps.ai_guardrails.ides import cursor as _cursor_mod from cycode.cli.apps.ai_guardrails.session_start_command import session_start_command @@ -285,19 +286,23 @@ def test_no_mcp_anywhere_still_reports_device( ) +@patch.object(_copilot_mod, '_collect_installed_plugins') +@patch.object(_copilot_mod, '_load_vscode_mcp_config') @patch.object(_codex_mod, '_load_codex_config') @patch.object(_cursor_mod, '_load_cursor_mcp_config') @patch.object(_claude_mod, 'load_claude_settings') @patch.object(_claude_mod, 'load_claude_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') -def test_claude_code_reports_global_file_and_plugin_metadata( +def test_claude_code_reports_config_files_and_plugin_metadata( mock_get_auth: MagicMock, mock_get_client: MagicMock, mock_load_config: MagicMock, mock_load_settings: MagicMock, mock_load_cursor: MagicMock, mock_load_codex: MagicMock, + mock_load_vscode: MagicMock, + mock_collect_copilot_plugins: MagicMock, mock_ctx: MagicMock, tmp_path: Path, ) -> None: @@ -308,6 +313,8 @@ def test_claude_code_reports_global_file_and_plugin_metadata( mock_get_client.return_value = mock_ai_client mock_load_cursor.return_value = None mock_load_codex.return_value = None + mock_load_vscode.return_value = None + mock_collect_copilot_plugins.return_value = {} # Set up a fake plugin directory on disk. plugin_dir = tmp_path / 'dummy-plugin' @@ -360,6 +367,8 @@ def test_claude_code_reports_global_file_and_plugin_metadata( ) +@patch.object(_copilot_mod, '_collect_installed_plugins') +@patch.object(_copilot_mod, '_load_vscode_mcp_config') @patch.object(_codex_mod, '_load_codex_config') @patch.object(_claude_mod, 'load_claude_settings') @patch.object(_claude_mod, 'load_claude_config') @@ -373,6 +382,8 @@ def test_cursor_trigger_sweeps_other_ides( mock_load_config: MagicMock, mock_load_settings: MagicMock, mock_load_codex: MagicMock, + mock_load_vscode: MagicMock, + mock_collect_copilot_plugins: MagicMock, mock_ctx: MagicMock, ) -> None: """A Cursor-triggered session start also reports Claude's config via config_files.""" @@ -385,6 +396,8 @@ def test_cursor_trigger_sweeps_other_ides( mock_load_config.return_value = {'mcpServers': claude_servers} mock_load_settings.return_value = None mock_load_codex.return_value = None + mock_load_vscode.return_value = None + mock_collect_copilot_plugins.return_value = {} payload = {'conversation_id': 'conv-456', 'model': 'gpt-4'} @@ -549,6 +562,7 @@ def test_collect_all_session_contexts_merges_plugins_first_wins() -> None: patch.object(IDES['cursor'], 'get_session_context', return_value=(None, {})), patch.object(IDES['claude-code'], 'get_session_context', return_value=(None, {'plug@m': claude_plugin})), patch.object(IDES['codex'], 'get_session_context', return_value=(None, {'plug@m': codex_plugin})), + patch.object(IDES['copilot'], 'get_session_context', return_value=(None, {})), ): _, plugins = collect_all_session_contexts() From 0eeb0f29ac9fe5354c10275b541317b1f7bb9660 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:44:42 +0300 Subject: [PATCH 107/123] CM-65504: Skip synthetic task-notification prompts in Claude Code guardrails scan (#500) Co-authored-by: Claude Fable 5 --- cycode/cli/apps/ai_guardrails/ides/base.py | 9 ++++++ .../apps/ai_guardrails/ides/claude_code.py | 10 ++++++ .../apps/ai_guardrails/scan/scan_command.py | 8 +++++ .../ai_guardrails/ides/test_claude_code.py | 31 +++++++++++++++++++ .../ai_guardrails/ides/test_contract.py | 5 +++ .../ai_guardrails/scan/test_scan_command.py | 27 ++++++++++++++++ 6 files changed, 90 insertions(+) diff --git a/cycode/cli/apps/ai_guardrails/ides/base.py b/cycode/cli/apps/ai_guardrails/ides/base.py index 84e4315f..29b4b200 100644 --- a/cycode/cli/apps/ai_guardrails/ides/base.py +++ b/cycode/cli/apps/ai_guardrails/ides/base.py @@ -157,6 +157,15 @@ def matches_payload(self, raw_payload: dict) -> bool: event (e.g. Cursor reading Claude Code hooks from ~/.claude/settings.json). """ + def is_synthetic_prompt(self, raw_payload: dict) -> bool: + """Return True when a prompt event carries IDE/harness-generated content + rather than text the user typed. + + Synthetic prompts are skipped without scanning or telemetry. + Default: False. Override for IDEs that inject synthetic user turns. + """ + return False + @abstractmethod def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: """Normalize a raw stdin payload into the canonical ``AIHookPayload``.""" diff --git a/cycode/cli/apps/ai_guardrails/ides/claude_code.py b/cycode/cli/apps/ai_guardrails/ides/claude_code.py index f48794ef..1b3f618b 100644 --- a/cycode/cli/apps/ai_guardrails/ides/claude_code.py +++ b/cycode/cli/apps/ai_guardrails/ides/claude_code.py @@ -22,6 +22,10 @@ _CLAUDE_CODE_EVENT_NAMES = frozenset({'UserPromptSubmit', 'PreToolUse'}) +# When a fork/subagent completes, the harness injects its result into the parent +# session as a synthetic user turn, which fires UserPromptSubmit. +_SYNTHETIC_PROMPT_PREFIXES = ('',) + _USER_HOOKS_DIR = Path.home() / '.claude' _HOOKS_FILE_NAME = 'settings.json' _REPO_SUBDIR = '.claude' @@ -284,6 +288,12 @@ def matches_payload(self, raw_payload: dict) -> bool: # processed as Claude Code events. return raw_payload.get('hook_event_name', '') in _CLAUDE_CODE_EVENT_NAMES and 'transcript_path' in raw_payload + def is_synthetic_prompt(self, raw_payload: dict) -> bool: + if raw_payload.get('hook_event_name') != 'UserPromptSubmit': + return False + prompt = raw_payload.get('prompt') or '' + return prompt.lstrip().startswith(_SYNTHETIC_PROMPT_PREFIXES) + def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: hook_event_name = raw_payload.get('hook_event_name', '') tool_name = raw_payload.get('tool_name', '') diff --git a/cycode/cli/apps/ai_guardrails/scan/scan_command.py b/cycode/cli/apps/ai_guardrails/scan/scan_command.py index cad92263..1a389d5e 100644 --- a/cycode/cli/apps/ai_guardrails/scan/scan_command.py +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -116,6 +116,14 @@ def scan_command( output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT))) return + # Fork/subagent completions arrive as synthetic user turns (e.g. Claude Code's + # ); they are agent-generated, not user prompts - skip before + # parse_hook_payload, which reads the transcript and IDE config from disk. + if ide_integration.is_synthetic_prompt(payload): + logger.debug('Synthetic prompt detected, skipping scan') + output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT))) + return + unified_payload = ide_integration.parse_hook_payload(payload) event_name = unified_payload.event_name logger.debug( diff --git a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py index 60fb331e..4dcab376 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py +++ b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py @@ -50,6 +50,37 @@ def test_matches_payload_rejects_vscode_copilot_payloads() -> None: ) +def test_is_synthetic_prompt_task_notification() -> None: + claude = ClaudeCode() + payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'Task dummy-task-1 completed', + } + assert claude.is_synthetic_prompt(payload) is True + + payload['prompt'] = ' \nTask dummy-task-2 completed' + assert claude.is_synthetic_prompt(payload) is True + + +def test_is_synthetic_prompt_regular_prompt() -> None: + claude = ClaudeCode() + assert claude.is_synthetic_prompt({'hook_event_name': 'UserPromptSubmit', 'prompt': 'Test prompt'}) is False + assert claude.is_synthetic_prompt({'hook_event_name': 'UserPromptSubmit', 'prompt': ''}) is False + assert claude.is_synthetic_prompt({'hook_event_name': 'UserPromptSubmit'}) is False + + +def test_is_synthetic_prompt_ignores_tool_events() -> None: + claude = ClaudeCode() + payload = { + 'hook_event_name': 'PreToolUse', + 'tool_name': 'Read', + 'tool_input': {'file_path': '/path/to/file'}, + 'prompt': 'not a prompt event', + } + assert claude.is_synthetic_prompt(payload) is False + + def test_parse_prompt_payload() -> None: unified = ClaudeCode().parse_hook_payload( { diff --git a/tests/cli/commands/ai_guardrails/ides/test_contract.py b/tests/cli/commands/ai_guardrails/ides/test_contract.py index 7d7ab773..0984c97a 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_contract.py +++ b/tests/cli/commands/ai_guardrails/ides/test_contract.py @@ -82,6 +82,11 @@ def test_matches_payload_rejects_unrelated_event_names(ide: IDE) -> None: assert ide.matches_payload({'hook_event_name': 'completely-fabricated-event'}) is False +def test_is_synthetic_prompt_rejects_empty(ide: IDE) -> None: + """The safe default: no payload is ever treated as synthetic unless an IDE opts in.""" + assert ide.is_synthetic_prompt({}) is False + + @pytest.mark.parametrize('event_type', list(AiHookEventType)) def test_build_hook_response_allow_returns_dict(ide: IDE, event_type: AiHookEventType) -> None: """ALLOW for every canonical event type yields a serializable dict.""" diff --git a/tests/cli/commands/ai_guardrails/scan/test_scan_command.py b/tests/cli/commands/ai_guardrails/scan/test_scan_command.py index 349a1ee3..8b7b611e 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_scan_command.py +++ b/tests/cli/commands/ai_guardrails/scan/test_scan_command.py @@ -81,6 +81,33 @@ def test_cursor_payload_with_claude_code_ide( assert response == {} # Claude Code allow_prompt returns empty dict +class TestSyntheticPromptSkipsProcessing: + """Tests that verify synthetic (harness-generated) prompts cause early exit without API calls.""" + + def test_task_notification_prompt_skipped( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Fork/subagent completions arrive as synthetic user turns + that fire UserPromptSubmit in the parent session; they must not be scanned.""" + payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'transcript_path': '/home/user/.claude/projects/transcript.jsonl', + 'prompt': 'Task dummy-task-1 completed', + } + mocker.patch('sys.stdin', StringIO(json.dumps(payload))) + + scan_command(mock_ctx, ide='claude-code') + + _assert_no_api_calls(mock_scan_command_deps) + response = json.loads(capsys.readouterr().out) + assert response == {} # Claude Code allow_prompt returns empty dict + + class TestInvalidPayloadSkipsProcessing: """Tests that verify invalid payloads cause early exit without API calls.""" From dea03fbcbeca0406ceaae810e16882b864442ede Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:45:37 +0300 Subject: [PATCH 108/123] CM-69233 ai guardrails respect ignores (#499) Co-authored-by: Claude Fable 5 --- .../cli/apps/ai_guardrails/scan/handlers.py | 7 +- cycode/cli/files_collector/file_excluder.py | 4 +- .../ai_guardrails/scan/test_handlers.py | 49 ++- .../configure/test_configure_command.py | 390 ++++++------------ 4 files changed, 178 insertions(+), 272 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index 8b8a2d71..ab05482a 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -26,6 +26,7 @@ from cycode.cli.apps.scan.code_scanner import _get_scan_documents_thread_func from cycode.cli.apps.scan.scan_parameters import get_scan_parameters from cycode.cli.cli_types import ScanTypeOption, SeverityOption +from cycode.cli.files_collector.file_excluder import is_path_configured_in_exclusions from cycode.cli.models import Document from cycode.cli.utils.progress_bar import DummyProgressBar, ScanProgressBarSection from cycode.cli.utils.scan_utils import build_violation_summary @@ -337,7 +338,7 @@ def _perform_scan( scan_id = local_scan_result.scan_id - if local_scan_result.detections_count > 0: + if local_scan_result.issue_detected: violation_summary = build_violation_summary([local_scan_result]) return violation_summary, scan_id @@ -360,6 +361,10 @@ def _scan_path_for_secrets(ctx: typer.Context, file_path: str, policy: dict) -> if not file_path or not os.path.isfile(file_path): return None, None + if is_path_configured_in_exclusions(str(ScanTypeOption.SECRET), os.path.abspath(file_path)): + logger.debug('Skipping scan; the path is in the ignore paths list, %s', {'file_path': file_path}) + return None, None + max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000) with open(file_path, encoding='utf-8', errors='replace') as f: diff --git a/cycode/cli/files_collector/file_excluder.py b/cycode/cli/files_collector/file_excluder.py index fc61f0e2..066d7669 100644 --- a/cycode/cli/files_collector/file_excluder.py +++ b/cycode/cli/files_collector/file_excluder.py @@ -25,7 +25,7 @@ def _is_subpath_of_cycode_configuration_folder(filename: str) -> bool: ) -def _is_path_configured_in_exclusions(scan_type: str, file_path: str) -> bool: +def is_path_configured_in_exclusions(scan_type: str, file_path: str) -> bool: exclusions_by_path = configuration_manager.get_exclusions_by_scan_type(scan_type).get( consts.EXCLUSIONS_BY_PATH_SECTION_NAME, [] ) @@ -106,7 +106,7 @@ def _is_relevant_file_to_scan_common(self, scan_type: str, filename: str) -> boo ) return False - if _is_path_configured_in_exclusions(scan_type, filename): + if is_path_configured_in_exclusions(scan_type, filename): logger.debug( 'The document is irrelevant because its path is in the ignore paths list, %s', {'filename': filename} ) diff --git a/tests/cli/commands/ai_guardrails/scan/test_handlers.py b/tests/cli/commands/ai_guardrails/scan/test_handlers.py index 36352a38..01f8790c 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_handlers.py +++ b/tests/cli/commands/ai_guardrails/scan/test_handlers.py @@ -1,5 +1,6 @@ """Tests for AI guardrails handlers.""" +import os from typing import Any from unittest.mock import MagicMock, patch @@ -8,12 +9,15 @@ from cycode.cli.apps.ai_guardrails.ides.base import DecisionAction, HookDecision from cycode.cli.apps.ai_guardrails.scan.handlers import ( + _perform_scan, + _scan_path_for_secrets, handle_before_mcp_execution, handle_before_read_file, handle_before_submit_prompt, ) from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType, AIHookOutcome, BlockReason +from cycode.cli.models import Document, LocalScanResult @pytest.fixture @@ -357,8 +361,6 @@ def test_handle_before_read_file_sensitive_path_scan_disabled_warns( def test_scan_path_for_secrets_directory(mock_ctx: MagicMock, default_policy: dict[str, Any], fs: Any) -> None: """Test that _scan_path_for_secrets returns (None, None) for directories.""" - from cycode.cli.apps.ai_guardrails.scan.handlers import _scan_path_for_secrets - fs.create_dir('/path/to/some_directory') result = _scan_path_for_secrets(mock_ctx, '/path/to/some_directory', default_policy) @@ -366,6 +368,49 @@ def test_scan_path_for_secrets_directory(mock_ctx: MagicMock, default_policy: di assert result == (None, None) +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._perform_scan') +def test_scan_path_for_secrets_skips_path_configured_in_exclusions( + mock_perform_scan: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any], fs: Any +) -> None: + """Test that a path ignored via `cycode ignore --by-path` is not scanned.""" + # `cycode ignore --by-path` stores absolute paths; on Windows that includes the drive prefix + excluded_dir = os.path.abspath(os.path.join(os.sep, 'project', 'secrets')) + file_path = os.path.join(excluded_dir, 'creds.env') + fs.create_file(file_path, contents='password=hunter2') + mock_perform_scan.return_value = ('Cycode found 1 violations', 'scan-id-123') + + with patch( + 'cycode.cli.files_collector.file_excluder.configuration_manager.get_exclusions_by_scan_type', + return_value={'paths': [excluded_dir]}, + ): + result = _scan_path_for_secrets(mock_ctx, file_path, default_policy) + + assert result == (None, None) + mock_perform_scan.assert_not_called() + + +def test_perform_scan_no_violation_when_all_detections_excluded(mock_ctx: MagicMock) -> None: + """Test that detections filtered out by ignore rules do not produce a violation.""" + local_scan_result = LocalScanResult( + scan_id='scan-id-123', + report_url=None, + document_detections=[], + issue_detected=False, + detections_count=1, + relevant_detections_count=0, + ) + document = Document(path='prompt-content.txt', content='some content', is_git_diff_format=False) + + with patch( + 'cycode.cli.apps.ai_guardrails.scan.handlers._get_scan_documents_thread_func', + return_value=lambda batch: ('scan-id-123', None, local_scan_result), + ): + violation_summary, scan_id = _perform_scan(mock_ctx, [document], {}, timeout_seconds=5.0) + + assert violation_summary is None + assert scan_id == 'scan-id-123' + + # Tests for handle_before_mcp_execution diff --git a/tests/cli/commands/configure/test_configure_command.py b/tests/cli/commands/configure/test_configure_command.py index 0d763edd..3548d4ed 100644 --- a/tests/cli/commands/configure/test_configure_command.py +++ b/tests/cli/commands/configure/test_configure_command.py @@ -1,314 +1,170 @@ +import os from typing import TYPE_CHECKING -from typer.testing import CliRunner +import pytest +import yaml +from click.testing import CliRunner +from typer.main import get_command from cycode.cli.app import app +from cycode.cli.apps.configure.consts import CONFIGURATION_MANAGER, CREDENTIALS_MANAGER +from cycode.cli.user_settings.config_file_manager import ConfigFileManager +from cycode.cli.user_settings.credentials_manager import CredentialsManager if TYPE_CHECKING: from pytest_mock import MockerFixture +# Built eagerly on the real filesystem; building it under pyfakefs breaks typer's +# pathlib.Path parameter introspection. +_click_app = get_command(app) -def test_configure_command_no_exist_values_in_file(mocker: 'MockerFixture') -> None: - # Arrange - app_url_user_input = 'new app url' - api_url_user_input = 'new api url' - client_id_user_input = 'new client id' - client_secret_user_input = 'new client secret' - id_token_user_input = 'new id token' - - mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', - return_value=(None, None), - ) - mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_oidc_credentials_from_file', - return_value=(None, None), - ) - mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_api_url', - return_value=None, - ) - mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_app_url', - return_value=None, - ) - - # side effect - multiple return values, each item in the list represents return of a call - mocker.patch( - 'typer.prompt', - side_effect=[ - api_url_user_input, - app_url_user_input, - client_id_user_input, - client_secret_user_input, - id_token_user_input, - ], - ) - - mocked_update_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials' - ) - mocked_update_oidc_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_oidc_credentials' - ) - mocked_update_api_base_url = mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_api_base_url' - ) - mocked_update_app_base_url = mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_app_base_url' - ) - - # Act - CliRunner().invoke(app, ['configure']) - - # Assert - mocked_update_credentials.assert_called_once_with(client_id_user_input, client_secret_user_input) - mocked_update_oidc_credentials.assert_called_once_with(client_id_user_input, id_token_user_input) - mocked_update_api_base_url.assert_called_once_with(api_url_user_input) - mocked_update_app_base_url.assert_called_once_with(app_url_user_input) +# `cycode configure` reads/writes the real ~/.cycode files; run every test on pyfakefs +# so file access never reaches the developer's machine. +pytestmark = pytest.mark.usefixtures('fs') +_CURRENT_CREDENTIALS = { + CredentialsManager.CLIENT_ID_FIELD_NAME: 'current client id', + CredentialsManager.CLIENT_SECRET_FIELD_NAME: 'current client secret', + CredentialsManager.ID_TOKEN_FIELD_NAME: 'current id token', +} +_CURRENT_CONFIG = { + ConfigFileManager.ENVIRONMENT_SECTION_NAME: { + ConfigFileManager.API_URL_FIELD_NAME: 'current api url', + ConfigFileManager.APP_URL_FIELD_NAME: 'current app url', + } +} -def test_configure_command_update_current_configs_in_files(mocker: 'MockerFixture') -> None: - # Arrange - app_url_user_input = 'new app url' - api_url_user_input = 'new api url' - client_id_user_input = 'new client id' - client_secret_user_input = 'new client secret' - id_token_user_input = 'new id token' - - mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', - return_value=('client id file', 'client secret file'), - ) - mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_oidc_credentials_from_file', - return_value=('client id file', 'id token file'), - ) - mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_api_url', - return_value='api url file', - ) - mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_app_url', - return_value='app url file', - ) - - # side effect - multiple return values, each item in the list represents return of a call - mocker.patch( - 'typer.prompt', - side_effect=[ - api_url_user_input, - app_url_user_input, - client_id_user_input, - client_secret_user_input, - id_token_user_input, - ], - ) - - mocked_update_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials' - ) - mocked_update_api_base_url = mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_api_base_url' - ) - mocked_update_app_base_url = mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_app_base_url' - ) - mocker_update_oidc_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_oidc_credentials' - ) - - # Act - CliRunner().invoke(app, ['configure']) - - # Assert - mocked_update_credentials.assert_called_once_with(client_id_user_input, client_secret_user_input) - mocker_update_oidc_credentials.assert_called_once_with(client_id_user_input, id_token_user_input) - mocked_update_api_base_url.assert_called_once_with(api_url_user_input) - mocked_update_app_base_url.assert_called_once_with(app_url_user_input) +def _credentials_filename() -> str: + return CREDENTIALS_MANAGER.get_filename() -def test_set_credentials_update_only_client_id(mocker: 'MockerFixture') -> None: - # Arrange - client_id_user_input = 'new client id' - current_client_id = 'client secret file' - mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', - return_value=('client id file', 'client secret file'), - ) - # side effect - multiple return values, each item in the list represents return of a call - mocker.patch('typer.prompt', side_effect=['', '', client_id_user_input, '', '']) - mocked_update_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials' - ) +def _config_filename() -> str: + return CONFIGURATION_MANAGER.global_config_file_manager.get_filename() - # Act - CliRunner().invoke(app, ['configure']) - # Assert - mocked_update_credentials.assert_called_once_with(client_id_user_input, current_client_id) +def _seed_yaml(filename: str, content: dict) -> None: + os.makedirs(os.path.dirname(filename), exist_ok=True) + with open(filename, 'w', encoding='UTF-8') as file: + yaml.safe_dump(content, file) -def test_configure_command_update_only_client_secret(mocker: 'MockerFixture') -> None: - # Arrange - client_secret_user_input = 'new client secret' - current_client_id = 'client secret file' +def _read_yaml(filename: str) -> dict: + with open(filename, encoding='UTF-8') as file: + return yaml.safe_load(file) - mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', - return_value=(current_client_id, 'client secret file'), - ) - # side effect - multiple return values, each item in the list represents return of a call - mocker.patch('typer.prompt', side_effect=['', '', '', client_secret_user_input, '']) - mocked_update_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials' - ) +def _run_configure(mocker: 'MockerFixture', prompt_answers: list[str]) -> None: + # Prompt order: api url, app url, client id, client secret, id token + mocker.patch('typer.prompt', side_effect=prompt_answers) + result = CliRunner().invoke(_click_app, ['configure']) + assert result.exit_code == 0 - # Act - CliRunner().invoke(app, ['configure']) - # Assert - mocked_update_credentials.assert_called_once_with(current_client_id, client_secret_user_input) +def test_configure_command_no_exist_values_in_file(mocker: 'MockerFixture') -> None: + _run_configure(mocker, ['new api url', 'new app url', 'new client id', 'new client secret', 'new id token']) + assert _read_yaml(_credentials_filename()) == { + CredentialsManager.CLIENT_ID_FIELD_NAME: 'new client id', + CredentialsManager.CLIENT_SECRET_FIELD_NAME: 'new client secret', + CredentialsManager.ID_TOKEN_FIELD_NAME: 'new id token', + } + assert _read_yaml(_config_filename()) == { + ConfigFileManager.ENVIRONMENT_SECTION_NAME: { + ConfigFileManager.API_URL_FIELD_NAME: 'new api url', + ConfigFileManager.APP_URL_FIELD_NAME: 'new app url', + } + } -def test_configure_command_update_only_api_url(mocker: 'MockerFixture') -> None: - # Arrange - api_url_user_input = 'new api url' - current_api_url = 'api url' - mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_api_url', - return_value=current_api_url, - ) +def test_configure_command_update_current_configs_in_files(mocker: 'MockerFixture') -> None: + _seed_yaml(_credentials_filename(), _CURRENT_CREDENTIALS) + _seed_yaml(_config_filename(), _CURRENT_CONFIG) - # side effect - multiple return values, each item in the list represents return of a call - mocker.patch('typer.prompt', side_effect=[api_url_user_input, '', '', '', '']) - mocked_update_api_base_url = mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_api_base_url' - ) + _run_configure(mocker, ['new api url', 'new app url', 'new client id', 'new client secret', 'new id token']) - # Act - CliRunner().invoke(app, ['configure']) + assert _read_yaml(_credentials_filename()) == { + CredentialsManager.CLIENT_ID_FIELD_NAME: 'new client id', + CredentialsManager.CLIENT_SECRET_FIELD_NAME: 'new client secret', + CredentialsManager.ID_TOKEN_FIELD_NAME: 'new id token', + } + assert _read_yaml(_config_filename()) == { + ConfigFileManager.ENVIRONMENT_SECTION_NAME: { + ConfigFileManager.API_URL_FIELD_NAME: 'new api url', + ConfigFileManager.APP_URL_FIELD_NAME: 'new app url', + } + } - # Assert - mocked_update_api_base_url.assert_called_once_with(api_url_user_input) +def test_set_credentials_update_only_client_id(mocker: 'MockerFixture') -> None: + _seed_yaml(_credentials_filename(), _CURRENT_CREDENTIALS) -def test_configure_command_update_only_id_token(mocker: 'MockerFixture') -> None: - # Arrange - current_client_id = 'client id file' - current_id_token = 'old id token' - new_id_token = 'new id token' + _run_configure(mocker, ['', '', 'new client id', '', '']) - mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', - return_value=(current_client_id, 'client secret file'), - ) - mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_oidc_credentials_from_file', - return_value=(current_client_id, current_id_token), - ) + # Client id is replaced in both the token and OIDC credential pairs; everything else is kept + assert _read_yaml(_credentials_filename()) == { + **_CURRENT_CREDENTIALS, + CredentialsManager.CLIENT_ID_FIELD_NAME: 'new client id', + } + assert not os.path.exists(_config_filename()) - mocker.patch('typer.prompt', side_effect=['', '', '', '', new_id_token]) - mocked_update_oidc_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_oidc_credentials' - ) +def test_configure_command_update_only_client_secret(mocker: 'MockerFixture') -> None: + _seed_yaml(_credentials_filename(), _CURRENT_CREDENTIALS) - # Act - CliRunner().invoke(app, ['configure']) + _run_configure(mocker, ['', '', '', 'new client secret', '']) - # Assert - mocked_update_oidc_credentials.assert_called_once_with(current_client_id, new_id_token) + assert _read_yaml(_credentials_filename()) == { + **_CURRENT_CREDENTIALS, + CredentialsManager.CLIENT_SECRET_FIELD_NAME: 'new client secret', + } -def test_configure_command_should_not_update_credentials(mocker: 'MockerFixture') -> None: - # Arrange - client_id_user_input = '' - client_secret_user_input = '' +def test_configure_command_update_only_api_url(mocker: 'MockerFixture') -> None: + _seed_yaml(_config_filename(), _CURRENT_CONFIG) - mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', - return_value=('client id file', 'client secret file'), - ) + _run_configure(mocker, ['new api url', '', '', '', '']) - # side effect - multiple return values, each item in the list represents return of a call - mocker.patch('typer.prompt', side_effect=['', '', client_id_user_input, client_secret_user_input, '']) - mocked_update_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials' - ) + assert _read_yaml(_config_filename()) == { + ConfigFileManager.ENVIRONMENT_SECTION_NAME: { + ConfigFileManager.API_URL_FIELD_NAME: 'new api url', + ConfigFileManager.APP_URL_FIELD_NAME: 'current app url', + } + } + assert not os.path.exists(_credentials_filename()) - # Act - CliRunner().invoke(app, ['configure']) - # Assert - assert not mocked_update_credentials.called +def test_configure_command_update_only_id_token(mocker: 'MockerFixture') -> None: + _seed_yaml(_credentials_filename(), _CURRENT_CREDENTIALS) + + _run_configure(mocker, ['', '', '', '', 'new id token']) + + assert _read_yaml(_credentials_filename()) == { + **_CURRENT_CREDENTIALS, + CredentialsManager.ID_TOKEN_FIELD_NAME: 'new id token', + } + + +def test_configure_command_should_not_update_credentials(mocker: 'MockerFixture') -> None: + _seed_yaml(_credentials_filename(), _CURRENT_CREDENTIALS) + + _run_configure(mocker, ['', '', '', '', '']) + + assert _read_yaml(_credentials_filename()) == _CURRENT_CREDENTIALS def test_configure_command_should_not_update_config_file(mocker: 'MockerFixture') -> None: - # Arrange - app_url_user_input = '' - api_url_user_input = '' - - mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_api_url', - return_value='api url file', - ) - mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_app_url', - return_value='app url file', - ) - - # side effect - multiple return values, each item in the list represents return of a call - mocker.patch('typer.prompt', side_effect=[api_url_user_input, app_url_user_input, '', '', '']) - mocked_update_api_base_url = mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_api_base_url' - ) - mocked_update_app_base_url = mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_app_base_url' - ) - - # Act - CliRunner().invoke(app, ['configure']) - - # Assert - assert not mocked_update_api_base_url.called - assert not mocked_update_app_base_url.called + _seed_yaml(_config_filename(), _CURRENT_CONFIG) + + _run_configure(mocker, ['', '', '', '', '']) + + assert _read_yaml(_config_filename()) == _CURRENT_CONFIG def test_configure_command_should_not_update_oidc_credentials(mocker: 'MockerFixture') -> None: - # Arrange - current_client_id = 'client id file' - current_client_secret = 'client secret file' - current_id_token = 'old id token' - - mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', - return_value=(current_client_id, current_client_secret), - ) - mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_oidc_credentials_from_file', - return_value=(current_client_id, current_id_token), - ) - mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_api_url', - return_value='api url file', - ) - mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_app_url', - return_value='app url file', - ) - - mocker.patch('typer.prompt', side_effect=['', '', '', '', '']) - - mocked_update_oidc_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_oidc_credentials' - ) - - # Act - CliRunner().invoke(app, ['configure']) - - # Assert - mocked_update_oidc_credentials.assert_not_called() + _seed_yaml(_credentials_filename(), _CURRENT_CREDENTIALS) + + # Re-entering the same client id must not rewrite anything + _run_configure(mocker, ['', '', 'current client id', '', '']) + + assert _read_yaml(_credentials_filename()) == _CURRENT_CREDENTIALS From ed2f7148b5258e9c754fa160ebc77d46b1a06582 Mon Sep 17 00:00:00 2001 From: omer-roth Date: Thu, 23 Jul 2026 10:00:20 +0300 Subject: [PATCH 109/123] CM-68642: stop SCA scan on restore command failure with --stop-on-error (#495) Co-authored-by: Claude Opus 4.8 --- .../sca/base_restore_dependencies.py | 3 ++ .../sca/test_base_restore_dependencies.py | 53 ++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/cycode/cli/files_collector/sca/base_restore_dependencies.py b/cycode/cli/files_collector/sca/base_restore_dependencies.py index 06431f72..d5167e92 100644 --- a/cycode/cli/files_collector/sca/base_restore_dependencies.py +++ b/cycode/cli/files_collector/sca/base_restore_dependencies.py @@ -40,6 +40,9 @@ def execute_commands( for command in commands: command_output = shell(command=command, timeout=timeout, working_directory=working_directory) + if command_output is None: # shell returns None when the command exited non-zero + logger.debug('Restore command failed, %s', {'command': command}) + return None if command_output: outputs.append(command_output) diff --git a/tests/cli/files_collector/sca/test_base_restore_dependencies.py b/tests/cli/files_collector/sca/test_base_restore_dependencies.py index b291a95f..7d8d8743 100644 --- a/tests/cli/files_collector/sca/test_base_restore_dependencies.py +++ b/tests/cli/files_collector/sca/test_base_restore_dependencies.py @@ -11,7 +11,7 @@ import pytest import typer -from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, execute_commands from cycode.cli.models import Document _LOCK_FILE_NAME = 'generated.lock' @@ -68,6 +68,39 @@ def side_effect( return side_effect +class TestExecuteCommands: + """Directly test the shell-failure sentinel handling in execute_commands.""" + + def test_returns_none_when_a_command_fails(self) -> None: + """shell() returns None on non-zero exit; execute_commands must propagate None, not ''.""" + with patch(f'{_BASE_MODULE}.shell', return_value=None): + result = execute_commands([['poetry', 'lock']], timeout=30) + + assert result is None + + def test_stops_at_first_failing_command(self) -> None: + """A failure in an earlier command short-circuits; later commands do not run.""" + mock_shell = MagicMock(side_effect=[None, 'should-not-run']) + with patch(f'{_BASE_MODULE}.shell', mock_shell): + result = execute_commands([['a'], ['b']], timeout=30) + + assert result is None + assert mock_shell.call_count == 1 + + def test_empty_output_success_is_not_treated_as_failure(self) -> None: + """A successful command with empty stdout ('') must NOT be treated as a failure.""" + with patch(f'{_BASE_MODULE}.shell', return_value=''): + result = execute_commands([['poetry', 'lock']], timeout=30) + + assert result == '' + + def test_joins_successful_outputs(self) -> None: + with patch(f'{_BASE_MODULE}.shell', side_effect=['out1', 'out2']): + result = execute_commands([['a'], ['b']], timeout=30) + + assert result == 'out1\nout2' + + class TestCleanupGeneratedFile: def test_generated_lockfile_is_deleted_after_restore(self, handler: _MinimalRestoreHandler, tmp_path: Path) -> None: doc = _make_doc(tmp_path) @@ -132,6 +165,24 @@ def test_failed_command_returns_none_and_no_file_created( assert result is None assert not lock_path.exists() + def test_shell_failure_propagates_to_none_and_no_lockfile( + self, handler: _MinimalRestoreHandler, tmp_path: Path + ) -> None: + """End-to-end failure path: shell() returns None (non-zero exit) -> restore returns None. + + Regression for the stop-on-error bug: execute_commands must NOT swallow a failed + command into an empty-string success. This exercises the real execute_commands with + only shell() mocked (the layer where a non-zero exit is signalled as None). + """ + doc = _make_doc(tmp_path) + lock_path = tmp_path / _LOCK_FILE_NAME + + with patch(f'{_BASE_MODULE}.shell', return_value=None): + result = handler.try_restore_dependencies(doc) + + assert result is None, 'A failed restore command must return None so stop-on-error can fire' + assert not lock_path.exists() + def test_generated_file_content_available_in_document_after_deletion( self, handler: _MinimalRestoreHandler, tmp_path: Path ) -> None: From 349fe1d3bdedf9bbebb1653cb87e5b323be98d4b Mon Sep 17 00:00:00 2001 From: valeriistryhun-dev Date: Mon, 27 Jul 2026 09:36:39 +0200 Subject: [PATCH 110/123] CM-68709: Add pip package manager support to SCA local scans (#502) --- README.md | 1 + cycode/cli/consts.py | 4 +- .../sca/python/restore_pip_dependencies.py | 69 ++++++ .../files_collector/sca/sca_file_collector.py | 2 + .../python/test_restore_pip_dependencies.py | 217 ++++++++++++++++++ 5 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 cycode/cli/files_collector/sca/python/restore_pip_dependencies.py create mode 100644 tests/cli/files_collector/sca/python/test_restore_pip_dependencies.py diff --git a/README.md b/README.md index d48c4fcc..6ccbee88 100644 --- a/README.md +++ b/README.md @@ -814,6 +814,7 @@ The following ecosystems support automatic lockfile restoration: | NuGet | `*.csproj` | `packages.lock.json` | `dotnet restore --use-lock-file` | | Ruby | `Gemfile` | `Gemfile.lock` | `bundle --quiet` | | Poetry | `pyproject.toml` | `poetry.lock` | `poetry lock` | +| pip | `pyproject.toml` / `requirements.txt` | `pylock.toml` | `pip lock .` / `pip lock -r requirements.txt -o pylock.toml` | | Pipenv | `Pipfile` | `Pipfile.lock` | `pipenv lock` | | PHP Composer | `composer.json` | `composer.lock` | `composer update --no-cache --no-install --no-scripts --ignore-platform-reqs` | diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 37ef2298..9007fda9 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -118,6 +118,7 @@ 'pyproject.toml', 'uv.lock', 'poetry.lock', + 'pylock.toml', 'pipfile', 'pipfile.lock', 'requirements.txt', @@ -175,8 +176,9 @@ 'sbt': ['build.sbt', 'build.scala', 'build.sbt.lock'], 'pypi_uv': ['pyproject.toml', 'uv.lock'], 'pypi_poetry': ['pyproject.toml', 'poetry.lock'], + 'pypi_pip': ['pyproject.toml', 'pylock.toml'], 'pypi_pipenv': ['Pipfile', 'Pipfile.lock'], - 'pypi_requirements': ['requirements.txt'], + 'pypi_requirements': ['requirements.txt', 'pylock.toml'], 'pypi_setup': ['setup.py'], 'hex': ['mix.exs', 'mix.lock'], 'swift_pm': ['Package.swift', 'Package.resolved'], diff --git a/cycode/cli/files_collector/sca/python/restore_pip_dependencies.py b/cycode/cli/files_collector/sca/python/restore_pip_dependencies.py new file mode 100644 index 00000000..29ebfc6e --- /dev/null +++ b/cycode/cli/files_collector/sca/python/restore_pip_dependencies.py @@ -0,0 +1,69 @@ +from pathlib import Path +from typing import Optional + +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content +from cycode.logger import get_logger + +logger = get_logger('Pip Restore Dependencies') + +PIP_PYPROJECT_MANIFEST_FILE_NAME = 'pyproject.toml' +PIP_REQUIREMENTS_MANIFEST_FILE_NAME = 'requirements.txt' +PIP_LOCK_FILE_NAME = 'pylock.toml' + +_POETRY_TOOL_SECTION = '[tool.poetry]' +_UV_TOOL_SECTION = '[tool.uv]' + + +def _indicates_plain_pip(pyproject_content: Optional[str]) -> bool: + """Return True if pyproject.toml content signals a plain-pip project (no Poetry, no uv).""" + if not pyproject_content: + return False + return _POETRY_TOOL_SECTION not in pyproject_content and _UV_TOOL_SECTION not in pyproject_content + + +class RestorePipDependencies(BaseRestoreDependencies): + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + manifest_name = Path(document.path).name + + if manifest_name == PIP_REQUIREMENTS_MANIFEST_FILE_NAME: + return True + + if manifest_name != PIP_PYPROJECT_MANIFEST_FILE_NAME: + return False + + manifest_dir = self.get_manifest_dir(document) + if manifest_dir and (Path(manifest_dir) / PIP_LOCK_FILE_NAME).is_file(): + return True + + return _indicates_plain_pip(document.content) + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + lockfile_path = Path(manifest_dir) / PIP_LOCK_FILE_NAME if manifest_dir else None + + if lockfile_path and lockfile_path.is_file(): + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, PIP_LOCK_FILE_NAME) + logger.debug('Using existing pylock.toml, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + return super().try_restore_dependencies(document) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + if Path(manifest_file_path).name == PIP_REQUIREMENTS_MANIFEST_FILE_NAME: + return [['pip', 'lock', '-r', 'requirements.txt', '-o', PIP_LOCK_FILE_NAME]] + + return [['pip', 'lock', '.']] + + def get_lock_file_name(self) -> str: + return PIP_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [PIP_LOCK_FILE_NAME] diff --git a/cycode/cli/files_collector/sca/sca_file_collector.py b/cycode/cli/files_collector/sca/sca_file_collector.py index 6bcfd494..4db5cd04 100644 --- a/cycode/cli/files_collector/sca/sca_file_collector.py +++ b/cycode/cli/files_collector/sca/sca_file_collector.py @@ -17,6 +17,7 @@ from cycode.cli.files_collector.sca.npm.restore_yarn_dependencies import RestoreYarnDependencies from cycode.cli.files_collector.sca.nuget.restore_nuget_dependencies import RestoreNugetDependencies from cycode.cli.files_collector.sca.php.restore_composer_dependencies import RestoreComposerDependencies +from cycode.cli.files_collector.sca.python.restore_pip_dependencies import RestorePipDependencies from cycode.cli.files_collector.sca.python.restore_pipenv_dependencies import RestorePipenvDependencies from cycode.cli.files_collector.sca.python.restore_poetry_dependencies import RestorePoetryDependencies from cycode.cli.files_collector.sca.python.restore_uv_dependencies import RestoreUvDependencies @@ -164,6 +165,7 @@ def _get_restore_handlers(ctx: typer.Context, is_git_diff: bool) -> list[BaseRes RestoreRubyDependencies(ctx, is_git_diff, build_dep_tree_timeout), RestoreUvDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be before Poetry for pyproject.toml RestorePoetryDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestorePipDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be after Uv & Poetry (pyproject.toml) RestorePipenvDependencies(ctx, is_git_diff, build_dep_tree_timeout), RestoreComposerDependencies(ctx, is_git_diff, build_dep_tree_timeout), ] diff --git a/tests/cli/files_collector/sca/python/test_restore_pip_dependencies.py b/tests/cli/files_collector/sca/python/test_restore_pip_dependencies.py new file mode 100644 index 00000000..c0f17476 --- /dev/null +++ b/tests/cli/files_collector/sca/python/test_restore_pip_dependencies.py @@ -0,0 +1,217 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.python.restore_pip_dependencies import ( + PIP_LOCK_FILE_NAME, + RestorePipDependencies, +) +from cycode.cli.models import Document + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_pip(mock_ctx: typer.Context) -> RestorePipDependencies: + return RestorePipDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_plain_pyproject_toml_matches(self, restore_pip: RestorePipDependencies) -> None: + content = '[project]\nname = "my-project"\ndependencies = ["requests"]\n' + doc = Document('pyproject.toml', content) + assert restore_pip.is_project(doc) is True + + def test_pyproject_toml_with_poetry_section_does_not_match(self, restore_pip: RestorePipDependencies) -> None: + content = '[tool.poetry]\nname = "my-project"\n' + doc = Document('pyproject.toml', content) + assert restore_pip.is_project(doc) is False + + def test_pyproject_toml_with_uv_section_does_not_match(self, restore_pip: RestorePipDependencies) -> None: + content = '[tool.uv]\nindex-url = "https://example.com"\n' + doc = Document('pyproject.toml', content) + assert restore_pip.is_project(doc) is False + + def test_pyproject_toml_with_existing_pylock_matches( + self, restore_pip: RestorePipDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'pyproject.toml').write_text('[project]\nname = "test"\n') + (tmp_path / PIP_LOCK_FILE_NAME).write_text('lock-version = "1.0"\n') + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[project]\nname = "test"\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + assert restore_pip.is_project(doc) is True + + def test_requirements_txt_matches(self, restore_pip: RestorePipDependencies) -> None: + doc = Document('requirements.txt', 'requests==2.31.0\n') + assert restore_pip.is_project(doc) is True + + def test_setup_py_does_not_match(self, restore_pip: RestorePipDependencies) -> None: + doc = Document('setup.py', 'from setuptools import setup\nsetup()\n') + assert restore_pip.is_project(doc) is False + + def test_empty_pyproject_toml_does_not_match(self, restore_pip: RestorePipDependencies) -> None: + # Same conservative behavior as Poetry/Uv's own is_project: empty content can't be + # confirmed as plain-pip, so don't claim it. + doc = Document('pyproject.toml', '') + assert restore_pip.is_project(doc) is False + + +class TestGetCommands: + def test_get_commands_for_pyproject_toml(self, restore_pip: RestorePipDependencies) -> None: + commands = restore_pip.get_commands('/path/to/pyproject.toml') + assert commands == [['pip', 'lock', '.']] + + def test_get_commands_for_requirements_txt(self, restore_pip: RestorePipDependencies) -> None: + commands = restore_pip.get_commands('/path/to/requirements.txt') + assert commands == [['pip', 'lock', '-r', 'requirements.txt', '-o', PIP_LOCK_FILE_NAME]] + + def test_get_lock_file_name(self, restore_pip: RestorePipDependencies) -> None: + assert restore_pip.get_lock_file_name() == PIP_LOCK_FILE_NAME + + +class TestTryRestoreDependencies: + def test_existing_pylock_returned_directly_for_pyproject_toml( + self, restore_pip: RestorePipDependencies, tmp_path: Path + ) -> None: + lock_content = 'lock-version = "1.0"\n\n[[packages]]\nname = "requests"\n' + (tmp_path / 'pyproject.toml').write_text('[project]\nname = "test"\n') + (tmp_path / PIP_LOCK_FILE_NAME).write_text(lock_content) + + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[project]\nname = "test"\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + result = restore_pip.try_restore_dependencies(doc) + + assert result is not None + assert PIP_LOCK_FILE_NAME in result.path + assert result.content == lock_content + + def test_existing_pylock_returned_directly_for_requirements_txt( + self, restore_pip: RestorePipDependencies, tmp_path: Path + ) -> None: + lock_content = 'lock-version = "1.0"\n\n[[packages]]\nname = "requests"\n' + (tmp_path / 'requirements.txt').write_text('requests==2.31.0\n') + (tmp_path / PIP_LOCK_FILE_NAME).write_text(lock_content) + + doc = Document( + str(tmp_path / 'requirements.txt'), + 'requests==2.31.0\n', + absolute_path=str(tmp_path / 'requirements.txt'), + ) + result = restore_pip.try_restore_dependencies(doc) + + assert result is not None + assert result.content == lock_content + + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestRestoreWithoutExistingLock: + def test_pyproject_toml_runs_pip_lock_dot(self, restore_pip: RestorePipDependencies, tmp_path: Path) -> None: + manifest_content = '[project]\nname = "test"\ndependencies = ["requests"]\n' + (tmp_path / 'pyproject.toml').write_text(manifest_content) + doc = Document( + str(tmp_path / 'pyproject.toml'), manifest_content, absolute_path=str(tmp_path / 'pyproject.toml') + ) + + seen_commands = [] + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + seen_commands.extend(commands) + (tmp_path / PIP_LOCK_FILE_NAME).write_text('lock-version = "1.0"\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_pip.try_restore_dependencies(doc) + + assert result is not None + assert seen_commands == [['pip', 'lock', '.']] + + def test_requirements_txt_runs_pip_lock_dash_r(self, restore_pip: RestorePipDependencies, tmp_path: Path) -> None: + (tmp_path / 'requirements.txt').write_text('requests==2.31.0\n') + doc = Document( + str(tmp_path / 'requirements.txt'), + 'requests==2.31.0\n', + absolute_path=str(tmp_path / 'requirements.txt'), + ) + + seen_commands = [] + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + seen_commands.extend(commands) + (tmp_path / PIP_LOCK_FILE_NAME).write_text('lock-version = "1.0"\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_pip.try_restore_dependencies(doc) + + assert result is not None + assert seen_commands == [['pip', 'lock', '-r', 'requirements.txt', '-o', PIP_LOCK_FILE_NAME]] + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_pip: RestorePipDependencies, tmp_path: Path + ) -> None: + manifest_content = '[project]\nname = "test"\ndependencies = ["requests"]\n' + (tmp_path / 'pyproject.toml').write_text(manifest_content) + doc = Document( + str(tmp_path / 'pyproject.toml'), manifest_content, absolute_path=str(tmp_path / 'pyproject.toml') + ) + lock_path = tmp_path / PIP_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('lock-version = "1.0"\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_pip.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{PIP_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_pip: RestorePipDependencies, tmp_path: Path) -> None: + lock_content = 'lock-version = "1.0"\n' + (tmp_path / 'pyproject.toml').write_text('[project]\nname = "test"\n') + lock_path = tmp_path / PIP_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[project]\nname = "test"\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + + result = restore_pip.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {PIP_LOCK_FILE_NAME} must not be deleted' From 9fdb9a4c764662eaf5f9de690111a7ce3c8f972d Mon Sep 17 00:00:00 2001 From: Christophe Date: Tue, 28 Jul 2026 13:34:51 +0200 Subject: [PATCH 111/123] Add MCP Toplist rank badge (#506) --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 6ccbee88..4965fb4f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Cycode CLI User Guide +[![MCP Toplist](https://mcptoplist.com/badge/glama%2Fcycodehq%2Fcycode-cli.svg)](https://mcptoplist.com/server/glama%2Fcycodehq%2Fcycode-cli) + The Cycode Command Line Interface (CLI) is an application you can install locally to scan your repositories for secrets, infrastructure as code misconfigurations, software composition analysis vulnerabilities, and static application security testing issues. This guide walks you through both installation and usage. From ce8fa233c98d413b527b9eb51f49a2ee24976304 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:41:03 +0300 Subject: [PATCH 112/123] CM-68943 Send ai-guardrails hook context with the scan so report-mode findings become violations (#504) Co-authored-by: Claude Opus 5 (1M context) --- README.md | 30 ++++- cycode/cli/apps/ai_guardrails/consts.py | 9 +- cycode/cli/apps/ai_guardrails/ides/copilot.py | 4 +- .../cli/apps/ai_guardrails/install_command.py | 14 +-- .../cli/apps/ai_guardrails/scan/handlers.py | 113 ++++++++++++++---- .../apps/ai_guardrails/scan/scan_command.py | 4 + cycode/cli/apps/ai_guardrails/scan/types.py | 9 ++ cycode/cli/utils/host_info.py | 52 ++++++++ .../ai_guardrails/scan/test_handlers.py | 81 ++++++++++++- 9 files changed, 274 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 4965fb4f..7ea39e36 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,9 @@ This guide walks you through both installation and usage. 1. [Discovering Commands](#discovering-commands) 2. [Examples](#platform-examples) 3. [Notes & Limitations](#platform-notes--limitations) -6. [Scan Command](#scan-command) +6. [AI Guardrails](#ai-guardrails-beta) + 1. [Data Collected by AI Guardrails](#data-collected-by-ai-guardrails) +7. [Scan Command](#scan-command) 1. [Running a Scan](#running-a-scan) 1. [Options](#options) 1. [Severity Threshold](#severity-option) @@ -704,6 +706,32 @@ cycode platform projects list --page-size 100 | jq '.items[].name' - **Override the cache TTL** with `CYCODE_SPEC_CACHE_TTL=`. +# AI Guardrails \[BETA\] + +AI Guardrails installs hooks into supported AI coding agents (Claude Code, Cursor, Copilot, Codex) so that +prompts, files the agent reads, and MCP tool arguments are scanned for secrets before they reach the model. + +## Data Collected by AI Guardrails + +Scanning happens server-side, so the scanned content leaves the machine: the prompt text, the contents of +files the agent reads, and MCP tool arguments are sent to your Cycode tenant to be checked for secrets. + +Each event is also reported with context about the developer and the machine, so a finding can be attributed +to the device and user it came from. Some of this is personal data: + +- **Device identifiers** — the machine's hostname and hardware serial number. +- **User identifiers** — the email address of the user signed in to the AI coding agent, and the local + operating-system username. +- **Environment details** — operating system and version, the AI agent, its version and the model in use, + the contents of the agent's MCP configuration files, and its enabled plugins. + +The hardware serial number is cached in a local temporary file, readable only by the user who ran the +command, so repeated hook invocations don't re-query the hardware. + +If collecting this data is not acceptable in your environment, do not install the guardrails hooks +(`cycode ai-guardrails uninstall` removes hooks that are already installed). + + # Scan Command ## Running a Scan diff --git a/cycode/cli/apps/ai_guardrails/consts.py b/cycode/cli/apps/ai_guardrails/consts.py index 8018fa73..4c962767 100644 --- a/cycode/cli/apps/ai_guardrails/consts.py +++ b/cycode/cli/apps/ai_guardrails/consts.py @@ -10,8 +10,13 @@ class PolicyMode(str, Enum): WARN = 'warn' -class InstallMode(str, Enum): - """Installation mode for ai-guardrails install command.""" +class GuardrailsMode(str, Enum): + """Guardrails enforcement mode. + + Used both as the ai-guardrails install-command mode and as the per-event + effective mode reported to the server (the ai_guardrails scan parameter's + `mode` field) + """ REPORT = 'report' BLOCK = 'block' diff --git a/cycode/cli/apps/ai_guardrails/ides/copilot.py b/cycode/cli/apps/ai_guardrails/ides/copilot.py index 12ef8f89..2cd6a427 100644 --- a/cycode/cli/apps/ai_guardrails/ides/copilot.py +++ b/cycode/cli/apps/ai_guardrails/ides/copilot.py @@ -333,9 +333,7 @@ def entry(command: str) -> dict: return { 'version': 1, 'hooks': { - 'sessionStart': [ - {'type': 'command', 'command': _SESSION_START_COMMAND, 'timeoutSec': _HOOK_TIMEOUT_SEC} - ], + 'sessionStart': [{'type': 'command', 'command': _SESSION_START_COMMAND}], 'userPromptSubmitted': [entry(_SCAN_PROMPT_COMMAND)], 'preToolUse': [entry(_SCAN_TOOL_COMMAND)], }, diff --git a/cycode/cli/apps/ai_guardrails/install_command.py b/cycode/cli/apps/ai_guardrails/install_command.py index 0ee5aacb..155cf83a 100644 --- a/cycode/cli/apps/ai_guardrails/install_command.py +++ b/cycode/cli/apps/ai_guardrails/install_command.py @@ -6,7 +6,7 @@ import typer from cycode.cli.apps.ai_guardrails.command_utils import console, resolve_repo_path, validate_scope -from cycode.cli.apps.ai_guardrails.consts import InstallMode, PolicyMode +from cycode.cli.apps.ai_guardrails.consts import GuardrailsMode, PolicyMode from cycode.cli.apps.ai_guardrails.hooks_manager import create_policy_file, install_hooks from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, IDES, resolve_ides @@ -40,14 +40,14 @@ def install_command( ), ] = None, mode: Annotated[ - InstallMode, + GuardrailsMode, typer.Option( '--mode', '-m', help='Installation mode: "report" for async non-blocking hooks with warn policy, ' '"block" for sync blocking hooks.', ), - ] = InstallMode.REPORT, + ] = GuardrailsMode.REPORT, ) -> None: """Install AI guardrails hooks for supported IDEs. @@ -65,7 +65,7 @@ def install_command( repo_path = resolve_repo_path(scope, repo_path) ides_to_install = resolve_ides(ide) - report_mode = mode == InstallMode.REPORT + report_mode = mode == GuardrailsMode.REPORT results: list[tuple[str, bool, str]] = [] for current_ide in ides_to_install: @@ -83,7 +83,7 @@ def install_command( all_success = False if any_success: - policy_mode = PolicyMode.WARN if mode == InstallMode.REPORT else PolicyMode.BLOCK + policy_mode = PolicyMode.WARN if mode == GuardrailsMode.REPORT else PolicyMode.BLOCK _install_policy(scope, repo_path, policy_mode) _print_next_steps(results, mode) @@ -99,7 +99,7 @@ def _install_policy(scope: str, repo_path: Optional[Path], policy_mode: PolicyMo console.print(f'[red]✗[/] {policy_message}', style='bold red') -def _print_next_steps(results: list[tuple[str, bool, str]], mode: InstallMode) -> None: +def _print_next_steps(results: list[tuple[str, bool, str]], mode: GuardrailsMode) -> None: console.print() console.print('[bold]Next steps:[/]') successful_ides = [name for name, success, _ in results if success] @@ -107,7 +107,7 @@ def _print_next_steps(results: list[tuple[str, bool, str]], mode: InstallMode) - console.print(f'1. Restart {ide_list} to activate the hooks') console.print('2. (Optional) Customize policy in ~/.cycode/ai-guardrails.yaml') console.print() - if mode == InstallMode.REPORT: + if mode == GuardrailsMode.REPORT: console.print('[dim]Report mode: hooks run async (non-blocking) and policy is set to warn.[/]') else: console.print('[dim]The hooks will scan prompts, file reads, and MCP tool calls for secrets.[/]') diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index ab05482a..e82c61d7 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -17,24 +17,29 @@ import typer -from cycode.cli.apps.ai_guardrails.consts import PolicyMode +from cycode.cli.apps.ai_guardrails.consts import GuardrailsMode, PolicyMode from cycode.cli.apps.ai_guardrails.ides.base import HookDecision from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value -from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType, AIHookOutcome, BlockReason +from cycode.cli.apps.ai_guardrails.scan.types import ( + SECRETS_BLOCK_REASON_BY_EVENT_TYPE, + AiHookEventType, + AIHookOutcome, + BlockReason, +) from cycode.cli.apps.ai_guardrails.scan.utils import is_denied_path, truncate_utf8 from cycode.cli.apps.scan.code_scanner import _get_scan_documents_thread_func from cycode.cli.apps.scan.scan_parameters import get_scan_parameters from cycode.cli.cli_types import ScanTypeOption, SeverityOption from cycode.cli.files_collector.file_excluder import is_path_configured_in_exclusions from cycode.cli.models import Document +from cycode.cli.utils.host_info import get_hostname, get_serial_number from cycode.cli.utils.progress_bar import DummyProgressBar, ScanProgressBarSection from cycode.cli.utils.scan_utils import build_violation_summary from cycode.logger import get_logger logger = get_logger('AI Guardrails') - HandlerFn = Callable[[typer.Context, AIHookPayload, dict], HookDecision] @@ -47,7 +52,7 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli ai_client.create_event(payload, AiHookEventType.PROMPT, AIHookOutcome.ALLOWED) return HookDecision.allow(AiHookEventType.PROMPT) - mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK) + effective_mode = get_effective_mode(policy, prompt_config) prompt = payload.prompt or '' max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000) timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000) @@ -59,12 +64,18 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli error_message = None try: - violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms) + violation_summary, scan_id = _scan_text_for_secrets( + ctx, + clipped, + timeout_ms, + payload=payload, + event_type=AiHookEventType.PROMPT, + effective_mode=effective_mode, + ) if violation_summary: - block_reason = BlockReason.SECRETS_IN_PROMPT - action = get_policy_value(prompt_config, 'action', default=PolicyMode.BLOCK) - if action == PolicyMode.BLOCK and mode == PolicyMode.BLOCK: + block_reason = SECRETS_BLOCK_REASON_BY_EVENT_TYPE[AiHookEventType.PROMPT] + if effective_mode == GuardrailsMode.BLOCK: outcome = AIHookOutcome.BLOCKED user_message = f'{violation_summary}. Remove secrets before sending.' return HookDecision.deny(AiHookEventType.PROMPT, user_message) @@ -97,9 +108,8 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: ai_client.create_event(payload, AiHookEventType.FILE_READ, AIHookOutcome.ALLOWED) return HookDecision.allow(AiHookEventType.FILE_READ) - mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK) file_path = payload.file_path or '' - action = get_policy_value(file_read_config, 'action', default=PolicyMode.BLOCK) + effective_mode = get_effective_mode(policy, file_read_config) scan_id = None block_reason = None @@ -110,7 +120,7 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: is_sensitive_path = is_denied_path(file_path, policy) if is_sensitive_path: block_reason = BlockReason.SENSITIVE_PATH - if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK: + if effective_mode == GuardrailsMode.BLOCK: outcome = AIHookOutcome.BLOCKED user_message = f'Cycode blocked sending {file_path} to the AI (sensitive path policy).' return HookDecision.deny( @@ -133,10 +143,12 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: outcome = AIHookOutcome.ALLOWED if get_policy_value(file_read_config, 'scan_content', default=True): - violation_summary, scan_id = _scan_path_for_secrets(ctx, file_path, policy) + violation_summary, scan_id = _scan_path_for_secrets( + ctx, file_path, policy, payload=payload, effective_mode=effective_mode + ) if violation_summary: - block_reason = BlockReason.SECRETS_IN_FILE - if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK: + block_reason = SECRETS_BLOCK_REASON_BY_EVENT_TYPE[AiHookEventType.FILE_READ] + if effective_mode == GuardrailsMode.BLOCK: outcome = AIHookOutcome.BLOCKED user_message = f'Cycode blocked reading {file_path}. {violation_summary}' return HookDecision.deny( @@ -191,7 +203,6 @@ class _ArgScanFeature: policy_key: str # 'mcp' or 'command_exec' scan_key: str # 'scan_arguments' or 'scan_command' event_type: AiHookEventType - block_reason: BlockReason deny_message: Callable[[str], str] deny_agent_message: str ask_message: Callable[[str], str] @@ -213,11 +224,10 @@ def _handle_arg_scan( ai_client.create_event(payload, feature.event_type, AIHookOutcome.ALLOWED) return HookDecision.allow(feature.event_type) - mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK) max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000) timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000) clipped = truncate_utf8(scan_text, max_bytes) - action = get_policy_value(feature_config, 'action', default=PolicyMode.BLOCK) + effective_mode = get_effective_mode(policy, feature_config) scan_id = None block_reason = None @@ -226,10 +236,17 @@ def _handle_arg_scan( try: if get_policy_value(feature_config, feature.scan_key, default=True): - violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms) + violation_summary, scan_id = _scan_text_for_secrets( + ctx, + clipped, + timeout_ms, + payload=payload, + event_type=feature.event_type, + effective_mode=effective_mode, + ) if violation_summary: - block_reason = feature.block_reason - if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK: + block_reason = SECRETS_BLOCK_REASON_BY_EVENT_TYPE[feature.event_type] + if effective_mode == GuardrailsMode.BLOCK: outcome = AIHookOutcome.BLOCKED return HookDecision.deny( feature.event_type, @@ -275,7 +292,6 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli policy_key='mcp', scan_key='scan_arguments', event_type=AiHookEventType.MCP_EXECUTION, - block_reason=BlockReason.SECRETS_IN_MCP_ARGS, deny_message=lambda v: f'Cycode blocked MCP tool call "{tool}". {v}', deny_agent_message='Do not pass secrets to tools. Use secret references (name/id) instead.', ask_message=lambda v: f'{v} in MCP tool call "{tool}". Allow execution?', @@ -295,6 +311,36 @@ def get_handler_for_event(event_type: str) -> Optional[HandlerFn]: return handlers.get(event_type) +def get_effective_mode(policy: dict, feature_config: dict) -> GuardrailsMode: + """The event only blocks when both the global mode and the per-guardrail action are block.""" + mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK) + action = get_policy_value(feature_config, 'action', default=PolicyMode.BLOCK) + return GuardrailsMode.BLOCK if (mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK) else GuardrailsMode.REPORT + + +def build_ai_guardrails_scan_parameters( + ctx: typer.Context, + paths: Optional[tuple[str, ...]], + payload: AIHookPayload, + event_type: AiHookEventType, + effective_mode: GuardrailsMode, +) -> dict: + scan_parameters = get_scan_parameters(ctx, paths) + scan_parameters.setdefault('metadata', {})['ai_guardrails'] = { + 'mode': effective_mode.value, + 'ide_provider': payload.ide_provider, + 'detection_source': SECRETS_BLOCK_REASON_BY_EVENT_TYPE[event_type].value, + 'device_id': get_serial_number(), + 'device_hostname': get_hostname(), + 'conversation_id': payload.conversation_id, + 'generation_id': payload.generation_id, + 'ide_user_email': payload.ide_user_email, + 'mcp_server_name': payload.mcp_server_name, + 'mcp_tool_name': payload.mcp_tool_name, + } + return scan_parameters + + def _setup_scan_context(ctx: typer.Context) -> typer.Context: """Set up minimal context for scan_documents without progress bars or printing.""" ctx.obj['progress_bar'] = DummyProgressBar([ScanProgressBarSection]) @@ -345,7 +391,14 @@ def _perform_scan( return None, scan_id -def _scan_text_for_secrets(ctx: typer.Context, text: str, timeout_ms: int) -> tuple[Optional[str], Optional[str]]: +def _scan_text_for_secrets( + ctx: typer.Context, + text: str, + timeout_ms: int, + payload: AIHookPayload, + event_type: AiHookEventType, + effective_mode: GuardrailsMode, +) -> tuple[Optional[str], Optional[str]]: """Scan text content for secrets using Cycode CLI.""" if not text: return None, None @@ -353,10 +406,17 @@ def _scan_text_for_secrets(ctx: typer.Context, text: str, timeout_ms: int) -> tu document = Document(path='prompt-content.txt', content=text, is_git_diff_format=False) scan_ctx = _setup_scan_context(ctx) timeout_seconds = timeout_ms / 1000.0 - return _perform_scan(scan_ctx, [document], get_scan_parameters(scan_ctx, None), timeout_seconds) + scan_parameters = build_ai_guardrails_scan_parameters(scan_ctx, None, payload, event_type, effective_mode) + return _perform_scan(scan_ctx, [document], scan_parameters, timeout_seconds) -def _scan_path_for_secrets(ctx: typer.Context, file_path: str, policy: dict) -> tuple[Optional[str], Optional[str]]: +def _scan_path_for_secrets( + ctx: typer.Context, + file_path: str, + policy: dict, + payload: AIHookPayload, + effective_mode: GuardrailsMode, +) -> tuple[Optional[str], Optional[str]]: """Scan a file path for secrets.""" if not file_path or not os.path.isfile(file_path): return None, None @@ -375,4 +435,7 @@ def _scan_path_for_secrets(ctx: typer.Context, file_path: str, policy: dict) -> document = Document(path=os.path.basename(file_path), content=content, is_git_diff_format=False) scan_ctx = _setup_scan_context(ctx) - return _perform_scan(scan_ctx, [document], get_scan_parameters(scan_ctx, (file_path,)), timeout_seconds) + scan_parameters = build_ai_guardrails_scan_parameters( + scan_ctx, (file_path,), payload, AiHookEventType.FILE_READ, effective_mode + ) + return _perform_scan(scan_ctx, [document], scan_parameters, timeout_seconds) diff --git a/cycode/cli/apps/ai_guardrails/scan/scan_command.py b/cycode/cli/apps/ai_guardrails/scan/scan_command.py index 1a389d5e..1c0c42b8 100644 --- a/cycode/cli/apps/ai_guardrails/scan/scan_command.py +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -8,6 +8,7 @@ """ from typing import Annotated, Optional, Union +from uuid import uuid4 import click import typer @@ -125,6 +126,9 @@ def scan_command( return unified_payload = ide_integration.parse_hook_payload(payload) + if not unified_payload.generation_id: + # Not every IDE dialect provides a generation id (e.g. Copilot) + unified_payload.generation_id = str(uuid4()) event_name = unified_payload.event_name logger.debug( 'Processing AI guardrails hook', diff --git a/cycode/cli/apps/ai_guardrails/scan/types.py b/cycode/cli/apps/ai_guardrails/scan/types.py index da42ed23..5d18e07d 100644 --- a/cycode/cli/apps/ai_guardrails/scan/types.py +++ b/cycode/cli/apps/ai_guardrails/scan/types.py @@ -41,3 +41,12 @@ class BlockReason(StrEnum): SECRETS_IN_MCP_ARGS = 'secrets_in_mcp_args' SENSITIVE_PATH = 'sensitive_path' SCAN_FAILURE = 'scan_failure' + + +# The reason each event type yields when a secret is found in it. Also travels with the scan as +# `detection_source`, so the violation and the hook event are labelled from the same vocabulary. +SECRETS_BLOCK_REASON_BY_EVENT_TYPE: dict[AiHookEventType, BlockReason] = { + AiHookEventType.PROMPT: BlockReason.SECRETS_IN_PROMPT, + AiHookEventType.FILE_READ: BlockReason.SECRETS_IN_FILE, + AiHookEventType.MCP_EXECUTION: BlockReason.SECRETS_IN_MCP_ARGS, +} diff --git a/cycode/cli/utils/host_info.py b/cycode/cli/utils/host_info.py index 23737b7a..60a36bed 100644 --- a/cycode/cli/utils/host_info.py +++ b/cycode/cli/utils/host_info.py @@ -1,8 +1,11 @@ import getpass +import os import platform import re import socket import subprocess +import tempfile +from pathlib import Path from typing import Optional from cycode.logger import get_logger @@ -11,6 +14,8 @@ _SUBPROCESS_TIMEOUT_SEC = 5 +_SERIAL_NUMBER_CACHE_FILE_NAME = '.cycode-device-serial' + _PLATFORM_NAMES = {'Darwin': 'macOS', 'Windows': 'Windows', 'Linux': 'Linux'} @@ -93,6 +98,19 @@ def get_last_login_user() -> Optional[str]: def get_serial_number() -> Optional[str]: + # The serial is immutable hardware info, but resolving it shells out (ioreg/WMI) + # and this runs in a fresh process per AI hook event - cache it on disk. + cached = _read_serial_number_cache() + if cached: + return cached + + serial = _resolve_serial_number() + if serial: + _write_serial_number_cache(serial) + return serial + + +def _resolve_serial_number() -> Optional[str]: try: system = platform.system() if system == 'Darwin': @@ -104,6 +122,40 @@ def get_serial_number() -> Optional[str]: return None +def _serial_number_cache_path() -> Path: + # The username suffix avoids collisions on OSes with a shared temp dir + return Path(tempfile.gettempdir()) / f'.cycode-device-serial-{getpass.getuser()}' + + +def _read_serial_number_cache() -> Optional[str]: + try: + return _serial_number_cache_path().read_text(encoding='utf-8').strip() or None + except Exception: + return None + + +def _write_serial_number_cache(serial: str) -> None: + try: + cache_path = _serial_number_cache_path() + + # The serial identifies the machine, and the temp dir is shared, so the cache is created + # readable by its owner alone (what mkstemp does) and moved into place atomically - a hook + # racing another one never reads a half-written cache, and the rename can't be redirected + # by a symlink planted at the destination the way an in-place write could. + file_descriptor, temp_path = tempfile.mkstemp( + dir=cache_path.parent, prefix=f'{_SERIAL_NUMBER_CACHE_FILE_NAME}.' + ) + try: + with os.fdopen(file_descriptor, 'w', encoding='utf-8') as temp_file: + temp_file.write(serial) + os.replace(temp_path, cache_path) + except Exception: + Path(temp_path).unlink(missing_ok=True) + raise + except Exception as e: + logger.debug('Failed to cache serial number', exc_info=e) + + def _get_macos_serial_number() -> Optional[str]: output = _run(['ioreg', '-c', 'IOPlatformExpertDevice', '-d', '2']) if not output: diff --git a/tests/cli/commands/ai_guardrails/scan/test_handlers.py b/tests/cli/commands/ai_guardrails/scan/test_handlers.py index 01f8790c..401482ac 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_handlers.py +++ b/tests/cli/commands/ai_guardrails/scan/test_handlers.py @@ -7,10 +7,14 @@ import pytest import typer +from cycode.cli.apps.ai_guardrails.consts import GuardrailsMode from cycode.cli.apps.ai_guardrails.ides.base import DecisionAction, HookDecision from cycode.cli.apps.ai_guardrails.scan.handlers import ( _perform_scan, _scan_path_for_secrets, + _scan_text_for_secrets, + build_ai_guardrails_scan_parameters, + get_effective_mode, handle_before_mcp_execution, handle_before_read_file, handle_before_submit_prompt, @@ -359,18 +363,26 @@ def test_handle_before_read_file_sensitive_path_scan_disabled_warns( assert call_args.kwargs['block_reason'] == BlockReason.SENSITIVE_PATH -def test_scan_path_for_secrets_directory(mock_ctx: MagicMock, default_policy: dict[str, Any], fs: Any) -> None: +def test_scan_path_for_secrets_directory( + mock_ctx: MagicMock, default_policy: dict[str, Any], mock_payload: AIHookPayload, fs: Any +) -> None: """Test that _scan_path_for_secrets returns (None, None) for directories.""" fs.create_dir('/path/to/some_directory') - result = _scan_path_for_secrets(mock_ctx, '/path/to/some_directory', default_policy) + result = _scan_path_for_secrets( + mock_ctx, '/path/to/some_directory', default_policy, payload=mock_payload, effective_mode=GuardrailsMode.BLOCK + ) assert result == (None, None) @patch('cycode.cli.apps.ai_guardrails.scan.handlers._perform_scan') def test_scan_path_for_secrets_skips_path_configured_in_exclusions( - mock_perform_scan: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any], fs: Any + mock_perform_scan: MagicMock, + mock_ctx: MagicMock, + default_policy: dict[str, Any], + mock_payload: AIHookPayload, + fs: Any, ) -> None: """Test that a path ignored via `cycode ignore --by-path` is not scanned.""" # `cycode ignore --by-path` stores absolute paths; on Windows that includes the drive prefix @@ -383,7 +395,9 @@ def test_scan_path_for_secrets_skips_path_configured_in_exclusions( 'cycode.cli.files_collector.file_excluder.configuration_manager.get_exclusions_by_scan_type', return_value={'paths': [excluded_dir]}, ): - result = _scan_path_for_secrets(mock_ctx, file_path, default_policy) + result = _scan_path_for_secrets( + mock_ctx, file_path, default_policy, payload=mock_payload, effective_mode=GuardrailsMode.BLOCK + ) assert result == (None, None) mock_perform_scan.assert_not_called() @@ -512,3 +526,62 @@ def test_handle_before_mcp_execution_scan_disabled( assert result == HookDecision.allow(AiHookEventType.MCP_EXECUTION) mock_scan.assert_not_called() + + +def test_get_effective_mode_block_only_when_both_mode_and_action_block() -> None: + """The event blocks only when both the global mode and the per-guardrail action are block.""" + assert get_effective_mode({'mode': 'block'}, {'action': 'block'}) == GuardrailsMode.BLOCK + assert get_effective_mode({'mode': 'block'}, {'action': 'warn'}) == GuardrailsMode.REPORT + assert get_effective_mode({'mode': 'warn'}, {'action': 'block'}) == GuardrailsMode.REPORT + assert get_effective_mode({'mode': 'warn'}, {'action': 'warn'}) == GuardrailsMode.REPORT + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers.get_serial_number', return_value='SER-123') +@patch('cycode.cli.apps.ai_guardrails.scan.handlers.get_hostname', return_value='test-host') +def test_build_ai_guardrails_scan_parameters( + mock_hostname: MagicMock, mock_serial: MagicMock, mock_ctx: MagicMock, mock_payload: AIHookPayload +) -> None: + """The built scan parameters embed the full hook context alongside the standard scan parameters.""" + mock_ctx.info_name = 'ai_guardrails' + + params = build_ai_guardrails_scan_parameters( + mock_ctx, None, mock_payload, AiHookEventType.PROMPT, effective_mode=GuardrailsMode.REPORT + ) + + assert params['command_type'] == 'ai_guardrails' + assert params['metadata']['ai_guardrails'] == { + 'mode': 'report', + 'ide_provider': 'cursor', + 'detection_source': 'secrets_in_prompt', + 'device_id': 'SER-123', + 'device_hostname': 'test-host', + 'conversation_id': 'test-conv-id', + 'generation_id': 'test-gen-id', + 'ide_user_email': 'test@example.com', + 'mcp_server_name': None, + 'mcp_tool_name': None, + } + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._perform_scan') +def test_scan_text_for_secrets_injects_ai_guardrails_scan_parameter( + mock_perform_scan: MagicMock, mock_ctx: MagicMock, mock_payload: AIHookPayload +) -> None: + """The scan parameters sent to the server include the ai_guardrails context.""" + mock_ctx.obj['progress_bar'] = MagicMock() + mock_perform_scan.return_value = (None, 'scan-id-123') + + _scan_text_for_secrets( + mock_ctx, + 'some text', + 1000, + payload=mock_payload, + event_type=AiHookEventType.PROMPT, + effective_mode=GuardrailsMode.REPORT, + ) + + ai_guardrails = mock_perform_scan.call_args.args[2]['metadata']['ai_guardrails'] + assert ai_guardrails['mode'] == 'report' + assert ai_guardrails['detection_source'] == 'secrets_in_prompt' + assert ai_guardrails['conversation_id'] == 'test-conv-id' + assert ai_guardrails['generation_id'] == 'test-gen-id' From 751159885810e85651ab7a3ab2724e99dcb000ac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 07:40:49 +0300 Subject: [PATCH 113/123] Bump docker/setup-buildx-action from 4.0.0 to 4.2.0 (#513) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-image.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 1266dcbf..92e3495e 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -64,7 +64,7 @@ jobs: uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Login to Docker Hub if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') }} From 7f656730fe8caacbd01f93616141d30924359143 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 08:14:06 +0300 Subject: [PATCH 114/123] Bump actions/checkout from 6.0.2 to 7.0.1 (#511) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build_executable.yml | 2 +- .github/workflows/docker-image.yml | 2 +- .github/workflows/pre_release.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/ruff.yml | 2 +- .github/workflows/tests.yml | 2 +- .github/workflows/tests_full.yml | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 2b81f2cc..56478c83 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -48,7 +48,7 @@ jobs: uploads.github.com - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 92e3495e..f0fb8a1d 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 2dee645b..7e8a6c4b 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -28,7 +28,7 @@ jobs: *.sigstore.dev - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1bc04888..0fc97b12 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ jobs: *.sigstore.dev - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 2c1cd8c6..78d393fb 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -21,7 +21,7 @@ jobs: pypi.org - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a9f2bc1c..84387453 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,7 +23,7 @@ jobs: *.ingest.us.sentry.io - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index b4eb3e48..6523c391 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -36,7 +36,7 @@ jobs: *.ingest.us.sentry.io - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 From 2dd03295a1de6cb2621662be532d9d460f59fe40 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 10:13:07 +0300 Subject: [PATCH 115/123] Bump docker/login-action from 4.1.0 to 4.5.0 (#510) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-image.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index f0fb8a1d..156914b6 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -68,7 +68,7 @@ jobs: - name: Login to Docker Hub if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') }} - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4.5.0 with: username: ${{ secrets.DOCKERHUB_USER }} password: ${{ secrets.DOCKERHUB_PASSWORD }} From 76d03f5f8d1111b8d1f97e1ac6ece149a1ce675a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 10:22:17 +0300 Subject: [PATCH 116/123] Bump actions/setup-python from 6.3.0 to 7.0.0 (#509) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build_executable.yml | 2 +- .github/workflows/docker-image.yml | 2 +- .github/workflows/pre_release.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/ruff.yml | 2 +- .github/workflows/tests.yml | 2 +- .github/workflows/tests_full.yml | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 56478c83..f1600611 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -61,7 +61,7 @@ jobs: - name: Set up Python 3.13 id: setup-python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.13' diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 156914b6..16782d7b 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -31,7 +31,7 @@ jobs: git checkout ${{ steps.latest_tag.outputs.LATEST_TAG }} - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.9' diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 7e8a6c4b..8f00fe49 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -33,7 +33,7 @@ jobs: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.9' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0fc97b12..e43f89eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,7 @@ jobs: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.9' diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 78d393fb..41cade54 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -24,7 +24,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: 3.9 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 84387453..c97318f0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,7 +26,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.9' diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index 6523c391..7e4de5ef 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -41,7 +41,7 @@ jobs: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} From 90600567db67f1d2aaf3fbd5d02e65e7882845aa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 10:33:41 +0300 Subject: [PATCH 117/123] Bump pypa/gh-action-pypi-publish from 1.14.0 to 1.14.1 (#512) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pre_release.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 8f00fe49..35649497 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -74,4 +74,4 @@ jobs: run: poetry build - name: Publish a Python distribution to PyPI - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e43f89eb..cc5cbe21 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,4 +73,4 @@ jobs: run: poetry build - name: Publish a Python distribution to PyPI - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 From e62b5337802f141897f19753ea4c8224f70a1d48 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 10:45:48 +0300 Subject: [PATCH 118/123] Bump tenacity from 9.0.0 to 9.1.2 (#507) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 10 +++++----- pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/poetry.lock b/poetry.lock index 3a39aaa5..d1734454 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1822,14 +1822,14 @@ full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart [[package]] name = "tenacity" -version = "9.0.0" +version = "9.1.2" description = "Retry code until it succeeds" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "tenacity-9.0.0-py3-none-any.whl", hash = "sha256:93de0c98785b27fcf659856aa9f54bfbd399e29969b0621bc7f762bd441b4539"}, - {file = "tenacity-9.0.0.tar.gz", hash = "sha256:807f37ca97d62aa361264d497b0e31e92b8027044942bfa756160d908320d73b"}, + {file = "tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138"}, + {file = "tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb"}, ] [package.extras] @@ -2027,4 +2027,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "09f70b525d7ba0c84e1a209e4ec1359d52a5e7901869c2b738abf5236f812594" +content-hash = "c854f790a9db6703d7aa2f7a7100e5629dcc3afebdf467b93cfbc9b3616ed4cf" diff --git a/pyproject.toml b/pyproject.toml index 57e59c30..bcd8a8d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ pyjwt = ">=2.8.0,<3.0" rich = ">=13.9.4, <14" patch-ng = "1.19.1" typer = "^0.15.3" -tenacity = ">=9.0.0,<9.1.0" +tenacity = ">=9.1.2,<9.2.0" mcp = { version = ">=1.28.1,<2.0.0", markers = "python_version >= '3.10'" } pydantic = ">=2.11.5,<3.0.0" pathvalidate = ">=3.3.1,<4.0.0" From 3b79f9493bf62ba1ffbb181c917f6e486f9fccb3 Mon Sep 17 00:00:00 2001 From: Omer Roth Date: Sun, 2 Aug 2026 14:26:51 +0300 Subject: [PATCH 119/123] CM-70014: update dependencies (Aug 2026) (#514) Co-authored-by: Claude Opus 5 (1M context) --- cycode/cli/apps/scan/commit_range_scanner.py | 5 +- .../files_collector/commit_range_documents.py | 22 +- poetry.lock | 1010 +++++++++++------ pyproject.toml | 8 +- .../test_commit_range_documents.py | 23 +- 5 files changed, 694 insertions(+), 374 deletions(-) diff --git a/cycode/cli/apps/scan/commit_range_scanner.py b/cycode/cli/apps/scan/commit_range_scanner.py index 298af34c..70b7e8e4 100644 --- a/cycode/cli/apps/scan/commit_range_scanner.py +++ b/cycode/cli/apps/scan/commit_range_scanner.py @@ -27,7 +27,7 @@ get_diff_file_content, get_diff_file_path, get_pre_commit_modified_documents, - get_safe_head_reference_for_diff, + get_staged_diff_index, parse_commit_range, ) from cycode.cli.files_collector.documents_walk_ignore import filter_documents_with_cycodeignore @@ -360,8 +360,7 @@ def _scan_sca_pre_commit(ctx: typer.Context, repo_path: str) -> None: def _scan_secret_pre_commit(ctx: typer.Context, repo_path: str) -> None: progress_bar = ctx.obj['progress_bar'] repo = git_proxy.get_repo(repo_path) - head_reference = get_safe_head_reference_for_diff(repo) - diff_index = repo.index.diff(head_reference, create_patch=True, R=True) + _, diff_index = get_staged_diff_index(repo) progress_bar.set_section_length(ScanProgressBarSection.PREPARE_LOCAL_FILES, len(diff_index)) diff --git a/cycode/cli/files_collector/commit_range_documents.py b/cycode/cli/files_collector/commit_range_documents.py index daa5c432..2fb63581 100644 --- a/cycode/cli/files_collector/commit_range_documents.py +++ b/cycode/cli/files_collector/commit_range_documents.py @@ -15,7 +15,7 @@ from cycode.logger import get_logger if TYPE_CHECKING: - from git import Diff, Repo + from git import Diff, DiffIndex, Repo from cycode.cli.utils.progress_bar import BaseProgressBar, ProgressBarSection @@ -47,6 +47,23 @@ def get_safe_head_reference_for_diff(repo: 'Repo') -> str: return consts.GIT_EMPTY_TREE_OBJECT +def get_staged_diff_index(repo: 'Repo') -> tuple[str, 'DiffIndex']: + """Diff the index against HEAD, or against the empty tree in repositories with no commits. + + GitPython only inverts the `R` flag for HEAD, so `R` must be off for the empty tree to keep + staged content showing up as added lines in both cases. + + Args: + repo: Git repository object + + Returns: + The reference that was diffed against, and the resulting diff index + """ + head_reference = get_safe_head_reference_for_diff(repo) + reverse = head_reference == consts.GIT_HEAD_COMMIT_REV + return head_reference, repo.index.diff(head_reference, create_patch=True, R=reverse) + + def _does_reach_to_max_commits_to_scan_limit(commit_ids: list[str], max_commits_count: Optional[int]) -> bool: if max_commits_count is None: return False @@ -411,8 +428,7 @@ def get_pre_commit_modified_documents( diff_documents = [] repo = git_proxy.get_repo(repo_path) - head_reference = get_safe_head_reference_for_diff(repo) - diff_index = repo.index.diff(head_reference, create_patch=True, R=True) + head_reference, diff_index = get_staged_diff_index(repo) progress_bar.set_section_length(progress_bar_section, len(diff_index)) for diff in diff_index: progress_bar.update(progress_bar_section) diff --git a/poetry.lock b/poetry.lock index d1734454..44fa8fb6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -20,11 +20,25 @@ description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" groups = ["main"] +markers = "python_version < \"3.14\"" files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] +[[package]] +name = "annotated-types" +version = "0.8.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0"}, + {file = "annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7"}, +] + [[package]] name = "anyio" version = "4.12.1" @@ -78,111 +92,185 @@ files = [ {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, ] +[[package]] +name = "backports-datetime-fromisoformat" +version = "2.0.3" +description = "Backport of Python 3.11's datetime.fromisoformat" +optional = false +python-versions = ">3" +groups = ["main"] +markers = "python_version < \"3.11\"" +files = [ + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f681f638f10588fa3c101ee9ae2b63d3734713202ddfcfb6ec6cea0778a29d4"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:cd681460e9142f1249408e5aee6d178c6d89b49e06d44913c8fdfb6defda8d1c"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:ee68bc8735ae5058695b76d3bb2aee1d137c052a11c8303f1e966aa23b72b65b"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8273fe7932db65d952a43e238318966eab9e49e8dd546550a41df12175cc2be4"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39d57ea50aa5a524bb239688adc1d1d824c31b6094ebd39aa164d6cadb85de22"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ac6272f87693e78209dc72e84cf9ab58052027733cd0721c55356d3c881791cf"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:44c497a71f80cd2bcfc26faae8857cf8e79388e3d5fbf79d2354b8c360547d58"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:6335a4c9e8af329cb1ded5ab41a666e1448116161905a94e054f205aa6d263bc"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2e4b66e017253cdbe5a1de49e0eecff3f66cd72bcb1229d7db6e6b1832c0443"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:43e2d648e150777e13bbc2549cc960373e37bf65bd8a5d2e0cef40e16e5d8dd0"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:4ce6326fd86d5bae37813c7bf1543bae9e4c215ec6f5afe4c518be2635e2e005"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7c8fac333bf860208fd522a5394369ee3c790d0aa4311f515fcc4b6c5ef8d75"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24a4da5ab3aa0cc293dc0662a0c6d1da1a011dc1edcbc3122a288cfed13a0b45"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:58ea11e3bf912bd0a36b0519eae2c5b560b3cb972ea756e66b73fb9be460af01"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8a375c7dbee4734318714a799b6c697223e4bbb57232af37fbfff88fb48a14c6"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:ac677b1664c4585c2e014739f6678137c8336815406052349c85898206ec7061"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66ce47ee1ba91e146149cf40565c3d750ea1be94faf660ca733d8601e0848147"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:8b7e069910a66b3bba61df35b5f879e5253ff0821a70375b9daf06444d046fa4"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:a3b5d1d04a9e0f7b15aa1e647c750631a873b298cdd1255687bb68779fe8eb35"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec1b95986430e789c076610aea704db20874f0781b8624f648ca9fb6ef67c6e1"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffe5f793db59e2f1d45ec35a1cf51404fdd69df9f6952a0c87c3060af4c00e32"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:620e8e73bd2595dfff1b4d256a12b67fce90ece3de87b38e1dde46b910f46f4d"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4cf9c0a985d68476c1cabd6385c691201dda2337d7453fb4da9679ce9f23f4e7"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:d144868a73002e6e2e6fef72333e7b0129cecdd121aa8f1edba7107fd067255d"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e81b26497a17c29595bc7df20bc6a872ceea5f8c9d6537283945d4b6396aec10"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:5ba00ead8d9d82fd6123eb4891c566d30a293454e54e32ff7ead7644f5f7e575"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-macosx_11_0_x86_64.whl", hash = "sha256:24d574cb4072e1640b00864e94c4c89858033936ece3fc0e1c6f7179f120d0a8"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9735695a66aad654500b0193525e590c693ab3368478ce07b34b443a1ea5e824"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63d39709e17eb72685d052ac82acf0763e047f57c86af1b791505b1fec96915d"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:1ea2cc84224937d6b9b4c07f5cb7c667f2bde28c255645ba27f8a675a7af8234"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4024e6d35a9fdc1b3fd6ac7a673bd16cb176c7e0b952af6428b7129a70f72cce"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5e2dcc94dc9c9ab8704409d86fcb5236316e9dcef6feed8162287634e3568f4c"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fa2de871801d824c255fac7e5e7e50f2be6c9c376fd9268b40c54b5e9da91f42"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:1314d4923c1509aa9696712a7bc0c7160d3b7acf72adafbbe6c558d523f5d491"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:b750ecba3a8815ad8bc48311552f3f8ab99dd2326d29df7ff670d9c49321f48f"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d5117dce805d8a2f78baeddc8c6127281fa0a5e2c40c6dd992ba6b2b367876"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb35f607bd1cbe37b896379d5f5ed4dc298b536f4b959cb63180e05cacc0539d"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:61c74710900602637d2d145dda9720c94e303380803bf68811b2a151deec75c2"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ece59af54ebf67ecbfbbf3ca9066f5687879e36527ad69d8b6e3ac565d565a62"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:d0a7c5f875068efe106f62233bc712d50db4d07c13c7db570175c7857a7b5dbd"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90e202e72a3d5aae673fcc8c9a4267d56b2f532beeb9173361293625fe4d2039"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2df98ef1b76f5a58bb493dda552259ba60c3a37557d848e039524203951c9f06"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7100adcda5e818b5a894ad0626e38118bb896a347f40ebed8981155675b9ba7b"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e410383f5d6a449a529d074e88af8bc80020bb42b402265f9c02c8358c11da5"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2797593760da6bcc32c4a13fa825af183cd4bfd333c60b3dbf84711afca26ef"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35a144fd681a0bea1013ccc4cd3fd4dc758ea17ee23dca019c02b82ec46fc0c4"}, + {file = "backports_datetime_fromisoformat-2.0.3.tar.gz", hash = "sha256:b58edc8f517b66b397abc250ecc737969486703a66eb97e01e6d51291b1a139d"}, +] + [[package]] name = "certifi" -version = "2026.5.20" +version = "2026.7.22" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" groups = ["main", "test"] files = [ - {file = "certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897"}, - {file = "certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d"}, + {file = "certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775"}, + {file = "certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55"}, ] [[package]] name = "cffi" -version = "2.0.0" +version = "2.1.0" description = "Foreign Function Interface for Python calling C code." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\" and platform_python_implementation != \"PyPy\"" files = [ - {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, - {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, - {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, - {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, - {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, - {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, - {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, - {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, - {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, - {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, - {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, - {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, - {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, - {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, - {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, - {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, - {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, - {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, - {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, - {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, - {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, - {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, - {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, - {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, - {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, - {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, - {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, - {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, - {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, - {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, - {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, - {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, - {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, - {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, - {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, - {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, - {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, - {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, - {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, - {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, - {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, - {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, - {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, - {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, - {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, - {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, - {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, - {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, - {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, - {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, - {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, + {file = "cffi-2.1.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0"}, + {file = "cffi-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0"}, + {file = "cffi-2.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43"}, + {file = "cffi-2.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c"}, + {file = "cffi-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd"}, + {file = "cffi-2.1.0-cp310-cp310-win32.whl", hash = "sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f"}, + {file = "cffi-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da"}, + {file = "cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc"}, + {file = "cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565"}, + {file = "cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c"}, + {file = "cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02"}, + {file = "cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e"}, + {file = "cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479"}, + {file = "cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458"}, + {file = "cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d"}, + {file = "cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f"}, + {file = "cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66"}, + {file = "cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe"}, + {file = "cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b"}, + {file = "cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a"}, + {file = "cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384"}, + {file = "cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6"}, + {file = "cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda"}, + {file = "cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b"}, + {file = "cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a"}, + {file = "cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224"}, + {file = "cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c"}, + {file = "cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a"}, + {file = "cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2"}, + {file = "cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512"}, + {file = "cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f"}, + {file = "cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a"}, + {file = "cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3"}, + {file = "cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d"}, + {file = "cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac"}, + {file = "cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6"}, + {file = "cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913"}, + {file = "cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d"}, + {file = "cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5"}, + {file = "cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce"}, + {file = "cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326"}, + {file = "cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd"}, + {file = "cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb"}, + {file = "cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804"}, + {file = "cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714"}, + {file = "cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376"}, + {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98"}, + {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13"}, + {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d"}, + {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056"}, + {file = "cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4"}, + {file = "cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94"}, + {file = "cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76"}, + {file = "cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5"}, + {file = "cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8"}, + {file = "cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c"}, + {file = "cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001"}, + {file = "cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3"}, + {file = "cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc"}, + {file = "cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699"}, + {file = "cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022"}, + {file = "cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0"}, + {file = "cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1"}, + {file = "cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28"}, + {file = "cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629"}, + {file = "cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6"}, + {file = "cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853"}, + {file = "cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda"}, + {file = "cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc"}, + {file = "cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca"}, + {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d"}, + {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8"}, + {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd"}, + {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f"}, + {file = "cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc"}, + {file = "cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9"}, + {file = "cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b"}, + {file = "cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5"}, + {file = "cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210"}, + {file = "cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9"}, ] [package.dependencies] @@ -190,141 +278,105 @@ pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} [[package]] name = "charset-normalizer" -version = "3.4.7" +version = "3.4.9" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" groups = ["main", "test"] files = [ - {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"}, - {file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"}, - {file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-win32.whl", hash = "sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-win_amd64.whl", hash = "sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-win_arm64.whl", hash = "sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe"}, + {file = "charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5"}, + {file = "charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b"}, ] [[package]] @@ -474,62 +526,59 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cryptography" -version = "48.0.0" +version = "49.0.0" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = "!=3.9.0,!=3.9.1,>=3.9" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6"}, - {file = "cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c"}, - {file = "cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3"}, - {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5"}, - {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c"}, - {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f"}, - {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25"}, - {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602"}, - {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c"}, - {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5"}, - {file = "cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321"}, - {file = "cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74"}, - {file = "cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4"}, - {file = "cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7"}, - {file = "cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec"}, - {file = "cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18"}, - {file = "cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20"}, - {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff"}, - {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c"}, - {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db"}, - {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741"}, - {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166"}, - {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336"}, - {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057"}, - {file = "cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae"}, - {file = "cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c"}, - {file = "cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f"}, - {file = "cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12"}, - {file = "cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86"}, - {file = "cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e"}, - {file = "cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f"}, - {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7"}, - {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832"}, - {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c"}, - {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a"}, - {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a"}, - {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a"}, - {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239"}, - {file = "cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c"}, - {file = "cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4"}, - {file = "cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd"}, - {file = "cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8"}, - {file = "cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855"}, - {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b"}, - {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13"}, - {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb"}, - {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355"}, - {file = "cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a"}, - {file = "cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920"}, + {file = "cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9"}, + {file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f"}, + {file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459"}, + {file = "cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e"}, + {file = "cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8"}, + {file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3"}, + {file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27"}, + {file = "cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61"}, + {file = "cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36"}, + {file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e"}, + {file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b"}, + {file = "cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6"}, + {file = "cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493"}, ] [package.dependencies] @@ -590,14 +639,14 @@ smmap = ">=3.0.1,<6" [[package]] name = "gitpython" -version = "3.1.50" +version = "3.1.57" description = "GitPython is a Python library used to interact with Git repositories" optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9"}, - {file = "gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc"}, + {file = "gitpython-3.1.57-py3-none-any.whl", hash = "sha256:4ccf7d73c10f5c9e76043fbb2675ac5a1b3ff5b41e648f56bcbed5f63792ecaf"}, + {file = "gitpython-3.1.57.tar.gz", hash = "sha256:c493ec57c0ef6b19743798b6a5af859c71814b524e7e6f97baa2f8e658961488"}, ] [package.dependencies] @@ -606,7 +655,7 @@ typing-extensions = {version = ">=3.10.0.2", markers = "python_version < \"3.10\ [package.extras] doc = ["sphinx (>=7.4.7,<8)", "sphinx-autodoc-typehints", "sphinx_rtd_theme"] -test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock ; python_version < \"3.8\"", "mypy (==1.18.2) ; python_version >= \"3.9\"", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions ; python_version < \"3.11\""] +test = ["basedpyright (==1.39.9) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock ; python_version < \"3.8\"", "mypy (==1.18.2) ; python_version >= \"3.9\"", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions ; python_version < \"3.11\""] [[package]] name = "h11" @@ -730,11 +779,25 @@ description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.8" groups = ["test"] +markers = "python_version < \"3.14\"" files = [ {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["test"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + [[package]] name = "jsonschema" version = "4.26.0" @@ -797,6 +860,7 @@ description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.8" groups = ["main"] +markers = "python_version < \"3.14\"" files = [ {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, @@ -815,24 +879,50 @@ profiling = ["gprof2dot"] rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a"}, + {file = "markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins (>=0.5.0)"] +profiling = ["gprof2dot"] +rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "pytest-timeout", "requests"] + [[package]] name = "marshmallow" -version = "3.26.2" +version = "4.0.1" description = "A lightweight library for converting complex datatypes to and from native Python datatypes." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73"}, - {file = "marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57"}, + {file = "marshmallow-4.0.1-py3-none-any.whl", hash = "sha256:72f14ef346f81269dbddee891bac547dda1501e9e08b6a809756ea3dbb7936a1"}, + {file = "marshmallow-4.0.1.tar.gz", hash = "sha256:e1d860bd262737cb2d34e1541b84cb52c32c72c9474e3fe6f30f137ef8b0d97f"}, ] [package.dependencies] -packaging = ">=17.0" +backports-datetime-fromisoformat = {version = "*", markers = "python_version < \"3.11\""} +typing-extensions = {version = "*", markers = "python_version < \"3.11\""} [package.extras] dev = ["marshmallow[tests]", "pre-commit (>=3.5,<5.0)", "tox"] -docs = ["autodocsumm (==0.2.14)", "furo (==2024.8.6)", "sphinx (==8.1.3)", "sphinx-copybutton (==0.5.2)", "sphinx-issues (==5.0.0)", "sphinxext-opengraph (==0.9.1)"] +docs = ["autodocsumm (==0.2.14)", "furo (==2025.7.19)", "sphinx (==8.2.3)", "sphinx-copybutton (==0.5.2)", "sphinx-issues (==5.0.1)", "sphinxext-opengraph (==0.12.0)"] tests = ["pytest", "simplejson"] [[package]] @@ -913,7 +1003,7 @@ version = "26.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["main", "executable", "test"] +groups = ["executable", "test"] files = [ {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, @@ -1147,15 +1237,15 @@ typing-extensions = ">=4.14.1" [[package]] name = "pydantic-settings" -version = "2.14.1" +version = "2.14.2" description = "Settings management using Pydantic" optional = false python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de"}, - {file = "pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa"}, + {file = "pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440"}, + {file = "pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f"}, ] [package.dependencies] @@ -1199,25 +1289,25 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyinstaller" -version = "6.20.0" +version = "6.21.0" description = "PyInstaller bundles a Python application and all its dependencies into a single package." optional = false -python-versions = "<3.15,>=3.8" +python-versions = "<3.16,>=3.8" groups = ["executable"] markers = "python_version < \"3.15\"" files = [ - {file = "pyinstaller-6.20.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:bf3be4e1284ee78ddccba5e29f99443a12a7b4673168288ffc4c9d38c6f7b90e"}, - {file = "pyinstaller-6.20.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:72ae9c1fdea134afa791f58bdc9a1934d5c7609753c111e0026bfc272b32b712"}, - {file = "pyinstaller-6.20.0-py3-none-manylinux2014_i686.whl", hash = "sha256:1031bcc307f3fbeffd4e162723e64d46dbf591c82dd0997413afb2a07328b941"}, - {file = "pyinstaller-6.20.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:8df3b3f347659fa2562d8d193a98ad4600133b8b8d07c268df89e4154376750e"}, - {file = "pyinstaller-6.20.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:b0d3cc9dd8120d448459bd3880a12e2f9774c51443af49047801446377999a59"}, - {file = "pyinstaller-6.20.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:03696bb6350177c6bc23bcaf78e71a33c4a89b6754dd90d1be2f318e978c918b"}, - {file = "pyinstaller-6.20.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:6357f1699f6af84f37e7367f031d4f68abdba65543b83990c9e8f5a4cebed0b7"}, - {file = "pyinstaller-6.20.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:0ab39c690abad26ba148e8f664f0478acc82a733997f4f22e757774832802da9"}, - {file = "pyinstaller-6.20.0-py3-none-win32.whl", hash = "sha256:9a7637e8e44b4387b13667fdcaac86ab6b29c446c16d34d8401539b81838759c"}, - {file = "pyinstaller-6.20.0-py3-none-win_amd64.whl", hash = "sha256:d588844e890ee80c4365867f98146636e1849bbca8e4284bbf0c809aff0f161a"}, - {file = "pyinstaller-6.20.0-py3-none-win_arm64.whl", hash = "sha256:bd53282c0a73e5c95573e1ddc8e5d564d4932bec91efbaed4dc5fdff9c2ae7f2"}, - {file = "pyinstaller-6.20.0.tar.gz", hash = "sha256:95c5c7e03d5d61e9dfb8ef259c699cf492bb1041beb6dbe83696608cec07347a"}, + {file = "pyinstaller-6.21.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:327d132389f37912609e01be62810cf96b5aa95b613903e4b8692e0d12fb0eda"}, + {file = "pyinstaller-6.21.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7071d4b094d5b40deeef5fa3d3b98a1b846087f7562b49209663d5f9281fe251"}, + {file = "pyinstaller-6.21.0-py3-none-manylinux2014_i686.whl", hash = "sha256:6b6374d652107dd4a2eeece903ff82bb4045bb5e1006c5a158a6dcdbefe84bf2"}, + {file = "pyinstaller-6.21.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4e3108b3f02384560da70e39b8bf22b0ad597d02bd68a40d76ea91c1cfa00cad"}, + {file = "pyinstaller-6.21.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:697532279f535ad572bda613db4f821540e235c7854ca6da4d3bf0373f4415ee"}, + {file = "pyinstaller-6.21.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:605169523a6b5ace39f13dfbff21add9f2bc43df99c7daf9394fefb2c45e8b6f"}, + {file = "pyinstaller-6.21.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:5fa56746c1e76f93634d018502301378a2d0c382553d37d8c3c34ff436c12dd1"}, + {file = "pyinstaller-6.21.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:42395ec76df8e8120c36b13339d9db8cab83e316a12839ee303cc00fc941bb74"}, + {file = "pyinstaller-6.21.0-py3-none-win32.whl", hash = "sha256:c6b28d30d8fd99ce162ff3aab5013ed44dbfb747566b1f01b9bed7964d7c14e9"}, + {file = "pyinstaller-6.21.0-py3-none-win_amd64.whl", hash = "sha256:7fae06c494ce0ebfe6bd3055c0e409def884f63af2e3705d06bd431ad9237fc7"}, + {file = "pyinstaller-6.21.0-py3-none-win_arm64.whl", hash = "sha256:f13c95c9c03fb567217135919f93815c305813126780b0ed6e0123cb8acaf025"}, + {file = "pyinstaller-6.21.0.tar.gz", hash = "sha256:bb9fab705983e393a2d1cac77d6972513057ad800215fd861dc15ff5272e98fd"}, ] [package.dependencies] @@ -1226,7 +1316,7 @@ importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""} packaging = ">=22.0" pefile = {version = ">=2022.5.30", markers = "sys_platform == \"win32\""} -pyinstaller-hooks-contrib = ">=2026.4" +pyinstaller-hooks-contrib = ">=2026.6" pywin32-ctypes = {version = ">=0.2.1", markers = "sys_platform == \"win32\""} setuptools = ">=42.0.0" @@ -1236,21 +1326,20 @@ hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"] [[package]] name = "pyinstaller-hooks-contrib" -version = "2026.5" +version = "2026.6" description = "Community maintained hooks for PyInstaller" optional = false python-versions = ">=3.8" groups = ["executable"] markers = "python_version < \"3.15\"" files = [ - {file = "pyinstaller_hooks_contrib-2026.5-py3-none-any.whl", hash = "sha256:ea1535783fbdac4626351709e83f3ea80b681d3a4745763ebb407b5e27342eb9"}, - {file = "pyinstaller_hooks_contrib-2026.5.tar.gz", hash = "sha256:f066dfca8f7c45ff6336c9cf9fe25b4e48bfeb322a1aa24faaedfb8a8d1b0b08"}, + {file = "pyinstaller_hooks_contrib-2026.6-py3-none-any.whl", hash = "sha256:fd13b8ac126b35361175edacd41a0d97080b75dd5f4b594ecefefff969509dd3"}, + {file = "pyinstaller_hooks_contrib-2026.6.tar.gz", hash = "sha256:bef5002c32f4f50bd55b005da12cff64eca8783e7eaf86a06a62410164bab725"}, ] [package.dependencies] importlib_metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} packaging = ">=22.0" -setuptools = ">=42.0.0" [[package]] name = "pyjwt" @@ -1346,46 +1435,47 @@ cli = ["click (>=5.0)"] [[package]] name = "python-multipart" -version = "0.0.30" +version = "0.0.32" description = "A streaming multipart parser for Python" optional = false python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "python_multipart-0.0.30-py3-none-any.whl", hash = "sha256:830964def8c90607ac5daa00514e3987815865713ade8d20febc9177ac0c3c5b"}, - {file = "python_multipart-0.0.30.tar.gz", hash = "sha256:0edfe0475c1f46ddd3ff7785a626f6118af32bdcf359bb21260367313bb32118"}, + {file = "python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23"}, + {file = "python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e"}, ] [[package]] name = "pywin32" -version = "311" -description = "Python for Window Extensions" +version = "312" +description = "Python for Windows Extensions" optional = false -python-versions = "*" +python-versions = ">=3.9" groups = ["main"] markers = "sys_platform == \"win32\" and python_version >= \"3.10\"" files = [ - {file = "pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3"}, - {file = "pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b"}, - {file = "pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b"}, - {file = "pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151"}, - {file = "pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503"}, - {file = "pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2"}, - {file = "pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31"}, - {file = "pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067"}, - {file = "pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852"}, - {file = "pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d"}, - {file = "pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d"}, - {file = "pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a"}, - {file = "pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee"}, - {file = "pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87"}, - {file = "pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42"}, - {file = "pywin32-311-cp38-cp38-win32.whl", hash = "sha256:6c6f2969607b5023b0d9ce2541f8d2cbb01c4f46bc87456017cf63b73f1e2d8c"}, - {file = "pywin32-311-cp38-cp38-win_amd64.whl", hash = "sha256:c8015b09fb9a5e188f83b7b04de91ddca4658cee2ae6f3bc483f0b21a77ef6cd"}, - {file = "pywin32-311-cp39-cp39-win32.whl", hash = "sha256:aba8f82d551a942cb20d4a83413ccbac30790b50efb89a75e4f586ac0bb8056b"}, - {file = "pywin32-311-cp39-cp39-win_amd64.whl", hash = "sha256:e0c4cfb0621281fe40387df582097fd796e80430597cb9944f0ae70447bacd91"}, - {file = "pywin32-311-cp39-cp39-win_arm64.whl", hash = "sha256:62ea666235135fee79bb154e695f3ff67370afefd71bd7fea7512fc70ef31e3d"}, + {file = "pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e"}, + {file = "pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db"}, + {file = "pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd"}, + {file = "pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c"}, + {file = "pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a"}, + {file = "pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47"}, + {file = "pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b"}, + {file = "pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc"}, + {file = "pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950"}, + {file = "pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c"}, + {file = "pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9"}, + {file = "pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831"}, + {file = "pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b"}, + {file = "pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e"}, + {file = "pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa"}, + {file = "pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed"}, + {file = "pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5"}, + {file = "pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9"}, + {file = "pywin32-312-cp39-cp39-win32.whl", hash = "sha256:d620900033cc7531e50727c3c8333091df5dd3ffe6d68cdca38c03f5821408d5"}, + {file = "pywin32-312-cp39-cp39-win_amd64.whl", hash = "sha256:dc90147579a905b8635e1b0ec6514967dcb07e6e0d9c42f1477feef14cac23bb"}, + {file = "pywin32-312-cp39-cp39-win_arm64.whl", hash = "sha256:02ebca0f0242b75292e218065004310d6a477407c09fa449bfe4f6022bc0c0fc"}, ] [[package]] @@ -1509,6 +1599,7 @@ description = "Python HTTP for Humans." optional = false python-versions = ">=3.9" groups = ["main", "test"] +markers = "python_version < \"3.14\"" files = [ {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, @@ -1524,16 +1615,39 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "requests" +version = "2.34.2" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.10" +groups = ["main", "test"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, + {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, +] + +[package.dependencies] +certifi = ">=2023.5.7" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.26,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] + [[package]] name = "responses" -version = "0.26.1" +version = "0.26.2" description = "A utility library for mocking out the `requests` Python library." optional = false python-versions = ">=3.8" groups = ["test"] files = [ - {file = "responses-0.26.1-py3-none-any.whl", hash = "sha256:8aacc4586eb08fb2208ef64a9eb4258d9b0c6e6f4260845f2f018ab847495345"}, - {file = "responses-0.26.1.tar.gz", hash = "sha256:2eb3218553cc8f79b57d257bac23af5e1bf381f5b9390b1767816f0843e01dc2"}, + {file = "responses-0.26.2-py3-none-any.whl", hash = "sha256:6fdfeabd58e5ec473b98dfe02e6d46d3173bd8dd573eff2ccccf1a05a5135364"}, + {file = "responses-0.26.2.tar.gz", hash = "sha256:9c9259b46a8349197edebf43cfa68a87e1a2802ef503ff8b2fecbabc0b45afd8"}, ] [package.dependencies] @@ -1546,20 +1660,19 @@ tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asy [[package]] name = "rich" -version = "13.9.4" +version = "15.0.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false -python-versions = ">=3.8.0" +python-versions = ">=3.9.0" groups = ["main"] files = [ - {file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"}, - {file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"}, + {file = "rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb"}, + {file = "rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"}, ] [package.dependencies] markdown-it-py = ">=2.2.0" pygments = ">=2.13.0,<3.0.0" -typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.11\""} [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] @@ -1571,7 +1684,7 @@ description = "Python bindings to Rust's persistent data structures (rpds)" optional = false python-versions = ">=3.10" groups = ["main"] -markers = "python_version >= \"3.10\"" +markers = "python_version < \"3.14\" and python_version >= \"3.10\"" files = [ {file = "rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288"}, {file = "rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00"}, @@ -1690,6 +1803,133 @@ files = [ {file = "rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84"}, ] +[[package]] +name = "rpds-py" +version = "2026.6.3" +description = "Python bindings to Rust's persistent data structures (rpds)" +optional = false +python-versions = ">=3.11" +groups = ["main"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7"}, + {file = "rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da"}, + {file = "rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4"}, + {file = "rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6"}, + {file = "rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93"}, + {file = "rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a"}, + {file = "rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127"}, + {file = "rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804"}, + {file = "rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0"}, + {file = "rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4"}, + {file = "rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa"}, + {file = "rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc"}, + {file = "rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822"}, + {file = "rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed"}, + {file = "rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f"}, + {file = "rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96"}, + {file = "rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223"}, + {file = "rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4"}, + {file = "rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7"}, + {file = "rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d"}, + {file = "rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97"}, + {file = "rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0"}, + {file = "rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80"}, + {file = "rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb"}, + {file = "rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e"}, + {file = "rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77"}, + {file = "rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698"}, + {file = "rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd"}, + {file = "rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d"}, + {file = "rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8"}, + {file = "rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5"}, + {file = "rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2"}, + {file = "rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13"}, + {file = "rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868"}, + {file = "rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187"}, + {file = "rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107"}, + {file = "rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba"}, + {file = "rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369"}, + {file = "rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146"}, + {file = "rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826"}, + {file = "rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4"}, +] + [[package]] name = "ruff" version = "0.15.20" @@ -1725,7 +1965,7 @@ description = "Most extensible Python build backend with support for C/C++ exten optional = false python-versions = ">=3.9" groups = ["executable"] -markers = "python_version < \"3.15\"" +markers = "python_version < \"3.14\"" files = [ {file = "setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb"}, {file = "setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9"}, @@ -1740,6 +1980,28 @@ enabler = ["pytest-enabler (>=2.2)"] test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.18.*)", "pytest-mypy"] +[[package]] +name = "setuptools" +version = "83.0.0" +description = "Most extensible Python build backend with support for C/C++ extension modules" +optional = false +python-versions = ">=3.10" +groups = ["executable"] +markers = "python_version == \"3.14\"" +files = [ + {file = "setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3"}, + {file = "setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.13.0) ; sys_platform != \"cygwin\""] +core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +enabler = ["pytest-enabler (>=3.4)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.18.*)", "pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] + [[package]] name = "shellingham" version = "1.5.4" @@ -1778,15 +2040,15 @@ files = [ [[package]] name = "sse-starlette" -version = "3.4.4" +version = "3.4.6" description = "SSE plugin for Starlette" optional = false python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973"}, - {file = "sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0"}, + {file = "sse_starlette-3.4.6-py3-none-any.whl", hash = "sha256:56217ab4c9a9f9c5db7b21e08732d3e7c2b807f45231ad23de0551a24c4a41f6"}, + {file = "sse_starlette-3.4.6.tar.gz", hash = "sha256:725f8a1bd6d26ae1b2c9610c0ef5065dfdd496f3988d28adcf8c4b49dc25c627"}, ] [package.dependencies] @@ -1802,15 +2064,15 @@ uvicorn = ["uvicorn (>=0.34.0)"] [[package]] name = "starlette" -version = "1.2.1" +version = "1.3.1" description = "The little ASGI library that shines." optional = false python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89"}, - {file = "starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6"}, + {file = "starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6"}, + {file = "starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0"}, ] [package.dependencies] @@ -1818,7 +2080,7 @@ anyio = ">=3.6.2,<5" typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""} [package.extras] -full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] +full = ["httpx (>=0.27.0,<0.29.0)", "httpx2 (>=2.0.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] [[package]] name = "tenacity" @@ -1827,6 +2089,7 @@ description = "Retry code until it succeeds" optional = false python-versions = ">=3.9" groups = ["main"] +markers = "python_version < \"3.14\"" files = [ {file = "tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138"}, {file = "tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb"}, @@ -1836,6 +2099,23 @@ files = [ doc = ["reno", "sphinx"] test = ["pytest", "tornado (>=4.5)", "typeguard"] +[[package]] +name = "tenacity" +version = "9.1.4" +description = "Retry code until it succeeds" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55"}, + {file = "tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a"}, +] + +[package.extras] +doc = ["reno", "sphinx"] +test = ["pytest", "tornado (>=4.5)", "typeguard"] + [[package]] name = "tomli" version = "2.4.1" @@ -1926,14 +2206,14 @@ typing-extensions = ">=3.7.4.3" [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" groups = ["main", "test"] files = [ - {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, - {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, + {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"}, + {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, ] markers = {test = "python_version < \"3.11\""} @@ -1954,14 +2234,14 @@ typing-extensions = ">=4.12.0" [[package]] name = "tzdata" -version = "2026.2" +version = "2026.3" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" groups = ["main"] files = [ - {file = "tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7"}, - {file = "tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10"}, + {file = "tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931"}, + {file = "tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415"}, ] [[package]] @@ -1971,6 +2251,7 @@ description = "HTTP library with thread-safe connection pooling, file post, and optional = false python-versions = ">=3.9" groups = ["main", "test"] +markers = "python_version < \"3.14\"" files = [ {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, @@ -1982,17 +2263,36 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] +[[package]] +name = "urllib3" +version = "2.7.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.10" +groups = ["main", "test"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, + {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, +] + +[package.extras] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] + [[package]] name = "uvicorn" -version = "0.48.0" +version = "0.51.0" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\" and sys_platform != \"emscripten\"" files = [ - {file = "uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad"}, - {file = "uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37"}, + {file = "uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b"}, + {file = "uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0"}, ] [package.dependencies] @@ -2001,7 +2301,7 @@ h11 = ">=0.8" typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} [package.extras] -standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.20)", "websockets (>=10.4)"] +standard = ["httptools (>=0.8.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.20)", "websockets (>=13.0)"] [[package]] name = "zipp" @@ -2027,4 +2327,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "c854f790a9db6703d7aa2f7a7100e5629dcc3afebdf467b93cfbc9b3616ed4cf" +content-hash = "ba9807509e16982bc1c02848ce47c5a149542dc6613dd8518192e79ea4acb351" diff --git a/pyproject.toml b/pyproject.toml index bcd8a8d5..2d2beccc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,13 +36,15 @@ version = "0.0.0" # DON'T TOUCH. Placeholder. Will be filled automatically on po click = ">=8.1.0,<8.2.0" colorama = ">=0.4.3,<0.5.0" pyyaml = ">=6.0,<7.0" -marshmallow = ">=3.15.0,<4.0.0" -gitpython = ">=3.1.50,<3.2.0" +# capped while we support Python 3.9 +marshmallow = ">=4.0.1,<4.1.0" +# floor required by get_staged_diff_index() +gitpython = ">=3.1.51,<3.2.0" arrow = ">=1.0.0,<1.5.0" requests = ">=2.32.4,<3.0" urllib3 = ">=2.4.0,<3.0.0" pyjwt = ">=2.8.0,<3.0" -rich = ">=13.9.4, <14" +rich = ">=15.0.0,<16.0.0" patch-ng = "1.19.1" typer = "^0.15.3" tenacity = ">=9.1.2,<9.2.0" diff --git a/tests/cli/files_collector/test_commit_range_documents.py b/tests/cli/files_collector/test_commit_range_documents.py index 999e0e0c..d972144c 100644 --- a/tests/cli/files_collector/test_commit_range_documents.py +++ b/tests/cli/files_collector/test_commit_range_documents.py @@ -16,6 +16,7 @@ collect_commit_range_diff_documents, get_diff_file_path, get_safe_head_reference_for_diff, + get_staged_diff_index, parse_commit_range, parse_pre_push_input, parse_pre_receive_input, @@ -85,12 +86,14 @@ def test_index_diff_works_on_bare_repository(self) -> None: repo.index.add(['staged_file.py']) - head_ref = get_safe_head_reference_for_diff(repo) - diff_index = repo.index.diff(head_ref, create_patch=True, R=True) + head_ref, diff_index = get_staged_diff_index(repo) + assert head_ref == consts.GIT_EMPTY_TREE_OBJECT assert len(diff_index) == 1 diff = diff_index[0] assert diff.b_path == 'staged_file.py' + # staged content must be an addition, not a removal + assert b"+print('staged content')" in diff.diff def test_index_diff_works_on_repository_with_commits(self) -> None: """Test that index.diff continues to work on repositories with existing commits.""" @@ -111,14 +114,17 @@ def test_index_diff_works_on_repository_with_commits(self) -> None: repo.index.add(['new_file.py', 'initial.py']) - head_ref = get_safe_head_reference_for_diff(repo) - diff_index = repo.index.diff(head_ref, create_patch=True, R=True) + head_ref, diff_index = get_staged_diff_index(repo) assert len(diff_index) == 2 file_paths = {diff.b_path or diff.a_path for diff in diff_index} assert 'new_file.py' in file_paths assert 'initial.py' in file_paths assert head_ref == consts.GIT_HEAD_COMMIT_REV + # staged content must be additions, not removals + patches = b''.join(diff.diff for diff in diff_index) + assert b"+print('new file')" in patches + assert b"+print('modified initial')" in patches def test_sequential_operations_on_same_repository(self) -> None: """Test behavior when transitioning from bare to committed repository.""" @@ -129,8 +135,7 @@ def test_sequential_operations_on_same_repository(self) -> None: repo.index.add(['test.py']) - head_ref_before = get_safe_head_reference_for_diff(repo) - diff_before = repo.index.diff(head_ref_before, create_patch=True, R=True) + head_ref_before, diff_before = get_staged_diff_index(repo) expected_empty_tree = consts.GIT_EMPTY_TREE_OBJECT assert head_ref_before == expected_empty_tree @@ -144,8 +149,7 @@ def test_sequential_operations_on_same_repository(self) -> None: repo.index.add(['new.py']) - head_ref_after = get_safe_head_reference_for_diff(repo) - diff_after = repo.index.diff(head_ref_after, create_patch=True, R=True) + head_ref_after, diff_after = get_staged_diff_index(repo) assert head_ref_after == consts.GIT_HEAD_COMMIT_REV assert len(diff_after) == 1 @@ -167,8 +171,7 @@ def test_git_mv_pre_commit_scan() -> None: repo.index.remove(['NEWFILE.txt']) repo.index.add(['RENAMED.txt']) - head_ref = get_safe_head_reference_for_diff(repo) - diff_index = repo.index.diff(head_ref, create_patch=True, R=True) + _, diff_index = get_staged_diff_index(repo) for diff in diff_index: file_path = get_path_by_os(get_diff_file_path(diff, repo=repo)) From 60dab833a2726c4a25d38f905f5d062bf1db24c3 Mon Sep 17 00:00:00 2001 From: Omer Roth Date: Thu, 6 Aug 2026 12:00:36 +0300 Subject: [PATCH 120/123] Upgrade OpenSSL on macOS Builder (#516) --- .github/workflows/build_executable.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index f1600611..dc1d34fe 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -59,6 +59,12 @@ jobs: git checkout $LATEST_TAG echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV + - name: Upgrade OpenSSL on macOS Builder + if: runner.os == 'macOS' + run: | + brew update + brew upgrade openssl@3 + - name: Set up Python 3.13 id: setup-python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 From 26a4bb23583e6d42f54e920e6c319412ba225a82 Mon Sep 17 00:00:00 2001 From: Omer Roth Date: Sun, 9 Aug 2026 16:58:37 +0300 Subject: [PATCH 121/123] CM-70014 cli update packages aug 2026 3 (#517) --- .github/workflows/build_executable.yml | 6 ------ pyinstaller.spec | 22 ++++++++++++++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index dc1d34fe..94f285ce 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -58,12 +58,6 @@ jobs: LATEST_TAG=$(git describe --tags `git rev-list --tags --max-count=1`) git checkout $LATEST_TAG echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV - - - name: Upgrade OpenSSL on macOS Builder - if: runner.os == 'macOS' - run: | - brew update - brew upgrade openssl@3 - name: Set up Python 3.13 id: setup-python diff --git a/pyinstaller.spec b/pyinstaller.spec index d93766a8..e5be2bc2 100644 --- a/pyinstaller.spec +++ b/pyinstaller.spec @@ -2,6 +2,10 @@ # Run `poetry run pyinstaller pyinstaller.spec` to generate the binary. # Set the env var `CYCODE_ONEDIR_MODE` to generate a single directory instead of a single file. +import os +import platform +import subprocess + _INIT_FILE_PATH = os.path.join('cycode', '__init__.py') _CODESIGN_IDENTITY = os.environ.get('APPLE_CERT_NAME') _ONEDIR_MODE = os.environ.get('CYCODE_ONEDIR_MODE') is not None @@ -43,6 +47,24 @@ a = Analysis( hiddenimports=_hiddenimports, ) +if platform.system() == 'Darwin': + # cryptography ships no macOS x86_64 wheel since 46.0.4, so on Intel it is built from source and + # dynamically links Homebrew's OpenSSL 3 (it needs symbols like `SSL_get0_group_name`, added in + # OpenSSL 3.2). PyInstaller also collects the older OpenSSL 3.0.x that ships with the + # setup-python toolcache Python; both land at the same destination name and the toolcache copy + # wins the dedup, which breaks `import cryptography` at runtime. Drop every collected + # libssl/libcrypto and inject Homebrew's, which satisfies both consumers. + try: + openssl_lib = os.path.join( + subprocess.check_output(['brew', '--prefix', 'openssl@3'], text=True).strip(), 'lib' + ) + a.binaries = [b for b in a.binaries if 'libssl' not in b[0] and 'libcrypto' not in b[0]] + for name in ('libssl.3.dylib', 'libcrypto.3.dylib'): + a.binaries.append((name, os.path.join(openssl_lib, name), 'BINARY')) + print(f'Replaced collected OpenSSL dylibs with Homebrew ones from {openssl_lib}') + except Exception as e: + print(f'Warning: Could not override OpenSSL binaries: {e}') + exe_args = [PYZ(a.pure), a.scripts, a.binaries, a.datas] if _ONEDIR_MODE: exe_args = [PYZ(a.pure), a.scripts] From 1e64043e34c3ef60688888f1d7513e6804b6a6b0 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:21:47 +0300 Subject: [PATCH 122/123] CM-70843: fix Copilot hook payloads being skipped once VS Code sends transcript_path (#519) Co-authored-by: Claude Opus 5 (1M context) --- .../apps/ai_guardrails/ides/claude_code.py | 10 +++++++--- cycode/cli/apps/ai_guardrails/ides/copilot.py | 16 ++++++--------- .../ai_guardrails/ides/test_claude_code.py | 20 +++++++++++++++++-- .../ai_guardrails/ides/test_copilot.py | 11 +++++++++- 4 files changed, 41 insertions(+), 16 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/ides/claude_code.py b/cycode/cli/apps/ai_guardrails/ides/claude_code.py index 1b3f618b..79ba9e8f 100644 --- a/cycode/cli/apps/ai_guardrails/ides/claude_code.py +++ b/cycode/cli/apps/ai_guardrails/ides/claude_code.py @@ -284,9 +284,13 @@ def render_hooks_config(self, async_mode: bool = False) -> dict: def matches_payload(self, raw_payload: dict) -> bool: # transcript_path is a documented Claude Code common field, present on every # hook event. VS Code Copilot emits near-identical payloads (same event names, - # snake_case fields) without it — requiring it keeps those from being - # processed as Claude Code events. - return raw_payload.get('hook_event_name', '') in _CLAUDE_CODE_EVENT_NAMES and 'transcript_path' in raw_payload + # snake_case fields) — Copilot additionally carries a top-level + # timestamp, which Claude Code never sends. + return ( + raw_payload.get('hook_event_name', '') in _CLAUDE_CODE_EVENT_NAMES + and 'transcript_path' in raw_payload + and 'timestamp' not in raw_payload + ) def is_synthetic_prompt(self, raw_payload: dict) -> bool: if raw_payload.get('hook_event_name') != 'UserPromptSubmit': diff --git a/cycode/cli/apps/ai_guardrails/ides/copilot.py b/cycode/cli/apps/ai_guardrails/ides/copilot.py index 2cd6a427..cfb20f0a 100644 --- a/cycode/cli/apps/ai_guardrails/ides/copilot.py +++ b/cycode/cli/apps/ai_guardrails/ides/copilot.py @@ -7,8 +7,9 @@ are rejected by ``matches_payload`` and fall through to the allow-and-skip path. VS Code sends Claude-style payloads (``hook_event_name``, ``tool_name``, -``tool_input``) with structural differences that ``matches_payload`` keys on: -a top-level ISO ``timestamp`` and no ``transcript_path``. Copilot hooks have no +``tool_input``), told apart by the one field Claude Code never sends: a top-level +ISO ``timestamp``. VS Code also sends a ``transcript_path`` of its own once a +workspace has chat history, so that field cannot discriminate. Copilot hooks have no matchers, so ``preToolUse`` fires for every tool; tools we don't scan pass through as raw event names, which match no handler and allow immediately. """ @@ -340,14 +341,9 @@ def entry(command: str) -> dict: } def matches_payload(self, raw_payload: dict) -> bool: - # Structural discrimination, no magic strings: VS Code Copilot events carry - # a top-level ISO timestamp and no transcript_path; real Claude Code events - # always carry transcript_path; Copilot CLI payloads have no hook_event_name. - return ( - raw_payload.get('hook_event_name', '') in _COPILOT_SCAN_EVENT_NAMES - and 'timestamp' in raw_payload - and 'transcript_path' not in raw_payload - ) + # Structural discrimination, no magic strings: Copilot events carry a top-level + # timestamp, Claude Code events never do. + return raw_payload.get('hook_event_name', '') in _COPILOT_SCAN_EVENT_NAMES and 'timestamp' in raw_payload def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: hook_event_name = raw_payload.get('hook_event_name', '') diff --git a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py index 4dcab376..657a7b5c 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py +++ b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py @@ -28,8 +28,9 @@ def test_matches_payload_only_claude_events() -> None: def test_matches_payload_rejects_vscode_copilot_payloads() -> None: - """VS Code Copilot sends the same event names in the same snake_case dialect, - but never a transcript_path — those events must not be claimed as Claude Code.""" + """VS Code Copilot sends the same event names in the same snake_case dialect, and + now a transcript_path of its own — only the top-level timestamp, which Claude Code + never sends, keeps those events from being claimed as Claude Code.""" claude = ClaudeCode() assert ( claude.matches_payload( @@ -48,6 +49,21 @@ def test_matches_payload_rejects_vscode_copilot_payloads() -> None: claude.matches_payload({'timestamp': '2026-07-14T13:32:46.517Z', 'hook_event_name': 'UserPromptSubmit'}) is False ) + # Carrying a transcript_path must not be enough to claim a Copilot event, or the + # same prompt gets processed twice when both integrations are installed. + assert ( + claude.matches_payload( + { + 'timestamp': '2026-08-13T10:55:29.000Z', + 'hook_event_name': 'UserPromptSubmit', + 'session_id': '43cbad91-ea8b-4d4a-9acc-56561421c5d2', + 'cwd': '/Users/user/project', + 'prompt': 'test prompt', + 'transcript_path': '/Users/user/Library/Application Support/Code/User/workspaceStorage/d/t.jsonl', + } + ) + is False + ) def test_is_synthetic_prompt_task_notification() -> None: diff --git a/tests/cli/commands/ai_guardrails/ides/test_copilot.py b/tests/cli/commands/ai_guardrails/ides/test_copilot.py index 8141f74f..c4aec1e1 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_copilot.py +++ b/tests/cli/commands/ai_guardrails/ides/test_copilot.py @@ -27,6 +27,13 @@ 'prompt': 'test prompt', } +# VS Code attaches a per-session transcript_path once the workspace has chat history. +_VSCODE_PROMPT_PAYLOAD_WITH_TRANSCRIPT = { + **_VSCODE_PROMPT_PAYLOAD, + 'cwd': '/Users/user/project', + 'transcript_path': '/Users/user/Library/Application Support/Code/User/workspaceStorage/dummy/t.jsonl', +} + _VSCODE_READ_FILE_PAYLOAD = { 'timestamp': '2026-07-14T13:35:08.758Z', 'hook_event_name': 'PreToolUse', @@ -81,10 +88,12 @@ def test_matches_payload_accepts_vscode_events() -> None: assert copilot.matches_payload(_VSCODE_PROMPT_PAYLOAD) is True assert copilot.matches_payload(_VSCODE_READ_FILE_PAYLOAD) is True assert copilot.matches_payload(_VSCODE_MCP_PAYLOAD) is True + assert copilot.matches_payload(_VSCODE_PROMPT_PAYLOAD_WITH_TRANSCRIPT) is True def test_matches_payload_rejects_claude_code_payloads() -> None: - # Same event names and dialect, but Claude Code always carries transcript_path. + # Same event names and dialect, and both carry transcript_path - only the + # top-level timestamp separates them, and Claude Code never sends one. assert Copilot().matches_payload(_CLAUDE_CODE_PAYLOAD) is False From c5e1f99c03b6037d648a3290fcca243f04d72d59 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:52:03 +0300 Subject: [PATCH 123/123] CM-71014 copilot agent dialect (#520) Co-authored-by: Claude Opus 5 (1M context) --- .../apps/ai_guardrails/ides/claude_code.py | 12 +- cycode/cli/apps/ai_guardrails/ides/copilot.py | 164 +++++++++++++----- .../apps/ai_guardrails/scan/scan_command.py | 10 +- .../ai_guardrails/ides/test_claude_code.py | 20 +-- .../ai_guardrails/ides/test_copilot.py | 105 +++++++++-- .../ai_guardrails/test_hooks_manager.py | 4 +- 6 files changed, 214 insertions(+), 101 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/ides/claude_code.py b/cycode/cli/apps/ai_guardrails/ides/claude_code.py index 79ba9e8f..131e8e1a 100644 --- a/cycode/cli/apps/ai_guardrails/ides/claude_code.py +++ b/cycode/cli/apps/ai_guardrails/ides/claude_code.py @@ -282,15 +282,9 @@ def render_hooks_config(self, async_mode: bool = False) -> dict: } def matches_payload(self, raw_payload: dict) -> bool: - # transcript_path is a documented Claude Code common field, present on every - # hook event. VS Code Copilot emits near-identical payloads (same event names, - # snake_case fields) — Copilot additionally carries a top-level - # timestamp, which Claude Code never sends. - return ( - raw_payload.get('hook_event_name', '') in _CLAUDE_CODE_EVENT_NAMES - and 'transcript_path' in raw_payload - and 'timestamp' not in raw_payload - ) + # transcript_path is a documented Claude Code field, present on every hook event. + # Positive test by design: an absence check breaks silently when a vendor adds a field. + return raw_payload.get('hook_event_name', '') in _CLAUDE_CODE_EVENT_NAMES and 'transcript_path' in raw_payload def is_synthetic_prompt(self, raw_payload: dict) -> bool: if raw_payload.get('hook_event_name') != 'UserPromptSubmit': diff --git a/cycode/cli/apps/ai_guardrails/ides/copilot.py b/cycode/cli/apps/ai_guardrails/ides/copilot.py index cfb20f0a..5f3bc76a 100644 --- a/cycode/cli/apps/ai_guardrails/ides/copilot.py +++ b/cycode/cli/apps/ai_guardrails/ides/copilot.py @@ -1,17 +1,24 @@ -"""GitHub Copilot (VS Code extension) integration for AI guardrails. +"""GitHub Copilot integration for AI guardrails. Hooks are installed in Copilot's native format to ``~/.copilot/hooks/cycode.json`` -(user scope) or ``/.github/hooks/cycode.json`` (repo scope). Both locations -are also read by Copilot CLI and the Copilot cloud coding agent, but only the -VS Code payload dialect is parsed here — CLI payloads (camelCase, no event name) -are rejected by ``matches_payload`` and fall through to the allow-and-skip path. - -VS Code sends Claude-style payloads (``hook_event_name``, ``tool_name``, -``tool_input``), told apart by the one field Claude Code never sends: a top-level -ISO ``timestamp``. VS Code also sends a ``transcript_path`` of its own once a -workspace has chat history, so that field cannot discriminate. Copilot hooks have no -matchers, so ``preToolUse`` fires for every tool; tools we don't scan pass -through as raw event names, which match no handler and allow immediately. +(user scope) or ``/.github/hooks/cycode.json`` (repo scope). One file, but +two runtimes are known to execute it: VS Code's own chat runtime, and the Copilot +agent runtime (Copilot CLI, and VS Code agent sessions). The repo-scope location +is also read by the Copilot cloud coding agent, whose dialect is untested here. + +Both deliver Claude-style payloads (``hook_event_name``, ``tool_name``, +``tool_input``) when the event keys are registered in PascalCase; the agent runtime +answers camelCase keys with its own dialect (``sessionId``, no event name) instead. +Copilot payloads are told apart from Claude Code's by the one field Claude Code +never sends, a top-level ``timestamp``; ``transcript_path`` cannot discriminate, +since VS Code sends one of its own whenever a folder is open. + +The tool vocabulary still differs by runtime — VS Code reads files with +``read_file``/``filePath`` and names MCP tools ``mcp__``, the agent +runtime uses ``Read``/``path`` and ``-`` — so both are accepted. +Copilot hooks have no matchers, so ``PreToolUse`` fires for every tool; tools we +don't scan pass through as raw event names, which match no handler and allow +immediately. """ import json @@ -37,20 +44,36 @@ logger = get_logger('AI Guardrails Copilot') -# Payload dialect (VS Code sends Claude-style PascalCase event names). +# Payload dialect (Claude-style PascalCase event names). _COPILOT_SCAN_EVENT_NAMES = frozenset({'UserPromptSubmit', 'PreToolUse'}) -_READ_FILE_TOOL = 'read_file' -# VS Code names MCP tools `mcp__` (single underscores). + +# Two tool vocabularies reach us through one hooks file: VS Code's own runtime +# names file reads `read_file` with a `filePath` argument, while the Copilot agent +# runtime (Copilot CLI, and VS Code agent sessions) names them `Read` with `path`. +# The names are disjoint, so both are accepted rather than switched between. +_READ_FILE_TOOLS = frozenset({'read_file', 'Read'}) +_READ_PATH_KEYS = ('path', 'filePath') + +# VS Code names MCP tools `mcp__` (single underscores); the agent +# runtime uses `-` with no prefix (its SDK documents that wire form), +# leaving a hyphen as the only marker of an MCP call there. Every built-in agent +# tool observed is lower snake_case (`view`, `glob`, `str_replace`, `ask_user`) or +# PascalCase (`Read`), so this holds for them — but SDK- or custom-agent-registered +# tools may be named freely. A hyphenated custom tool would be scanned as an MCP +# call with no resolvable server: an extra scan, never a missed one, which is the +# safe direction to err for a guardrail. _MCP_TOOL_PREFIX = 'mcp_' +_MCP_AGENT_SEPARATOR = '-' -# Hooks-file dialect (Copilot-native camelCase event names). -_HOOK_EVENTS = ['userPromptSubmitted', 'preToolUse'] +# Hooks-file event keys. Their case selects the agent runtime's payload dialect. +_HOOK_EVENTS = ['UserPromptSubmit', 'PreToolUse'] _COPILOT_HOME_ENV_VAR = 'COPILOT_HOME' _HOOKS_FILE_NAME = 'cycode.json' _REPO_HOOKS_SUBDIR = Path('.github') / 'hooks' _HOOK_TIMEOUT_SEC = 20 _MCP_CONFIG_FILENAME = 'mcp.json' +_AGENT_MCP_CONFIG_FILENAME = 'mcp-config.json' # Plugin sources. CLI installs register in ~/.copilot/config.json and auto-surface # in VS Code; VS Code UI installs register in ~/.vscode/agent-plugins/installed.json; @@ -68,13 +91,9 @@ Path('.claude-plugin') / 'plugin.json', ) -# --event is ignored by the VS Code payload parsing (the payload self-describes) -# but Copilot CLI payloads carry no event name at all — baking the flag in now -# means CLI support won't require customers to re-install hooks. Values use the -# payload-dialect spelling so a future CLI path can inject them straight into -# hook_event_name and reuse the existing parsing. -_SCAN_PROMPT_COMMAND = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide copilot --event UserPromptSubmit' -_SCAN_TOOL_COMMAND = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide copilot --event PreToolUse' +# One command for both events: every runtime self-describes via hook_event_name once +# the events are registered in PascalCase, so --event is no longer passed. +_SCAN_COMMAND = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide copilot' _SESSION_START_COMMAND = f'{CYCODE_SESSION_START_COMMAND} --ide copilot' @@ -253,7 +272,11 @@ def _collect_installed_plugins() -> dict: def _known_mcp_server_names() -> list[str]: - """Config-declared MCP server names: user-level ``mcp.json`` + plugin configs. + """Config-declared MCP server names, across both runtimes' config files. + + VS Code declares them in its user-level ``mcp.json`` under ``servers``; the + agent runtime uses ``~/.copilot/mcp-config.json`` under ``mcpServers``. Both are + read because one hooks file serves both, and plugin configs contribute to either. Best-effort inventory: servers contributed by extensions, ``chat.mcp.discovery`` imports, dev containers, or non-default profiles are not discoverable from disk. @@ -261,6 +284,12 @@ def _known_mcp_server_names() -> list[str]: config = _load_vscode_mcp_config() servers = (config or {}).get('servers') names = list(servers.keys()) if isinstance(servers, dict) else [] + + agent_config = _load_jsonc(_copilot_home() / _AGENT_MCP_CONFIG_FILENAME) or {} + agent_servers = agent_config.get('mcpServers') + if isinstance(agent_servers, dict): + names.extend(agent_servers.keys()) + for plugin in _collect_installed_plugins().values(): names.extend(plugin.get('mcp_server_names') or []) return names @@ -279,22 +308,57 @@ def _server_name_variants(server_name: str) -> set[str]: return {v for v in (server_name, underscored, collapsed) if v} -def split_mcp_tool_name(tool_name: str, server_names: Iterable[str]) -> tuple[Optional[str], Optional[str]]: - """Split ``mcp__`` into ``(server, tool)``. +def _read_file_path(tool_name: str, tool_input: object) -> Optional[str]: + """Path of a file-read tool call, or None when this isn't one. + + The agent runtime reuses its read tool for directory listings, with a payload + identical to a file read, so the path has to be stat-ed to tell them apart — + VS Code has no such ambiguity (`read_file` vs `list_dir`). A path that isn't an + existing file (a directory, or already deleted) has nothing to scan. + """ + if tool_name not in _READ_FILE_TOOLS or not isinstance(tool_input, dict): + return None + + raw_path = next((tool_input[key] for key in _READ_PATH_KEYS if tool_input.get(key)), None) + if not isinstance(raw_path, str): + return None + + try: + if not Path(raw_path).is_file(): + return None + except OSError as e: + logger.debug('Failed to stat read path, %s', {'path': raw_path}, exc_info=e) + return None + return raw_path + - The ```` part is VS Code's sanitized (and possibly truncated) form of - the server's SELF-REPORTED handshake name, not the config key — so matching - against known config names (and their normalized variants) is best-effort. - When nothing matches, return the unsplit remainder as the tool rather than - fabricating a server from a guessed split. +def is_mcp_tool_name(tool_name: str) -> bool: + """Whether a tool name is an MCP call in either runtime's naming scheme.""" + return tool_name.startswith(_MCP_TOOL_PREFIX) or _MCP_AGENT_SEPARATOR in tool_name + + +def split_mcp_tool_name(tool_name: str, server_names: Iterable[str]) -> tuple[Optional[str], Optional[str]]: + """Split an MCP tool name into ``(server, tool)``. + + Handles both naming schemes: VS Code's ``mcp__`` and the agent + runtime's prefix-less ``-``. In the VS Code form the ```` + part is a sanitized (and possibly truncated) form of the server's SELF-REPORTED + handshake name rather than the config key, so matching against known config + names (and their normalized variants) is best-effort. Server names may + themselves contain the separator, hence the longest-match. When nothing + matches, return the unsplit remainder as the tool rather than fabricating a + server from a guessed split. """ - rest = tool_name[len(_MCP_TOOL_PREFIX) :] + if tool_name.startswith(_MCP_TOOL_PREFIX): + rest, separator = tool_name[len(_MCP_TOOL_PREFIX) :], '_' + else: + rest, separator = tool_name, _MCP_AGENT_SEPARATOR best_server = None best_variant_len = -1 for server in server_names: for variant in _server_name_variants(server): - if (rest == variant or rest.startswith(f'{variant}_')) and len(variant) > best_variant_len: + if (rest == variant or rest.startswith(f'{variant}{separator}')) and len(variant) > best_variant_len: best_server = server best_variant_len = len(variant) if best_server is not None: @@ -318,13 +382,17 @@ def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: def render_hooks_config(self, async_mode: bool = False) -> dict: def entry(command: str) -> dict: if async_mode: - # Copilot has no async hook flag; background via shell on unix. The - # explicit <&0 keeps the payload flowing: a bare `cmd &` gets its stdin - # reattached to /dev/null by the shell (job control is off in hooks). - # Windows PowerShell has no trailing-& operator, so it stays sync. + # Copilot has no async hook flag; background via shell on unix. Both + # redirects are load-bearing. `<&0` keeps the payload flowing: a bare + # `cmd &` gets its stdin reattached to /dev/null by the shell (job + # control is off in hooks), so the scan reads nothing and allows. The + # stdout redirect is what actually makes it async: the backgrounded + # child inherits the hook's stdout and the runner waits on that pipe + # for EOF, so without it the scan blocks the response it was meant to + # run behind. Windows PowerShell has no trailing-&, so it stays sync. return { 'type': 'command', - 'bash': f'{command} <&0 &', + 'bash': f'{command} <&0 >/dev/null 2>&1 &', 'powershell': command, 'timeoutSec': _HOOK_TIMEOUT_SEC, } @@ -334,9 +402,9 @@ def entry(command: str) -> dict: return { 'version': 1, 'hooks': { - 'sessionStart': [{'type': 'command', 'command': _SESSION_START_COMMAND}], - 'userPromptSubmitted': [entry(_SCAN_PROMPT_COMMAND)], - 'preToolUse': [entry(_SCAN_TOOL_COMMAND)], + 'SessionStart': [{'type': 'command', 'command': _SESSION_START_COMMAND}], + 'UserPromptSubmit': [entry(_SCAN_COMMAND)], + 'PreToolUse': [entry(_SCAN_COMMAND)], }, } @@ -350,21 +418,21 @@ def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: tool_name = raw_payload.get('tool_name', '') tool_input = raw_payload.get('tool_input') + read_path = _read_file_path(tool_name, tool_input) + if hook_event_name == 'UserPromptSubmit': canonical_event: Union[AiHookEventType, str] = AiHookEventType.PROMPT - elif hook_event_name == 'PreToolUse' and tool_name == _READ_FILE_TOOL: + elif hook_event_name == 'PreToolUse' and read_path is not None: canonical_event = AiHookEventType.FILE_READ - elif hook_event_name == 'PreToolUse' and tool_name.startswith(_MCP_TOOL_PREFIX): + elif hook_event_name == 'PreToolUse' and is_mcp_tool_name(tool_name): canonical_event = AiHookEventType.MCP_EXECUTION else: - # No matchers in Copilot hooks: preToolUse fires for every tool. Pass + # No matchers in Copilot hooks: PreToolUse fires for every tool. Pass # the raw tool name through — it matches no handler, so scan_command # answers with a neutral allow before any policy/network work. canonical_event = tool_name or hook_event_name - file_path = None - if canonical_event == AiHookEventType.FILE_READ and isinstance(tool_input, dict): - file_path = tool_input.get('filePath') + file_path = read_path if canonical_event == AiHookEventType.FILE_READ else None mcp_server_name = None mcp_tool_name = None diff --git a/cycode/cli/apps/ai_guardrails/scan/scan_command.py b/cycode/cli/apps/ai_guardrails/scan/scan_command.py index 1c0c42b8..5cf5da38 100644 --- a/cycode/cli/apps/ai_guardrails/scan/scan_command.py +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -83,14 +83,6 @@ def scan_command( hidden=True, ), ] = DEFAULT_IDE_NAME, - event: Annotated[ - Optional[str], - typer.Option( - '--event', - help='Hook event that triggered the scan, for IDEs whose payloads omit it (e.g. Copilot CLI).', - hidden=True, - ), - ] = None, ) -> None: """Scan content from AI IDE hooks for secrets. @@ -132,7 +124,7 @@ def scan_command( event_name = unified_payload.event_name logger.debug( 'Processing AI guardrails hook', - extra={'event_name': event_name, 'ide': ide_integration.name, 'cli_event_hint': event}, + extra={'event_name': event_name, 'ide': ide_integration.name}, ) # Resolved before any policy/client work: Copilot hooks have no matchers, so diff --git a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py index 657a7b5c..743bb372 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py +++ b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py @@ -28,9 +28,8 @@ def test_matches_payload_only_claude_events() -> None: def test_matches_payload_rejects_vscode_copilot_payloads() -> None: - """VS Code Copilot sends the same event names in the same snake_case dialect, and - now a transcript_path of its own — only the top-level timestamp, which Claude Code - never sends, keeps those events from being claimed as Claude Code.""" + """VS Code Copilot sends the same event names in the same snake_case dialect, so + the documented transcript_path is what keeps its events from being claimed here.""" claude = ClaudeCode() assert ( claude.matches_payload( @@ -49,21 +48,6 @@ def test_matches_payload_rejects_vscode_copilot_payloads() -> None: claude.matches_payload({'timestamp': '2026-07-14T13:32:46.517Z', 'hook_event_name': 'UserPromptSubmit'}) is False ) - # Carrying a transcript_path must not be enough to claim a Copilot event, or the - # same prompt gets processed twice when both integrations are installed. - assert ( - claude.matches_payload( - { - 'timestamp': '2026-08-13T10:55:29.000Z', - 'hook_event_name': 'UserPromptSubmit', - 'session_id': '43cbad91-ea8b-4d4a-9acc-56561421c5d2', - 'cwd': '/Users/user/project', - 'prompt': 'test prompt', - 'transcript_path': '/Users/user/Library/Application Support/Code/User/workspaceStorage/d/t.jsonl', - } - ) - is False - ) def test_is_synthetic_prompt_task_notification() -> None: diff --git a/tests/cli/commands/ai_guardrails/ides/test_copilot.py b/tests/cli/commands/ai_guardrails/ides/test_copilot.py index c4aec1e1..5d5f6d4f 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_copilot.py +++ b/tests/cli/commands/ai_guardrails/ides/test_copilot.py @@ -27,7 +27,9 @@ 'prompt': 'test prompt', } -# VS Code attaches a per-session transcript_path once the workspace has chat history. +# VS Code attaches a per-session transcript_path whenever a folder is open: the +# transcript dir is derived from the extension's workspace storageUri, which is +# undefined only in an empty window. _VSCODE_PROMPT_PAYLOAD_WITH_TRANSCRIPT = { **_VSCODE_PROMPT_PAYLOAD, 'cwd': '/Users/user/project', @@ -60,7 +62,37 @@ 'model': 'auto', } -# Copilot CLI dialect: camelCase, epoch-ms timestamp, no event name, stringified args. +# Agent-runtime payloads (Copilot CLI, and VS Code agent sessions) under PascalCase +# event keys: same Claude-style dialect as VS Code, but a `cwd` and its own tool +# vocabulary (`Read`/`path`, `-`) rather than VS Code's. +_AGENT_PROMPT_PAYLOAD = { + 'cwd': '/Users/user/project', + 'hook_event_name': 'UserPromptSubmit', + 'prompt': 'test prompt', + 'session_id': '826a14c1-cfb5-4946-9618-8b0bb7060466', + 'timestamp': '2026-08-14T08:49:22.000Z', +} + +_AGENT_READ_FILE_PAYLOAD = { + 'cwd': '/Users/user/project', + 'hook_event_name': 'PreToolUse', + 'session_id': '826a14c1-cfb5-4946-9618-8b0bb7060466', + 'timestamp': '2026-08-14T08:51:17.000Z', + 'tool_name': 'Read', + 'tool_input': {'path': '/Users/user/.gitconfig'}, +} + +_AGENT_MCP_PAYLOAD = { + 'cwd': '/Users/user/project', + 'hook_event_name': 'PreToolUse', + 'session_id': '826a14c1-cfb5-4946-9618-8b0bb7060466', + 'timestamp': '2026-08-14T08:51:17.000Z', + 'tool_name': 'gitlab-get_user', + 'tool_input': {'user_id': 'dummy-user'}, +} + +# Stale pre-PascalCase installs still emit this: camelCase, epoch-ms timestamp, +# no event name, stringified args. Rejected — they are corrected on reinstall. _COPILOT_CLI_TOOL_PAYLOAD = { 'sessionId': '826a14c1-cfb5-4946-9618-8b0bb7060466', 'timestamp': 1784038775604, @@ -98,7 +130,7 @@ def test_matches_payload_rejects_claude_code_payloads() -> None: def test_matches_payload_rejects_copilot_cli_payloads() -> None: - # CLI dialect is unsupported until its own parsing lands - must skip fail-open. + # Only reachable from a stale camelCase install; corrected by reinstalling hooks. assert Copilot().matches_payload(_COPILOT_CLI_TOOL_PAYLOAD) is False @@ -122,11 +154,25 @@ def test_parse_prompt_payload() -> None: assert unified.prompt == 'test prompt' -def test_parse_read_file_payload() -> None: - unified = Copilot().parse_hook_payload(_VSCODE_READ_FILE_PAYLOAD) - assert unified.event_name == AiHookEventType.FILE_READ - assert unified.file_path == '/Users/user/.gitconfig' - assert unified.mcp_tool_name is None +def test_parse_read_file_payload(fs: FakeFilesystem) -> None: + fs.create_file('/Users/user/.gitconfig') + # Agent-runtime naming (`Read` + `path`) must map identically to VS Code's + # (`read_file` + `filePath`): one hooks file serves both runtimes. + for payload in (_VSCODE_READ_FILE_PAYLOAD, _AGENT_READ_FILE_PAYLOAD): + unified = Copilot().parse_hook_payload(payload) + assert unified.event_name == AiHookEventType.FILE_READ + assert unified.file_path == '/Users/user/.gitconfig' + assert unified.mcp_tool_name is None + + +def test_parse_read_of_directory_is_not_a_file_read(fs: FakeFilesystem) -> None: + # The agent runtime reuses `Read` for directory listings with an identical + # payload shape, so only a stat separates them. + fs.create_dir('/Users/user/project') + payload = {**_AGENT_READ_FILE_PAYLOAD, 'tool_input': {'path': '/Users/user/project'}} + unified = Copilot().parse_hook_payload(payload) + assert unified.event_name == 'Read' + assert unified.file_path is None def test_parse_mcp_payload_without_known_servers_reports_raw(fs: FakeFilesystem) -> None: @@ -139,6 +185,33 @@ def test_parse_mcp_payload_without_known_servers_reports_raw(fs: FakeFilesystem) assert unified.mcp_arguments == {'user_id': 'dummy-user'} +def test_parse_agent_mcp_payload_uses_hyphenated_naming(fs: FakeFilesystem) -> None: + # The agent runtime names MCP tools `-` with no prefix, and + # declares its servers in its own config rather than VS Code's mcp.json. + fs.create_file( + Path.home() / '.copilot' / 'mcp-config.json', + contents=json.dumps({'mcpServers': {'gitlab': {'command': 'dummy-mcp'}}}), + ) + unified = Copilot().parse_hook_payload(_AGENT_MCP_PAYLOAD) + assert unified.event_name == AiHookEventType.MCP_EXECUTION + assert unified.mcp_server_name == 'gitlab' + assert unified.mcp_tool_name == 'get_user' + assert unified.mcp_arguments == {'user_id': 'dummy-user'} + + +def test_parse_agent_mcp_payload_prefers_longest_hyphenated_server(fs: FakeFilesystem) -> None: + # Server names may themselves contain the separator, so the split must not be + # greedy on the first hyphen. + fs.create_file( + Path.home() / '.copilot' / 'mcp-config.json', + contents=json.dumps({'mcpServers': {'gitlab': {}, 'gitlab-selfhosted': {}}}), + ) + payload = {**_AGENT_MCP_PAYLOAD, 'tool_name': 'gitlab-selfhosted-get_user'} + unified = Copilot().parse_hook_payload(payload) + assert unified.mcp_server_name == 'gitlab-selfhosted' + assert unified.mcp_tool_name == 'get_user' + + def test_parse_mcp_payload_with_known_server_containing_underscores(fs: FakeFilesystem) -> None: fs.create_file( _vscode_mcp_config_path(), @@ -232,21 +305,23 @@ def test_render_hooks_config_sync_uses_cross_platform_command() -> None: rendered = Copilot().render_hooks_config() assert rendered['version'] == 1 - prompt_entry = rendered['hooks']['userPromptSubmitted'][0] - assert prompt_entry['command'] == 'cycode ai-guardrails scan --ide copilot --event UserPromptSubmit' + prompt_entry = rendered['hooks']['UserPromptSubmit'][0] + assert prompt_entry['command'] == 'cycode ai-guardrails scan --ide copilot' assert 'bash' not in prompt_entry - tool_entry = rendered['hooks']['preToolUse'][0] - assert tool_entry['command'] == 'cycode ai-guardrails scan --ide copilot --event PreToolUse' + tool_entry = rendered['hooks']['PreToolUse'][0] + assert tool_entry['command'] == 'cycode ai-guardrails scan --ide copilot' - session_entry = rendered['hooks']['sessionStart'][0] + session_entry = rendered['hooks']['SessionStart'][0] assert session_entry['command'] == 'cycode ai-guardrails session-start --ide copilot' def test_render_hooks_config_async_backgrounds_on_unix() -> None: rendered = Copilot().render_hooks_config(async_mode=True) - tool_entry = rendered['hooks']['preToolUse'][0] - assert tool_entry['bash'].endswith('&') + tool_entry = rendered['hooks']['PreToolUse'][0] + # <&0 keeps the payload (a bare `cmd &` gets stdin from /dev/null and scans nothing); + # the stdout redirect releases the pipe the runner waits on, or it still blocks. + assert tool_entry['bash'].endswith('<&0 >/dev/null 2>&1 &') assert not tool_entry['powershell'].endswith('&') assert 'command' not in tool_entry diff --git a/tests/cli/commands/ai_guardrails/test_hooks_manager.py b/tests/cli/commands/ai_guardrails/test_hooks_manager.py index cf478fcc..1a7b7c2f 100644 --- a/tests/cli/commands/ai_guardrails/test_hooks_manager.py +++ b/tests/cli/commands/ai_guardrails/test_hooks_manager.py @@ -287,7 +287,7 @@ def test_copilot_dedicated_file_install_uninstall_lifecycle(fs: FakeFilesystem) assert success is True saved = json.loads(hooks_path.read_text()) assert saved['version'] == 1 - assert set(saved['hooks']) == {'sessionStart', 'userPromptSubmitted', 'preToolUse'} + assert set(saved['hooks']) == {'SessionStart', 'UserPromptSubmit', 'PreToolUse'} assert all(len(entries) == 1 for entries in saved['hooks'].values()) # Reinstall (also flipping mode) must replace, not duplicate. @@ -295,7 +295,7 @@ def test_copilot_dedicated_file_install_uninstall_lifecycle(fs: FakeFilesystem) assert success is True saved = json.loads(hooks_path.read_text()) assert all(len(entries) == 1 for entries in saved['hooks'].values()) - assert saved['hooks']['preToolUse'][0]['bash'].endswith('&') + assert saved['hooks']['PreToolUse'][0]['bash'].endswith('&') # Uninstall deletes the emptied dedicated file rather than leaving a husk. success, _ = uninstall_hooks(copilot)