Skip to content

Commit 2da4e42

Browse files
Ilanlidoclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 2615e02 commit 2da4e42

5 files changed

Lines changed: 78 additions & 6 deletions

File tree

cycode/cli/apps/ai_guardrails/scan/scan_command.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
``HookDecision``); ``IDE.build_hook_response`` is the per-IDE translation step.
88
"""
99

10-
import sys
1110
from typing import Annotated, Optional, Union
1211

1312
import click
@@ -18,7 +17,7 @@
1817
from cycode.cli.apps.ai_guardrails.scan.handlers import get_handler_for_event
1918
from cycode.cli.apps.ai_guardrails.scan.policy import load_policy
2019
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
21-
from cycode.cli.apps.ai_guardrails.scan.utils import output_json, safe_json_parse
20+
from cycode.cli.apps.ai_guardrails.scan.utils import output_json, read_stdin_text, safe_json_parse
2221
from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError
2322
from cycode.cli.utils.get_api_client import get_ai_security_manager_client, get_scan_cycode_client
2423
from cycode.logger import get_logger
@@ -91,7 +90,7 @@ def scan_command(
9190
"""
9291
ide_integration = get_ide(ide)
9392

94-
stdin_data = sys.stdin.read().strip()
93+
stdin_data = read_stdin_text().strip()
9594
payload = safe_json_parse(stdin_data)
9695

9796
if not payload:
@@ -113,7 +112,8 @@ def scan_command(
113112
event_name = unified_payload.event_name
114113
logger.debug('Processing AI guardrails hook', extra={'event_name': event_name, 'ide': ide_integration.name})
115114

116-
workspace_roots = payload.get('workspace_roots', ['.'])
115+
# `or` (not a .get default) - Cursor sends workspace_roots=[] when no folder is open.
116+
workspace_roots = payload.get('workspace_roots') or ['.']
117117
policy = load_policy(workspace_roots[0])
118118

119119
try:

cycode/cli/apps/ai_guardrails/scan/utils.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,28 @@
66

77
import json
88
import os
9+
import sys
910
from pathlib import Path
1011

1112
from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value
1213

1314

15+
def read_stdin_text() -> str:
16+
"""Read the hook payload from stdin as UTF-8 text.
17+
18+
Reads bytes and decodes with utf-8-sig: hook payloads are UTF-8 JSON, but on Windows
19+
Python decodes piped stdin with the ANSI code page (mojibake for non-ASCII prompts),
20+
and Cursor on Windows prefixes the payload with a UTF-8 BOM - the -sig codec strips it.
21+
"""
22+
buffer = getattr(sys.stdin, 'buffer', None)
23+
if buffer is not None:
24+
return buffer.read().decode('utf-8-sig', errors='replace')
25+
# No .buffer (tests mocking sys.stdin with StringIO, exotic streams) - text-mode fallback.
26+
# lstrip the BOM here too: an already-decoded stream leaves it as U+FEFF, which json.loads
27+
# rejects (and .strip() doesn't remove - it is not whitespace).
28+
return sys.stdin.read().lstrip('\ufeff')
29+
30+
1431
def safe_json_parse(s: str) -> dict:
1532
"""Parse JSON string, returning empty dict on failure."""
1633
try:

cycode/cli/apps/ai_guardrails/session_start_command.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, get_ide
99
from cycode.cli.apps.ai_guardrails.ides.base import IDE
10-
from cycode.cli.apps.ai_guardrails.scan.utils import safe_json_parse
10+
from cycode.cli.apps.ai_guardrails.scan.utils import read_stdin_text, safe_json_parse
1111
from cycode.cli.apps.auth.auth_common import get_authorization_info
1212
from cycode.cli.apps.auth.auth_manager import AuthManager
1313
from cycode.cli.exceptions.handle_auth_errors import handle_auth_exception
@@ -78,7 +78,7 @@ def session_start_command(
7878
logger.debug('No stdin payload (TTY), skipping session initialization')
7979
return
8080

81-
stdin_data = sys.stdin.read().strip()
81+
stdin_data = read_stdin_text().strip()
8282
payload = safe_json_parse(stdin_data)
8383
if not payload:
8484
logger.debug('Empty or invalid stdin payload, skipping session initialization')

tests/cli/commands/ai_guardrails/scan/test_scan_command.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,30 @@ def test_claude_code_payload_with_claude_code_ide(
141141
mock_scan_command_deps['get_handler'].assert_called_once()
142142
mock_handler.assert_called_once()
143143

144+
def test_empty_workspace_roots_falls_back_to_cwd(
145+
self,
146+
mock_ctx: MagicMock,
147+
mocker: MockerFixture,
148+
mock_scan_command_deps: dict[str, MagicMock],
149+
) -> None:
150+
"""Cursor sends workspace_roots=[] when no folder is open - must not crash."""
151+
payload = {
152+
'hook_event_name': 'beforeSubmitPrompt',
153+
'conversation_id': 'conv-123',
154+
'prompt': 'test',
155+
'workspace_roots': [],
156+
}
157+
mocker.patch('sys.stdin', StringIO(json.dumps(payload)))
158+
159+
mock_scan_command_deps['load_policy'].return_value = {'fail_open': True}
160+
mock_handler = MagicMock(return_value=HookDecision.allow(AiHookEventType.PROMPT))
161+
mock_scan_command_deps['get_handler'].return_value = mock_handler
162+
163+
scan_command(mock_ctx, ide='cursor')
164+
165+
mock_scan_command_deps['load_policy'].assert_called_once_with('.')
166+
mock_handler.assert_called_once()
167+
144168

145169
class TestDefaultIdeParameterViaCli:
146170
"""Tests that verify default IDE parameter works correctly via CLI invocation."""

tests/cli/commands/ai_guardrails/scan/test_utils.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,43 @@
11
"""Tests for AI guardrails utility functions."""
22

3+
import io
4+
from unittest.mock import patch
5+
36
from cycode.cli.apps.ai_guardrails.scan.utils import (
47
is_denied_path,
58
matches_glob,
69
normalize_path,
10+
read_stdin_text,
11+
safe_json_parse,
712
)
813

914

15+
def test_read_stdin_text_decodes_bom_and_utf8() -> None:
16+
"""utf-8-sig byte decode strips the BOM Cursor sends on Windows and avoids ANSI mojibake."""
17+
raw = '\ufeff{"prompt": "café"}'.encode() # utf-8 with BOM, multi-byte non-ASCII content
18+
fake_stdin = io.TextIOWrapper(io.BytesIO(raw), encoding='utf-8')
19+
20+
with patch('sys.stdin', fake_stdin):
21+
text = read_stdin_text()
22+
23+
assert safe_json_parse(text)['prompt'] == 'café'
24+
25+
26+
def test_read_stdin_text_falls_back_without_buffer() -> None:
27+
"""Streams without .buffer (e.g. StringIO in tests) fall back to a text-mode read, BOM-stripped."""
28+
with patch('sys.stdin', io.StringIO('{"a": 1}')):
29+
assert read_stdin_text() == '{"a": 1}'
30+
31+
with patch('sys.stdin', io.StringIO('\ufeff{"a": 1}')):
32+
assert read_stdin_text() == '{"a": 1}'
33+
34+
35+
def test_safe_json_parse_invalid_and_empty() -> None:
36+
"""Invalid JSON and empty inputs return an empty dict."""
37+
assert safe_json_parse('not valid json {') == {}
38+
assert safe_json_parse('') == {}
39+
40+
1041
def test_normalize_path_rejects_escape() -> None:
1142
"""Test that paths attempting to escape are rejected."""
1243
path = '../../../etc/passwd'

0 commit comments

Comments
 (0)