lucidscan 0.5.12__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lucidscan/__init__.py +12 -0
- lucidscan/bootstrap/__init__.py +26 -0
- lucidscan/bootstrap/paths.py +160 -0
- lucidscan/bootstrap/platform.py +111 -0
- lucidscan/bootstrap/validation.py +76 -0
- lucidscan/bootstrap/versions.py +119 -0
- lucidscan/cli/__init__.py +50 -0
- lucidscan/cli/__main__.py +8 -0
- lucidscan/cli/arguments.py +405 -0
- lucidscan/cli/commands/__init__.py +64 -0
- lucidscan/cli/commands/autoconfigure.py +294 -0
- lucidscan/cli/commands/help.py +69 -0
- lucidscan/cli/commands/init.py +656 -0
- lucidscan/cli/commands/list_scanners.py +59 -0
- lucidscan/cli/commands/scan.py +307 -0
- lucidscan/cli/commands/serve.py +142 -0
- lucidscan/cli/commands/status.py +84 -0
- lucidscan/cli/commands/validate.py +105 -0
- lucidscan/cli/config_bridge.py +152 -0
- lucidscan/cli/exit_codes.py +17 -0
- lucidscan/cli/runner.py +284 -0
- lucidscan/config/__init__.py +29 -0
- lucidscan/config/ignore.py +178 -0
- lucidscan/config/loader.py +431 -0
- lucidscan/config/models.py +316 -0
- lucidscan/config/validation.py +645 -0
- lucidscan/core/__init__.py +3 -0
- lucidscan/core/domain_runner.py +463 -0
- lucidscan/core/git.py +174 -0
- lucidscan/core/logging.py +34 -0
- lucidscan/core/models.py +207 -0
- lucidscan/core/streaming.py +340 -0
- lucidscan/core/subprocess_runner.py +164 -0
- lucidscan/detection/__init__.py +21 -0
- lucidscan/detection/detector.py +154 -0
- lucidscan/detection/frameworks.py +270 -0
- lucidscan/detection/languages.py +328 -0
- lucidscan/detection/tools.py +229 -0
- lucidscan/generation/__init__.py +15 -0
- lucidscan/generation/config_generator.py +275 -0
- lucidscan/generation/package_installer.py +330 -0
- lucidscan/mcp/__init__.py +20 -0
- lucidscan/mcp/formatter.py +510 -0
- lucidscan/mcp/server.py +297 -0
- lucidscan/mcp/tools.py +1049 -0
- lucidscan/mcp/watcher.py +237 -0
- lucidscan/pipeline/__init__.py +17 -0
- lucidscan/pipeline/executor.py +187 -0
- lucidscan/pipeline/parallel.py +181 -0
- lucidscan/plugins/__init__.py +40 -0
- lucidscan/plugins/coverage/__init__.py +28 -0
- lucidscan/plugins/coverage/base.py +160 -0
- lucidscan/plugins/coverage/coverage_py.py +454 -0
- lucidscan/plugins/coverage/istanbul.py +411 -0
- lucidscan/plugins/discovery.py +107 -0
- lucidscan/plugins/enrichers/__init__.py +61 -0
- lucidscan/plugins/enrichers/base.py +63 -0
- lucidscan/plugins/linters/__init__.py +26 -0
- lucidscan/plugins/linters/base.py +125 -0
- lucidscan/plugins/linters/biome.py +448 -0
- lucidscan/plugins/linters/checkstyle.py +393 -0
- lucidscan/plugins/linters/eslint.py +368 -0
- lucidscan/plugins/linters/ruff.py +498 -0
- lucidscan/plugins/reporters/__init__.py +45 -0
- lucidscan/plugins/reporters/base.py +30 -0
- lucidscan/plugins/reporters/json_reporter.py +79 -0
- lucidscan/plugins/reporters/sarif_reporter.py +303 -0
- lucidscan/plugins/reporters/summary_reporter.py +61 -0
- lucidscan/plugins/reporters/table_reporter.py +81 -0
- lucidscan/plugins/scanners/__init__.py +57 -0
- lucidscan/plugins/scanners/base.py +60 -0
- lucidscan/plugins/scanners/checkov.py +484 -0
- lucidscan/plugins/scanners/opengrep.py +464 -0
- lucidscan/plugins/scanners/trivy.py +492 -0
- lucidscan/plugins/test_runners/__init__.py +27 -0
- lucidscan/plugins/test_runners/base.py +111 -0
- lucidscan/plugins/test_runners/jest.py +381 -0
- lucidscan/plugins/test_runners/karma.py +481 -0
- lucidscan/plugins/test_runners/playwright.py +434 -0
- lucidscan/plugins/test_runners/pytest.py +598 -0
- lucidscan/plugins/type_checkers/__init__.py +27 -0
- lucidscan/plugins/type_checkers/base.py +106 -0
- lucidscan/plugins/type_checkers/mypy.py +355 -0
- lucidscan/plugins/type_checkers/pyright.py +313 -0
- lucidscan/plugins/type_checkers/typescript.py +280 -0
- lucidscan-0.5.12.dist-info/METADATA +242 -0
- lucidscan-0.5.12.dist-info/RECORD +91 -0
- lucidscan-0.5.12.dist-info/WHEEL +5 -0
- lucidscan-0.5.12.dist-info/entry_points.txt +34 -0
- lucidscan-0.5.12.dist-info/licenses/LICENSE +201 -0
- lucidscan-0.5.12.dist-info/top_level.txt +1 -0
lucidscan/core/models.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Union
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from lucidscan.config.ignore import IgnorePatterns
|
|
10
|
+
from lucidscan.config.models import LucidScanConfig
|
|
11
|
+
from lucidscan.core.streaming import StreamHandler
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ScanDomain(str, Enum):
|
|
15
|
+
"""Scanning domains supported by lucidscan (security-focused)."""
|
|
16
|
+
|
|
17
|
+
SCA = "sca"
|
|
18
|
+
CONTAINER = "container"
|
|
19
|
+
IAC = "iac"
|
|
20
|
+
SAST = "sast"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ToolDomain(str, Enum):
|
|
24
|
+
"""All tool domains supported by lucidscan pipeline.
|
|
25
|
+
|
|
26
|
+
This enum covers all types of tools in the quality pipeline:
|
|
27
|
+
linting, type checking, security scanning, testing, and coverage.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
LINTING = "linting"
|
|
31
|
+
TYPE_CHECKING = "type_checking"
|
|
32
|
+
SECURITY = "security"
|
|
33
|
+
TESTING = "testing"
|
|
34
|
+
COVERAGE = "coverage"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Type alias for any domain type (ScanDomain or ToolDomain)
|
|
38
|
+
DomainType = Union[ScanDomain, ToolDomain]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Severity(str, Enum):
|
|
42
|
+
"""Unified severity levels used across all scanners."""
|
|
43
|
+
|
|
44
|
+
CRITICAL = "critical"
|
|
45
|
+
HIGH = "high"
|
|
46
|
+
MEDIUM = "medium"
|
|
47
|
+
LOW = "low"
|
|
48
|
+
INFO = "info"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class UnifiedIssue:
|
|
53
|
+
"""Normalized issue representation shared by all tools.
|
|
54
|
+
|
|
55
|
+
This unified schema handles issues from all domains:
|
|
56
|
+
- Linting: code style and quality issues
|
|
57
|
+
- Type checking: type errors and warnings
|
|
58
|
+
- Security (SAST/SCA/IaC/Container): vulnerabilities and misconfigurations
|
|
59
|
+
- Testing: test failures
|
|
60
|
+
- Coverage: coverage gaps
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
# Core identification
|
|
64
|
+
id: str
|
|
65
|
+
domain: DomainType # The domain category (linting, sast, sca, etc.)
|
|
66
|
+
source_tool: str # The actual tool (ruff, trivy, mypy, etc.)
|
|
67
|
+
severity: Severity
|
|
68
|
+
|
|
69
|
+
# Content
|
|
70
|
+
rule_id: str # Rule identifier (E501, CVE-2024-1234, CKV_AWS_1)
|
|
71
|
+
title: str
|
|
72
|
+
description: str
|
|
73
|
+
recommendation: Optional[str] = None
|
|
74
|
+
documentation_url: Optional[str] = None
|
|
75
|
+
|
|
76
|
+
# Location
|
|
77
|
+
file_path: Optional[Path] = None
|
|
78
|
+
line_start: Optional[int] = None
|
|
79
|
+
line_end: Optional[int] = None
|
|
80
|
+
column_start: Optional[int] = None
|
|
81
|
+
column_end: Optional[int] = None
|
|
82
|
+
code_snippet: Optional[str] = None
|
|
83
|
+
|
|
84
|
+
# Fix information
|
|
85
|
+
fixable: bool = False
|
|
86
|
+
suggested_fix: Optional[str] = None
|
|
87
|
+
|
|
88
|
+
# Domain-specific fields
|
|
89
|
+
dependency: Optional[str] = None # For SCA (e.g., "lodash@4.17.20")
|
|
90
|
+
iac_resource: Optional[str] = None # For IaC (e.g., "aws_s3_bucket.public")
|
|
91
|
+
|
|
92
|
+
# Extensibility
|
|
93
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class ScanContext:
|
|
98
|
+
"""Context provided to scanner plugins during scan execution.
|
|
99
|
+
|
|
100
|
+
Contains target paths, configuration, and scan settings needed
|
|
101
|
+
by plugins to execute their scans.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
project_root: Path
|
|
105
|
+
paths: List[Path]
|
|
106
|
+
enabled_domains: Sequence[DomainType]
|
|
107
|
+
config: "LucidScanConfig" = None # type: ignore[assignment]
|
|
108
|
+
ignore_patterns: Optional["IgnorePatterns"] = None
|
|
109
|
+
stream_handler: Optional["StreamHandler"] = None
|
|
110
|
+
# Coverage result populated after coverage analysis (for MCP/CLI access)
|
|
111
|
+
coverage_result: Any = None
|
|
112
|
+
|
|
113
|
+
def get_scanner_options(self, domain: str) -> Dict[str, Any]:
|
|
114
|
+
"""Get plugin-specific options for a domain.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
domain: Domain name (sca, sast, iac, container).
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Dictionary of plugin-specific options.
|
|
121
|
+
"""
|
|
122
|
+
if self.config is None:
|
|
123
|
+
return {}
|
|
124
|
+
# Handle legacy dict-based config for backwards compatibility
|
|
125
|
+
if isinstance(self.config, dict):
|
|
126
|
+
return self.config
|
|
127
|
+
return self.config.get_scanner_options(domain)
|
|
128
|
+
|
|
129
|
+
def get_exclude_patterns(self) -> List[str]:
|
|
130
|
+
"""Get ignore patterns for scanner exclude flags.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
List of patterns suitable for --exclude flags.
|
|
134
|
+
"""
|
|
135
|
+
if self.ignore_patterns is None:
|
|
136
|
+
return []
|
|
137
|
+
return self.ignore_patterns.get_exclude_patterns()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class ScanMetadata:
|
|
142
|
+
"""Metadata about the scan execution."""
|
|
143
|
+
|
|
144
|
+
lucidscan_version: str
|
|
145
|
+
scan_started_at: str
|
|
146
|
+
scan_finished_at: str
|
|
147
|
+
duration_ms: int
|
|
148
|
+
project_root: str
|
|
149
|
+
scanners_used: List[Dict[str, Any]] = field(default_factory=list)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass
|
|
153
|
+
class ScanSummary:
|
|
154
|
+
"""Summary statistics for scan results."""
|
|
155
|
+
|
|
156
|
+
total: int = 0
|
|
157
|
+
by_severity: Dict[str, int] = field(default_factory=dict)
|
|
158
|
+
by_scanner: Dict[str, int] = field(default_factory=dict)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass
|
|
162
|
+
class CoverageSummary:
|
|
163
|
+
"""Summary of coverage analysis results."""
|
|
164
|
+
|
|
165
|
+
coverage_percentage: float = 0.0
|
|
166
|
+
threshold: float = 80.0
|
|
167
|
+
total_lines: int = 0
|
|
168
|
+
covered_lines: int = 0
|
|
169
|
+
missing_lines: int = 0
|
|
170
|
+
passed: bool = True
|
|
171
|
+
# Test statistics
|
|
172
|
+
tests_total: int = 0
|
|
173
|
+
tests_passed: int = 0
|
|
174
|
+
tests_failed: int = 0
|
|
175
|
+
tests_skipped: int = 0
|
|
176
|
+
tests_errors: int = 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass
|
|
180
|
+
class ScanResult:
|
|
181
|
+
"""Aggregated result for a scan over one project or path set."""
|
|
182
|
+
|
|
183
|
+
issues: List[UnifiedIssue] = field(default_factory=list)
|
|
184
|
+
schema_version: str = "1.0"
|
|
185
|
+
metadata: Optional[ScanMetadata] = None
|
|
186
|
+
summary: Optional[ScanSummary] = None
|
|
187
|
+
coverage_summary: Optional[CoverageSummary] = None
|
|
188
|
+
|
|
189
|
+
def compute_summary(self) -> ScanSummary:
|
|
190
|
+
"""Compute summary statistics from issues."""
|
|
191
|
+
by_severity: Dict[str, int] = {}
|
|
192
|
+
by_domain: Dict[str, int] = {}
|
|
193
|
+
|
|
194
|
+
for issue in self.issues:
|
|
195
|
+
sev = issue.severity.value
|
|
196
|
+
by_severity[sev] = by_severity.get(sev, 0) + 1
|
|
197
|
+
|
|
198
|
+
domain = issue.domain.value
|
|
199
|
+
by_domain[domain] = by_domain.get(domain, 0) + 1
|
|
200
|
+
|
|
201
|
+
return ScanSummary(
|
|
202
|
+
total=len(self.issues),
|
|
203
|
+
by_severity=by_severity,
|
|
204
|
+
by_scanner=by_domain, # Keep field name for backwards compatibility
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Stream handler abstraction for live output during scans.
|
|
2
|
+
|
|
3
|
+
Provides a unified interface for streaming tool output to different targets:
|
|
4
|
+
- CLI: Print to console with optional Rich formatting
|
|
5
|
+
- MCP: Send notifications to AI agents
|
|
6
|
+
- Null: No-op for backward compatibility
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from enum import Enum
|
|
17
|
+
from typing import Callable, Coroutine, Optional, TextIO, Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StreamType(str, Enum):
|
|
21
|
+
"""Type of stream output."""
|
|
22
|
+
|
|
23
|
+
STDOUT = "stdout"
|
|
24
|
+
STDERR = "stderr"
|
|
25
|
+
STATUS = "status"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class StreamEvent:
|
|
30
|
+
"""A streaming event from a tool execution."""
|
|
31
|
+
|
|
32
|
+
tool_name: str
|
|
33
|
+
stream_type: StreamType
|
|
34
|
+
content: str
|
|
35
|
+
line_number: Optional[int] = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class StreamHandler(ABC):
|
|
39
|
+
"""Abstract base class for stream handlers.
|
|
40
|
+
|
|
41
|
+
Implementations must be thread-safe as multiple tools may emit
|
|
42
|
+
events concurrently from different threads.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def emit(self, event: StreamEvent) -> None:
|
|
47
|
+
"""Emit a stream event.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
event: The stream event to emit.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def start_tool(self, tool_name: str) -> None:
|
|
55
|
+
"""Signal that a tool has started execution.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
tool_name: Name of the tool that started.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def end_tool(self, tool_name: str, success: bool) -> None:
|
|
63
|
+
"""Signal that a tool has finished execution.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
tool_name: Name of the tool that finished.
|
|
67
|
+
success: Whether the tool completed successfully.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class NullStreamHandler(StreamHandler):
|
|
72
|
+
"""No-op handler for backward compatibility.
|
|
73
|
+
|
|
74
|
+
Use this when streaming is not needed - all methods are no-ops.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def emit(self, event: StreamEvent) -> None:
|
|
78
|
+
"""No-op emit."""
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
def start_tool(self, tool_name: str) -> None:
|
|
82
|
+
"""No-op start."""
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
def end_tool(self, tool_name: str, success: bool) -> None:
|
|
86
|
+
"""No-op end."""
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class CLIStreamHandler(StreamHandler):
|
|
91
|
+
"""Thread-safe CLI stream handler.
|
|
92
|
+
|
|
93
|
+
Streams tool output to the console with optional Rich formatting.
|
|
94
|
+
Shows both raw tool output and formatted status messages.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
output: TextIO = sys.stderr,
|
|
100
|
+
show_output: bool = True,
|
|
101
|
+
use_rich: bool = False,
|
|
102
|
+
):
|
|
103
|
+
"""Initialize CLIStreamHandler.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
output: Output stream to write to (default: stderr).
|
|
107
|
+
show_output: Whether to show raw tool output lines.
|
|
108
|
+
use_rich: Whether to use Rich for formatted output.
|
|
109
|
+
"""
|
|
110
|
+
self._output = output
|
|
111
|
+
self._show_output = show_output
|
|
112
|
+
self._use_rich = use_rich
|
|
113
|
+
self._lock = threading.Lock()
|
|
114
|
+
self._console = None
|
|
115
|
+
|
|
116
|
+
if use_rich:
|
|
117
|
+
try:
|
|
118
|
+
from rich.console import Console # type: ignore[import-not-found]
|
|
119
|
+
|
|
120
|
+
self._console = Console(file=output, force_terminal=True)
|
|
121
|
+
except ImportError:
|
|
122
|
+
self._use_rich = False
|
|
123
|
+
|
|
124
|
+
def emit(self, event: StreamEvent) -> None:
|
|
125
|
+
"""Emit a stream event to the console.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
event: The stream event to emit.
|
|
129
|
+
"""
|
|
130
|
+
if not self._show_output:
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
with self._lock:
|
|
134
|
+
if event.stream_type == StreamType.STATUS:
|
|
135
|
+
self._print_status(f"[{event.tool_name}] {event.content}")
|
|
136
|
+
else:
|
|
137
|
+
# Show raw output with tool prefix
|
|
138
|
+
prefix = f" {event.tool_name}: "
|
|
139
|
+
self._print_line(prefix, event.content)
|
|
140
|
+
|
|
141
|
+
def start_tool(self, tool_name: str) -> None:
|
|
142
|
+
"""Signal that a tool has started.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
tool_name: Name of the tool that started.
|
|
146
|
+
"""
|
|
147
|
+
with self._lock:
|
|
148
|
+
self._print_status(f"[{tool_name}] Starting...")
|
|
149
|
+
|
|
150
|
+
def end_tool(self, tool_name: str, success: bool) -> None:
|
|
151
|
+
"""Signal that a tool has finished.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
tool_name: Name of the tool that finished.
|
|
155
|
+
success: Whether the tool completed successfully.
|
|
156
|
+
"""
|
|
157
|
+
with self._lock:
|
|
158
|
+
if success:
|
|
159
|
+
self._print_status(f"[{tool_name}] Done")
|
|
160
|
+
else:
|
|
161
|
+
self._print_status(f"[{tool_name}] Failed")
|
|
162
|
+
|
|
163
|
+
def _print_status(self, message: str) -> None:
|
|
164
|
+
"""Print a status message.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
message: The status message to print.
|
|
168
|
+
"""
|
|
169
|
+
if self._console:
|
|
170
|
+
self._console.print(f"[bold cyan]{message}[/bold cyan]")
|
|
171
|
+
else:
|
|
172
|
+
print(message, file=self._output, flush=True)
|
|
173
|
+
|
|
174
|
+
def _print_line(self, prefix: str, content: str) -> None:
|
|
175
|
+
"""Print a line of tool output.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
prefix: Prefix to add before the content.
|
|
179
|
+
content: The content to print.
|
|
180
|
+
"""
|
|
181
|
+
if self._console:
|
|
182
|
+
self._console.print(f"[dim]{prefix}[/dim]{content}")
|
|
183
|
+
else:
|
|
184
|
+
print(f"{prefix}{content}", file=self._output, flush=True)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class CallbackStreamHandler(StreamHandler):
|
|
188
|
+
"""Handler that invokes callbacks for stream events.
|
|
189
|
+
|
|
190
|
+
Useful for MCP or async contexts where events need to be
|
|
191
|
+
forwarded to another system.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
def __init__(
|
|
195
|
+
self,
|
|
196
|
+
on_event: Optional[Callable[[StreamEvent], None]] = None,
|
|
197
|
+
on_start: Optional[Callable[[str], None]] = None,
|
|
198
|
+
on_end: Optional[Callable[[str, bool], None]] = None,
|
|
199
|
+
):
|
|
200
|
+
"""Initialize CallbackStreamHandler.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
on_event: Callback for stream events.
|
|
204
|
+
on_start: Callback when a tool starts.
|
|
205
|
+
on_end: Callback when a tool ends.
|
|
206
|
+
"""
|
|
207
|
+
self._on_event = on_event
|
|
208
|
+
self._on_start = on_start
|
|
209
|
+
self._on_end = on_end
|
|
210
|
+
self._lock = threading.Lock()
|
|
211
|
+
|
|
212
|
+
def emit(self, event: StreamEvent) -> None:
|
|
213
|
+
"""Emit a stream event via callback.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
event: The stream event to emit.
|
|
217
|
+
"""
|
|
218
|
+
if self._on_event:
|
|
219
|
+
with self._lock:
|
|
220
|
+
self._on_event(event)
|
|
221
|
+
|
|
222
|
+
def start_tool(self, tool_name: str) -> None:
|
|
223
|
+
"""Signal that a tool has started via callback.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
tool_name: Name of the tool that started.
|
|
227
|
+
"""
|
|
228
|
+
if self._on_start:
|
|
229
|
+
with self._lock:
|
|
230
|
+
self._on_start(tool_name)
|
|
231
|
+
# Also emit as event if callback registered
|
|
232
|
+
if self._on_event:
|
|
233
|
+
self.emit(
|
|
234
|
+
StreamEvent(
|
|
235
|
+
tool_name=tool_name,
|
|
236
|
+
stream_type=StreamType.STATUS,
|
|
237
|
+
content="started",
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
def end_tool(self, tool_name: str, success: bool) -> None:
|
|
242
|
+
"""Signal that a tool has finished via callback.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
tool_name: Name of the tool that finished.
|
|
246
|
+
success: Whether the tool completed successfully.
|
|
247
|
+
"""
|
|
248
|
+
if self._on_end:
|
|
249
|
+
with self._lock:
|
|
250
|
+
self._on_end(tool_name, success)
|
|
251
|
+
# Also emit as event if callback registered
|
|
252
|
+
if self._on_event:
|
|
253
|
+
status = "completed" if success else "failed"
|
|
254
|
+
self.emit(
|
|
255
|
+
StreamEvent(
|
|
256
|
+
tool_name=tool_name,
|
|
257
|
+
stream_type=StreamType.STATUS,
|
|
258
|
+
content=status,
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# Type alias for async callbacks
|
|
264
|
+
AsyncEventCallback = Callable[[StreamEvent], Coroutine[Any, Any, None]]
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class MCPStreamHandler(StreamHandler):
|
|
268
|
+
"""Handler that streams events via async MCP notifications.
|
|
269
|
+
|
|
270
|
+
This handler bridges synchronous tool execution (running in thread pool)
|
|
271
|
+
with async MCP notification delivery. It captures the event loop at
|
|
272
|
+
construction time and uses run_coroutine_threadsafe to schedule
|
|
273
|
+
async callbacks from synchronous contexts.
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
def __init__(
|
|
277
|
+
self,
|
|
278
|
+
on_event: AsyncEventCallback,
|
|
279
|
+
loop: Optional[asyncio.AbstractEventLoop] = None,
|
|
280
|
+
):
|
|
281
|
+
"""Initialize MCPStreamHandler.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
on_event: Async callback for stream events.
|
|
285
|
+
loop: Event loop to use. If None, uses the running loop.
|
|
286
|
+
"""
|
|
287
|
+
self._on_event = on_event
|
|
288
|
+
self._loop = loop or asyncio.get_running_loop()
|
|
289
|
+
self._lock = threading.Lock()
|
|
290
|
+
|
|
291
|
+
def _schedule_async(self, coro: Coroutine[Any, Any, None]) -> None:
|
|
292
|
+
"""Schedule an async coroutine from a sync context.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
coro: Coroutine to schedule.
|
|
296
|
+
"""
|
|
297
|
+
try:
|
|
298
|
+
asyncio.run_coroutine_threadsafe(coro, self._loop)
|
|
299
|
+
except RuntimeError:
|
|
300
|
+
# Loop might be closed, ignore
|
|
301
|
+
pass
|
|
302
|
+
|
|
303
|
+
def emit(self, event: StreamEvent) -> None:
|
|
304
|
+
"""Emit a stream event via async MCP callback.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
event: The stream event to emit.
|
|
308
|
+
"""
|
|
309
|
+
with self._lock:
|
|
310
|
+
self._schedule_async(self._on_event(event))
|
|
311
|
+
|
|
312
|
+
def start_tool(self, tool_name: str) -> None:
|
|
313
|
+
"""Signal that a tool has started.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
tool_name: Name of the tool that started.
|
|
317
|
+
"""
|
|
318
|
+
self.emit(
|
|
319
|
+
StreamEvent(
|
|
320
|
+
tool_name=tool_name,
|
|
321
|
+
stream_type=StreamType.STATUS,
|
|
322
|
+
content="started",
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
def end_tool(self, tool_name: str, success: bool) -> None:
|
|
327
|
+
"""Signal that a tool has finished.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
tool_name: Name of the tool that finished.
|
|
331
|
+
success: Whether the tool completed successfully.
|
|
332
|
+
"""
|
|
333
|
+
status = "completed" if success else "failed"
|
|
334
|
+
self.emit(
|
|
335
|
+
StreamEvent(
|
|
336
|
+
tool_name=tool_name,
|
|
337
|
+
stream_type=StreamType.STATUS,
|
|
338
|
+
content=status,
|
|
339
|
+
)
|
|
340
|
+
)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Subprocess runner with streaming support.
|
|
2
|
+
|
|
3
|
+
Provides utilities for running external tools with real-time output streaming.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import queue
|
|
9
|
+
import subprocess
|
|
10
|
+
import threading
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import List, Optional, Union
|
|
13
|
+
|
|
14
|
+
from lucidscan.core.streaming import (
|
|
15
|
+
NullStreamHandler,
|
|
16
|
+
StreamEvent,
|
|
17
|
+
StreamHandler,
|
|
18
|
+
StreamType,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_with_streaming(
|
|
23
|
+
cmd: List[str],
|
|
24
|
+
cwd: Union[str, Path],
|
|
25
|
+
tool_name: str,
|
|
26
|
+
stream_handler: Optional[StreamHandler] = None,
|
|
27
|
+
timeout: int = 120,
|
|
28
|
+
capture_output: bool = True,
|
|
29
|
+
) -> subprocess.CompletedProcess:
|
|
30
|
+
"""Run a command with optional streaming output.
|
|
31
|
+
|
|
32
|
+
This function runs a subprocess and optionally streams its output
|
|
33
|
+
line-by-line to a StreamHandler. When streaming is enabled, output
|
|
34
|
+
is still captured and returned in the CompletedProcess for parsing.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
cmd: Command and arguments to run.
|
|
38
|
+
cwd: Working directory for the command.
|
|
39
|
+
tool_name: Name of the tool (used in stream events).
|
|
40
|
+
stream_handler: Handler for streaming output. If None or NullStreamHandler,
|
|
41
|
+
uses regular subprocess.run for efficiency.
|
|
42
|
+
timeout: Timeout in seconds (default: 120).
|
|
43
|
+
capture_output: Whether to capture output (default: True). If False and
|
|
44
|
+
streaming is enabled, output goes only to the stream handler.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
CompletedProcess with stdout/stderr captured (if capture_output=True).
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
subprocess.TimeoutExpired: If the command times out.
|
|
51
|
+
subprocess.SubprocessError: If the command fails to start.
|
|
52
|
+
"""
|
|
53
|
+
handler = stream_handler or NullStreamHandler()
|
|
54
|
+
cwd_str = str(cwd)
|
|
55
|
+
|
|
56
|
+
# If no streaming requested, use simple subprocess.run for efficiency
|
|
57
|
+
if isinstance(handler, NullStreamHandler):
|
|
58
|
+
return subprocess.run(
|
|
59
|
+
cmd,
|
|
60
|
+
capture_output=capture_output,
|
|
61
|
+
text=True,
|
|
62
|
+
encoding="utf-8",
|
|
63
|
+
errors="replace",
|
|
64
|
+
cwd=cwd_str,
|
|
65
|
+
timeout=timeout,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Streaming mode with Popen
|
|
69
|
+
handler.start_tool(tool_name)
|
|
70
|
+
|
|
71
|
+
stdout_lines: List[str] = []
|
|
72
|
+
stderr_lines: List[str] = []
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
with subprocess.Popen( # nosemgrep: python36-compatibility-Popen1, python36-compatibility-Popen2
|
|
76
|
+
cmd,
|
|
77
|
+
stdout=subprocess.PIPE,
|
|
78
|
+
stderr=subprocess.PIPE,
|
|
79
|
+
text=True,
|
|
80
|
+
encoding="utf-8",
|
|
81
|
+
errors="replace",
|
|
82
|
+
cwd=cwd_str,
|
|
83
|
+
) as proc:
|
|
84
|
+
# Use a queue to collect output from both streams
|
|
85
|
+
output_queue: queue.Queue = queue.Queue()
|
|
86
|
+
|
|
87
|
+
def read_stream(
|
|
88
|
+
stream,
|
|
89
|
+
stream_type: StreamType,
|
|
90
|
+
lines_list: List[str],
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Read lines from a stream and put them in the queue."""
|
|
93
|
+
try:
|
|
94
|
+
for line_num, line in enumerate(stream, 1):
|
|
95
|
+
line = line.rstrip("\n\r")
|
|
96
|
+
lines_list.append(line)
|
|
97
|
+
output_queue.put((stream_type, line, line_num))
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
finally:
|
|
101
|
+
# Signal EOF for this stream
|
|
102
|
+
output_queue.put((stream_type, None, None))
|
|
103
|
+
|
|
104
|
+
# Start reader threads for stdout and stderr
|
|
105
|
+
stdout_thread = threading.Thread(
|
|
106
|
+
target=read_stream,
|
|
107
|
+
args=(proc.stdout, StreamType.STDOUT, stdout_lines),
|
|
108
|
+
daemon=True,
|
|
109
|
+
)
|
|
110
|
+
stderr_thread = threading.Thread(
|
|
111
|
+
target=read_stream,
|
|
112
|
+
args=(proc.stderr, StreamType.STDERR, stderr_lines),
|
|
113
|
+
daemon=True,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
stdout_thread.start()
|
|
117
|
+
stderr_thread.start()
|
|
118
|
+
|
|
119
|
+
# Process output as it arrives
|
|
120
|
+
streams_closed = 0
|
|
121
|
+
while streams_closed < 2:
|
|
122
|
+
try:
|
|
123
|
+
stream_type, line, line_num = output_queue.get(timeout=timeout)
|
|
124
|
+
if line is None:
|
|
125
|
+
streams_closed += 1
|
|
126
|
+
else:
|
|
127
|
+
handler.emit(
|
|
128
|
+
StreamEvent(
|
|
129
|
+
tool_name=tool_name,
|
|
130
|
+
stream_type=stream_type,
|
|
131
|
+
content=line,
|
|
132
|
+
line_number=line_num,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
except queue.Empty:
|
|
136
|
+
# Timeout waiting for output
|
|
137
|
+
proc.kill()
|
|
138
|
+
handler.end_tool(tool_name, False)
|
|
139
|
+
raise subprocess.TimeoutExpired(cmd, timeout)
|
|
140
|
+
|
|
141
|
+
# Wait for reader threads to finish
|
|
142
|
+
stdout_thread.join(timeout=1)
|
|
143
|
+
stderr_thread.join(timeout=1)
|
|
144
|
+
|
|
145
|
+
# Wait for process to complete
|
|
146
|
+
proc.wait()
|
|
147
|
+
|
|
148
|
+
success = proc.returncode == 0
|
|
149
|
+
handler.end_tool(tool_name, success)
|
|
150
|
+
|
|
151
|
+
# Return CompletedProcess with captured output
|
|
152
|
+
return subprocess.CompletedProcess(
|
|
153
|
+
args=cmd,
|
|
154
|
+
returncode=proc.returncode,
|
|
155
|
+
stdout="\n".join(stdout_lines) if capture_output else "",
|
|
156
|
+
stderr="\n".join(stderr_lines) if capture_output else "",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
except subprocess.TimeoutExpired:
|
|
160
|
+
handler.end_tool(tool_name, False)
|
|
161
|
+
raise
|
|
162
|
+
except Exception as e:
|
|
163
|
+
handler.end_tool(tool_name, False)
|
|
164
|
+
raise subprocess.SubprocessError(f"Failed to run {tool_name}: {e}") from e
|