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,602 @@
1
+ """
2
+ reasoner.py — Agentic Reasoning Loop (v2)
3
+ ==========================================
4
+ Implements the agentic scanning loop using Google ADK.
5
+
6
+ ADK handles the internal reasoning loop — tool selection, invocation,
7
+ and termination. We supply the agent definition, tools, and system prompt.
8
+ ADK's Runner executes locally (no Vertex AI deployment required).
9
+
10
+ SESSION STORAGE:
11
+ Uses InMemorySessionService — sessions are ephemeral and discarded when
12
+ the process exits. This is appropriate because findings are accumulated
13
+ in-memory via the ToolDispatcher (not from session state) and no
14
+ downstream code reads sessions back after a scan completes.
15
+
16
+ MULTI-PROVIDER SUPPORT:
17
+ Model selection is handled via llm_provider.make_model() which reads
18
+ the SCANNER_MODEL env var (provider:model format):
19
+ google → bare Gemini model string (e.g. "gemini-3.5-flash")
20
+ all others → LiteLlm("provider/model") via llm_provider
21
+
22
+ CONTROL PLANE (ASI compliance for our own scanner):
23
+ MAX_ITERATIONS = 40 — hard cap on tool call rounds (ASI08)
24
+ MAX_FILES_FETCHED = 50 — limit on files the agent can read (ASI08)
25
+ LOOP_TIMEOUT_SECS = 600 — wall-clock timeout (ASI08)
26
+
27
+ OUTPUT:
28
+ Identical format to v1: (findings_list, summary_string, trace_dict)
29
+ The rest of the pipeline (triage, report assembly) is unchanged.
30
+
31
+ Canonical import path: from agent_scanner.reasoner import run_agentic_reasoning
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import asyncio
37
+ import concurrent.futures
38
+ import json
39
+ import logging
40
+ import os
41
+ import time
42
+ import uuid
43
+ from contextlib import suppress
44
+ from typing import Any
45
+
46
+ from switch_trust.schema import RepoFile
47
+ from switch_trust.scan.schema import AgentProfile, RawFinding
48
+ from switch_trust.scan.static_scanner import StaticFinding
49
+ from switch_trust.scan.taxonomy import AGENT_TAXONOMY
50
+ from switch_trust.scan.tool_dispatcher import ToolDispatcher
51
+ from switch_trust.scan.trace_logger_file import FileTraceLogger
52
+ from switch_trust.scan.trace_logger_log import LogTraceLogger
53
+ from google.adk.agents import LlmAgent
54
+ from google.adk import Runner
55
+ from google.adk.sessions import InMemorySessionService
56
+ from google.genai import types as genai_types
57
+ from switch_trust.scan.exception_classification import (
58
+ FailureDomain,
59
+ classify_exception,
60
+ is_auth_error,
61
+ is_model_error,
62
+ )
63
+ from switch_trust.scan.llm_provider import _safe_error, make_model
64
+ from switch_trust.secret_anonymizer import anonymize_secrets
65
+
66
+ logger = logging.getLogger(__name__)
67
+
68
+ # ── Operational limits ────────────────────────────────────────────────────────
69
+ # All three limits are configurable via environment variables.
70
+ # The values below are the production defaults — override per-deployment
71
+ # without code changes or redeployment.
72
+ #
73
+ # ADK_MAX_ITERATIONS — max tool-call rounds before forced stop (default: 40)
74
+ # ADK_MAX_FILES_FETCHED — max distinct files the agent may read (default: 50)
75
+ # ADK_MAX_FETCH_TOKENS — token budget for all fetch_file content (default: 200000)
76
+ # ADK_LOOP_TIMEOUT_SECS — wall-clock timeout for the full ADK loop (default: 600)
77
+ MAX_ITERATIONS = int(os.getenv("ADK_MAX_ITERATIONS", "80"))
78
+ MAX_FILES_FETCHED = int(os.getenv("ADK_MAX_FILES_FETCHED", "50"))
79
+ MAX_FETCH_TOKENS = int(os.getenv("ADK_MAX_FETCH_TOKENS", "200000"))
80
+ LOOP_TIMEOUT_SECS = int(os.getenv("ADK_LOOP_TIMEOUT_SECS", "600"))
81
+
82
+ # The `exit_reason` values that mean "we stopped early because one of our own
83
+ # budgets ran out", as opposed to "we finished" or "we broke". Kept next to the
84
+ # guards in `_run_adk_async` that produce them, not in the caller that
85
+ # interprets them, so a new guard cannot silently fall outside the set.
86
+ BUDGET_EXIT_REASONS = frozenset(
87
+ {
88
+ "timeout",
89
+ "max_iterations",
90
+ "max_files",
91
+ "max_tokens",
92
+ "max_bandit_calls",
93
+ "max_cvss_calls",
94
+ }
95
+ )
96
+
97
+
98
+ # ── System prompt ─────────────────────────────────────────────────────────────
99
+
100
+
101
+ def _build_system_prompt() -> str:
102
+ taxonomy_lines = []
103
+ for _cat_key, cat_data in AGENT_TAXONOMY.items():
104
+ taxonomy_lines.append(
105
+ f"\n{cat_data['asi_code']} — {cat_data['asi_title']}: {cat_data['description']}"
106
+ )
107
+ for subcat_key, subcat_data in cat_data["subcategories"].items():
108
+ taxonomy_lines.append(
109
+ f" [{subcat_data['severity'].upper()}] {subcat_key}: "
110
+ f"{subcat_data['title']} — {subcat_data['description']}"
111
+ )
112
+ taxonomy_ref = "\n".join(taxonomy_lines)
113
+
114
+ return f"""You are an application security researcher specializing in AI
115
+ agents. You investigate source code and reason about how an agent actually
116
+ behaves — how untrusted data flows into it, what its tools can do, how it is
117
+ exposed, and where a control is missing — then report every well-evidenced
118
+ vulnerability you can substantiate against the taxonomy below.
119
+
120
+ You are NOT a grep script. The most important agentic vulnerabilities are
121
+ behavioral and data-flow properties, not single-line syntactic patterns:
122
+ untrusted input reaching the agent, tool output fed back to the model,
123
+ state-changing tools with no confirmation, an unauthenticated entry point, an
124
+ unbounded run, missing guardrails or monitoring. Reason about the code; do not
125
+ merely match tokens.
126
+
127
+ TAXONOMY — every finding maps to one of these ASI subcategories. Use the exact
128
+ subcategory key (e.g. `indirect_prompt_injection`) as the `subcategory` argument:
129
+ {taxonomy_ref}
130
+
131
+ PROCEDURE:
132
+
133
+ 1. Read every file. Call read_source(resource_type="file", path=<path>) for
134
+ EVERY file in the AGENT PROFILES (and any repo-local file they import that is
135
+ relevant). Use read_source(resource_type="agent", path=<agent_id>) for the
136
+ full agent profile when useful.
137
+
138
+ 2. Understand what the static layer already found. Call get_findings() on each
139
+ file first, so you do not re-report a finding the static analyzers already
140
+ surfaced (Bandit/OpenGrep/detect-secrets). Your job is the reasoning layer:
141
+ the behavioral and cross-cutting issues static rules miss.
142
+
143
+ 3. Investigate against the taxonomy. For each agent and tool, reason through the
144
+ ASI categories and ask the kinds of questions below. Use
145
+ analyze_code(mode="search") to trace data flow (e.g. where a request body,
146
+ query param, or tool argument is used). Investigate — do not assume clean.
147
+
148
+ 4. Report each finding you can evidence. Call compute_cvss(vuln_type=<subcategory>)
149
+ then report_finding() for it. One call per finding; do not batch.
150
+
151
+ 5. When the investigation is complete, output ONLY:
152
+ {{"summary": "N findings reported", "investigation_notes": "<brief notes>"}}
153
+
154
+ WHAT TO REASON ABOUT (illustrative, not exhaustive — apply judgment, and use any
155
+ taxonomy subcategory that fits, not only these):
156
+
157
+ - ASI01 goal hijack: Does untrusted input (an HTTP body, request param, message,
158
+ or file) reach the agent's run/invocation or its prompt? That is
159
+ direct_prompt_injection even when the input is passed as a run argument rather
160
+ than concatenated into a template (e.g. `Runner.run(agent, req.input)`). Is
161
+ tool OUTPUT (search results, error strings that echo user input, retrieved
162
+ documents) fed back into the model without sanitization? That is
163
+ indirect_prompt_injection / goal_manipulation_via_rag.
164
+ - ASI02 tool misuse: Do tools validate their inputs? Can a tool touch the
165
+ filesystem, network, or shell beyond what its purpose requires
166
+ (excessive_tool_permissions, unvalidated_tool_input, path_traversal)?
167
+ - ASI03 identity/privilege: Is the agent exposed through an endpoint with no
168
+ authentication (missing_auth_on_endpoint)? Hardcoded credentials? An agent
169
+ running with more privilege than it needs?
170
+ - ASI05 code execution: eval/exec/compile, shell=True/os.system, unsafe
171
+ deserialization, or model output used to generate/execute code.
172
+ - ASI06 memory/context: Shared mutable state across sessions, unsanitized
173
+ persistent memory, RAG poisoning.
174
+ - ASI08 cascading failures: Is the agent run without a turn/iteration bound
175
+ (`Runner.run`/loop with no max_turns/max_iterations/recursion_limit →
176
+ unbounded_agent_loop)? Missing circuit breaker or blast-radius limit?
177
+ - ASI09 human/agent trust: Do state-changing or destructive actions (returns,
178
+ refunds, writes, deletes, purchases) proceed with no human confirmation or
179
+ human-in-the-loop (missing_action_confirmation, no_human_in_the_loop)? Is
180
+ sensitive data returned in output?
181
+ - ASI10 rogue agents: Missing behavioral guardrails, unchecked delegation to
182
+ sub-agents, no monitoring/kill switch.
183
+
184
+ REPORTING RULES:
185
+ - Call compute_cvss(vuln_type=<subcategory>) before EVERY report_finding().
186
+ - `subcategory` MUST be copied EXACTLY from the taxonomy keys above (e.g.
187
+ `indirect_prompt_injection`). Do not reword, expand or abbreviate it. There is
188
+ no `category` argument — the ASI category is derived from the subcategory.
189
+ - Set evidence to the EXACT code (the offending line or minimal snippet), not a
190
+ description. Set evidence_file to the file path and evidence_line to the line.
191
+ - Set agent_name to the agent display name from AGENT PROFILES.
192
+ - Set confidence to how strongly the code evidences the issue: "high" when the
193
+ code plainly shows it, "medium"/"low" when it depends on runtime context or
194
+ configuration you cannot fully see. Set hallucination_flag=true when you infer
195
+ an issue you cannot fully prove from the code in front of you.
196
+ - Do NOT report the same issue twice, and do NOT re-report a finding already
197
+ surfaced by static analysis (you checked via get_findings()).
198
+ - Report every distinct, evidenced issue — do not stop at one per category, and
199
+ do not withhold a real finding because it is behavioral rather than syntactic.
200
+
201
+ EXAMPLE:
202
+ agent.py line 180: `result = await Runner.run(agent, req.input)` — the FastAPI
203
+ request body `req.input` is passed straight to the agent with no validation or
204
+ turn bound.
205
+ Step 1: compute_cvss(vuln_type="direct_prompt_injection", exposed_over_network=true)
206
+ Step 2: report_finding(
207
+ subcategory="direct_prompt_injection",
208
+ title="Untrusted request body passed directly to the agent",
209
+ description="The /run endpoint feeds req.input into Runner.run(agent, ...) "
210
+ "with no sanitization, so an attacker controls the agent's input.",
211
+ impact="An attacker can hijack the agent's goal or exfiltrate data via the "
212
+ "HTTP endpoint.",
213
+ remediation="Validate and constrain user input; apply input guardrails "
214
+ "before invoking the agent.",
215
+ affected_component="agent.py",
216
+ evidence="result = await Runner.run(agent, req.input)",
217
+ confidence="high", hallucination_flag=false,
218
+ evidence_file="agent.py", evidence_line=180,
219
+ agent_name="bookstore_agent")
220
+
221
+ LIMITS: {MAX_ITERATIONS} rounds, {MAX_FILES_FETCHED} files, {MAX_FETCH_TOKENS} tokens.
222
+ """
223
+
224
+
225
+ # ── Initial investigation brief ───────────────────────────────────────────────
226
+
227
+
228
+ def _build_initial_context(
229
+ agents: list[AgentProfile],
230
+ python_files: list[RepoFile],
231
+ static_count: int,
232
+ ) -> str:
233
+ parts = ["=== INVESTIGATION BRIEF ===\n"]
234
+ parts.append(f"Repository contains {len(python_files)} Python file(s).")
235
+ parts.append(
236
+ f"Pre-computed static scan found {static_count} findings "
237
+ f"(available via get_static_findings_for_file).\n"
238
+ )
239
+
240
+ parts.append(f"=== AGENT PROFILES SUMMARY ({len(agents)} agent(s)) ===")
241
+ sorted_agents = sorted(agents, key=lambda a: a.agent_id)
242
+ for a in sorted_agents[:15]:
243
+ graph_info = (
244
+ f" | graph_nodes={a.graph_nodes}" if getattr(a, "graph_nodes", None) else ""
245
+ )
246
+ bounded = (
247
+ f" | bounded={a.has_recursion_limit}"
248
+ if getattr(a, "has_recursion_limit", None) is not None
249
+ else ""
250
+ )
251
+ parts.append(
252
+ f"\nAgent: {a.role or a.agent_id} | ID: {a.agent_id}"
253
+ f" | Framework: {a.framework} | File: {a.source_file}"
254
+ f"{graph_info}{bounded}\n"
255
+ f" Tools: {a.tools or []}\n"
256
+ f" LLM: {a.llm or '?'} | Memory: {a.memory} | Delegation: {a.allow_delegation}\n"
257
+ f" Code preview (use get_agent_profile for full content):\n"
258
+ f" {anonymize_secrets(a.raw_code[:300].strip())}"
259
+ )
260
+
261
+ parts.append("\n=== AVAILABLE FILES ===")
262
+ parts.append("Use read_source(resource_type='list') to see all fetched files.")
263
+ parts.append("Use read_source(resource_type='file', path=...) to read any file.")
264
+ parts.append(
265
+ "\n\nBegin the PROCEDURE now: fetch the full source code for each agent "
266
+ "file listed above, review the existing static findings, then investigate "
267
+ "against the taxonomy and report every issue you can evidence."
268
+ )
269
+ return "\n".join(parts)
270
+
271
+
272
+ # ── ADK agent builder ─────────────────────────────────────────────────────────
273
+
274
+
275
+ def _build_adk_agent(adk_model: Any, adk_tools: list[Any]) -> Any:
276
+ """Build and return a Google ADK LlmAgent instance."""
277
+ return LlmAgent(
278
+ name="agent_security_scanner",
279
+ model=adk_model,
280
+ description=(
281
+ "AI agent security researcher that scans codebases for "
282
+ "OWASP ASI01-ASI10 vulnerabilities."
283
+ ),
284
+ instruction=_build_system_prompt(),
285
+ tools=adk_tools,
286
+ )
287
+
288
+
289
+ # ── Session service factory ───────────────────────────────────────────────────
290
+
291
+
292
+ def _create_session_service() -> Any:
293
+ """Create and return an in-memory ADK SessionService."""
294
+ return InMemorySessionService()
295
+
296
+
297
+ # ── Async ADK runner ──────────────────────────────────────────────────────────
298
+
299
+
300
+ async def _run_adk_async(
301
+ agent,
302
+ initial_message: str,
303
+ dispatcher: ToolDispatcher,
304
+ session_id: str,
305
+ tracer,
306
+ ) -> tuple[str, str]:
307
+ """
308
+ Run the ADK agent via InMemorySessionService-backed Runner.
309
+
310
+ Returns (final_text_response, exit_reason).
311
+ Findings are accumulated via dispatcher.session_findings as side-effects
312
+ of report_finding() tool calls made by the agent.
313
+ """
314
+ APP_NAME = "agent_security_scanner"
315
+ USER_ID = "scanner"
316
+
317
+ session_service = _create_session_service()
318
+ runner = Runner(
319
+ agent=agent,
320
+ app_name=APP_NAME,
321
+ session_service=session_service,
322
+ )
323
+
324
+ session = await session_service.create_session(
325
+ app_name=APP_NAME,
326
+ user_id=USER_ID,
327
+ session_id=session_id,
328
+ )
329
+ logger.info("Session created: %s", session.id)
330
+
331
+ user_content = genai_types.Content(
332
+ role="user",
333
+ parts=[genai_types.Part.from_text(text=initial_message)],
334
+ )
335
+
336
+ final_text = ""
337
+ exit_reason = "completed"
338
+ iteration = 0
339
+ loop_start = time.monotonic()
340
+
341
+ event_stream = runner.run_async(
342
+ user_id=USER_ID,
343
+ session_id=session_id,
344
+ new_message=user_content,
345
+ )
346
+ try:
347
+ async for event in event_stream:
348
+ # ── Control plane guards ──────────────────────────────────────
349
+
350
+ elapsed = time.monotonic() - loop_start
351
+ if elapsed > LOOP_TIMEOUT_SECS:
352
+ logger.warning(
353
+ "Timeout after %.1fs (limit=%ds)", elapsed, LOOP_TIMEOUT_SECS
354
+ )
355
+ exit_reason = "timeout"
356
+ break
357
+
358
+ if hasattr(event, "actions") and event.actions:
359
+ iteration += 1
360
+ tracer.set_iteration(iteration)
361
+ logger.debug("Iteration %d/%d", iteration, MAX_ITERATIONS)
362
+ if iteration >= MAX_ITERATIONS:
363
+ logger.warning("Max iterations (%d) reached", MAX_ITERATIONS)
364
+ exit_reason = "max_iterations"
365
+ break
366
+
367
+ if dispatcher._call_counts.get("fetch_file", 0) > MAX_FILES_FETCHED:
368
+ logger.warning("File fetch limit (%d) reached", MAX_FILES_FETCHED)
369
+ exit_reason = "max_files"
370
+ break
371
+
372
+ if dispatcher.tokens_consumed > MAX_FETCH_TOKENS:
373
+ logger.warning(
374
+ "Token budget exhausted (%d/%d)",
375
+ dispatcher.tokens_consumed,
376
+ MAX_FETCH_TOKENS,
377
+ )
378
+ exit_reason = "max_tokens"
379
+ break
380
+
381
+ if dispatcher._call_counts.get("run_targeted_bandit", 0) > 5:
382
+ logger.warning("Bandit call limit (5) reached")
383
+ exit_reason = "max_bandit_calls"
384
+ break
385
+
386
+ if dispatcher._call_counts.get("compute_cvss", 0) > 50:
387
+ logger.warning("compute_cvss call limit (50) reached")
388
+ exit_reason = "max_cvss_calls"
389
+ break
390
+
391
+ if hasattr(event, "content") and event.content:
392
+ for part in event.content.parts or []:
393
+ if hasattr(part, "text") and part.text:
394
+ final_text += part.text
395
+ finally:
396
+ # ADK's Runner wraps this generator's `yield` in an OTEL span
397
+ # (google/adk/runners.py::_run_with_trace). Every `break` above
398
+ # leaves it suspended mid-span rather than exhausted. Left alone, the
399
+ # event loop's implicit shutdown_asyncgens() closes it later — in a
400
+ # different contextvars.Context than the one that attached the span,
401
+ # which makes the span's detach raise "Token ... was created in a
402
+ # different Context". Closing it here, in this same coroutine, closes
403
+ # the span in the Context that opened it.
404
+ with suppress(Exception):
405
+ await event_stream.aclose()
406
+
407
+ return final_text, exit_reason
408
+
409
+
410
+ # ── Main entry point ──────────────────────────────────────────────────────────
411
+
412
+
413
+ def run_agentic_reasoning(
414
+ agents: list[AgentProfile],
415
+ python_files: list[RepoFile],
416
+ static_findings: list[StaticFinding],
417
+ output_path: str | None = None,
418
+ ) -> tuple[list[RawFinding], str, dict[str, Any]]:
419
+ """
420
+ Main entry point for v2 agentic reasoning using Google ADK.
421
+
422
+ The ADK model is resolved via llm_provider.make_model() using the
423
+ SCANNER_MODEL env var.
424
+
425
+ Session storage uses InMemorySessionService (ephemeral, per-scan).
426
+
427
+ Returns:
428
+ (findings_list, summary_string, trace_metadata_dict)
429
+ """
430
+ if not agents and not python_files:
431
+ logger.info("No agents or files found — skipping agentic scan")
432
+ return [], "No agent profiles or files found to analyze.", {}
433
+
434
+ # Validate that each agent has the three fields the dispatcher and investigation
435
+ # brief depend on. Check each field value individually — a field is "missing"
436
+ # only when its value is None or an empty string, not when its string value
437
+ # differs from the field name (which was the original bug: the old code did
438
+ # `required_fields - set(values)` which always returned the full set because
439
+ # "researcher" != "agent_id", etc.).
440
+ _REQUIRED = ("agent_id", "framework", "source_file")
441
+ invalid_agents = []
442
+ for agent in agents:
443
+ missing = [f for f in _REQUIRED if not getattr(agent, f, None)]
444
+ if missing:
445
+ logger.warning(
446
+ "[reasoner] Agent '%s' missing required fields: %s — skipping",
447
+ getattr(agent, "agent_id", "?"),
448
+ missing,
449
+ )
450
+ invalid_agents.append(agent)
451
+
452
+ if invalid_agents:
453
+ agents = [a for a in agents if a not in invalid_agents]
454
+ logger.warning(
455
+ "[reasoner] Excluded %d agents with missing required fields. "
456
+ "Proceeding with %d valid agents.",
457
+ len(invalid_agents),
458
+ len(agents),
459
+ )
460
+
461
+ if not agents and not python_files:
462
+ logger.info("No valid agents or files after validation — skipping agentic scan")
463
+ return [], "No valid agent profiles or files found to analyze.", {}
464
+
465
+ try:
466
+ adk_model = make_model(scanner="agent", phase="reasoner")
467
+ logger.info("ADK model: %s", adk_model)
468
+ except (ImportError, ValueError) as e:
469
+ logger.error("ADK model resolution failed: %s", e)
470
+ logger.error("ADK setup failed — cannot proceed without ADK: %s", e)
471
+ return [], f"ADK setup failed — {e}", {"error": str(e)}
472
+
473
+ # Set up dispatcher and tracer.
474
+ # IMPORTANT: tracer must be created before build_adk_tools() is called so
475
+ # the tool closures can close over it. Previously the order was reversed,
476
+ # which meant the tracer was never passed in and the trace file showed
477
+ # calls=0 despite the agent making dozens of tool calls.
478
+ repo_files_by_path = {rf.path: rf for rf in python_files}
479
+ dispatcher = ToolDispatcher(
480
+ repo_files=repo_files_by_path,
481
+ agents=agents,
482
+ static_findings=static_findings,
483
+ )
484
+
485
+ session_id = f"AGT-{uuid.uuid4().hex[:8].upper()}"
486
+ tracer = (
487
+ FileTraceLogger(session_id=session_id, output_path=output_path)
488
+ if output_path
489
+ else LogTraceLogger()
490
+ )
491
+ tracer.start(provider_model=str(adk_model))
492
+
493
+ # Build ADK tools with the tracer now wired in — every tool call will be
494
+ # recorded in the JSONL trace file and counted in dispatcher._call_counts.
495
+ adk_tools = dispatcher.get_adk_tools(tracer=tracer)
496
+
497
+ # Build ADK agent
498
+ try:
499
+ adk_agent = _build_adk_agent(adk_model, adk_tools)
500
+ except ImportError as e:
501
+ logger.error("ADK agent build failed: %s", e)
502
+ return [], f"ADK agent build failed — {e}", {"error": str(e)}
503
+
504
+ # Build initial investigation brief
505
+ initial_ctx = _build_initial_context(agents, python_files, len(static_findings))
506
+
507
+ logger.info(
508
+ "Starting ADK scan | session: %s | agents: %d | files: %d",
509
+ session_id,
510
+ len(agents),
511
+ len(python_files),
512
+ )
513
+
514
+ # Execute ADK reasoning loop.
515
+ #
516
+ # Always run the coroutine in a dedicated worker thread with a hard
517
+ # deadline. asyncio.run() works in that thread whether or not the caller
518
+ # already has a running event loop (Jupyter/FastAPI as well as the plain
519
+ # CLI path), and future.result(timeout=...) bounds the whole loop even
520
+ # when the model call never returns a control-plane event — the in-loop
521
+ # guard in _run_adk_async only fires between events, so a stalled LLM call
522
+ # would otherwise hang forever on the CLI path. On timeout we shut the
523
+ # pool down without waiting so we don't block on the stuck worker thread.
524
+ exit_reason = "completed"
525
+ final_text = ""
526
+ # Classified where the exception still exists, then carried out in the trace
527
+ # (`error_domain`, and `error_reason` — "auth"/"model" — for the specific
528
+ # customer-actionable config failures). The exception itself is discarded here
529
+ # for telemetry safety, so the caller cannot re-classify it — see
530
+ # `_agent_scan_summary`.
531
+ error_domain: FailureDomain | None = None
532
+ error_reason: str | None = None
533
+
534
+ pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
535
+ try:
536
+ future = pool.submit(
537
+ asyncio.run,
538
+ _run_adk_async(adk_agent, initial_ctx, dispatcher, session_id, tracer),
539
+ )
540
+ final_text, exit_reason = future.result(timeout=LOOP_TIMEOUT_SECS + 30)
541
+ except concurrent.futures.TimeoutError:
542
+ logger.error("ADK execution timed out after %ds", LOOP_TIMEOUT_SECS + 30)
543
+ exit_reason = "timeout"
544
+ except Exception as e:
545
+ err_type = type(e).__name__
546
+ safe_msg = _safe_error(e)
547
+ first_line = safe_msg.split("\n", 1)[0]
548
+ logger.error("ADK execution failed (%s): %s", err_type, first_line)
549
+ logger.debug("ADK execution failed (full):\n%s", safe_msg, exc_info=True)
550
+ exit_reason = "error"
551
+ error_domain = classify_exception(e)
552
+ if is_auth_error(e):
553
+ error_reason = "auth"
554
+ elif is_model_error(e):
555
+ error_reason = "model"
556
+ finally:
557
+ pool.shutdown(wait=False)
558
+
559
+ # Collect results
560
+ findings = dispatcher.session_findings
561
+ summary = "Agentic investigation complete."
562
+
563
+ if final_text:
564
+ clean = final_text.strip()
565
+ if clean.startswith("```"):
566
+ lines = clean.splitlines()
567
+ clean = "\n".join(lines[1:]).rsplit("```", 1)[0].strip()
568
+ try:
569
+ parsed = json.loads(clean)
570
+ if isinstance(parsed, dict):
571
+ summary = parsed.get("summary", summary)
572
+ except (json.JSONDecodeError, TypeError):
573
+ if len(final_text.strip()) > 20:
574
+ summary = final_text.strip()
575
+
576
+ tracer.finish(findings_count=len(findings), exit_reason=exit_reason)
577
+ trace_meta = tracer.as_dict()
578
+ trace_meta.update(
579
+ {
580
+ "exit_reason": exit_reason,
581
+ "adk_model": str(adk_model),
582
+ "session_id": session_id,
583
+ "tokens_consumed": dispatcher.tokens_consumed,
584
+ }
585
+ )
586
+ if error_domain is not None:
587
+ # Bounded enum value, safe to carry out (unlike str(exc)); the stage
588
+ # summary reads it back to attribute the failure.
589
+ trace_meta["error_domain"] = error_domain.value
590
+ if error_reason is not None:
591
+ # "auth"/"model" — lets the caller surface a precise "API key invalid" /
592
+ # "model invalid" line to the customer. Bounded, like error_domain.
593
+ trace_meta["error_reason"] = error_reason
594
+
595
+ logger.info(
596
+ "ADK scan complete — findings: %d | exit: %s | session: %s",
597
+ len(findings),
598
+ exit_reason,
599
+ session_id,
600
+ )
601
+
602
+ return findings, summary, trace_meta