Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions cycode/cli/apps/ai_guardrails/scan/scan_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions cycode/cli/apps/ai_guardrails/scan/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions cycode/cli/apps/ai_guardrails/session_start_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down
24 changes: 24 additions & 0 deletions tests/cli/commands/ai_guardrails/scan/test_scan_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
31 changes: 31 additions & 0 deletions tests/cli/commands/ai_guardrails/scan/test_utils.py
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
Loading