From 2da4e42715491e1090ce976989b9d0197aaaa349 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Tue, 7 Jul 2026 19:01:49 +0300 Subject: [PATCH] CM-68330: fix hook payload handling on Cursor for Windows Two payload bugs found in Windows/Cursor MDM testing: - Cursor sends the hook payload with a UTF-8 BOM; json.loads rejects it and safe_json_parse returned {}, silently allowing without scanning. Read stdin bytes and decode utf-8-sig at both hook entry points - strips the BOM and pins the payload to UTF-8 regardless of the Windows ANSI code page (non-ASCII prompts were mojibake under cp1252). The text-mode fallback path lstrips U+FEFF as defense-in-depth. - Cursor sends workspace_roots=[] when no folder is open; the .get() default only applies when the key is missing, so workspace_roots[0] raised IndexError. Fall back to '.' via `or`. 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'