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,172 @@
1
+ import ast
2
+ import logging
3
+ import os
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ REQUIREMENTS_FILE = "requirements.txt"
10
+ PYTHON_EXTENSIONS = ".py"
11
+ PYTHON_IGNORES = "__init__.py"
12
+
13
+ # Import module root -> Framework name
14
+ FRAMEWORK_ROOTS: dict[str, str] = {
15
+ "google.adk": "Google ADK",
16
+ "google.genai": "Google GenAI",
17
+ "anthropic": "Anthropic",
18
+ "openai": "OpenAI",
19
+ "agents": "OpenAI Agents SDK",
20
+ "langgraph": "LangGraph",
21
+ "crewai": "CrewAI",
22
+ "autogen": "AutoGen",
23
+ "transformers": "HuggingFace Transformers",
24
+ "smolagents": "HuggingFace smolagents",
25
+ }
26
+
27
+
28
+ class FileType(Enum):
29
+ PYTHON = "python"
30
+ REQUIREMENTS = "requirements"
31
+ OTHER = "other"
32
+
33
+
34
+ @dataclass
35
+ class RelevantFile:
36
+ path: str
37
+ type: FileType
38
+ evidence: str | None = None
39
+ framework: str | None = None
40
+
41
+
42
+ def _detect_relevant_file(
43
+ abs_file_path: str,
44
+ ) -> RelevantFile | None:
45
+ """Detects relevant files for scanning."""
46
+ if os.path.basename(abs_file_path).lower() == REQUIREMENTS_FILE:
47
+ logger.info("File in scope: %s", abs_file_path)
48
+ return RelevantFile(path=abs_file_path, type=FileType.REQUIREMENTS)
49
+ elif abs_file_path.endswith(PYTHON_EXTENSIONS) and not abs_file_path.endswith(
50
+ PYTHON_IGNORES
51
+ ):
52
+ match = _detect_framework_in_file(abs_file_path)
53
+ if match:
54
+ logger.info("File in scope: %s", abs_file_path)
55
+ return match
56
+ return None
57
+
58
+
59
+ def find_relevant_files(path: str) -> list[RelevantFile]:
60
+ """`path` might be a file or a directory.
61
+ If it is a file, detect its type, if it is a directory,
62
+ recursively find relevant files in the given directory."""
63
+ relevant_files = []
64
+
65
+ path = os.path.abspath(path)
66
+ if os.path.isfile(path):
67
+ relevant_file = _detect_relevant_file(path)
68
+ if relevant_file:
69
+ relevant_files.append(relevant_file)
70
+ else:
71
+ for dirpath, _, filenames in os.walk(path):
72
+ for filename in filenames:
73
+ relevant_file = _detect_relevant_file(os.path.join(dirpath, filename))
74
+ if relevant_file:
75
+ relevant_files.append(relevant_file)
76
+
77
+ return relevant_files
78
+
79
+
80
+ def is_relevant_file(file_path: str) -> bool:
81
+ file_name = os.path.basename(file_path).lower()
82
+
83
+ if file_name == REQUIREMENTS_FILE:
84
+ return True
85
+
86
+ if file_path.endswith(PYTHON_EXTENSIONS) and not file_path.endswith(PYTHON_IGNORES):
87
+ return is_relevant_python_file(file_path)
88
+
89
+ return file_path.endswith(".py")
90
+
91
+
92
+ def is_relevant_python_file(file_path: str) -> bool:
93
+ content = ""
94
+ try:
95
+ with open(file_path, encoding="utf-8") as f:
96
+ content = f.read()
97
+
98
+ except Exception as e:
99
+ logger.warning("Failed to read file %s: %s", file_path, e)
100
+ return True
101
+
102
+ return has_relevant_imports(content)
103
+
104
+
105
+ def has_relevant_imports(content: str) -> bool:
106
+ try:
107
+ tree = ast.parse(content)
108
+ except SyntaxError:
109
+ return False
110
+
111
+ for node in ast.walk(tree):
112
+ if isinstance(node, ast.Import):
113
+ for alias in node.names:
114
+ if _matches_framework(alias.name):
115
+ return True
116
+ elif isinstance(node, ast.ImportFrom):
117
+ if node.module and _matches_framework(node.module):
118
+ return True
119
+
120
+ return False
121
+
122
+
123
+ def _detect_framework_in_file(file_path: str) -> RelevantFile | None:
124
+ """Read a Python file and return a RelevantFile if it imports a known framework."""
125
+ try:
126
+ with open(file_path, encoding="utf-8") as f:
127
+ content = f.read()
128
+ except Exception as e:
129
+ logger.warning("Failed to read file %s: %s", file_path, e)
130
+ return RelevantFile(path=file_path, type=FileType.PYTHON)
131
+
132
+ try:
133
+ tree = ast.parse(content)
134
+ except SyntaxError:
135
+ return None
136
+
137
+ for node in ast.walk(tree):
138
+ if isinstance(node, ast.Import):
139
+ for alias in node.names:
140
+ framework = _get_framework_name(alias.name)
141
+ if framework:
142
+ return RelevantFile(
143
+ path=file_path,
144
+ type=FileType.PYTHON,
145
+ evidence=ast.get_source_segment(content, node)
146
+ or f"import {alias.name}",
147
+ framework=framework,
148
+ )
149
+ elif isinstance(node, ast.ImportFrom):
150
+ if node.module:
151
+ framework = _get_framework_name(node.module)
152
+ if framework:
153
+ return RelevantFile(
154
+ path=file_path,
155
+ type=FileType.PYTHON,
156
+ evidence=ast.get_source_segment(content, node)
157
+ or f"from {node.module} import ...",
158
+ framework=framework,
159
+ )
160
+
161
+ return None
162
+
163
+
164
+ def _matches_framework(module: str) -> bool:
165
+ return _get_framework_name(module) is not None
166
+
167
+
168
+ def _get_framework_name(module: str) -> str | None:
169
+ for root, name in FRAMEWORK_ROOTS.items():
170
+ if module == root or module.startswith(root + "."):
171
+ return name
172
+ return None
@@ -0,0 +1,156 @@
1
+ """Subcommand for `switch-trust init`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import stat
9
+
10
+ from rich.prompt import Prompt
11
+
12
+ from switch_trust.cli.console import console, select
13
+ from switch_trust.cli.utils import (
14
+ CLIENT_ID_ENV_VAR,
15
+ TELEMETRY_CONSENT_ENV_VAR,
16
+ generate_client_id,
17
+ get_switch_trust_config_path,
18
+ get_switch_trust_dir,
19
+ get_switch_trust_env_path,
20
+ prompt_telemetry_consent,
21
+ )
22
+
23
+ _PROVIDER_DEFAULTS: dict[str, dict[str, str | None]] = {
24
+ "gemini": {
25
+ "model": "gemini-3.8-flash",
26
+ "api_key_var": "GEMINI_API_KEY",
27
+ },
28
+ "openai": {
29
+ "model": "gpt-5.6-sol",
30
+ "api_key_var": "OPENAI_API_KEY",
31
+ },
32
+ "anthropic": {
33
+ "model": "claude-sonnet-5",
34
+ "api_key_var": "ANTHROPIC_API_KEY",
35
+ },
36
+ "litellm": {
37
+ "model": "gpt-5.6-sol",
38
+ "api_key_var": None,
39
+ },
40
+ }
41
+
42
+ _LITELLM_BACKENDS: dict[str, str] = {
43
+ "openai": "OPENAI_API_KEY",
44
+ "gemini": "GEMINI_API_KEY",
45
+ "anthropic": "ANTHROPIC_API_KEY",
46
+ }
47
+
48
+
49
+ def register(
50
+ subparsers: argparse._SubParsersAction,
51
+ ) -> None:
52
+ subparsers.add_parser(
53
+ "init",
54
+ help="Initialize switch-trust configuration",
55
+ )
56
+
57
+
58
+ def run_init() -> None:
59
+ switch_trust_dir = get_switch_trust_dir()
60
+
61
+ console.print("[bold cyan]Switch Trust Setup[/bold cyan]")
62
+ console.print()
63
+
64
+ if switch_trust_dir.exists():
65
+ proceed = select(
66
+ "Existing configuration will be overwritten. Do you want to proceed?",
67
+ options=["yes", "no"],
68
+ default_index=0,
69
+ )
70
+ if proceed.lower() == "no":
71
+ console.print("[dim]Aborted.[/dim]")
72
+ return
73
+ console.print()
74
+
75
+ provider = select(
76
+ "Switch Trust CLI needs an LLM for certain functionality. Select your model provider:",
77
+ options=["gemini", "openai", "anthropic", "litellm"],
78
+ default_index=0,
79
+ )
80
+ console.print()
81
+
82
+ defaults = _PROVIDER_DEFAULTS[provider]
83
+
84
+ if provider == "litellm":
85
+ model_name = Prompt.ask(
86
+ "LiteLLM model string (e.g. openai/gpt-5.4, gemini/gemini-2.5-flash)",
87
+ default="openai/gpt-5.4",
88
+ )
89
+ console.print()
90
+
91
+ backend = model_name.split("/")[0] if "/" in model_name else None
92
+ api_key_var = _LITELLM_BACKENDS.get(backend, None) if backend else None
93
+ if not api_key_var:
94
+ api_key_var = Prompt.ask("API key env var name")
95
+ console.print()
96
+
97
+ generator_model = f"litellm:{model_name}"
98
+ else:
99
+ model_name = Prompt.ask(
100
+ "Model name",
101
+ default=defaults["model"],
102
+ )
103
+ console.print()
104
+ api_key_var = defaults["api_key_var"]
105
+ generator_model = f"{provider}:{model_name}"
106
+
107
+ assert api_key_var is not None
108
+ api_key = Prompt.ask(
109
+ f"Enter your {api_key_var}",
110
+ password=True,
111
+ )
112
+ console.print()
113
+
114
+ telemetry_consent = prompt_telemetry_consent()
115
+
116
+ client_id = generate_client_id()
117
+ env_lines = [
118
+ f"GENERATOR_MODEL={generator_model}",
119
+ f"{api_key_var}={api_key}",
120
+ "EXECUTOR_MAX_WORKERS=20",
121
+ f"{CLIENT_ID_ENV_VAR}={client_id}",
122
+ f"{TELEMETRY_CONSENT_ENV_VAR}={telemetry_consent}",
123
+ ]
124
+
125
+ default_config = {
126
+ "models": [],
127
+ "evaluations": [],
128
+ "detectors": [],
129
+ "message_collections": [],
130
+ "model_evaluations": [],
131
+ }
132
+
133
+ old_umask = os.umask(0o077)
134
+ try:
135
+ switch_trust_dir.mkdir(parents=True, exist_ok=True)
136
+
137
+ env_path = get_switch_trust_env_path()
138
+ env_path.write_text("\n".join(env_lines) + "\n")
139
+ env_path.chmod(stat.S_IRUSR | stat.S_IWUSR)
140
+
141
+ config_path = get_switch_trust_config_path()
142
+ config_path.write_text(
143
+ json.dumps(default_config, indent=2) + "\n",
144
+ )
145
+ finally:
146
+ os.umask(old_umask)
147
+
148
+ console.print(f"[green]Created {env_path}[/green]")
149
+ console.print(
150
+ f"[green]Created {config_path}[/green]",
151
+ )
152
+ console.print()
153
+ console.print(
154
+ "[bold green]Switch Trust initialized successfully![/bold green]",
155
+ )
156
+ console.print()
@@ -0,0 +1,384 @@
1
+ """
2
+ CLI entry point for Switch Trust CLI.
3
+
4
+ Usage:
5
+ python -m switch_trust.cli init
6
+ python -m switch_trust.cli eval models list
7
+ python -m switch_trust.cli eval run <model-evaluation-id>
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import asyncio
14
+ import logging
15
+ import os
16
+ import sys
17
+ import time
18
+ import traceback
19
+ from datetime import datetime
20
+ from typing import TYPE_CHECKING
21
+
22
+ from dotenv import dotenv_values, find_dotenv
23
+ from rich.panel import Panel
24
+ from rich.text import Text
25
+
26
+ from switch_trust.cli import eval_cli, init_cli, scan_cli
27
+ from switch_trust.cli.console import CLI_WIDTH, console
28
+ from switch_trust.cli.utils import (
29
+ ensure_client_id,
30
+ ensure_telemetry_consent,
31
+ get_client_id,
32
+ get_switch_trust_dir,
33
+ get_telemetry_consent,
34
+ is_ci,
35
+ legacy_env_vars_used,
36
+ )
37
+ from switch_trust.cli.version import VERSION
38
+ from switch_trust.eval.common.log import setup_file_logging, silence_noisy_loggers
39
+
40
+ if TYPE_CHECKING:
41
+ from switch_trust.eval.db.json.repository_json import JsonRepository
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ _LOGO_LINES = [
46
+ "███████╗██╗ ██╗██╗████████╗ ██████╗██╗ ██╗ ████████╗██████╗ ██╗ ██╗███████╗████████╗",
47
+ "██╔════╝██║ ██║██║╚══██╔══╝██╔════╝██║ ██║ ╚══██╔══╝██╔══██╗██║ ██║██╔════╝╚══██╔══╝",
48
+ "███████╗██║ █╗ ██║██║ ██║ ██║ ███████║ ██║ ██████╔╝██║ ██║███████╗ ██║",
49
+ "╚════██║██║███╗██║██║ ██║ ██║ ██╔══██║ ██║ ██╔══██╗██║ ██║╚════██║ ██║",
50
+ "███████║╚███╔███╔╝██║ ██║ ╚██████╗██║ ██║ ██║ ██║ ██║╚██████╔╝███████║ ██║",
51
+ "╚══════╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝",
52
+ ]
53
+
54
+
55
+ _TIMESTAMP = datetime.now().strftime("%Y%m%dT%H%M%S")
56
+
57
+ _BUILTIN_CONFIG = os.path.join(
58
+ os.path.dirname(os.path.abspath(__file__)),
59
+ "builtin_config.json",
60
+ )
61
+
62
+
63
+ def _count(repo: JsonRepository) -> dict[str, int]:
64
+ return {
65
+ "models": len(repo.models.list()),
66
+ "evaluations": len(repo.evaluations.list()),
67
+ "detectors": len(repo.detectors.list()),
68
+ "collections": len(repo.message_collections.list()),
69
+ "assignments": len(repo.model_evaluations.list_all()),
70
+ }
71
+
72
+
73
+ def _fmt_count(total: int, builtin: int, user: int) -> str:
74
+ return f"{total} [dim]({builtin} builtin, {user} user)[/dim]"
75
+
76
+
77
+ def _print_logo() -> None:
78
+ console.print()
79
+ # Center the block by its widest line so every row shares one indent —
80
+ # centering rows independently misaligns the art when they differ in width.
81
+ logo_pad = max(0, (CLI_WIDTH - max(len(line) for line in _LOGO_LINES)) // 2)
82
+ for line in _LOGO_LINES:
83
+ console.print(" " * logo_pad + f"[bold cyan]{line}[/bold cyan]")
84
+ version_text = f"v{VERSION}"
85
+ pad = max(0, (CLI_WIDTH - len(version_text)) // 2)
86
+ console.print(" " * pad + f"[dim][bold cyan]{version_text}[/bold cyan][/dim]")
87
+ console.print()
88
+
89
+
90
+ def _print_config_info(
91
+ merged: JsonRepository,
92
+ builtin: JsonRepository,
93
+ user: JsonRepository,
94
+ config_path: str,
95
+ ) -> None:
96
+ m = _count(merged)
97
+ b = _count(builtin)
98
+ u = _count(user)
99
+
100
+ console.print(f"[dim]Config: {config_path}[/dim]")
101
+ for label, key in [
102
+ ("Models", "models"),
103
+ ("Evaluations", "evaluations"),
104
+ ("Detectors", "detectors"),
105
+ ("Collections", "collections"),
106
+ ("Assignments", "assignments"),
107
+ ]:
108
+ counts = _fmt_count(m[key], b[key], u[key])
109
+ console.print(f"[dim]{label + ':':<14}{counts}[/dim]")
110
+ console.print()
111
+
112
+
113
+ def _print_path(
114
+ path: str,
115
+ ) -> None:
116
+ console.print(f"[dim]Path: {path}[/dim]")
117
+ console.print()
118
+
119
+
120
+ def _print_shutdown(
121
+ elapsed: float,
122
+ output_path: str | None,
123
+ log_path: str | None,
124
+ ) -> None:
125
+ console.print()
126
+ parts = [f"[dim]Completed in [bold cyan]{elapsed:.1f}s[/bold cyan][/dim]"]
127
+ if output_path:
128
+ parts.append(f"[dim]Results: [bold cyan]{output_path}[/bold cyan][/dim]")
129
+ if log_path:
130
+ parts.append(f"[dim]Logs: [bold cyan]{log_path}[/bold cyan][/dim]")
131
+ console.print(" ".join(parts))
132
+ console.print()
133
+
134
+
135
+ def _dispatch(args: argparse.Namespace) -> str | None:
136
+ logger.info("Command: %s", " ".join(sys.argv))
137
+
138
+ if args.command == "eval":
139
+ return _dispatch_eval(args)
140
+ elif args.command == "init":
141
+ return _dispatch_init(args)
142
+ elif args.command == "scan":
143
+ return _dispatch_scan(args)
144
+ else:
145
+ console.print(f"[red]Unknown command: {args.command}[/red]")
146
+ sys.exit(1)
147
+
148
+
149
+ def _dispatch_init(args: argparse.Namespace) -> str | None:
150
+ init_cli.run_init()
151
+ return None
152
+
153
+
154
+ def _dispatch_scan(args: argparse.Namespace) -> str | None:
155
+ logger.info("switch-trust v%s | scan mode", VERSION)
156
+ _print_path(args.path)
157
+ return scan_cli.handle_scan(args)
158
+
159
+
160
+ def _dispatch_eval(args: argparse.Namespace) -> str | None:
161
+ """Run the requested command. Returns output file path if any."""
162
+ from switch_trust.eval.db.json.repository_json import ( # noqa: PLC0415 - deferred import cost
163
+ JsonRepository,
164
+ )
165
+
166
+ if args.command != "eval":
167
+ console.print(f"[red]Unknown command: {args.command}[/red]")
168
+ sys.exit(1)
169
+
170
+ config_path = getattr(args, "config", None) or str(
171
+ init_cli.get_switch_trust_config_path(),
172
+ )
173
+ builtin = JsonRepository(_BUILTIN_CONFIG)
174
+ user = JsonRepository(config_path)
175
+ store = user.merge(builtin)
176
+
177
+ _print_config_info(store, builtin, user, config_path)
178
+
179
+ m = _count(store)
180
+ logger.info(
181
+ "switch-trust v%s | models=%d, evaluations=%d, assignments=%d",
182
+ VERSION,
183
+ m["models"],
184
+ m["evaluations"],
185
+ m["assignments"],
186
+ )
187
+
188
+ cmd = args.eval_cmd
189
+ if cmd == "models":
190
+ eval_cli.handle_models(args, store)
191
+ elif cmd == "evaluations":
192
+ eval_cli.handle_evaluations(args, store)
193
+ elif cmd == "model-evaluations":
194
+ eval_cli.handle_model_evaluations(
195
+ args,
196
+ store,
197
+ user,
198
+ )
199
+ elif cmd == "run":
200
+ return asyncio.run(eval_cli.handle_run(args, store))
201
+ else:
202
+ console.print(f"[red]Unknown eval subcommand: {cmd}[/red]")
203
+ sys.exit(1)
204
+ return None
205
+
206
+
207
+ def _print_error(error: BaseException) -> None:
208
+ error_type = type(error).__name__
209
+ error_msg = str(error) or "(no message)"
210
+
211
+ body = Text()
212
+ body.append(f"{error_type}: ", style="bold red")
213
+ body.append(error_msg)
214
+
215
+ panel = Panel(
216
+ body,
217
+ title="[bold red]Error[/bold red]",
218
+ border_style="red",
219
+ width=min(CLI_WIDTH, max(60, len(error_msg) + 20)),
220
+ padding=(1, 2),
221
+ )
222
+ console.print()
223
+ console.print(panel)
224
+
225
+
226
+ def _load_environment(override: bool = False) -> None:
227
+ """Load env vars with precedence: real environment > local ``.env`` > global.
228
+
229
+ Both dotfiles are read into a single mapping — a project-local ``.env`` from
230
+ the current working directory layered over the global ``~/.switch-trust/.env`` so
231
+ the local file wins for any shared key — and then applied to ``os.environ``
232
+ in one pass. Applying the merged mapping ourselves (rather than two
233
+ ``load_dotenv`` calls) is what lets a value already in ``os.environ`` — set
234
+ from the shell, CI, or the command line (e.g. ``FOO=bar switch-trust ...``) —
235
+ stay authoritative.
236
+
237
+ The global file is resolved via ``get_switch_trust_dir()`` rather than
238
+ ``get_switch_trust_env_path()`` so that a ``cwd/.env`` cannot mask the global
239
+ file — its global-only keys (e.g. a persisted client id) must still load.
240
+
241
+ ``override`` controls whether the merged dotfile values overwrite entries
242
+ already in ``os.environ``. It stays ``False`` on normal startup so real
243
+ shell/CI/command-line variables win over the dotfiles. It must be ``True``
244
+ when reloading right after ``init`` (re)writes an env file — otherwise stale
245
+ values loaded earlier this run would shadow the freshly written ones. Since
246
+ ``init`` may write to *either* the local or the global file, the reload has
247
+ to be able to pick up updates from both, which is why the flag applies to
248
+ the merged result rather than to one file.
249
+ """
250
+ merged: dict[str, str | None] = {}
251
+
252
+ global_env = get_switch_trust_dir() / ".env"
253
+ if global_env.exists():
254
+ merged.update(dotenv_values(global_env))
255
+
256
+ local_dotenv = find_dotenv(usecwd=True)
257
+ if local_dotenv:
258
+ # Local layered last so it wins over the global file for shared keys.
259
+ merged.update(dotenv_values(local_dotenv))
260
+
261
+ for key, value in merged.items():
262
+ if value is None:
263
+ continue
264
+ if override or key not in os.environ:
265
+ os.environ[key] = value
266
+
267
+
268
+ def main(argv: list[str] | None = None) -> None:
269
+ parser = argparse.ArgumentParser(
270
+ prog="switch-trust",
271
+ description="Switch Trust CLI — AI Agent Evaluation Framework",
272
+ )
273
+ parser.add_argument(
274
+ "--version",
275
+ action="version",
276
+ version=f"%(prog)s {VERSION}",
277
+ )
278
+
279
+ subparsers = parser.add_subparsers(dest="command")
280
+ subparsers.required = True
281
+ eval_cli.register(subparsers)
282
+ init_cli.register(subparsers)
283
+ scan_cli.register(subparsers)
284
+
285
+ # Parse first: --version / --help / arg errors exit here, before we pay for
286
+ # the heavy imports below (telemetry SDK, eval framework logging).
287
+ args = parser.parse_args(argv)
288
+
289
+ from switch_trust.cli.telemetry import ( # noqa: PLC0415 - deferred import cost
290
+ command_span,
291
+ emit_event,
292
+ init_telemetry,
293
+ record_error,
294
+ record_interruption,
295
+ )
296
+
297
+ silence_noisy_loggers()
298
+
299
+ ci = is_ci()
300
+ switch_trust_env = init_cli.get_switch_trust_env_path()
301
+ is_first_time = not switch_trust_env.exists()
302
+
303
+ if is_first_time and args.command != "init":
304
+ if ci:
305
+ console.print(
306
+ "[dim]CI environment detected — skipping interactive init.[/dim]",
307
+ )
308
+ else:
309
+ console.print(
310
+ "[yellow]First-time setup required. Running switch-trust init...[/yellow]",
311
+ )
312
+ console.print()
313
+ init_cli.run_init()
314
+
315
+ _load_environment()
316
+
317
+ log_path = getattr(args, "log", None) or f"switch_trust_{_TIMESTAMP}.log"
318
+ setup_file_logging(log_path)
319
+ _print_logo()
320
+
321
+ # On a first-time `switch-trust init` the .env doesn't exist yet and init (run
322
+ # via _dispatch below) is the sole author of the client id.
323
+ if not (is_first_time and args.command == "init"):
324
+ ensure_client_id()
325
+ if args.command != "init":
326
+ ensure_telemetry_consent()
327
+
328
+ if get_telemetry_consent():
329
+ client_id = get_client_id()
330
+ if client_id:
331
+ init_telemetry(client_id, VERSION)
332
+
333
+ # Separate any deprecated-env-var notes above from the command output below.
334
+ if legacy_env_vars_used():
335
+ console.print()
336
+
337
+ def _try_init_telemetry_after_init() -> None:
338
+ """Re-check consent after init creates the .env for the first time."""
339
+ _load_environment(override=True)
340
+ ensure_client_id()
341
+ if get_telemetry_consent():
342
+ client_id = get_client_id()
343
+ if client_id:
344
+ init_telemetry(client_id, VERSION)
345
+ emit_event("init", is_ci=ci, is_first_time=is_first_time)
346
+
347
+ subcommand_parts = []
348
+ for attr in ("eval_cmd", "models_cmd", "evals_cmd", "me_cmd"):
349
+ val = getattr(args, attr, None)
350
+ if val:
351
+ subcommand_parts.append(val)
352
+ subcommand = " ".join(subcommand_parts) if subcommand_parts else None
353
+
354
+ with command_span(
355
+ args.command,
356
+ subcommand,
357
+ is_ci=ci,
358
+ is_first_time=is_first_time,
359
+ ) as span:
360
+ t0 = time.monotonic()
361
+ output_path: str | None = None
362
+ try:
363
+ output_path = _dispatch(args)
364
+ if args.command == "init" and span is None:
365
+ _try_init_telemetry_after_init()
366
+ except KeyboardInterrupt:
367
+ record_interruption(span)
368
+ logger.info("Interrupted by user")
369
+ console.print("\n[dim]Interrupted.[/dim]")
370
+ sys.exit(130)
371
+ except Exception as e:
372
+ record_error(span, e)
373
+ logger.critical(
374
+ "Fatal error: %s: %s\n%s",
375
+ type(e).__name__,
376
+ e,
377
+ traceback.format_exc(),
378
+ )
379
+ _print_error(e)
380
+ elapsed = time.monotonic() - t0
381
+ _print_shutdown(elapsed, output_path, log_path)
382
+ sys.exit(1)
383
+ elapsed = time.monotonic() - t0
384
+ _print_shutdown(elapsed, output_path, log_path)