Skip to content
Merged
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
7 changes: 3 additions & 4 deletions cycode/cli/apps/ai_guardrails/scan/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,14 @@
AIHookOutcome,
BlockReason,
)
from cycode.cli.apps.ai_guardrails.scan.utils import is_denied_path, truncate_utf8
from cycode.cli.apps.ai_guardrails.scan.utils import build_violation_summary, 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')
Expand Down Expand Up @@ -76,7 +75,7 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli
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.'
user_message = f'Remove secrets before sending. {violation_summary}'
return HookDecision.deny(AiHookEventType.PROMPT, user_message)
outcome = AIHookOutcome.WARNED
return HookDecision.allow(AiHookEventType.PROMPT)
Expand Down Expand Up @@ -283,7 +282,7 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
event_type=AiHookEventType.MCP_EXECUTION,
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_message=lambda v: f'Allow MCP tool call "{tool}"? {v}',
ask_agent_message='Possible secrets detected in tool arguments; proceed with caution.',
),
scan_text=args_text,
Expand Down
60 changes: 59 additions & 1 deletion cycode/cli/apps/ai_guardrails/scan/utils.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
"""
Utility functions for AI guardrails.

Includes JSON parsing, path matching, and text handling utilities.
Includes JSON parsing, path matching, text handling and hook-message utilities.
"""

import json
import os
import sys
from collections import defaultdict
from pathlib import Path
from typing import TYPE_CHECKING

from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value
from cycode.cli.cli_types import SeverityOption

if TYPE_CHECKING:
from cycode.cli.models import LocalScanResult

# Keeps the hook message readable when a single file trips dozens of detections
MAX_VIOLATION_DETAIL_LINES = 5


def read_stdin_text() -> str:
Expand Down Expand Up @@ -87,3 +96,52 @@ def is_denied_path(file_path: str, policy: dict) -> bool:
def output_json(obj: dict) -> None:
"""Write JSON response to stdout (for IDE to read)."""
print(json.dumps(obj), end='') # noqa: T201


def _build_detection_lines(
local_scan_results: list['LocalScanResult'], max_lines: int = MAX_VIOLATION_DETAIL_LINES
) -> str:
"""One line per distinct finding: what it is, and the value hash identifying it.

The value hash is safe to display; the value itself is not. Detections excluded by an existing
ignore rule are already gone from `document_detections`, so only what actually blocked is listed.
"""
type_by_sha = {}
for local_scan_result in local_scan_results:
for document_detections in local_scan_result.document_detections:
for detection in document_detections.detections:
sha = detection.detection_details.get('sha512')
if sha and sha not in type_by_sha:
type_by_sha[sha] = detection.type or detection.message

if not type_by_sha:
return ''

lines = [f' - {detection_type}: {sha}' for sha, detection_type in list(type_by_sha.items())[:max_lines]]
remaining = len(type_by_sha) - len(lines)
if remaining:
lines.append(f' - ...and {remaining} more')

return '\n' + '\n'.join(lines)


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}')

summary = f'Cycode found {detections_count} violations: {" | ".join(severity_parts)}'
return summary + _build_detection_lines(local_scan_results)
23 changes: 0 additions & 23 deletions cycode/cli/utils/scan_utils.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import os
from collections import defaultdict
from typing import TYPE_CHECKING, Optional
from uuid import UUID, uuid4

import typer

from cycode.cli import consts
from cycode.cli.cli_types import SeverityOption

if TYPE_CHECKING:
from cycode.cli.models import LocalScanResult
Expand Down Expand Up @@ -41,24 +39,3 @@ 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)}'
58 changes: 57 additions & 1 deletion tests/cli/commands/ai_guardrails/scan/test_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
)
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
from cycode.cli.apps.ai_guardrails.scan.utils import MAX_VIOLATION_DETAIL_LINES, build_violation_summary
from cycode.cli.models import Document, DocumentDetections, LocalScanResult
from cycode.cyclient.models import Detection


@pytest.fixture
Expand Down Expand Up @@ -420,6 +422,60 @@ def test_perform_scan_no_violation_when_all_detections_excluded(mock_ctx: MagicM
assert scan_id == 'scan-id-123'


def _local_scan_result_with_detections(*shas: str) -> LocalScanResult:
document = Document(path='prompt-content.txt', content='some content', is_git_diff_format=False)
detections = [
Detection(
detection_type_id='type-id',
type='GitHub Token',
message='Hardcoded secret',
detection_details={'sha512': sha},
detection_rule_id='rule-id',
severity='High',
)
for sha in shas
]
return LocalScanResult(
scan_id='scan-id-123',
report_url=None,
document_detections=[DocumentDetections(document=document, detections=detections)],
issue_detected=True,
detections_count=len(detections),
relevant_detections_count=len(detections),
)


def test_violation_summary_lists_one_line_per_distinct_sha() -> None:
"""A blocked developer needs the value hash to act on the finding (e.g. `cycode ignore --by-sha`)."""
summary = build_violation_summary([_local_scan_result_with_detections('sha-aaa', 'sha-bbb', 'sha-aaa')])

assert 'Cycode found 3 violations' in summary
assert 'GitHub Token: sha-aaa' in summary
assert 'GitHub Token: sha-bbb' in summary
# Repeated values collapse to one line; the hash identifies the value, not the occurrence
assert summary.count('sha-aaa') == 1


def test_violation_summary_caps_the_detection_lines() -> None:
"""A file full of detections must not turn the hook message into a wall of text."""
shas = [f'sha-{index}' for index in range(MAX_VIOLATION_DETAIL_LINES + 3)]

summary = build_violation_summary([_local_scan_result_with_detections(*shas)])

assert summary.count('GitHub Token: ') == MAX_VIOLATION_DETAIL_LINES
assert '...and 3 more' in summary


def test_violation_summary_omits_lines_without_a_sha() -> None:
"""Non-secret scan types carry no value hash, so there is nothing to list."""
local_scan_result = _local_scan_result_with_detections('sha-aaa')
local_scan_result.document_detections[0].detections[0].detection_details = {}

summary = build_violation_summary([local_scan_result])

assert 'GitHub Token' not in summary


# Tests for handle_before_mcp_execution


Expand Down