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,482 @@
1
+ """
2
+ Output format abstraction for switch-trust CLI.
3
+
4
+ Provides pluggable formatters for scan and eval results.
5
+ To add a new format: subclass ScanOutputFormatter / EvalOutputFormatter,
6
+ register in the SCAN_OUTPUT_FORMATTERS / EVAL_OUTPUT_FORMATTERS dicts.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import dataclasses
12
+ import json
13
+ import re
14
+ from abc import ABC, abstractmethod
15
+ from enum import Enum
16
+ from typing import Any
17
+
18
+ from switch_trust.cli.version import VERSION
19
+ from switch_trust.eval.common.utils import now_utc, strip_nulls
20
+
21
+
22
+ class OutputFormat(str, Enum):
23
+ JSON = "json"
24
+ SARIF = "sarif"
25
+
26
+
27
+ # ── Scan output formatters ──────────────────────────────────────────────────
28
+
29
+ # Agent discovery (the InventoryParser pass) is out of scope for the CLI, so
30
+ # these ScanReport fields are structurally always 0 / empty on this path —
31
+ # `run_core` is never handed any AgentProfile objects. Emitting them advertises
32
+ # a measurement the CLI never makes and creates a "framework detected but 0
33
+ # agents" contradiction, so drop them from CLI output rather than ship a
34
+ # permanent `agents_found: 0`. The shared schema keeps the fields for the
35
+ # inventory sensor path, which does populate them; the SARIF formatter already
36
+ # omits them.
37
+ _CLI_UNPOPULATED_SCAN_FIELDS = ("agents_found", "agent_profiles")
38
+
39
+
40
+ class ScanOutputFormatter(ABC):
41
+ @abstractmethod
42
+ def format(self, report: Any) -> str: ...
43
+
44
+ @property
45
+ @abstractmethod
46
+ def extension(self) -> str: ...
47
+
48
+
49
+ class JsonScanOutputFormatter(ScanOutputFormatter):
50
+ @property
51
+ def extension(self) -> str:
52
+ return "json"
53
+
54
+ def format(self, report: Any) -> str:
55
+ data = dataclasses.asdict(report)
56
+ for key in _CLI_UNPOPULATED_SCAN_FIELDS:
57
+ data.pop(key, None)
58
+ return json.dumps(data, indent=2)
59
+
60
+
61
+ class SarifScanOutputFormatter(ScanOutputFormatter):
62
+ @property
63
+ def extension(self) -> str:
64
+ return "sarif"
65
+
66
+ def format(self, report: Any) -> str:
67
+ return json.dumps(self._to_sarif(report), indent=2)
68
+
69
+ def _severity_to_level(self, severity: str) -> str:
70
+ s = severity.lower()
71
+ if s in ("critical", "high"):
72
+ return "error"
73
+ if s == "medium":
74
+ return "warning"
75
+ return "note"
76
+
77
+ @staticmethod
78
+ def _normalize_text_paths(text: str) -> str:
79
+ return re.sub(r"(?<![/\w])Users/", "/Users/", text)
80
+
81
+ @staticmethod
82
+ def _normalize_uri(path: str) -> str:
83
+ if not path:
84
+ return path
85
+ if path.startswith("/"):
86
+ return path
87
+ if len(path) > 1 and path[0].isalpha() and path[1] == ":":
88
+ return path
89
+ if path.startswith("file:"):
90
+ return path
91
+ parts = path.split("/")
92
+ if len(parts) >= 2 and parts[0].lower() == "users":
93
+ return "/" + path
94
+ return path
95
+
96
+ def _to_sarif(self, report: Any) -> dict:
97
+ rules: list[dict] = []
98
+ rule_index: dict[str, int] = {}
99
+ results: list[dict] = []
100
+
101
+ for finding in report.findings:
102
+ rule_id = f"{finding.category}/{finding.subcategory}"
103
+ if rule_id not in rule_index:
104
+ rule_index[rule_id] = len(rules)
105
+ rule: dict[str, Any] = {
106
+ "id": rule_id,
107
+ "shortDescription": {"text": finding.subcategory},
108
+ "properties": {"tags": [finding.category]},
109
+ }
110
+ if finding.remediation:
111
+ rule["help"] = {"text": finding.remediation}
112
+ rules.append(rule)
113
+
114
+ # A location's URI must be a file path. Evidence carries one; an
115
+ # affected component's `name` does not — it is a free-text label the
116
+ # reasoning model writes, and for AI findings `path` is never set, so
117
+ # the old `comp.path or comp.name` fallback published things like
118
+ # "request_return", "check_order" and "agent.py:app" as artifact
119
+ # URIs. Which label it chose varied run to run, so the same
120
+ # vulnerability landed under a different location on each scan while
121
+ # the correct file sat in `relatedLocations` all along.
122
+ #
123
+ # Preference order is therefore evidence file, then a component that
124
+ # carries a real `path` (static findings set one). A component name
125
+ # is never used: no location beats a wrong one, and the names are
126
+ # preserved under `properties.affectedComponents`.
127
+ locations = []
128
+ for ev in finding.evidence or []:
129
+ if not ev.file:
130
+ continue
131
+ loc: dict[str, Any] = {
132
+ "physicalLocation": {
133
+ "artifactLocation": {"uri": self._normalize_uri(ev.file)},
134
+ },
135
+ }
136
+ if ev.line:
137
+ loc["physicalLocation"]["region"] = {"startLine": ev.line}
138
+ if ev.column:
139
+ loc["physicalLocation"]["region"]["startColumn"] = ev.column
140
+ locations.append(loc)
141
+
142
+ if not locations:
143
+ for comp in finding.affected_components or []:
144
+ if not comp.path:
145
+ continue
146
+ locations.append(
147
+ {
148
+ "physicalLocation": {
149
+ "artifactLocation": {
150
+ "uri": self._normalize_uri(comp.path)
151
+ },
152
+ },
153
+ }
154
+ )
155
+
156
+ related_locations = []
157
+ for idx, ev in enumerate(finding.evidence or []):
158
+ if not ev.file:
159
+ continue
160
+ rel: dict[str, Any] = {
161
+ "id": idx,
162
+ "physicalLocation": {
163
+ "artifactLocation": {"uri": self._normalize_uri(ev.file)},
164
+ },
165
+ }
166
+ if ev.line:
167
+ rel["physicalLocation"]["region"] = {"startLine": ev.line}
168
+ if ev.code_snippet:
169
+ rel["physicalLocation"]["region"] = rel["physicalLocation"].get(
170
+ "region", {}
171
+ )
172
+ rel["physicalLocation"]["region"]["snippet"] = {
173
+ "text": ev.code_snippet
174
+ }
175
+ if ev.context:
176
+ rel["message"] = {"text": ev.context}
177
+ related_locations.append(rel)
178
+
179
+ message_parts = []
180
+ if finding.impact:
181
+ message_parts.append(f"Impact: {finding.impact}")
182
+ if not message_parts:
183
+ message_parts.append(finding.title or finding.description)
184
+
185
+ result: dict[str, Any] = {
186
+ "ruleId": rule_id,
187
+ "ruleIndex": rule_index[rule_id],
188
+ "level": self._severity_to_level(finding.ai_spm_severity),
189
+ "message": {
190
+ "text": self._normalize_text_paths(" ".join(message_parts))
191
+ },
192
+ }
193
+
194
+ if finding.id:
195
+ result["guid"] = finding.id
196
+
197
+ if locations:
198
+ result["locations"] = locations
199
+
200
+ if related_locations:
201
+ result["relatedLocations"] = related_locations
202
+
203
+ props: dict[str, Any] = {}
204
+ if finding.cvss_scores:
205
+ props["cvssv4"] = {
206
+ "baseScore": finding.cvss_scores.base_score,
207
+ "vector": finding.cvss_scores.vector,
208
+ }
209
+ if finding.source:
210
+ props["source"] = finding.source
211
+ if finding.ai_spm_severity:
212
+ props["severity"] = finding.ai_spm_severity
213
+ # Kept because these no longer appear as location URIs. For an AI
214
+ # finding this is the agent, tool or function the model named, which
215
+ # is useful context even though it is not a path.
216
+ component_names = [
217
+ comp.name for comp in finding.affected_components or [] if comp.name
218
+ ]
219
+ if component_names:
220
+ props["affectedComponents"] = component_names
221
+ if finding.hallucination_flag:
222
+ props["hallucinationFlag"] = True
223
+ if props:
224
+ result["properties"] = props
225
+
226
+ results.append(result)
227
+
228
+ invocation: dict[str, Any] = {"executionSuccessful": True}
229
+ if report.scan_timestamp:
230
+ invocation["startTimeUtc"] = report.scan_timestamp
231
+
232
+ run: dict[str, Any] = {
233
+ "tool": {
234
+ "driver": {
235
+ "name": "switch-trust-scan",
236
+ "version": VERSION,
237
+ "informationUri": "https://github.com/sandbox-quantum/switch-trust",
238
+ "rules": rules,
239
+ },
240
+ },
241
+ "invocations": [invocation],
242
+ "results": results,
243
+ }
244
+
245
+ artifacts = []
246
+ seen_uris: set[str] = set()
247
+ for r in results:
248
+ for loc in r.get("locations", []):
249
+ uri = (
250
+ loc.get("physicalLocation", {})
251
+ .get("artifactLocation", {})
252
+ .get("uri", "")
253
+ )
254
+ if uri and uri not in seen_uris:
255
+ seen_uris.add(uri)
256
+ artifacts.append({"location": {"uri": uri}})
257
+ if artifacts:
258
+ run["artifacts"] = artifacts
259
+
260
+ run_props: dict[str, Any] = {}
261
+ if report.repo_name:
262
+ run_props["repoName"] = report.repo_name
263
+ if report.scan_metadata:
264
+ run_props["scanMetadata"] = report.scan_metadata
265
+ if run_props:
266
+ run["properties"] = run_props
267
+
268
+ return {
269
+ "$schema": (
270
+ "https://docs.oasis-open.org/sarif/sarif/v2.1.0"
271
+ "/errata01/os/schemas/sarif-schema-2.1.0.json"
272
+ ),
273
+ "version": "2.1.0",
274
+ "runs": [run],
275
+ }
276
+
277
+
278
+ # ── Eval output formatters ──────────────────────────────────────────────────
279
+
280
+ EVAL_SCHEMA_VERSION = "2.0"
281
+
282
+
283
+ class EvalOutputFormatter(ABC):
284
+ @abstractmethod
285
+ def format(self, runs: list, config_path: str) -> str: ...
286
+
287
+ @property
288
+ @abstractmethod
289
+ def extension(self) -> str: ...
290
+
291
+
292
+ class JsonEvalOutputFormatter(EvalOutputFormatter):
293
+ @property
294
+ def extension(self) -> str:
295
+ return "json"
296
+
297
+ def format(self, runs: list, config_path: str) -> str:
298
+ output = prepare_eval_output(runs, config_path)
299
+ return json.dumps(output, indent=2, default=str)
300
+
301
+
302
+ class SarifEvalOutputFormatter(EvalOutputFormatter):
303
+ @property
304
+ def extension(self) -> str:
305
+ return "sarif"
306
+
307
+ def format(self, runs: list, config_path: str) -> str:
308
+ return json.dumps(self._to_sarif(runs, config_path), indent=2, default=str)
309
+
310
+ def _score_to_level(self, score: float) -> str:
311
+ if score >= 0.7:
312
+ return "error"
313
+ if score >= 0.3:
314
+ return "warning"
315
+ return "note"
316
+
317
+ def _to_sarif(self, runs: list, config_path: str) -> dict:
318
+ rules: list[dict] = []
319
+ rule_index: dict[str, int] = {}
320
+ results: list[dict] = []
321
+
322
+ for run in runs:
323
+ eval_name = run.evaluation.get("name", "unknown")
324
+ eval_type = run.evaluation.get("type", "unknown")
325
+ model_name = run.model.get("name", "unknown")
326
+
327
+ if eval_name not in rule_index:
328
+ rule_index[eval_name] = len(rules)
329
+ rules.append(
330
+ {
331
+ "id": eval_name,
332
+ "shortDescription": {"text": eval_name},
333
+ "properties": {"tags": ["ai-red-teaming", eval_type]},
334
+ }
335
+ )
336
+
337
+ achieved = 0.0
338
+ max_score = 0.0
339
+ total = 0
340
+ summary_props: dict[str, Any] = {}
341
+ if run.summary:
342
+ s = (
343
+ run.summary
344
+ if isinstance(run.summary, dict)
345
+ else run.summary.to_dict()
346
+ )
347
+ achieved = s.get("achieved_score", 0.0)
348
+ max_score = s.get("max_score", 0.0)
349
+ total = s.get("total_evaluations", 0)
350
+ summary_props = {
351
+ "status": s.get("status", ""),
352
+ "totalEvaluations": total,
353
+ "finishedEvaluations": s.get("finished_evaluations", 0),
354
+ "errorEvaluations": s.get("error_evaluations", 0),
355
+ }
356
+
357
+ if achieved > 0:
358
+ avg_score = achieved / total if total > 0 else 0.0
359
+ results.append(
360
+ {
361
+ "ruleId": eval_name,
362
+ "ruleIndex": rule_index[eval_name],
363
+ "level": self._score_to_level(avg_score),
364
+ "message": {
365
+ "text": (
366
+ f"Model '{model_name}' scored {achieved:.1f}/{max_score:.1f} "
367
+ f"({avg_score:.0%}) on evaluation '{eval_name}' "
368
+ f"across {total} prompts."
369
+ ),
370
+ },
371
+ "locations": [
372
+ {
373
+ "logicalLocations": [
374
+ {
375
+ "name": model_name,
376
+ "kind": "module",
377
+ "fullyQualifiedName": run.model.get(
378
+ "id", model_name
379
+ ),
380
+ }
381
+ ],
382
+ }
383
+ ],
384
+ "properties": {
385
+ "modelEvaluationId": run.model_evaluation_id,
386
+ "modelEvaluationName": run.model_evaluation_name,
387
+ "achievedScore": achieved,
388
+ "maxScore": max_score,
389
+ "averageScore": round(avg_score, 4),
390
+ "totalPrompts": total,
391
+ **summary_props,
392
+ },
393
+ }
394
+ )
395
+
396
+ return {
397
+ "$schema": (
398
+ "https://docs.oasis-open.org/sarif/sarif/v2.1.0"
399
+ "/errata01/os/schemas/sarif-schema-2.1.0.json"
400
+ ),
401
+ "version": "2.1.0",
402
+ "runs": [
403
+ {
404
+ "tool": {
405
+ "driver": {
406
+ "name": "switch-trust-eval",
407
+ "version": VERSION,
408
+ "informationUri": "https://github.com/sandbox-quantum/switch-trust",
409
+ "rules": rules,
410
+ },
411
+ },
412
+ "invocations": [{"executionSuccessful": True}],
413
+ "results": results,
414
+ "properties": {
415
+ "configFile": config_path,
416
+ },
417
+ }
418
+ ],
419
+ }
420
+
421
+
422
+ # ── Helpers ──────────────────────────────────────────────────────────────────
423
+
424
+
425
+ def _strip_session(session: dict | None) -> dict | None:
426
+ if session is None:
427
+ return None
428
+ session.pop("id", None)
429
+ session.pop("timestamp", None)
430
+ session.pop("metadata", None)
431
+ for msg in session.get("messages", []):
432
+ msg.pop("id", None)
433
+ msg.pop("timestamp", None)
434
+ msg.pop("metadata", None)
435
+ return session
436
+
437
+
438
+ def _strip_result(result: dict) -> dict:
439
+ result.pop("status", None)
440
+ result["session"] = _strip_session(result.get("session"))
441
+ return result
442
+
443
+
444
+ def prepare_eval_output(runs: list, config_path: str) -> dict:
445
+ # Deliberately lazy: runner imports get_eval_output_formatter from this
446
+ # module at import time, so hoisting this would close the cycle.
447
+ from switch_trust.cli.runner import _aggregate_summary # noqa: PLC0415
448
+
449
+ overall = _aggregate_summary(runs)
450
+ raw: dict[str, Any] = {
451
+ "schema_version": EVAL_SCHEMA_VERSION,
452
+ "config_file": config_path,
453
+ "timestamp": now_utc().isoformat(),
454
+ "summary": overall.to_dict(),
455
+ "runs": [r.to_dict() for r in runs],
456
+ }
457
+ for run in raw["runs"]:
458
+ for result in run.get("results", []):
459
+ _strip_result(result)
460
+ return strip_nulls(raw)
461
+
462
+
463
+ # ── Registry ─────────────────────────────────────────────────────────────────
464
+
465
+
466
+ SCAN_OUTPUT_FORMATTERS: dict[OutputFormat, ScanOutputFormatter] = {
467
+ OutputFormat.JSON: JsonScanOutputFormatter(),
468
+ OutputFormat.SARIF: SarifScanOutputFormatter(),
469
+ }
470
+
471
+ EVAL_OUTPUT_FORMATTERS: dict[OutputFormat, EvalOutputFormatter] = {
472
+ OutputFormat.JSON: JsonEvalOutputFormatter(),
473
+ OutputFormat.SARIF: SarifEvalOutputFormatter(),
474
+ }
475
+
476
+
477
+ def get_scan_output_formatter(fmt: OutputFormat) -> ScanOutputFormatter:
478
+ return SCAN_OUTPUT_FORMATTERS[fmt]
479
+
480
+
481
+ def get_eval_output_formatter(fmt: OutputFormat) -> EvalOutputFormatter:
482
+ return EVAL_OUTPUT_FORMATTERS[fmt]
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ from rich.progress import (
4
+ BarColumn,
5
+ Progress,
6
+ ProgressColumn,
7
+ TaskID,
8
+ TextColumn,
9
+ TimeElapsedColumn,
10
+ )
11
+ from rich.spinner import Spinner
12
+ from rich.text import Text
13
+
14
+ from switch_trust.cli.console import console, score_style
15
+ from switch_trust.eval.core.eval.evaluation import Evaluation
16
+
17
+
18
+ class StatusColumn(ProgressColumn):
19
+ """Spinner while running, green check when done, red X on error."""
20
+
21
+ def __init__(self) -> None:
22
+ super().__init__()
23
+ self._spinner = Spinner("dots")
24
+
25
+ def render(self, task: object) -> Text:
26
+ if task.finished:
27
+ status = task.fields.get("final_status", "finished")
28
+ if status == "error":
29
+ return Text("✗", style="bold red")
30
+ return Text("✔", style="bold green")
31
+ return self._spinner.render(task.get_time())
32
+
33
+
34
+ class ProgressOrScoreColumn(ProgressColumn):
35
+ """Shows M/N while running, score percentage when finished."""
36
+
37
+ def render(self, task: object) -> Text:
38
+ if task.finished:
39
+ score = task.fields.get("score")
40
+ if score is not None:
41
+ pct = score * 100
42
+ return Text(f"{pct:.0f}%", style=score_style(score))
43
+ return Text("—", style="dim")
44
+ completed = int(task.completed)
45
+ if task.total is not None:
46
+ return Text(f"{completed}/{int(task.total)}")
47
+ return Text(f"{completed}/?")
48
+
49
+
50
+ def create_progress() -> Progress:
51
+ return Progress(
52
+ StatusColumn(),
53
+ TextColumn("{task.description}"),
54
+ BarColumn(),
55
+ ProgressOrScoreColumn(),
56
+ TextColumn("[dim]|[/dim]"),
57
+ TimeElapsedColumn(),
58
+ console=console,
59
+ )
60
+
61
+
62
+ def pad_description(name: str, width: int) -> str:
63
+ if len(name) > width:
64
+ return name[: width - 1] + "…"
65
+ return name.ljust(width)
66
+
67
+
68
+ class RichObserver:
69
+ """Evaluation observer that updates a task in a shared Progress."""
70
+
71
+ def __init__(self, progress: Progress, task_id: TaskID):
72
+ self._progress = progress
73
+ self._task_id = task_id
74
+
75
+ def __call__(self, evaluation: Evaluation) -> None:
76
+ summary = evaluation.get_summary()
77
+ done = summary.finished_evaluations + summary.error_evaluations
78
+ self._progress.update(self._task_id, completed=done)
79
+
80
+ def finish(self, evaluation: Evaluation) -> None:
81
+ summary = evaluation.get_summary()
82
+ total = summary.total_evaluations or 1
83
+ self._progress.update(
84
+ self._task_id,
85
+ completed=total,
86
+ total=total,
87
+ final_status=summary.status.value,
88
+ score=summary.score,
89
+ )