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.
- switch_trust/__init__.py +0 -0
- switch_trust/cli/__init__.py +0 -0
- switch_trust/cli/__main__.py +3 -0
- switch_trust/cli/builtin_config.json +879 -0
- switch_trust/cli/console.py +127 -0
- switch_trust/cli/eval_cli.py +905 -0
- switch_trust/cli/file_filter.py +172 -0
- switch_trust/cli/init_cli.py +156 -0
- switch_trust/cli/main.py +384 -0
- switch_trust/cli/output_formatters.py +482 -0
- switch_trust/cli/rich_observer.py +89 -0
- switch_trust/cli/runner.py +274 -0
- switch_trust/cli/scan_cli.py +314 -0
- switch_trust/cli/telemetry.py +430 -0
- switch_trust/cli/utils.py +158 -0
- switch_trust/cli/version.py +1 -0
- switch_trust/data/detector_prompts/llm01_adversarial.txt +59 -0
- switch_trust/data/detector_prompts/llm01_fixed.txt +6 -0
- switch_trust/data/detector_prompts/llm02_adversarial.txt +79 -0
- switch_trust/data/detector_prompts/llm02_fixed.txt +6 -0
- switch_trust/data/detector_prompts/llm05_adversarial.txt +67 -0
- switch_trust/data/detector_prompts/llm05_fixed.txt +14 -0
- switch_trust/data/detector_prompts/llm06_adversarial.txt +139 -0
- switch_trust/data/detector_prompts/llm06_fixed.txt +13 -0
- switch_trust/data/detector_prompts/llm07_adversarial.txt +189 -0
- switch_trust/data/detector_prompts/llm07_fixed.txt +12 -0
- switch_trust/data/detector_prompts/llm09_adversarial.txt +83 -0
- switch_trust/data/detector_prompts/llm09_fixed.txt +13 -0
- switch_trust/data/llm01_prompt_injection.csv +6708 -0
- switch_trust/data/llm02_sensitive_information.csv +1069 -0
- switch_trust/data/llm05_unsafe_output.csv +3882 -0
- switch_trust/data/llm06_excessive_agency.csv +778 -0
- switch_trust/data/llm07_system_prompt_leakage.csv +971 -0
- switch_trust/data/llm09_hallucination-goals.csv +32419 -0
- switch_trust/data/llm09_hallucination.csv +599 -0
- switch_trust/data/pii_leakage.csv +706 -0
- switch_trust/data/secret_leakage.csv +1380 -0
- switch_trust/eval/__init__.py +0 -0
- switch_trust/eval/common/__init__.py +0 -0
- switch_trust/eval/common/converter_anthropic.py +172 -0
- switch_trust/eval/common/converter_genai.py +171 -0
- switch_trust/eval/common/converter_openai.py +198 -0
- switch_trust/eval/common/log.py +116 -0
- switch_trust/eval/common/reference.py +25 -0
- switch_trust/eval/common/schema.py +190 -0
- switch_trust/eval/common/utils.py +119 -0
- switch_trust/eval/core/__init__.py +0 -0
- switch_trust/eval/core/detectors/__init__.py +0 -0
- switch_trust/eval/core/detectors/detector.py +25 -0
- switch_trust/eval/core/detectors/detector_garak.py +112 -0
- switch_trust/eval/core/detectors/detector_model.py +123 -0
- switch_trust/eval/core/detectors/detector_model_adversarial.py +91 -0
- switch_trust/eval/core/detectors/detector_pii.py +67 -0
- switch_trust/eval/core/detectors/detector_secret.py +88 -0
- switch_trust/eval/core/detectors/detector_topic_guard.py +63 -0
- switch_trust/eval/core/detectors/detector_toxicity.py +78 -0
- switch_trust/eval/core/eval/__init__.py +0 -0
- switch_trust/eval/core/eval/eval_creator.py +166 -0
- switch_trust/eval/core/eval/evaluation.py +112 -0
- switch_trust/eval/core/eval/evaluation_adversarial.py +412 -0
- switch_trust/eval/core/eval/evaluation_garak_module.py +45 -0
- switch_trust/eval/core/eval/evaluation_garak_probe.py +308 -0
- switch_trust/eval/core/eval/evaluation_message_list.py +46 -0
- switch_trust/eval/core/eval/evaluation_multi.py +276 -0
- switch_trust/eval/core/eval/evaluation_single.py +89 -0
- switch_trust/eval/core/eval/evaluation_single_prompt.py +62 -0
- switch_trust/eval/core/eval/evaluation_topic_guard.py +305 -0
- switch_trust/eval/core/eval/metric_conciseness.py +179 -0
- switch_trust/eval/core/eval/metric_factual_accuracy.py +312 -0
- switch_trust/eval/core/eval/metric_instruction_adherence.py +177 -0
- switch_trust/eval/core/eval/metric_tone.py +180 -0
- switch_trust/eval/core/eval/metric_toxicity.py +146 -0
- switch_trust/eval/core/eval/observer.py +137 -0
- switch_trust/eval/core/message/__init__.py +0 -0
- switch_trust/eval/core/message/message_collection.py +37 -0
- switch_trust/eval/core/message/message_collection_csv.py +44 -0
- switch_trust/eval/core/message/message_collection_garak.py +46 -0
- switch_trust/eval/core/message/message_collection_memory.py +34 -0
- switch_trust/eval/core/models/__init__.py +0 -0
- switch_trust/eval/core/models/generator_model.py +421 -0
- switch_trust/eval/core/models/model.py +172 -0
- switch_trust/eval/core/models/model_adk.py +186 -0
- switch_trust/eval/core/models/model_anthropic.py +79 -0
- switch_trust/eval/core/models/model_anthropic_agent.py +104 -0
- switch_trust/eval/core/models/model_gemini.py +146 -0
- switch_trust/eval/core/models/model_generic_http.py +120 -0
- switch_trust/eval/core/models/model_http.py +135 -0
- switch_trust/eval/core/models/model_huggingface.py +75 -0
- switch_trust/eval/core/models/model_langserve.py +96 -0
- switch_trust/eval/core/models/model_litellm.py +52 -0
- switch_trust/eval/core/models/model_ollama.py +65 -0
- switch_trust/eval/core/models/model_openai.py +71 -0
- switch_trust/eval/core/models/model_openai_agent.py +92 -0
- switch_trust/eval/core/models/model_openai_compatible.py +66 -0
- switch_trust/eval/core/models/model_per_endpoint_limiter.py +80 -0
- switch_trust/eval/core/models/model_retry.py +204 -0
- switch_trust/eval/core/models/model_sync_wrapper.py +38 -0
- switch_trust/eval/core/models/model_vertex_agent_runtime.py +315 -0
- switch_trust/eval/core/models/response_schema.py +148 -0
- switch_trust/eval/core/optional_deps.py +57 -0
- switch_trust/eval/db/__init__.py +0 -0
- switch_trust/eval/db/base/__init__.py +0 -0
- switch_trust/eval/db/base/common_types.py +16 -0
- switch_trust/eval/db/base/detectors/__init__.py +0 -0
- switch_trust/eval/db/base/detectors/detector_helpers.py +88 -0
- switch_trust/eval/db/base/detectors/detector_repository.py +96 -0
- switch_trust/eval/db/base/detectors/detector_types.py +77 -0
- switch_trust/eval/db/base/eval/__init__.py +0 -0
- switch_trust/eval/db/base/eval/eval_helpers.py +186 -0
- switch_trust/eval/db/base/eval/eval_repository.py +120 -0
- switch_trust/eval/db/base/eval/eval_run.py +142 -0
- switch_trust/eval/db/base/eval/eval_types.py +127 -0
- switch_trust/eval/db/base/eval/model_eval_repository.py +79 -0
- switch_trust/eval/db/base/eval/model_eval_run_repository.py +72 -0
- switch_trust/eval/db/base/eval/model_eval_run_result_repository.py +42 -0
- switch_trust/eval/db/base/eval/model_eval_run_result_types.py +21 -0
- switch_trust/eval/db/base/eval/model_eval_run_types.py +78 -0
- switch_trust/eval/db/base/eval/model_eval_types.py +69 -0
- switch_trust/eval/db/base/message/__init__.py +0 -0
- switch_trust/eval/db/base/message/message_collection_helpers.py +50 -0
- switch_trust/eval/db/base/message/message_collection_repository.py +95 -0
- switch_trust/eval/db/base/message/message_collection_types.py +87 -0
- switch_trust/eval/db/base/models/__init__.py +0 -0
- switch_trust/eval/db/base/models/model_helpers.py +253 -0
- switch_trust/eval/db/base/models/model_repository.py +104 -0
- switch_trust/eval/db/base/models/model_types.py +159 -0
- switch_trust/eval/db/json/__init__.py +0 -0
- switch_trust/eval/db/json/repository_json.py +559 -0
- switch_trust/scan/__init__.py +12 -0
- switch_trust/scan/agent_scanner.py +812 -0
- switch_trust/scan/config/agent_opengrep_rules.yaml +533 -0
- switch_trust/scan/config/agent_taxonomy.json +517 -0
- switch_trust/scan/config/agentic_cvss_mapping.yaml +136 -0
- switch_trust/scan/config/compliance_mappings.json +110 -0
- switch_trust/scan/config/triage_prompt.txt +326 -0
- switch_trust/scan/constants.py +15 -0
- switch_trust/scan/exception_classification.py +179 -0
- switch_trust/scan/llm_provider.py +330 -0
- switch_trust/scan/opengrep_resolver.py +12 -0
- switch_trust/scan/reasoner.py +602 -0
- switch_trust/scan/schema.py +315 -0
- switch_trust/scan/scorer.py +372 -0
- switch_trust/scan/static_scanner.py +681 -0
- switch_trust/scan/taxonomy.py +121 -0
- switch_trust/scan/tool_dispatcher.py +773 -0
- switch_trust/scan/trace_logger.py +118 -0
- switch_trust/scan/trace_logger_file.py +143 -0
- switch_trust/scan/trace_logger_log.py +100 -0
- switch_trust/scan/triage.py +614 -0
- switch_trust/schema.py +79 -0
- switch_trust/secret_anonymizer.py +72 -0
- switch_trust_cli-1.7.0.dist-info/METADATA +495 -0
- switch_trust_cli-1.7.0.dist-info/RECORD +157 -0
- switch_trust_cli-1.7.0.dist-info/WHEEL +5 -0
- switch_trust_cli-1.7.0.dist-info/entry_points.txt +2 -0
- switch_trust_cli-1.7.0.dist-info/licenses/LICENSE +210 -0
- 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()
|
switch_trust/cli/main.py
ADDED
|
@@ -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)
|