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,905 @@
1
+ """
2
+ Subcommands for `switch-trust eval`.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import sys
9
+ from datetime import datetime
10
+ from typing import TYPE_CHECKING
11
+
12
+ from rich.panel import Panel
13
+ from rich.table import Table
14
+ from rich.text import Text
15
+
16
+ from switch_trust.cli.console import CLI_WIDTH, console
17
+ from switch_trust.cli.utils import get_switch_trust_config_path
18
+ from switch_trust.eval.core.eval.evaluation import EvaluationStatus
19
+ from switch_trust.eval.db.base.eval.model_eval_types import DbModelEvaluation
20
+
21
+ if TYPE_CHECKING:
22
+ from switch_trust.eval.db.base.eval.model_eval_types import DbModelEvaluation
23
+ from switch_trust.eval.db.json.repository_json import JsonRepository
24
+
25
+ _DEFAULT_CONFIG = str(get_switch_trust_config_path())
26
+
27
+ _DEFAULT_LOG = f"switch_trust_{datetime.now().strftime('%Y%m%dT%H%M%S')}.log"
28
+
29
+ _config_parent = argparse.ArgumentParser(add_help=False)
30
+ _config_parent.add_argument(
31
+ "--config",
32
+ default=_DEFAULT_CONFIG,
33
+ help=f"Path to JSON config file (default: {_DEFAULT_CONFIG})",
34
+ )
35
+ _config_parent.add_argument(
36
+ "--log",
37
+ help=f"Log file path (default: {_DEFAULT_LOG})",
38
+ )
39
+
40
+
41
+ _tag_parent = argparse.ArgumentParser(add_help=False)
42
+ _tag_parent.add_argument(
43
+ "--tag",
44
+ action="append",
45
+ default=[],
46
+ metavar="KEY=VALUE",
47
+ help="Filter by tag (repeatable)",
48
+ )
49
+
50
+
51
+ def _parse_tags(raw: list[str]) -> dict[str, str]:
52
+ tags: dict[str, str] = {}
53
+ for item in raw:
54
+ if "=" not in item:
55
+ console.print(
56
+ f"[red]Invalid tag format: {item!r} (expected KEY=VALUE)[/red]",
57
+ )
58
+ sys.exit(1)
59
+ k, v = item.split("=", 1)
60
+ tags[k] = v
61
+ return tags
62
+
63
+
64
+ def _matches_tags(
65
+ entity_tags: dict[str, str],
66
+ filter_tags: dict[str, str],
67
+ ) -> bool:
68
+ return all(entity_tags.get(k) == v for k, v in filter_tags.items())
69
+
70
+
71
+ def register(subparsers: argparse._SubParsersAction) -> None:
72
+ eval_parser = subparsers.add_parser(
73
+ "eval",
74
+ help="Evaluation commands",
75
+ )
76
+ eval_sub = eval_parser.add_subparsers(dest="eval_cmd")
77
+ eval_sub.required = True
78
+
79
+ _register_models(eval_sub)
80
+ _register_evaluations(eval_sub)
81
+ _register_model_evaluations(eval_sub)
82
+ _register_run(eval_sub)
83
+
84
+
85
+ # -- models -------------------------------------------------------
86
+
87
+
88
+ def _register_models(
89
+ subparsers: argparse._SubParsersAction,
90
+ ) -> None:
91
+ models_parser = subparsers.add_parser(
92
+ "models",
93
+ help="Model commands",
94
+ )
95
+ models_sub = models_parser.add_subparsers(
96
+ dest="models_cmd",
97
+ )
98
+ models_sub.required = True
99
+
100
+ models_sub.add_parser(
101
+ "list",
102
+ help="List all models",
103
+ parents=[_config_parent, _tag_parent],
104
+ )
105
+
106
+ show_parser = models_sub.add_parser(
107
+ "show",
108
+ help="Show model details",
109
+ parents=[_config_parent],
110
+ )
111
+ show_parser.add_argument("id", help="Model ID")
112
+
113
+
114
+ def handle_models(
115
+ args: argparse.Namespace,
116
+ store: JsonRepository,
117
+ ) -> None:
118
+ if args.models_cmd == "list":
119
+ _models_list(store, _parse_tags(args.tag))
120
+ elif args.models_cmd == "show":
121
+ _models_show(store, args.id)
122
+
123
+
124
+ def _models_list(
125
+ store: JsonRepository,
126
+ filter_tags: dict[str, str] | None = None,
127
+ ) -> None:
128
+ models = store.models.list()
129
+ if filter_tags:
130
+ models = [m for m in models if _matches_tags(m.tags, filter_tags)]
131
+ if not models:
132
+ console.print("[dim]No models configured.[/dim]")
133
+ return
134
+ table = Table(title="Models", show_lines=False, width=CLI_WIDTH)
135
+ table.add_column("ID", style="dim", ratio=2, overflow="ellipsis", no_wrap=True)
136
+ table.add_column("Name", style="bold", ratio=3, overflow="ellipsis", no_wrap=True)
137
+ table.add_column("Type", ratio=1, overflow="ellipsis", no_wrap=True)
138
+ for m in models:
139
+ table.add_row(m.id, m.name, m.type.value)
140
+ console.print(table)
141
+
142
+
143
+ def _models_show(store: JsonRepository, model_id: str) -> None:
144
+ model_id = _resolve_id(
145
+ model_id,
146
+ [m.id for m in store.models.list()],
147
+ )
148
+ model = store.models.get(model_id)
149
+
150
+ detail = Table.grid(padding=(0, 2))
151
+ detail.add_column(style="key", justify="right")
152
+ detail.add_column()
153
+ detail.add_row("ID", model.id)
154
+ detail.add_row("Name", model.name)
155
+ detail.add_row("Type", model.type.value)
156
+ detail.add_row("Model name", model.model_name)
157
+ if model.host:
158
+ detail.add_row("Host", model.host)
159
+ if model.endpoint:
160
+ detail.add_row("Endpoint", model.endpoint)
161
+ if model.tags:
162
+ detail.add_row("Tags", _fmt_tags(model.tags))
163
+ if model.description:
164
+ detail.add_row("Description", model.description)
165
+
166
+ console.print(Panel(detail, title=f"[bold]{model.name}[/bold]", expand=False))
167
+
168
+ me_view = store.model_evaluations.list_by_model(
169
+ model.id,
170
+ limit=100,
171
+ )
172
+ if me_view.items:
173
+ table = Table(
174
+ title=f"Connected evaluations ({me_view.total})",
175
+ width=CLI_WIDTH,
176
+ )
177
+ table.add_column("ID", style="dim", ratio=2, overflow="ellipsis", no_wrap=True)
178
+ table.add_column(
179
+ "Name", style="bold", ratio=3, overflow="ellipsis", no_wrap=True
180
+ )
181
+ table.add_column("Evaluation", ratio=3, overflow="ellipsis", no_wrap=True)
182
+ for item in me_view.items:
183
+ eval_name = item.evaluation_ref.name if item.evaluation_ref else "?"
184
+ eval_cell = Text(eval_name)
185
+ eval_cell.append("\n")
186
+ eval_cell.append(item.config.evaluation_id, style="dim")
187
+ table.add_row(item.config.id, item.config.name, eval_cell)
188
+ console.print(table)
189
+
190
+
191
+ # -- evaluations ---------------------------------------------------
192
+
193
+
194
+ def _register_evaluations(
195
+ subparsers: argparse._SubParsersAction,
196
+ ) -> None:
197
+ evals_parser = subparsers.add_parser(
198
+ "evaluations",
199
+ help="Evaluation commands",
200
+ )
201
+ evals_sub = evals_parser.add_subparsers(
202
+ dest="evals_cmd",
203
+ )
204
+ evals_sub.required = True
205
+
206
+ evals_sub.add_parser(
207
+ "list",
208
+ help="List all evaluations",
209
+ parents=[_config_parent, _tag_parent],
210
+ )
211
+
212
+ show_parser = evals_sub.add_parser(
213
+ "show",
214
+ help="Show evaluation details",
215
+ parents=[_config_parent],
216
+ )
217
+ show_parser.add_argument("id", help="Evaluation ID")
218
+
219
+
220
+ def handle_evaluations(
221
+ args: argparse.Namespace,
222
+ store: JsonRepository,
223
+ ) -> None:
224
+ if args.evals_cmd == "list":
225
+ _evaluations_list(store, _parse_tags(args.tag))
226
+ elif args.evals_cmd == "show":
227
+ _evaluations_show(store, args.id)
228
+
229
+
230
+ def _evaluations_list(
231
+ store: JsonRepository,
232
+ filter_tags: dict[str, str] | None = None,
233
+ ) -> None:
234
+ evaluations = store.evaluations.list()
235
+ if filter_tags:
236
+ evaluations = [e for e in evaluations if _matches_tags(e.tags, filter_tags)]
237
+ if not evaluations:
238
+ console.print("[dim]No evaluations configured.[/dim]")
239
+ return
240
+ table = Table(title="Evaluations", show_lines=False, width=CLI_WIDTH)
241
+ table.add_column("ID", style="dim", ratio=2)
242
+ table.add_column("Name", style="bold", ratio=3, overflow="ellipsis", no_wrap=True)
243
+ table.add_column("Type", ratio=2, overflow="ellipsis", no_wrap=True)
244
+ for e in evaluations:
245
+ table.add_row(e.id, e.name, e.type.value)
246
+ console.print(table)
247
+
248
+
249
+ def _evaluations_show(
250
+ store: JsonRepository,
251
+ eval_id: str,
252
+ ) -> None:
253
+ eval_id = _resolve_id(
254
+ eval_id,
255
+ [e.id for e in store.evaluations.list()],
256
+ )
257
+ evaluation = store.evaluations.get(eval_id)
258
+
259
+ detail = Table.grid(padding=(0, 2))
260
+ detail.add_column(style="key", justify="right")
261
+ detail.add_column()
262
+ detail.add_row("ID", evaluation.id)
263
+ detail.add_row("Name", evaluation.name)
264
+ detail.add_row("Type", evaluation.type.value)
265
+ detail.add_row("Approach", evaluation.approach.value)
266
+ if evaluation.tags:
267
+ detail.add_row("Tags", _fmt_tags(evaluation.tags))
268
+ if evaluation.description:
269
+ detail.add_row("Description", evaluation.description)
270
+ if evaluation.adversarial_goals:
271
+ prompts = evaluation.num_prompts or 5
272
+ turns = evaluation.max_turns or 5
273
+ detail.add_row("Prompts", str(prompts))
274
+ detail.add_row("Max turns", str(turns))
275
+
276
+ console.print(Panel(detail, title=f"[bold]{evaluation.name}[/bold]", expand=False))
277
+
278
+ me_view = store.model_evaluations.list_by_evaluation(
279
+ evaluation.id,
280
+ limit=100,
281
+ )
282
+ if me_view.items:
283
+ table = Table(
284
+ title=f"Connected models ({me_view.total})",
285
+ width=CLI_WIDTH,
286
+ )
287
+ table.add_column("ID", style="dim", ratio=2, overflow="ellipsis", no_wrap=True)
288
+ table.add_column(
289
+ "Name", style="bold", ratio=3, overflow="ellipsis", no_wrap=True
290
+ )
291
+ table.add_column("Model", ratio=3, overflow="ellipsis", no_wrap=True)
292
+ for item in me_view.items:
293
+ model_name = item.model_ref.name if item.model_ref else "?"
294
+ model_cell = Text(model_name)
295
+ model_cell.append("\n")
296
+ model_cell.append(item.config.model_id, style="dim")
297
+ table.add_row(item.config.id, item.config.name, model_cell)
298
+ console.print(table)
299
+
300
+
301
+ # -- model-evaluations --------------------------------------------
302
+
303
+ _model_selector_parent = argparse.ArgumentParser(
304
+ add_help=False,
305
+ )
306
+ _model_selector_parent.add_argument(
307
+ "--model",
308
+ action="append",
309
+ default=[],
310
+ metavar="ID",
311
+ help="Model ID (repeatable)",
312
+ )
313
+ _model_selector_parent.add_argument(
314
+ "--model-tag",
315
+ action="append",
316
+ default=[],
317
+ metavar="KEY=VALUE",
318
+ help="Match models by tag (repeatable)",
319
+ )
320
+
321
+ _eval_selector_parent = argparse.ArgumentParser(
322
+ add_help=False,
323
+ )
324
+ _eval_selector_parent.add_argument(
325
+ "--eval",
326
+ action="append",
327
+ default=[],
328
+ metavar="ID",
329
+ help="Evaluation ID (repeatable)",
330
+ )
331
+ _eval_selector_parent.add_argument(
332
+ "--eval-tag",
333
+ action="append",
334
+ default=[],
335
+ metavar="KEY=VALUE",
336
+ help="Match evaluations by tag (repeatable)",
337
+ )
338
+
339
+
340
+ def _register_model_evaluations(
341
+ subparsers: argparse._SubParsersAction,
342
+ ) -> None:
343
+ me_parser = subparsers.add_parser(
344
+ "model-evaluations",
345
+ help="Model-evaluation assignment commands",
346
+ )
347
+ me_sub = me_parser.add_subparsers(dest="me_cmd")
348
+ me_sub.required = True
349
+ me_sub.add_parser(
350
+ "list",
351
+ help="List all model-evaluation assignments",
352
+ parents=[
353
+ _config_parent,
354
+ _tag_parent,
355
+ _model_selector_parent,
356
+ _eval_selector_parent,
357
+ ],
358
+ )
359
+ me_sub.add_parser(
360
+ "attach",
361
+ help="Assign evaluations to models",
362
+ parents=[
363
+ _config_parent,
364
+ _model_selector_parent,
365
+ _eval_selector_parent,
366
+ ],
367
+ )
368
+ me_sub.add_parser(
369
+ "detach",
370
+ help="Remove evaluation assignments from models",
371
+ parents=[
372
+ _config_parent,
373
+ _model_selector_parent,
374
+ _eval_selector_parent,
375
+ ],
376
+ )
377
+
378
+
379
+ def handle_model_evaluations(
380
+ args: argparse.Namespace,
381
+ store: JsonRepository,
382
+ user_store: JsonRepository,
383
+ ) -> None:
384
+ if args.me_cmd == "list":
385
+ _model_evaluations_list(
386
+ store,
387
+ filter_tags=_parse_tags(args.tag),
388
+ models=_resolve_models(args, store),
389
+ evaluations=_resolve_evaluations(args, store),
390
+ )
391
+ elif args.me_cmd == "attach":
392
+ _model_evaluations_attach(args, store, user_store)
393
+ elif args.me_cmd == "detach":
394
+ _model_evaluations_detach(args, store, user_store)
395
+
396
+
397
+ def _model_evaluations_list(
398
+ store: JsonRepository,
399
+ filter_tags: dict[str, str] | None = None,
400
+ models: list | None = None,
401
+ evaluations: list | None = None,
402
+ ) -> None:
403
+ items = store.model_evaluations.list_all()
404
+ if filter_tags:
405
+ items = [me for me in items if _matches_tags(me.tags, filter_tags)]
406
+ if models:
407
+ model_ids = {m.id for m in models}
408
+ items = [me for me in items if me.model_id in model_ids]
409
+ if evaluations:
410
+ eval_ids = {e.id for e in evaluations}
411
+ items = [me for me in items if me.evaluation_id in eval_ids]
412
+ if not items:
413
+ console.print("[dim]No model-evaluation assignments configured.[/dim]")
414
+ return
415
+
416
+ table = Table(
417
+ title="Model-Evaluation Assignments",
418
+ show_lines=False,
419
+ width=CLI_WIDTH,
420
+ )
421
+ table.add_column("ID", style="dim", ratio=2, overflow="ellipsis", no_wrap=True)
422
+ table.add_column("Name", style="bold", ratio=2, overflow="ellipsis", no_wrap=True)
423
+ table.add_column("Model", ratio=2, overflow="ellipsis", no_wrap=True)
424
+ table.add_column("Evaluation", ratio=3, overflow="ellipsis", no_wrap=True)
425
+
426
+ for me in items:
427
+ model_name = "?"
428
+ eval_name = "?"
429
+ try:
430
+ model_name = store.models.get(
431
+ me.model_id,
432
+ ).name
433
+ except KeyError:
434
+ pass
435
+ try:
436
+ eval_name = store.evaluations.get(
437
+ me.evaluation_id,
438
+ ).name
439
+ except KeyError:
440
+ pass
441
+
442
+ model_cell = Text(model_name)
443
+ model_cell.append("\n")
444
+ model_cell.append(me.model_id, style="dim")
445
+
446
+ eval_cell = Text(eval_name)
447
+ eval_cell.append("\n")
448
+ eval_cell.append(me.evaluation_id, style="dim")
449
+
450
+ table.add_row(me.id, me.name, model_cell, eval_cell)
451
+
452
+ console.print(table)
453
+
454
+
455
+ def _resolve_models(
456
+ args: argparse.Namespace,
457
+ store: JsonRepository,
458
+ ) -> list:
459
+ model_ids = args.model
460
+ model_tags = _parse_tags(args.model_tag)
461
+
462
+ if not model_ids and not model_tags:
463
+ return []
464
+
465
+ models = []
466
+ seen_ids: set[str] = set()
467
+
468
+ for mid in model_ids:
469
+ resolved = _resolve_id(
470
+ mid,
471
+ [m.id for m in store.models.list()],
472
+ )
473
+ try:
474
+ m = store.models.get(resolved)
475
+ if m.id not in seen_ids:
476
+ models.append(m)
477
+ seen_ids.add(m.id)
478
+ except KeyError:
479
+ raise ValueError(f"model {mid!r} not found") from None
480
+
481
+ if model_tags:
482
+ for m in store.models.list():
483
+ if m.id not in seen_ids and _matches_tags(m.tags, model_tags):
484
+ models.append(m)
485
+ seen_ids.add(m.id)
486
+
487
+ return models
488
+
489
+
490
+ def _resolve_evaluations(
491
+ args: argparse.Namespace,
492
+ store: JsonRepository,
493
+ ) -> list:
494
+ eval_ids = getattr(args, "eval", [])
495
+ eval_tags = _parse_tags(args.eval_tag)
496
+
497
+ if not eval_ids and not eval_tags:
498
+ return []
499
+
500
+ evaluations = []
501
+ seen_ids: set[str] = set()
502
+
503
+ for eid in eval_ids:
504
+ resolved = _resolve_id(
505
+ eid,
506
+ [e.id for e in store.evaluations.list()],
507
+ )
508
+ try:
509
+ e = store.evaluations.get(resolved)
510
+ if e.id not in seen_ids:
511
+ evaluations.append(e)
512
+ seen_ids.add(e.id)
513
+ except KeyError:
514
+ raise ValueError(f"evaluation {eid!r} not found") from None
515
+
516
+ if eval_tags:
517
+ for e in store.evaluations.list():
518
+ if e.id not in seen_ids and _matches_tags(e.tags, eval_tags):
519
+ evaluations.append(e)
520
+ seen_ids.add(e.id)
521
+
522
+ return evaluations
523
+
524
+
525
+ def _model_evaluations_attach(
526
+ args: argparse.Namespace,
527
+ store: JsonRepository,
528
+ user_store: JsonRepository,
529
+ ) -> None:
530
+
531
+ models = _resolve_models(args, store)
532
+ evaluations = _resolve_evaluations(args, store)
533
+
534
+ if not models:
535
+ raise ValueError("no models matched — specify --model or --model-tag")
536
+ if not evaluations:
537
+ raise ValueError("no evaluations matched — specify --eval or --eval-tag")
538
+
539
+ added: list[DbModelEvaluation] = []
540
+ skipped = 0
541
+
542
+ for m in models:
543
+ for e in evaluations:
544
+ me = DbModelEvaluation(
545
+ model_id=m.id,
546
+ evaluation_id=e.id,
547
+ name=f"{m.name} / {e.name}",
548
+ )
549
+ if user_store.model_evaluations.add(me):
550
+ added.append(me)
551
+ else:
552
+ skipped += 1
553
+
554
+ if added:
555
+ user_store.save()
556
+ table = Table(
557
+ title=f"Attached ({len(added)} new)",
558
+ show_lines=False,
559
+ width=CLI_WIDTH,
560
+ )
561
+ table.add_column("Model", ratio=2)
562
+ table.add_column("Evaluation", ratio=3)
563
+ for me in added:
564
+ table.add_row(
565
+ _name_for(store, "model", me.model_id),
566
+ _name_for(store, "eval", me.evaluation_id),
567
+ )
568
+ console.print(table)
569
+
570
+ if skipped:
571
+ console.print(
572
+ f"[dim]{skipped} already attached, skipped.[/dim]",
573
+ )
574
+ if not added and not skipped:
575
+ console.print("[dim]Nothing to attach.[/dim]")
576
+
577
+
578
+ def _model_evaluations_detach(
579
+ args: argparse.Namespace,
580
+ store: JsonRepository,
581
+ user_store: JsonRepository,
582
+ ) -> None:
583
+ models = _resolve_models(args, store)
584
+ evaluations = _resolve_evaluations(args, store)
585
+
586
+ if not models and not evaluations:
587
+ raise ValueError("specify at least --model/--model-tag or --eval/--eval-tag")
588
+
589
+ model_ids = {m.id for m in models} if models else None
590
+ eval_ids = {e.id for e in evaluations} if evaluations else None
591
+
592
+ removed: list[DbModelEvaluation] = []
593
+
594
+ if model_ids and eval_ids:
595
+ for mid in model_ids:
596
+ for eid in eval_ids:
597
+ removed.extend(
598
+ user_store.model_evaluations.remove(
599
+ model_id=mid,
600
+ evaluation_id=eid,
601
+ ),
602
+ )
603
+ elif model_ids:
604
+ for mid in model_ids:
605
+ removed.extend(
606
+ user_store.model_evaluations.remove(
607
+ model_id=mid,
608
+ ),
609
+ )
610
+ elif eval_ids:
611
+ for eid in eval_ids:
612
+ removed.extend(
613
+ user_store.model_evaluations.remove(
614
+ evaluation_id=eid,
615
+ ),
616
+ )
617
+
618
+ if removed:
619
+ user_store.save()
620
+ table = Table(
621
+ title=f"Detached ({len(removed)} removed)",
622
+ show_lines=False,
623
+ width=CLI_WIDTH,
624
+ )
625
+ table.add_column("Model", ratio=2)
626
+ table.add_column("Evaluation", ratio=3)
627
+ for me in removed:
628
+ table.add_row(
629
+ _name_for(store, "model", me.model_id),
630
+ _name_for(store, "eval", me.evaluation_id),
631
+ )
632
+ console.print(table)
633
+ else:
634
+ console.print("[dim]No matching assignments found.[/dim]")
635
+
636
+
637
+ def _name_for(
638
+ store: JsonRepository,
639
+ kind: str,
640
+ id: str,
641
+ ) -> str:
642
+ try:
643
+ if kind == "model":
644
+ return store.models.get(id).name
645
+ return store.evaluations.get(id).name
646
+ except KeyError:
647
+ return id
648
+
649
+
650
+ # -- run -----------------------------------------------------------
651
+
652
+
653
+ def _register_run(
654
+ subparsers: argparse._SubParsersAction,
655
+ ) -> None:
656
+ run_parser = subparsers.add_parser(
657
+ "run",
658
+ help="Run evaluations",
659
+ parents=[_config_parent],
660
+ )
661
+ run_parser.add_argument(
662
+ "model_evaluation_id",
663
+ nargs="?",
664
+ help="Model-evaluation ID to run",
665
+ )
666
+ run_parser.add_argument(
667
+ "--model",
668
+ help="Run all evaluations for this model ID",
669
+ )
670
+ run_parser.add_argument(
671
+ "--output",
672
+ "-o",
673
+ help="Output file path (default: eval_<timestamp>.<format>)",
674
+ )
675
+ run_parser.add_argument(
676
+ "--format",
677
+ "-f",
678
+ choices=["json", "sarif"],
679
+ default="json",
680
+ help="Output format (default: json)",
681
+ )
682
+ run_parser.add_argument(
683
+ "--concurrency",
684
+ "-c",
685
+ type=int,
686
+ default=20,
687
+ help="Max concurrent tasks (default: 20)",
688
+ )
689
+ run_parser.add_argument(
690
+ "--model-tag",
691
+ action="append",
692
+ default=[],
693
+ metavar="KEY=VALUE",
694
+ help="Filter by model tag (repeatable)",
695
+ )
696
+ run_parser.add_argument(
697
+ "--eval-tag",
698
+ action="append",
699
+ default=[],
700
+ metavar="KEY=VALUE",
701
+ help="Filter by evaluation tag (repeatable)",
702
+ )
703
+
704
+
705
+ async def handle_run(
706
+ args: argparse.Namespace,
707
+ store: JsonRepository,
708
+ ) -> str | None:
709
+ # Imported lazily: the runner and eval core pull in the evaluation
710
+ # framework, which is only needed when a run actually executes.
711
+ from switch_trust.cli.rich_observer import ( # noqa: PLC0415 - deferred import cost
712
+ create_progress,
713
+ )
714
+ from switch_trust.cli.runner import ( # noqa: PLC0415 - deferred import cost
715
+ MAX_CONSECUTIVE_RUN_FAILURES,
716
+ CliRunResult,
717
+ log_run_summary,
718
+ print_results,
719
+ run_cli_evaluation,
720
+ write_output,
721
+ )
722
+
723
+ model_tag_filter = _parse_tags(args.model_tag)
724
+ eval_tag_filter = _parse_tags(args.eval_tag)
725
+
726
+ model_evaluations = _resolve_run_targets(
727
+ store,
728
+ args,
729
+ model_tag_filter,
730
+ eval_tag_filter,
731
+ )
732
+ if not model_evaluations:
733
+ return None
734
+
735
+ eval_names = [
736
+ store.evaluations.get(me.evaluation_id).name for me in model_evaluations
737
+ ]
738
+ desc_width = min(max((len(n) for n in eval_names), default=20), 40)
739
+
740
+ progress = create_progress()
741
+
742
+ results: list[CliRunResult] = []
743
+ consecutive_failures = 0
744
+ try:
745
+ progress.start()
746
+ for me in model_evaluations:
747
+ result = await run_cli_evaluation(
748
+ me,
749
+ store,
750
+ args.concurrency,
751
+ progress=progress,
752
+ desc_width=desc_width,
753
+ )
754
+ results.append(result)
755
+ if result.summary and result.summary.status == EvaluationStatus.ERROR:
756
+ consecutive_failures += 1
757
+ if consecutive_failures >= MAX_CONSECUTIVE_RUN_FAILURES:
758
+ console.print(
759
+ f"[red]Aborting: {consecutive_failures} consecutive"
760
+ f" evaluations failed. Is the target reachable?[/red]",
761
+ )
762
+ break
763
+ else:
764
+ consecutive_failures = 0
765
+ finally:
766
+ progress.stop()
767
+
768
+ if not results:
769
+ return None
770
+
771
+ print_results(results)
772
+ log_run_summary(results)
773
+
774
+ fmt = getattr(args, "format", "json") or "json"
775
+ ext = fmt if fmt != "json" else "json"
776
+ output_path = args.output or (
777
+ f"eval_{datetime.now().strftime('%Y%m%dT%H%M%S')}.{ext}"
778
+ )
779
+ write_output(results, args.config, output_path, fmt=fmt)
780
+ return output_path
781
+
782
+
783
+ def _filter_by_tags(
784
+ items: list,
785
+ store: JsonRepository,
786
+ model_tags: dict[str, str],
787
+ eval_tags: dict[str, str],
788
+ ) -> list:
789
+ filtered = []
790
+ for me in items:
791
+ if model_tags:
792
+ try:
793
+ model = store.models.get(me.model_id)
794
+ if not _matches_tags(model.tags, model_tags):
795
+ continue
796
+ except KeyError:
797
+ continue
798
+ if eval_tags:
799
+ try:
800
+ ev = store.evaluations.get(
801
+ me.evaluation_id,
802
+ )
803
+ if not _matches_tags(ev.tags, eval_tags):
804
+ continue
805
+ except KeyError:
806
+ continue
807
+ filtered.append(me)
808
+ return filtered
809
+
810
+
811
+ def _resolve_run_targets(
812
+ store: JsonRepository,
813
+ args: argparse.Namespace,
814
+ model_tags: dict[str, str],
815
+ eval_tags: dict[str, str],
816
+ ) -> list:
817
+ if args.model_evaluation_id and args.model:
818
+ raise ValueError("specify either a model-evaluation ID or --model, not both")
819
+
820
+ if not args.model_evaluation_id and not args.model:
821
+ raise ValueError("specify a model-evaluation ID or --model <model-id>")
822
+
823
+ if args.model_evaluation_id:
824
+ me_id = _resolve_id(
825
+ args.model_evaluation_id,
826
+ [me.id for me in store.model_evaluations.list_all()],
827
+ )
828
+ try:
829
+ me = store.model_evaluations.get(me_id)
830
+ items = _filter_by_tags(
831
+ [me],
832
+ store,
833
+ model_tags,
834
+ eval_tags,
835
+ )
836
+ if not items:
837
+ console.print(
838
+ "[yellow]Model-evaluation filtered out by tag filters.[/yellow]",
839
+ )
840
+ return items
841
+ except KeyError:
842
+ raise ValueError(
843
+ f"model-evaluation {args.model_evaluation_id!r} not found"
844
+ ) from None
845
+
846
+ model_id = _resolve_id(
847
+ args.model,
848
+ [m.id for m in store.models.list()],
849
+ )
850
+ try:
851
+ model = store.models.get(model_id)
852
+ except KeyError:
853
+ raise ValueError(f"model {args.model!r} not found") from None
854
+
855
+ if model_tags and not _matches_tags(
856
+ model.tags,
857
+ model_tags,
858
+ ):
859
+ console.print(
860
+ f"[yellow]Model {model_id!r} does not match --model-tag filters.[/yellow]",
861
+ )
862
+ return []
863
+
864
+ me_view = store.model_evaluations.list_by_model(
865
+ model_id,
866
+ limit=1000,
867
+ )
868
+ items = [item.config for item in me_view.items]
869
+ if eval_tags:
870
+ items = _filter_by_tags(
871
+ items,
872
+ store,
873
+ {},
874
+ eval_tags,
875
+ )
876
+ if not items:
877
+ console.print(
878
+ f"[yellow]No evaluations assigned to model "
879
+ f"{model_id!r} (after tag filtering).[/yellow]",
880
+ )
881
+ return items
882
+
883
+
884
+ # -- formatting helpers -------------------------------------------
885
+
886
+
887
+ def _resolve_id(
888
+ prefix: str,
889
+ all_ids: list[str],
890
+ ) -> str:
891
+ if prefix in all_ids:
892
+ return prefix
893
+ matches = [full_id for full_id in all_ids if full_id.startswith(prefix)]
894
+ if len(matches) == 1:
895
+ return matches[0]
896
+ if len(matches) > 1:
897
+ console.print(
898
+ f"[yellow]Ambiguous ID prefix {prefix!r}, "
899
+ f"matches: {[m[:12] for m in matches]}[/yellow]",
900
+ )
901
+ return prefix
902
+
903
+
904
+ def _fmt_tags(tags: dict[str, str]) -> str:
905
+ return ", ".join(f"{k}={v}" for k, v in tags.items())