switch-trust-cli 1.7.0__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.
Files changed (157) hide show
  1. switch_trust/__init__.py +0 -0
  2. switch_trust/cli/__init__.py +0 -0
  3. switch_trust/cli/__main__.py +3 -0
  4. switch_trust/cli/builtin_config.json +879 -0
  5. switch_trust/cli/console.py +127 -0
  6. switch_trust/cli/eval_cli.py +905 -0
  7. switch_trust/cli/file_filter.py +172 -0
  8. switch_trust/cli/init_cli.py +156 -0
  9. switch_trust/cli/main.py +384 -0
  10. switch_trust/cli/output_formatters.py +482 -0
  11. switch_trust/cli/rich_observer.py +89 -0
  12. switch_trust/cli/runner.py +274 -0
  13. switch_trust/cli/scan_cli.py +314 -0
  14. switch_trust/cli/telemetry.py +430 -0
  15. switch_trust/cli/utils.py +158 -0
  16. switch_trust/cli/version.py +1 -0
  17. switch_trust/data/detector_prompts/llm01_adversarial.txt +59 -0
  18. switch_trust/data/detector_prompts/llm01_fixed.txt +6 -0
  19. switch_trust/data/detector_prompts/llm02_adversarial.txt +79 -0
  20. switch_trust/data/detector_prompts/llm02_fixed.txt +6 -0
  21. switch_trust/data/detector_prompts/llm05_adversarial.txt +67 -0
  22. switch_trust/data/detector_prompts/llm05_fixed.txt +14 -0
  23. switch_trust/data/detector_prompts/llm06_adversarial.txt +139 -0
  24. switch_trust/data/detector_prompts/llm06_fixed.txt +13 -0
  25. switch_trust/data/detector_prompts/llm07_adversarial.txt +189 -0
  26. switch_trust/data/detector_prompts/llm07_fixed.txt +12 -0
  27. switch_trust/data/detector_prompts/llm09_adversarial.txt +83 -0
  28. switch_trust/data/detector_prompts/llm09_fixed.txt +13 -0
  29. switch_trust/data/llm01_prompt_injection.csv +6708 -0
  30. switch_trust/data/llm02_sensitive_information.csv +1069 -0
  31. switch_trust/data/llm05_unsafe_output.csv +3882 -0
  32. switch_trust/data/llm06_excessive_agency.csv +778 -0
  33. switch_trust/data/llm07_system_prompt_leakage.csv +971 -0
  34. switch_trust/data/llm09_hallucination-goals.csv +32419 -0
  35. switch_trust/data/llm09_hallucination.csv +599 -0
  36. switch_trust/data/pii_leakage.csv +706 -0
  37. switch_trust/data/secret_leakage.csv +1380 -0
  38. switch_trust/eval/__init__.py +0 -0
  39. switch_trust/eval/common/__init__.py +0 -0
  40. switch_trust/eval/common/converter_anthropic.py +172 -0
  41. switch_trust/eval/common/converter_genai.py +171 -0
  42. switch_trust/eval/common/converter_openai.py +198 -0
  43. switch_trust/eval/common/log.py +116 -0
  44. switch_trust/eval/common/reference.py +25 -0
  45. switch_trust/eval/common/schema.py +190 -0
  46. switch_trust/eval/common/utils.py +119 -0
  47. switch_trust/eval/core/__init__.py +0 -0
  48. switch_trust/eval/core/detectors/__init__.py +0 -0
  49. switch_trust/eval/core/detectors/detector.py +25 -0
  50. switch_trust/eval/core/detectors/detector_garak.py +112 -0
  51. switch_trust/eval/core/detectors/detector_model.py +123 -0
  52. switch_trust/eval/core/detectors/detector_model_adversarial.py +91 -0
  53. switch_trust/eval/core/detectors/detector_pii.py +67 -0
  54. switch_trust/eval/core/detectors/detector_secret.py +88 -0
  55. switch_trust/eval/core/detectors/detector_topic_guard.py +63 -0
  56. switch_trust/eval/core/detectors/detector_toxicity.py +78 -0
  57. switch_trust/eval/core/eval/__init__.py +0 -0
  58. switch_trust/eval/core/eval/eval_creator.py +166 -0
  59. switch_trust/eval/core/eval/evaluation.py +112 -0
  60. switch_trust/eval/core/eval/evaluation_adversarial.py +412 -0
  61. switch_trust/eval/core/eval/evaluation_garak_module.py +45 -0
  62. switch_trust/eval/core/eval/evaluation_garak_probe.py +308 -0
  63. switch_trust/eval/core/eval/evaluation_message_list.py +46 -0
  64. switch_trust/eval/core/eval/evaluation_multi.py +276 -0
  65. switch_trust/eval/core/eval/evaluation_single.py +89 -0
  66. switch_trust/eval/core/eval/evaluation_single_prompt.py +62 -0
  67. switch_trust/eval/core/eval/evaluation_topic_guard.py +305 -0
  68. switch_trust/eval/core/eval/metric_conciseness.py +179 -0
  69. switch_trust/eval/core/eval/metric_factual_accuracy.py +312 -0
  70. switch_trust/eval/core/eval/metric_instruction_adherence.py +177 -0
  71. switch_trust/eval/core/eval/metric_tone.py +180 -0
  72. switch_trust/eval/core/eval/metric_toxicity.py +146 -0
  73. switch_trust/eval/core/eval/observer.py +137 -0
  74. switch_trust/eval/core/message/__init__.py +0 -0
  75. switch_trust/eval/core/message/message_collection.py +37 -0
  76. switch_trust/eval/core/message/message_collection_csv.py +44 -0
  77. switch_trust/eval/core/message/message_collection_garak.py +46 -0
  78. switch_trust/eval/core/message/message_collection_memory.py +34 -0
  79. switch_trust/eval/core/models/__init__.py +0 -0
  80. switch_trust/eval/core/models/generator_model.py +421 -0
  81. switch_trust/eval/core/models/model.py +172 -0
  82. switch_trust/eval/core/models/model_adk.py +186 -0
  83. switch_trust/eval/core/models/model_anthropic.py +79 -0
  84. switch_trust/eval/core/models/model_anthropic_agent.py +104 -0
  85. switch_trust/eval/core/models/model_gemini.py +146 -0
  86. switch_trust/eval/core/models/model_generic_http.py +120 -0
  87. switch_trust/eval/core/models/model_http.py +135 -0
  88. switch_trust/eval/core/models/model_huggingface.py +75 -0
  89. switch_trust/eval/core/models/model_langserve.py +96 -0
  90. switch_trust/eval/core/models/model_litellm.py +52 -0
  91. switch_trust/eval/core/models/model_ollama.py +65 -0
  92. switch_trust/eval/core/models/model_openai.py +71 -0
  93. switch_trust/eval/core/models/model_openai_agent.py +92 -0
  94. switch_trust/eval/core/models/model_openai_compatible.py +66 -0
  95. switch_trust/eval/core/models/model_per_endpoint_limiter.py +80 -0
  96. switch_trust/eval/core/models/model_retry.py +204 -0
  97. switch_trust/eval/core/models/model_sync_wrapper.py +38 -0
  98. switch_trust/eval/core/models/model_vertex_agent_runtime.py +315 -0
  99. switch_trust/eval/core/models/response_schema.py +148 -0
  100. switch_trust/eval/core/optional_deps.py +57 -0
  101. switch_trust/eval/db/__init__.py +0 -0
  102. switch_trust/eval/db/base/__init__.py +0 -0
  103. switch_trust/eval/db/base/common_types.py +16 -0
  104. switch_trust/eval/db/base/detectors/__init__.py +0 -0
  105. switch_trust/eval/db/base/detectors/detector_helpers.py +88 -0
  106. switch_trust/eval/db/base/detectors/detector_repository.py +96 -0
  107. switch_trust/eval/db/base/detectors/detector_types.py +77 -0
  108. switch_trust/eval/db/base/eval/__init__.py +0 -0
  109. switch_trust/eval/db/base/eval/eval_helpers.py +186 -0
  110. switch_trust/eval/db/base/eval/eval_repository.py +120 -0
  111. switch_trust/eval/db/base/eval/eval_run.py +142 -0
  112. switch_trust/eval/db/base/eval/eval_types.py +127 -0
  113. switch_trust/eval/db/base/eval/model_eval_repository.py +79 -0
  114. switch_trust/eval/db/base/eval/model_eval_run_repository.py +72 -0
  115. switch_trust/eval/db/base/eval/model_eval_run_result_repository.py +42 -0
  116. switch_trust/eval/db/base/eval/model_eval_run_result_types.py +21 -0
  117. switch_trust/eval/db/base/eval/model_eval_run_types.py +78 -0
  118. switch_trust/eval/db/base/eval/model_eval_types.py +69 -0
  119. switch_trust/eval/db/base/message/__init__.py +0 -0
  120. switch_trust/eval/db/base/message/message_collection_helpers.py +50 -0
  121. switch_trust/eval/db/base/message/message_collection_repository.py +95 -0
  122. switch_trust/eval/db/base/message/message_collection_types.py +87 -0
  123. switch_trust/eval/db/base/models/__init__.py +0 -0
  124. switch_trust/eval/db/base/models/model_helpers.py +253 -0
  125. switch_trust/eval/db/base/models/model_repository.py +104 -0
  126. switch_trust/eval/db/base/models/model_types.py +159 -0
  127. switch_trust/eval/db/json/__init__.py +0 -0
  128. switch_trust/eval/db/json/repository_json.py +559 -0
  129. switch_trust/scan/__init__.py +12 -0
  130. switch_trust/scan/agent_scanner.py +812 -0
  131. switch_trust/scan/config/agent_opengrep_rules.yaml +533 -0
  132. switch_trust/scan/config/agent_taxonomy.json +517 -0
  133. switch_trust/scan/config/agentic_cvss_mapping.yaml +136 -0
  134. switch_trust/scan/config/compliance_mappings.json +110 -0
  135. switch_trust/scan/config/triage_prompt.txt +326 -0
  136. switch_trust/scan/constants.py +15 -0
  137. switch_trust/scan/exception_classification.py +179 -0
  138. switch_trust/scan/llm_provider.py +330 -0
  139. switch_trust/scan/opengrep_resolver.py +12 -0
  140. switch_trust/scan/reasoner.py +602 -0
  141. switch_trust/scan/schema.py +315 -0
  142. switch_trust/scan/scorer.py +372 -0
  143. switch_trust/scan/static_scanner.py +681 -0
  144. switch_trust/scan/taxonomy.py +121 -0
  145. switch_trust/scan/tool_dispatcher.py +773 -0
  146. switch_trust/scan/trace_logger.py +118 -0
  147. switch_trust/scan/trace_logger_file.py +143 -0
  148. switch_trust/scan/trace_logger_log.py +100 -0
  149. switch_trust/scan/triage.py +614 -0
  150. switch_trust/schema.py +79 -0
  151. switch_trust/secret_anonymizer.py +72 -0
  152. switch_trust_cli-1.7.0.dist-info/METADATA +495 -0
  153. switch_trust_cli-1.7.0.dist-info/RECORD +157 -0
  154. switch_trust_cli-1.7.0.dist-info/WHEEL +5 -0
  155. switch_trust_cli-1.7.0.dist-info/entry_points.txt +2 -0
  156. switch_trust_cli-1.7.0.dist-info/licenses/LICENSE +210 -0
  157. switch_trust_cli-1.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,274 @@
1
+ """
2
+ CLI evaluation runner.
3
+
4
+ Runs evaluations without a database, using the JsonRepository for
5
+ configuration and printing progress to the console.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from dataclasses import dataclass, field
12
+
13
+ from dataclasses_json import dataclass_json
14
+ from rich.panel import Panel
15
+ from rich.progress import Progress
16
+ from rich.table import Table
17
+
18
+ from switch_trust.cli.console import CLI_WIDTH, console, score_style, status_style
19
+ from switch_trust.cli.output_formatters import (
20
+ OutputFormat,
21
+ get_eval_output_formatter,
22
+ )
23
+ from switch_trust.cli.rich_observer import RichObserver, pad_description
24
+ from switch_trust.eval.core.eval.evaluation import (
25
+ EvaluationResult,
26
+ EvaluationStatus,
27
+ EvaluationSummary,
28
+ )
29
+ from switch_trust.eval.db.base.eval.model_eval_types import DbModelEvaluation
30
+ from switch_trust.eval.db.json.repository_json import JsonRepository
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ MAX_CONSECUTIVE_RUN_FAILURES = 3
35
+
36
+
37
+ @dataclass_json
38
+ @dataclass
39
+ class CliRunResult:
40
+ model_evaluation_id: str
41
+ model_evaluation_name: str
42
+ model: dict = field(default_factory=dict)
43
+ evaluation: dict = field(default_factory=dict)
44
+ summary: EvaluationSummary | None = None
45
+ results: list[EvaluationResult] = field(
46
+ default_factory=list,
47
+ )
48
+
49
+
50
+ async def run_cli_evaluation(
51
+ model_evaluation: DbModelEvaluation,
52
+ store: JsonRepository,
53
+ concurrency: int,
54
+ progress: Progress,
55
+ desc_width: int,
56
+ ) -> CliRunResult:
57
+ db_eval = store.evaluations.get(
58
+ model_evaluation.evaluation_id,
59
+ )
60
+ db_model = store.models.get(
61
+ model_evaluation.model_id,
62
+ )
63
+
64
+ logger.info("Running evaluation: %s", db_eval.name)
65
+
66
+ desc = pad_description(db_eval.name, desc_width)
67
+ task_id = progress.add_task(desc, total=None)
68
+
69
+ evaluation = store.evaluations.get_evaluation(
70
+ model_evaluation.evaluation_id,
71
+ message_collection_repo=store.message_collections,
72
+ detector_repo=store.detectors,
73
+ )
74
+ model = store.models.get_model(model_evaluation.model_id)
75
+
76
+ observer = RichObserver(progress, task_id)
77
+
78
+ try:
79
+ await evaluation.init()
80
+ total = evaluation.get_summary().total_evaluations
81
+ progress.update(task_id, total=total)
82
+ evaluation.add_observer(observer)
83
+ await evaluation.run(model, concurrency)
84
+ except Exception as e:
85
+ logger.error(
86
+ "Evaluation failed: %s (%s: %s)",
87
+ db_eval.name,
88
+ type(e).__name__,
89
+ e,
90
+ )
91
+ progress.console.print(
92
+ f"[red]Error ({db_eval.name}): {e}[/red]",
93
+ )
94
+ finally:
95
+ evaluation.remove_observer(observer)
96
+ observer.finish(evaluation)
97
+
98
+ summary = evaluation.get_summary()
99
+ results = evaluation.get_results()
100
+
101
+ logger.info(
102
+ "Evaluation finished: %s (score=%s)",
103
+ db_eval.name,
104
+ summary.score if summary else "N/A",
105
+ )
106
+
107
+ return CliRunResult(
108
+ model_evaluation_id=model_evaluation.id,
109
+ model_evaluation_name=model_evaluation.name,
110
+ model={
111
+ "id": db_model.id,
112
+ "name": db_model.name,
113
+ "type": db_model.type.value,
114
+ },
115
+ evaluation={
116
+ "id": db_eval.id,
117
+ "name": db_eval.name,
118
+ "type": db_eval.type.value,
119
+ },
120
+ summary=summary,
121
+ results=results,
122
+ )
123
+
124
+
125
+ def _aggregate_summary(
126
+ results: list[CliRunResult],
127
+ ) -> EvaluationSummary:
128
+ total = 0
129
+ finished = 0
130
+ errors = 0
131
+ max_score = 0.0
132
+ achieved = 0.0
133
+ error_msgs: list[str] = []
134
+ has_error = False
135
+ has_non_error = False
136
+
137
+ for r in results:
138
+ s = r.summary
139
+ if s is None:
140
+ continue
141
+ total += s.total_evaluations
142
+ finished += s.finished_evaluations
143
+ errors += s.error_evaluations
144
+ error_msgs.extend(s.error_messages)
145
+
146
+ if s.status == EvaluationStatus.ERROR:
147
+ has_error = True
148
+ else:
149
+ has_non_error = True
150
+ max_score += s.max_score
151
+ achieved += s.achieved_score
152
+
153
+ if has_error and not has_non_error:
154
+ status = EvaluationStatus.ERROR
155
+ elif total > 0 and finished + errors >= total:
156
+ status = EvaluationStatus.FINISHED
157
+ else:
158
+ status = EvaluationStatus.RUNNING
159
+
160
+ return EvaluationSummary(
161
+ status=status,
162
+ total_evaluations=total,
163
+ finished_evaluations=finished,
164
+ error_evaluations=errors,
165
+ max_score=max_score,
166
+ achieved_score=achieved,
167
+ error_messages=error_msgs,
168
+ )
169
+
170
+
171
+ def _make_summary_panel(
172
+ summary: EvaluationSummary,
173
+ title: str,
174
+ width: int | None = None,
175
+ ) -> Panel:
176
+ grid = Table.grid(padding=(0, 1))
177
+ grid.add_column(style="key", justify="right")
178
+ grid.add_column()
179
+
180
+ st = summary.status.value.upper()
181
+ grid.add_row("Status", f"[{status_style(summary.status.value)}]{st}[/]")
182
+
183
+ if summary.score is not None:
184
+ pct = summary.score * 100
185
+ style = score_style(summary.score)
186
+ grid.add_row("Score", f"[{style}]{summary.score:.2f} ({pct:.0f}%)[/]")
187
+
188
+ completed = summary.finished_evaluations
189
+ total = summary.total_evaluations
190
+ errors = summary.error_evaluations
191
+ grid.add_row(
192
+ "Completed",
193
+ f"{completed} [dim]/ {total}[/dim]",
194
+ )
195
+ if errors:
196
+ grid.add_row("Errors", f"[red]{errors}[/red]")
197
+
198
+ return Panel(grid, title=f"[bold]{title}[/bold]", width=width)
199
+
200
+
201
+ def log_run_summary(results: list[CliRunResult]) -> None:
202
+ for r in results:
203
+ s = r.summary
204
+ name = r.evaluation.get("name", "?")
205
+ if s is None:
206
+ logger.info("Evaluation: %s | status=no_result", name)
207
+ continue
208
+
209
+ score_str = f"{s.score:.2f}" if s.score is not None else "N/A"
210
+ error_str = f" | error={s.error_messages[0]}" if s.error_messages else ""
211
+ logger.info(
212
+ "Evaluation: %s | status=%s | score=%s | completed=%d/%d%s",
213
+ name,
214
+ s.status.value,
215
+ score_str,
216
+ s.finished_evaluations,
217
+ s.total_evaluations,
218
+ error_str,
219
+ )
220
+
221
+ overall = _aggregate_summary(results)
222
+ overall_score = f"{overall.score:.2f}" if overall.score is not None else "N/A"
223
+ logger.info(
224
+ "Overall: status=%s | score=%s | completed=%d/%d | errors=%d",
225
+ overall.status.value,
226
+ overall_score,
227
+ overall.finished_evaluations,
228
+ overall.total_evaluations,
229
+ overall.error_evaluations,
230
+ )
231
+
232
+
233
+ def print_results(results: list[CliRunResult]) -> None:
234
+ with_summary = [r for r in results if r.summary is not None]
235
+ console.print()
236
+
237
+ if with_summary:
238
+ cols_per_row = min(len(with_summary), 2)
239
+ col_width = CLI_WIDTH // cols_per_row
240
+
241
+ renderables = [
242
+ _make_summary_panel(
243
+ r.summary,
244
+ r.evaluation.get("name", "?"),
245
+ width=col_width,
246
+ )
247
+ for r in with_summary
248
+ ]
249
+
250
+ for row_start in range(0, len(renderables), cols_per_row):
251
+ row = renderables[row_start : row_start + cols_per_row]
252
+ grid = Table.grid(padding=0)
253
+ for _ in row:
254
+ grid.add_column()
255
+ grid.add_row(*row)
256
+ console.print(grid)
257
+
258
+ overall = _aggregate_summary(results)
259
+ console.print(
260
+ _make_summary_panel(overall, "Overall", width=CLI_WIDTH),
261
+ )
262
+
263
+
264
+ def write_output(
265
+ runs: list[CliRunResult],
266
+ config_path: str,
267
+ output_path: str,
268
+ fmt: str = "json",
269
+ ) -> None:
270
+
271
+ formatter = get_eval_output_formatter(OutputFormat(fmt))
272
+ content = formatter.format(runs, config_path)
273
+ with open(output_path, "w") as f:
274
+ f.write(content)
@@ -0,0 +1,314 @@
1
+ """
2
+ Subcommands for `switch-trust scan`.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import dataclasses
9
+ import datetime
10
+ import logging
11
+ import os
12
+ from typing import TYPE_CHECKING
13
+
14
+ from rich.panel import Panel
15
+ from rich.table import Table
16
+
17
+ from switch_trust.cli.console import CLI_WIDTH, console, severity_style
18
+ from switch_trust.cli.file_filter import FileType, RelevantFile, find_relevant_files
19
+ from switch_trust.cli.output_formatters import (
20
+ OutputFormat,
21
+ get_scan_output_formatter,
22
+ )
23
+
24
+ if TYPE_CHECKING:
25
+ from switch_trust.scan.schema import ScanReport
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ def register(subparsers: argparse._SubParsersAction) -> None:
31
+ scan_parser = subparsers.add_parser(
32
+ "scan",
33
+ help="Agent security scanning commands",
34
+ )
35
+ scan_parser.add_argument(
36
+ "path",
37
+ help="Path to a file or folder to scan",
38
+ )
39
+ scan_parser.add_argument(
40
+ "--output",
41
+ "-o",
42
+ help="Output file path (default: scan_<timestamp>.<format>)",
43
+ )
44
+ scan_parser.add_argument(
45
+ "--format",
46
+ "-f",
47
+ choices=["json", "sarif"],
48
+ default="json",
49
+ help="Output format (default: json)",
50
+ )
51
+ scan_parser.add_argument(
52
+ "--log",
53
+ help="Log file path (default: switch_trust_<timestamp>.log)",
54
+ )
55
+
56
+
57
+ def write_output(report: ScanReport, output_path: str, fmt: str = "json"):
58
+
59
+ formatter = get_scan_output_formatter(OutputFormat(fmt))
60
+ content = formatter.format(report)
61
+ with open(output_path, "w") as f:
62
+ f.write(content)
63
+
64
+
65
+ def print_report(report: ScanReport) -> None:
66
+ console.print()
67
+
68
+ # ── Summary panel ────────────────────────────────────────────
69
+ grid = Table.grid(padding=(0, 2))
70
+ grid.add_column(style="key", justify="right")
71
+ grid.add_column()
72
+
73
+ grid.add_row("Framework", report.framework_detected or "unknown")
74
+
75
+ meta = report.scan_metadata or {}
76
+ py_files = meta.get("python_files", 0)
77
+ total_files = meta.get("total_files_scanned", 0)
78
+ grid.add_row("Files", f"{py_files} Python, {total_files} total scanned")
79
+
80
+ total = len(report.findings)
81
+ pre_triage = (
82
+ len(report.pre_triage_findings) if report.pre_triage_findings else total
83
+ )
84
+ if pre_triage != total:
85
+ grid.add_row("Findings", f"{total} [dim]({pre_triage} pre-triage)[/dim]")
86
+ else:
87
+ grid.add_row("Findings", str(total))
88
+
89
+ tools = meta.get("tools_used", [])
90
+ if tools:
91
+ grid.add_row("Tools", ", ".join(tools))
92
+
93
+ skipped = meta.get("tools_skipped") or []
94
+ if skipped:
95
+ grid.add_row(
96
+ "Skipped",
97
+ "[bold yellow]"
98
+ + ", ".join(s.get("tool", "unknown") for s in skipped)
99
+ + "[/bold yellow]",
100
+ )
101
+
102
+ if meta.get("ai_summary"):
103
+ grid.add_row("AI summary", meta["ai_summary"])
104
+
105
+ console.print(Panel(grid, title="[bold]Scan Summary[/bold]", width=CLI_WIDTH))
106
+
107
+ # ── Partial-scan warning ─────────────────────────────────────
108
+ # A skipped tool means whole rule families never ran, so the findings
109
+ # below are incomplete. Say so loudly — the log file is not enough.
110
+ if skipped:
111
+ warn = Table.grid(padding=(0, 1))
112
+ warn.add_column()
113
+ plural = "s were" if len(skipped) > 1 else " was"
114
+ warn.add_row(
115
+ f"[bold yellow]This scan is incomplete — "
116
+ f"{len(skipped)} analysis tool{plural} skipped.[/bold yellow]"
117
+ )
118
+ for s in skipped:
119
+ warn.add_row("")
120
+ warn.add_row(
121
+ f"[bold]{s.get('tool', 'unknown')}[/bold] — {s.get('reason', '')}"
122
+ )
123
+ console.print(
124
+ Panel(
125
+ warn,
126
+ title="[bold yellow]Warning[/bold yellow]",
127
+ border_style="yellow",
128
+ width=CLI_WIDTH,
129
+ )
130
+ )
131
+
132
+ # ── Findings table ───────────────────────────────────────────
133
+ if not report.findings:
134
+ console.print("[dim]No findings.[/dim]")
135
+ console.print()
136
+ return
137
+
138
+ table = Table(
139
+ title="Findings",
140
+ show_lines=True,
141
+ width=CLI_WIDTH,
142
+ )
143
+ table.add_column("Sev", max_width=10, ratio=1)
144
+ table.add_column("CVSS", max_width=6, ratio=1)
145
+ table.add_column("Title", style="bold", ratio=5)
146
+ table.add_column("File", ratio=3)
147
+ table.add_column("Source", style="dim", ratio=1)
148
+
149
+ for f in report.findings:
150
+ sev = (
151
+ f.ai_spm_severity
152
+ if isinstance(f, dataclasses.Field) or hasattr(f, "ai_spm_severity")
153
+ else f.get("ai_spm_severity", "")
154
+ )
155
+ cvss = ""
156
+ title = ""
157
+ source = ""
158
+ file_name = ""
159
+
160
+ if isinstance(f, dict):
161
+ sev = f.get("ai_spm_severity", "")
162
+ cvss_scores = f.get("cvss_scores", {})
163
+ cvss = str(cvss_scores.get("base_score", "")) if cvss_scores else ""
164
+ title = f.get("title", "") or f.get("description", "")[:80]
165
+ source = f.get("source", "")
166
+ components = f.get("affected_components", [])
167
+ if components:
168
+ file_name = os.path.basename(components[0].get("name", ""))
169
+ else:
170
+ sev = f.ai_spm_severity
171
+ cvss = str(f.cvss_scores.base_score) if f.cvss_scores else ""
172
+ title = f.title or f.description[:80]
173
+ source = f.source
174
+ if f.affected_components:
175
+ file_name = os.path.basename(f.affected_components[0].name)
176
+
177
+ sev_display = f"[{severity_style(sev)}]{sev}[/]"
178
+ source_label = (
179
+ source.replace("_", " ")
180
+ .replace("ai reasoning", "AI")
181
+ .replace("static ", "")
182
+ )
183
+
184
+ table.add_row(sev_display, cvss, title, file_name, source_label)
185
+
186
+ console.print(table)
187
+
188
+ # ── Category summary ─────────────────────────────────────────
189
+ non_zero = {
190
+ k: v
191
+ for k, v in (report.category_summary or {}).items()
192
+ if (v.get("count", 0) if isinstance(v, dict) else getattr(v, "count", 0)) > 0
193
+ }
194
+
195
+ if non_zero:
196
+ cat_table = Table(
197
+ title="Category Summary",
198
+ show_lines=False,
199
+ width=CLI_WIDTH,
200
+ )
201
+ cat_table.add_column("Category", style="bold", ratio=5)
202
+ cat_table.add_column("Total", justify="right", ratio=1)
203
+ cat_table.add_column("Crit", justify="right", ratio=1)
204
+ cat_table.add_column("High", justify="right", ratio=1)
205
+ cat_table.add_column("Med", justify="right", ratio=1)
206
+ cat_table.add_column("Low", justify="right", ratio=1)
207
+
208
+ for v in non_zero.values():
209
+ if isinstance(v, dict):
210
+ asi_title = v.get("asi_title", "")
211
+ count = v.get("count", 0)
212
+ crit = v.get("critical", 0)
213
+ high = v.get("high", 0)
214
+ med = v.get("medium", 0)
215
+ low = v.get("low", 0)
216
+ else:
217
+ asi_title = getattr(v, "asi_title", "")
218
+ count = getattr(v, "count", 0)
219
+ crit = getattr(v, "critical", 0)
220
+ high = getattr(v, "high", 0)
221
+ med = getattr(v, "medium", 0)
222
+ low = getattr(v, "low", 0)
223
+
224
+ cat_table.add_row(
225
+ asi_title,
226
+ str(count),
227
+ f"[{severity_style('critical')}]{crit}[/]" if crit else str(crit),
228
+ f"[{severity_style('high')}]{high}[/]" if high else str(high),
229
+ f"[{severity_style('medium')}]{med}[/]" if med else str(med),
230
+ str(low),
231
+ )
232
+
233
+ console.print(cat_table)
234
+
235
+ console.print()
236
+
237
+
238
+ def handle_scan(args: argparse.Namespace) -> str:
239
+ # Imported lazily: pulling in the agent scanner loads the Google ADK stack,
240
+ # which is expensive and only needed when a scan actually runs.
241
+ from switch_trust.scan.agent_scanner import ( # noqa: PLC0415 - deferred import cost
242
+ run_core,
243
+ )
244
+ from switch_trust.schema import RepoFile
245
+
246
+ path: str = os.path.abspath(os.path.expanduser(args.path))
247
+
248
+ if not os.path.exists(path):
249
+ # Raise instead of sys.exit so the error flows through main's unified
250
+ # exception handler (record_error -> telemetry, error panel, logging).
251
+ # sys.exit raises SystemExit, a BaseException that bypasses that path.
252
+ raise FileNotFoundError(f"Path does not exist: {path}")
253
+
254
+ files: list[RelevantFile] = find_relevant_files(path)
255
+
256
+ if not files:
257
+ # Raise (rather than sys.exit) so the error reaches main's unified
258
+ # handler and is recorded to telemetry. See the path check above.
259
+ raise ValueError("No scannable targets found")
260
+
261
+ model = os.environ.get("GENERATOR_MODEL")
262
+
263
+ logger.info(
264
+ "Starting agent scan on %s files with model '%s'", len(files), model or "none"
265
+ )
266
+
267
+ python_files: list[RepoFile] = []
268
+ requirements_files: list[RepoFile] = []
269
+ for rf in files:
270
+ with open(rf.path, encoding="utf-8", errors="replace") as fh:
271
+ content = fh.read()
272
+ repo_file = RepoFile(path=rf.path, content=content, size=len(content))
273
+ if rf.type == FileType.REQUIREMENTS:
274
+ requirements_files.append(repo_file)
275
+ elif rf.type == FileType.PYTHON:
276
+ python_files.append(repo_file)
277
+
278
+ logger.info(
279
+ "Files categorized: %d Python, %d requirements",
280
+ len(python_files),
281
+ len(requirements_files),
282
+ )
283
+
284
+ fw_counts: dict[str, int] = {}
285
+ for rf in files:
286
+ if rf.framework:
287
+ fw_counts[rf.framework] = fw_counts.get(rf.framework, 0) + 1
288
+ frameworks = list(fw_counts)
289
+ primary = max(fw_counts, key=fw_counts.get) if fw_counts else "unknown"
290
+
291
+ logger.info("Frameworks detected: %s (primary: %s)", ", ".join(frameworks), primary)
292
+
293
+ # Let a scan failure propagate to main's unified exception handler so it is
294
+ # recorded to telemetry; previously it was swallowed into sys.exit(1).
295
+ report = run_core(
296
+ python_files,
297
+ requirements_files,
298
+ skip_triage=False,
299
+ agentic=True,
300
+ model_string=model,
301
+ repo_name=os.path.basename(path),
302
+ primary_framework=primary,
303
+ frameworks_detected=frameworks,
304
+ total_files_scanned=len(files),
305
+ )
306
+
307
+ print_report(report)
308
+ fmt = getattr(args, "format", "json") or "json"
309
+ ext = fmt if fmt != "json" else "json"
310
+ output_path = args.output or (
311
+ f"scan_{datetime.datetime.now().strftime('%Y%m%dT%H%M%S')}.{ext}"
312
+ )
313
+ write_output(report, output_path, fmt=fmt)
314
+ return output_path