flintai-cli 1.0.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 (147) hide show
  1. flintai/__init__.py +0 -0
  2. flintai/cli/__init__.py +0 -0
  3. flintai/cli/__main__.py +3 -0
  4. flintai/cli/builtin_config.json +911 -0
  5. flintai/cli/console.py +116 -0
  6. flintai/cli/eval_cli.py +891 -0
  7. flintai/cli/init_cli.py +144 -0
  8. flintai/cli/main.py +262 -0
  9. flintai/cli/rich_observer.py +89 -0
  10. flintai/cli/runner.py +305 -0
  11. flintai/cli/scan_cli.py +238 -0
  12. flintai/cli/utils.py +37 -0
  13. flintai/cli/version.py +1 -0
  14. flintai/data/detector_prompts/llm01_adversarial.txt +55 -0
  15. flintai/data/detector_prompts/llm01_fixed.txt +8 -0
  16. flintai/data/detector_prompts/llm02_adversarial.txt +75 -0
  17. flintai/data/detector_prompts/llm02_fixed.txt +8 -0
  18. flintai/data/detector_prompts/llm05_adversarial.txt +63 -0
  19. flintai/data/detector_prompts/llm05_fixed.txt +16 -0
  20. flintai/data/detector_prompts/llm06_adversarial.txt +135 -0
  21. flintai/data/detector_prompts/llm06_fixed.txt +15 -0
  22. flintai/data/detector_prompts/llm07_adversarial.txt +185 -0
  23. flintai/data/detector_prompts/llm07_fixed.txt +14 -0
  24. flintai/data/detector_prompts/llm09_adversarial.txt +77 -0
  25. flintai/data/detector_prompts/llm09_fixed.txt +15 -0
  26. flintai/data/llm01_prompt_injection.csv +6708 -0
  27. flintai/data/llm02_sensitive_information.csv +1069 -0
  28. flintai/data/llm05_unsafe_output.csv +3882 -0
  29. flintai/data/llm06_excessive_agency.csv +778 -0
  30. flintai/data/llm07_system_prompt_leakage.csv +971 -0
  31. flintai/data/llm09_hallucination-goals.csv +32419 -0
  32. flintai/data/llm09_hallucination.csv +599 -0
  33. flintai/data/pii_leakage.csv +706 -0
  34. flintai/data/secret_leakage.csv +1380 -0
  35. flintai/eval/__init__.py +0 -0
  36. flintai/eval/common/converter_anthropic.py +151 -0
  37. flintai/eval/common/converter_genai.py +169 -0
  38. flintai/eval/common/converter_openai.py +165 -0
  39. flintai/eval/common/log.py +96 -0
  40. flintai/eval/common/reference.py +25 -0
  41. flintai/eval/common/schema.py +192 -0
  42. flintai/eval/common/utils.py +74 -0
  43. flintai/eval/core/__init__.py +0 -0
  44. flintai/eval/core/detectors/__init__.py +1 -0
  45. flintai/eval/core/detectors/detector.py +25 -0
  46. flintai/eval/core/detectors/detector_garak.py +80 -0
  47. flintai/eval/core/detectors/detector_model.py +76 -0
  48. flintai/eval/core/detectors/detector_pii.py +64 -0
  49. flintai/eval/core/detectors/detector_secret.py +82 -0
  50. flintai/eval/core/detectors/detector_topic_guard.py +70 -0
  51. flintai/eval/core/detectors/detector_toxicity.py +70 -0
  52. flintai/eval/core/eval/__init__.py +0 -0
  53. flintai/eval/core/eval/eval_creator.py +197 -0
  54. flintai/eval/core/eval/evaluation.py +100 -0
  55. flintai/eval/core/eval/evaluation_adversarial.py +523 -0
  56. flintai/eval/core/eval/evaluation_garak_module.py +52 -0
  57. flintai/eval/core/eval/evaluation_garak_probe.py +274 -0
  58. flintai/eval/core/eval/evaluation_message_list.py +44 -0
  59. flintai/eval/core/eval/evaluation_multi.py +207 -0
  60. flintai/eval/core/eval/evaluation_single.py +79 -0
  61. flintai/eval/core/eval/evaluation_single_prompt.py +58 -0
  62. flintai/eval/core/eval/evaluation_topic_guard.py +318 -0
  63. flintai/eval/core/eval/metric_conciseness.py +181 -0
  64. flintai/eval/core/eval/metric_factual_accuracy.py +294 -0
  65. flintai/eval/core/eval/metric_instruction_adherence.py +180 -0
  66. flintai/eval/core/eval/metric_tone.py +183 -0
  67. flintai/eval/core/eval/metric_toxicity.py +147 -0
  68. flintai/eval/core/eval/observer.py +136 -0
  69. flintai/eval/core/eval/probe_adversarial.py +14 -0
  70. flintai/eval/core/message/__init__.py +0 -0
  71. flintai/eval/core/message/message_collection.py +37 -0
  72. flintai/eval/core/message/message_collection_csv.py +46 -0
  73. flintai/eval/core/message/message_collection_garak.py +52 -0
  74. flintai/eval/core/message/message_collection_memory.py +36 -0
  75. flintai/eval/core/models/__init__.py +0 -0
  76. flintai/eval/core/models/generator_model.py +88 -0
  77. flintai/eval/core/models/model.py +111 -0
  78. flintai/eval/core/models/model_adk.py +158 -0
  79. flintai/eval/core/models/model_anthropic.py +51 -0
  80. flintai/eval/core/models/model_anthropic_agent.py +92 -0
  81. flintai/eval/core/models/model_gemini.py +107 -0
  82. flintai/eval/core/models/model_generic_http.py +117 -0
  83. flintai/eval/core/models/model_huggingface.py +61 -0
  84. flintai/eval/core/models/model_langserve.py +94 -0
  85. flintai/eval/core/models/model_litellm.py +38 -0
  86. flintai/eval/core/models/model_ollama.py +50 -0
  87. flintai/eval/core/models/model_openai.py +35 -0
  88. flintai/eval/core/models/model_openai_agent.py +80 -0
  89. flintai/eval/core/models/model_openai_compatible.py +57 -0
  90. flintai/eval/core/models/model_retry.py +134 -0
  91. flintai/eval/core/models/model_sync_wrapper.py +35 -0
  92. flintai/eval/db/__init__.py +0 -0
  93. flintai/eval/db/base/__init__.py +0 -0
  94. flintai/eval/db/base/detectors/__init__.py +1 -0
  95. flintai/eval/db/base/detectors/detector_helpers.py +78 -0
  96. flintai/eval/db/base/detectors/detector_repository.py +91 -0
  97. flintai/eval/db/base/detectors/detector_types.py +77 -0
  98. flintai/eval/db/base/eval/__init__.py +1 -0
  99. flintai/eval/db/base/eval/eval_helpers.py +227 -0
  100. flintai/eval/db/base/eval/eval_repository.py +115 -0
  101. flintai/eval/db/base/eval/eval_run.py +139 -0
  102. flintai/eval/db/base/eval/eval_types.py +129 -0
  103. flintai/eval/db/base/eval/model_eval_repository.py +78 -0
  104. flintai/eval/db/base/eval/model_eval_run_repository.py +70 -0
  105. flintai/eval/db/base/eval/model_eval_run_result_repository.py +40 -0
  106. flintai/eval/db/base/eval/model_eval_run_result_types.py +21 -0
  107. flintai/eval/db/base/eval/model_eval_run_types.py +84 -0
  108. flintai/eval/db/base/eval/model_eval_types.py +71 -0
  109. flintai/eval/db/base/message/__init__.py +0 -0
  110. flintai/eval/db/base/message/message_collection_helpers.py +61 -0
  111. flintai/eval/db/base/message/message_collection_repository.py +92 -0
  112. flintai/eval/db/base/message/message_collection_types.py +89 -0
  113. flintai/eval/db/base/models/__init__.py +0 -0
  114. flintai/eval/db/base/models/model_helpers.py +172 -0
  115. flintai/eval/db/base/models/model_repository.py +95 -0
  116. flintai/eval/db/base/models/model_types.py +156 -0
  117. flintai/eval/db/json/__init__.py +0 -0
  118. flintai/eval/db/json/repository_json.py +603 -0
  119. flintai/scan/__init__.py +0 -0
  120. flintai/scan/agent_scanner.py +797 -0
  121. flintai/scan/config/agent_opengrep_rules.yaml +533 -0
  122. flintai/scan/config/agent_taxonomy.json +481 -0
  123. flintai/scan/config/agentic_cvss_mapping.yaml +136 -0
  124. flintai/scan/config/compliance_mappings.json +110 -0
  125. flintai/scan/config/triage_prompt.txt +335 -0
  126. flintai/scan/constants.py +15 -0
  127. flintai/scan/file_filter.py +156 -0
  128. flintai/scan/llm_provider.py +197 -0
  129. flintai/scan/opengrep_resolver.py +12 -0
  130. flintai/scan/reasoner.py +552 -0
  131. flintai/scan/schema.py +337 -0
  132. flintai/scan/scorer.py +371 -0
  133. flintai/scan/secret_anonymizer.py +72 -0
  134. flintai/scan/static_scanner.py +542 -0
  135. flintai/scan/taxonomy.py +75 -0
  136. flintai/scan/tool_dispatcher.py +754 -0
  137. flintai/scan/trace_logger.py +118 -0
  138. flintai/scan/trace_logger_file.py +143 -0
  139. flintai/scan/trace_logger_log.py +100 -0
  140. flintai/scan/triage.py +496 -0
  141. flintai/schema.py +58 -0
  142. flintai_cli-1.0.0.dist-info/METADATA +442 -0
  143. flintai_cli-1.0.0.dist-info/RECORD +147 -0
  144. flintai_cli-1.0.0.dist-info/WHEEL +5 -0
  145. flintai_cli-1.0.0.dist-info/entry_points.txt +2 -0
  146. flintai_cli-1.0.0.dist-info/licenses/LICENSE +210 -0
  147. flintai_cli-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,754 @@
1
+ """
2
+ tool_dispatcher.py — Tool Implementations for the Agentic Reasoning Loop
3
+ =========================================================================
4
+ Routes tool calls from the AI reasoning agent to their implementations.
5
+
6
+ DESIGN PRINCIPLES:
7
+ - All tools are READ-ONLY. No tool mutates state, writes files, or makes
8
+ network calls. The repository files are already in memory from Layer 1.
9
+ - report_finding() is the only "stateful" tool — it accumulates findings
10
+ into the session list. This is intentional and auditable.
11
+ - Every tool returns a string. The string becomes the tool message in the
12
+ conversation and is visible in the trace log.
13
+ - Tool errors return an "ERROR: ..." string rather than raising exceptions,
14
+ so the agent can decide how to handle failures gracefully.
15
+
16
+ SAFETY:
17
+ - fetch_file() enforces path validation — no path traversal.
18
+ - run_targeted_bandit() is rate-limited by TOOL_CALL_LIMITS in tools.py.
19
+ - The tool allowlist is enforced at the SDK level, not here.
20
+ """
21
+
22
+ import ast
23
+ import fnmatch
24
+ import functools
25
+ import json
26
+ import logging
27
+ import os
28
+ import re
29
+ import subprocess
30
+ import tempfile
31
+ import typing
32
+
33
+ import yaml
34
+ from cvss import CVSS4
35
+ from flintai.schema import RepoFile
36
+ from flintai.scan.schema import AgentProfile, RawFinding
37
+ from flintai.scan.secret_anonymizer import anonymize_secrets
38
+ from flintai.scan.static_scanner import StaticFinding
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ # Lazy-loaded CVSS mapping — loaded once on first compute_cvss() call
44
+ _CVSS_MAPPING: dict | None = None
45
+ _CVSS_MAPPING_PATH = os.path.join(
46
+ os.path.dirname(os.path.abspath(__file__)), "config", "agentic_cvss_mapping.yaml"
47
+ )
48
+
49
+
50
+ def _load_cvss_mapping() -> dict:
51
+ """Load agentic_cvss_mapping.yaml once and cache it."""
52
+ global _CVSS_MAPPING
53
+ if _CVSS_MAPPING is not None:
54
+ return _CVSS_MAPPING
55
+ try:
56
+ with open(_CVSS_MAPPING_PATH, "r", encoding="utf-8") as f:
57
+ data = yaml.safe_load(f)
58
+ _CVSS_MAPPING = data.get("agentic_cvss_mapping", {})
59
+ return _CVSS_MAPPING
60
+ except Exception as e:
61
+ logger.warning("Could not load CVSS mapping: %s", e)
62
+ _CVSS_MAPPING = {}
63
+ return _CVSS_MAPPING
64
+
65
+
66
+ # ── Token approximation ───────────────────────────────────────────────────────
67
+ MAX_TOKENS_PER_FILE = 4000 # per fetch_file() call
68
+ CHARS_PER_TOKEN = 4 # rough approximation
69
+
70
+
71
+ def _truncate(text: str, max_tokens: int = MAX_TOKENS_PER_FILE) -> str:
72
+ max_chars = max_tokens * CHARS_PER_TOKEN
73
+ if len(text) <= max_chars:
74
+ return text
75
+ return (
76
+ text[:max_chars]
77
+ + f"\n\n[... truncated at {max_tokens} tokens. Use start_line/end_line to read more ...]" # noqa: B950
78
+ )
79
+
80
+
81
+ # ── Session state — populated by ToolDispatcher.__init__ ─────────────────────
82
+
83
+
84
+ class ToolDispatcher:
85
+ """
86
+ Stateful dispatcher for a single scan session.
87
+
88
+ Holds references to the in-memory repository data so tool implementations
89
+ can work without any I/O. Tracks per-tool call counts for rate limiting.
90
+
91
+ Usage:
92
+ dispatcher = ToolDispatcher(
93
+ repo_files={"crew.py": RepoFile(...), ...},
94
+ agents=[AgentProfile(...), ...],
95
+ static_findings=[StaticFinding(...), ...],
96
+ )
97
+ result = dispatcher.dispatch("fetch_file", {"path": "crew.py"})
98
+ """
99
+
100
+ def __init__(
101
+ self,
102
+ repo_files: dict[str, RepoFile],
103
+ agents: list[AgentProfile],
104
+ static_findings: list[StaticFinding],
105
+ ):
106
+ self._files = repo_files # {relative_path: RepoFile}
107
+ self._agents = {a.agent_id: a for a in agents}
108
+ self._findings = static_findings
109
+ self._session_findings: list[RawFinding] = []
110
+ self._call_counts: dict[str, int] = {} # tool_name → call count
111
+ self._tokens_consumed: int = 0 # total tokens fetched via fetch_file
112
+
113
+ # Build a flattened search index: (path, line_number, line_text)
114
+ self._search_index: list[tuple[str, int, str]] = []
115
+ for path, rf in self._files.items():
116
+ for i, line in enumerate(rf.content.splitlines(), start=1):
117
+ self._search_index.append((path, i, line))
118
+
119
+ @property
120
+ def session_findings(self) -> list[RawFinding]:
121
+ """All findings reported via report_finding() during this session."""
122
+ return self._session_findings
123
+
124
+ @property
125
+ def tokens_consumed(self) -> int:
126
+ """Total approximate tokens fetched via fetch_file() during this session."""
127
+ return self._tokens_consumed
128
+
129
+ # ── Tool methods (ADK-facing interface) ──────────────────────────────────
130
+
131
+ def read_source(
132
+ self,
133
+ resource_type: str,
134
+ path: str = "",
135
+ pattern: str = "",
136
+ start_line: int = 0,
137
+ end_line: int = 0,
138
+ ) -> str:
139
+ """Read source data from the scanned repository.
140
+
141
+ Use resource_type to select what to read:
142
+ - 'file': Fetch file content (supports line ranges). Returns up to 4000 tokens.
143
+ - 'agent': Return the full extracted AgentProfile for an agent, untruncated.
144
+ - 'list': List all available files, optionally filtered by a glob pattern.
145
+
146
+ Args:
147
+ resource_type: What to read — 'file', 'agent', or 'list'.
148
+ path: For 'file': relative file path. For 'agent': the agent_id.
149
+ pattern: For 'list': optional glob pattern (e.g. '*.py').
150
+ start_line: For 'file': first line to return (1-indexed, optional).
151
+ end_line: For 'file': last line to return (optional).
152
+
153
+ Returns:
154
+ File content, agent profile, or file listing. ERROR string on failure.
155
+ """
156
+ self._call_counts["read_source"] = self._call_counts.get("read_source", 0) + 1
157
+ if resource_type == "file":
158
+ if not path:
159
+ return "ERROR: 'path' is required when resource_type='file'."
160
+ self._call_counts["fetch_file"] = self._call_counts.get("fetch_file", 0) + 1
161
+ return self._fetch_file(
162
+ path,
163
+ start_line if start_line else None,
164
+ end_line if end_line else None,
165
+ )
166
+ elif resource_type == "agent":
167
+ if not path:
168
+ return (
169
+ "ERROR: 'path' (agent_id) is required when resource_type='agent'."
170
+ )
171
+ return self._get_agent_profile(path)
172
+ elif resource_type == "list":
173
+ return self._list_files(pattern or None)
174
+ return (
175
+ f"ERROR: Invalid resource_type '{resource_type}'. "
176
+ "Use 'file', 'agent', or 'list'."
177
+ )
178
+
179
+ def analyze_code(
180
+ self,
181
+ mode: str,
182
+ pattern: str = "",
183
+ file_path: str = "",
184
+ file_extension: str = "",
185
+ max_results: int = 20,
186
+ ) -> str:
187
+ """Analyze code patterns in the repository.
188
+
189
+ Use mode to select the analysis type:
190
+ - 'search': Search all files for lines matching a regex or substring.
191
+ - 'imports': List all imports in a Python file and identify repo-local ones.
192
+
193
+ Args:
194
+ mode: 'search' for pattern matching, 'imports' for import resolution.
195
+ pattern: For 'search': regex or substring (case-insensitive).
196
+ file_path: For 'imports': relative path of the Python file.
197
+ file_extension: For 'search': optional filter (e.g. '.py').
198
+ max_results: For 'search': max matching lines (default 20).
199
+
200
+ Returns:
201
+ Matching results or import list. ERROR string on failure.
202
+ """
203
+ self._call_counts["analyze_code"] = self._call_counts.get("analyze_code", 0) + 1
204
+ if mode == "search":
205
+ if not pattern:
206
+ return "ERROR: 'pattern' is required when mode='search'."
207
+ return self._search_codebase(pattern, file_extension or None, max_results)
208
+ elif mode == "imports":
209
+ if not file_path:
210
+ return "ERROR: 'file_path' is required when mode='imports'."
211
+ return self._resolve_imports(file_path)
212
+ return f"ERROR: Invalid mode '{mode}'. Use 'search' or 'imports'."
213
+
214
+ def get_findings(
215
+ self,
216
+ file_path: str,
217
+ mode: str = "cached",
218
+ ) -> str:
219
+ """Retrieve security findings for a specific file.
220
+
221
+ Use mode to select the source:
222
+ - 'cached': Return pre-computed findings from Bandit, OpenGrep,
223
+ and detect-secrets (fast, no cost).
224
+ - 'fresh': Run Bandit now (slower, rate-limited to 5 calls/session).
225
+
226
+ Args:
227
+ file_path: Relative path of the file.
228
+ mode: 'cached' for pre-computed, 'fresh' to run Bandit. Default: 'cached'.
229
+
230
+ Returns:
231
+ Security findings for the file. ERROR string on failure.
232
+ """
233
+ self._call_counts["get_findings"] = self._call_counts.get("get_findings", 0) + 1
234
+ if mode == "fresh":
235
+ return self._run_targeted_bandit(file_path)
236
+ return self._get_static_findings_for_file(file_path)
237
+
238
+ # ── Tool implementations ─────────────────────────────────────────────────
239
+
240
+ def _fetch_file(
241
+ self,
242
+ path: str,
243
+ start_line: int | None = None,
244
+ end_line: int | None = None,
245
+ ) -> str:
246
+ """
247
+ Return the content of a repository file.
248
+ Supports optional line range for targeted reads of large files.
249
+ """
250
+ # Path validation — prevent traversal and reject negative line numbers.
251
+ # Use normpath + split to check individual path components rather than
252
+ # a substring match, which is bypassable with lstrip tricks.
253
+ normalized = os.path.normpath(path)
254
+ if any(part == ".." for part in normalized.replace("\\", "/").split("/")):
255
+ return f"ERROR: Path traversal not permitted: {path!r}"
256
+
257
+ # Exact match first
258
+ rf = self._files.get(path) or self._files.get(normalized)
259
+
260
+ # Fuzzy match: if path is a filename, find it in any directory
261
+ if rf is None:
262
+ basename = os.path.basename(path)
263
+ matches = [p for p in self._files if os.path.basename(p) == basename]
264
+ if len(matches) == 1:
265
+ rf = self._files[matches[0]]
266
+ path = matches[0] # update path for display
267
+ elif len(matches) > 1:
268
+ return (
269
+ f"ERROR: Ambiguous path '{path}' matches multiple files:\n"
270
+ + "\n".join(f" {m}" for m in sorted(matches))
271
+ + "\nPlease specify the full path."
272
+ )
273
+
274
+ if rf is None:
275
+ available = sorted(self._files.keys())[:20]
276
+ return (
277
+ f"ERROR: File not found: '{path}'\n"
278
+ f"Available files (first 20):\n"
279
+ + "\n".join(f" {p}" for p in available)
280
+ )
281
+
282
+ lines = rf.content.splitlines()
283
+ total = len(lines)
284
+
285
+ if start_line is not None or end_line is not None:
286
+ # Reject negative line numbers — they would silently clamp to 1
287
+ # via max(1, ...) and confuse the agent about what was returned.
288
+ if start_line is not None and start_line < 1:
289
+ return (
290
+ f"ERROR: start_line must be >= 1, got {start_line}. "
291
+ f"Use a value between 1 and {total}."
292
+ )
293
+ if end_line is not None and end_line < 1:
294
+ return (
295
+ f"ERROR: end_line must be >= 1, got {end_line}. "
296
+ f"Use a value between 1 and {total}."
297
+ )
298
+ # Fix #27: Validate line number bounds before slicing.
299
+ # Out-of-range requests return an empty result that confuses the agent.
300
+ if start_line is not None and start_line > total:
301
+ return (
302
+ f"ERROR: start_line {start_line} exceeds file length ({total} lines) "
303
+ f"for '{path}'. Use a value between 1 and {total}."
304
+ )
305
+ if end_line is not None and end_line > total:
306
+ # Clamp silently — requesting lines past EOF is a benign mistake.
307
+ end_line = total
308
+
309
+ s = max(1, start_line or 1) - 1 # convert to 0-indexed
310
+ e = min(total, end_line or total)
311
+ selected = lines[s:e]
312
+ header = f"# {path} (lines {s+1}–{e} of {total})\n"
313
+ content = header + "\n".join(
314
+ f"{s+1+i:4d} {line}" for i, line in enumerate(selected)
315
+ )
316
+ else:
317
+ header = f"# {path} ({total} lines)\n"
318
+ content = header + "\n".join(
319
+ f"{i+1:4d} {line}" for i, line in enumerate(lines)
320
+ )
321
+
322
+ result = _truncate(content)
323
+ result = anonymize_secrets(result)
324
+ self._tokens_consumed += len(result) // CHARS_PER_TOKEN
325
+ return result
326
+
327
+ def _get_agent_profile(self, agent_id: str) -> str:
328
+ """Return the full AgentProfile for an agent, without truncation."""
329
+ agent = self._agents.get(agent_id)
330
+ if agent is None:
331
+ available = sorted(self._agents.keys())
332
+ return (
333
+ f"ERROR: Agent '{agent_id}' not found.\n"
334
+ f"Available agents: {available}"
335
+ )
336
+ parts = [
337
+ f"AgentProfile: {agent.agent_id}",
338
+ f" Framework: {agent.framework}",
339
+ f" Source file: {agent.source_file}",
340
+ f" Role: {agent.role or 'not specified'}",
341
+ f" Goal: {agent.goal or 'not specified'}",
342
+ f" Backstory: {agent.backstory or 'not specified'}",
343
+ f" LLM: {agent.llm or 'not specified'}",
344
+ f" Memory: {agent.memory}",
345
+ f" Allow delegation: {agent.allow_delegation}",
346
+ f" Verbose: {agent.verbose}",
347
+ f" Tools: {agent.tools or []}",
348
+ f" Tool details: {agent.tool_details or []}",
349
+ f" System prompt: {agent.system_prompt or 'not specified'}",
350
+ f"\n--- Full raw_code ---\n{anonymize_secrets(agent.raw_code)}",
351
+ ]
352
+ return "\n".join(parts)
353
+
354
+ def _resolve_imports(self, file_path: str) -> str:
355
+ """
356
+ Parse a Python file's import statements and identify which imported
357
+ modules are also present in the repository.
358
+ """
359
+ rf = self._files.get(file_path)
360
+ if rf is None:
361
+ return f"ERROR: File not found: '{file_path}'"
362
+
363
+ try:
364
+ tree = ast.parse(rf.content)
365
+ except SyntaxError as e:
366
+ return f"ERROR: Could not parse {file_path}: {e}"
367
+
368
+ stdlib_skip = {
369
+ "os",
370
+ "sys",
371
+ "re",
372
+ "json",
373
+ "time",
374
+ "datetime",
375
+ "pathlib",
376
+ "typing",
377
+ "dataclasses",
378
+ "abc",
379
+ "uuid",
380
+ "tempfile",
381
+ "subprocess",
382
+ "logging",
383
+ "argparse",
384
+ "base64",
385
+ "urllib",
386
+ "collections",
387
+ "itertools",
388
+ "functools",
389
+ }
390
+
391
+ imported = []
392
+ for node in ast.walk(tree):
393
+ if isinstance(node, ast.Import):
394
+ for alias in node.names:
395
+ imported.append(alias.name.split(".")[0])
396
+ elif isinstance(node, ast.ImportFrom):
397
+ if node.module:
398
+ imported.append(node.module.split(".")[0])
399
+
400
+ results = []
401
+ for mod in sorted(set(imported)):
402
+ if mod in stdlib_skip:
403
+ continue
404
+ # Look for matching file in repo
405
+ candidates = [
406
+ p for p in self._files if os.path.basename(p).replace(".py", "") == mod
407
+ ]
408
+ if candidates:
409
+ results.append(f" {mod} → {candidates[0]} [IN REPO — can fetch]")
410
+ else:
411
+ results.append(f" {mod} → (external package or not fetched)")
412
+
413
+ if not results:
414
+ return f"No non-stdlib imports found in {file_path}"
415
+
416
+ return f"Imports in {file_path}:\n" + "\n".join(results)
417
+
418
+ def _search_codebase(
419
+ self,
420
+ pattern: str,
421
+ file_extension: str | None = None,
422
+ max_results: int = 20,
423
+ ) -> str:
424
+ """
425
+ Search all fetched files for lines matching a pattern.
426
+ Returns file:line matches with surrounding context.
427
+ """
428
+ matches = []
429
+ regex = None
430
+ try:
431
+ regex = re.compile(pattern, re.IGNORECASE)
432
+ except re.error:
433
+ # Fall back to literal substring match
434
+ regex = None
435
+
436
+ for file_path, line_no, line_text in self._search_index:
437
+ if file_extension and not file_path.endswith(file_extension):
438
+ continue
439
+ hit = (
440
+ (regex.search(line_text) is not None)
441
+ if regex
442
+ else (pattern.lower() in line_text.lower())
443
+ )
444
+ if hit:
445
+ matches.append(f" {file_path}:{line_no}: {line_text.rstrip()}")
446
+ if len(matches) >= max_results:
447
+ break
448
+
449
+ if not matches:
450
+ return f"No matches found for pattern '{pattern}'"
451
+
452
+ header = f"Search results for '{pattern}' ({len(matches)} matches"
453
+ if len(matches) == max_results:
454
+ header += f", limit reached — narrow your pattern for more precision" # noqa: F541
455
+ header += "):"
456
+ return header + "\n" + "\n".join(matches)
457
+
458
+ def _run_targeted_bandit(self, file_path: str) -> str:
459
+ """
460
+ Run Bandit on a specific file from the in-memory repository.
461
+ Writes the file to a temp dir, runs Bandit, returns results.
462
+ """
463
+ rf = self._files.get(file_path)
464
+ if rf is None:
465
+ return f"ERROR: File not found: '{file_path}'"
466
+
467
+ if not file_path.endswith(".py"):
468
+ return f"ERROR: Bandit only works on Python files: '{file_path}'"
469
+
470
+ try:
471
+ with tempfile.TemporaryDirectory() as tmp:
472
+ safe_name = os.path.basename(file_path).replace("/", "_")
473
+ tmp_path = os.path.join(tmp, safe_name)
474
+ with open(tmp_path, "w", encoding="utf-8") as f:
475
+ f.write(rf.content)
476
+
477
+ result = subprocess.run(
478
+ ["bandit", tmp_path, "-f", "json", "-q", "--severity-level", "low"],
479
+ capture_output=True,
480
+ text=True,
481
+ timeout=30,
482
+ )
483
+ stdout = result.stdout.strip()
484
+
485
+ # Fix #8: Guard against pathologically large Bandit output.
486
+ # A well-formed Python file should never produce more than ~500 KB
487
+ # of Bandit JSON. If it does, truncate before parsing to prevent
488
+ # memory exhaustion on adversarially crafted files.
489
+ _MAX_BANDIT_OUTPUT = 500_000 # bytes
490
+ if len(stdout) > _MAX_BANDIT_OUTPUT:
491
+ stdout = stdout[:_MAX_BANDIT_OUTPUT]
492
+ # The truncated JSON will fail to parse below — handle gracefully.
493
+
494
+ if not stdout:
495
+ return f"Bandit found no issues in {file_path}"
496
+
497
+ data = json.loads(stdout)
498
+ issues = data.get("results", [])
499
+ if not issues:
500
+ return f"Bandit found no issues in {file_path}"
501
+
502
+ lines = [f"Bandit results for {file_path} ({len(issues)} issues):"]
503
+ for issue in issues:
504
+ lines.append(
505
+ f" [{issue.get('issue_severity', '?')}] "
506
+ f"Line {issue.get('line_number', '?')}: "
507
+ f"{issue.get('test_id', '?')} — {issue.get('issue_text', '')}"
508
+ )
509
+ if issue.get("code"):
510
+ lines.append(f" Code: {issue['code'][:100].strip()}")
511
+ return "\n".join(lines)
512
+
513
+ except FileNotFoundError:
514
+ return "ERROR: Bandit is not installed or not on PATH"
515
+ except subprocess.TimeoutExpired:
516
+ return f"ERROR: Bandit timed out scanning {file_path}"
517
+ except Exception as e:
518
+ return f"ERROR: Bandit scan failed: {e}"
519
+
520
+ def _get_static_findings_for_file(self, file_path: str) -> str:
521
+ """Return pre-computed static findings attributed to a specific file."""
522
+ basename = os.path.basename(file_path)
523
+ matching = [
524
+ f
525
+ for f in self._findings
526
+ if (
527
+ hasattr(f, "filepath")
528
+ and (
529
+ f.filepath == file_path or os.path.basename(f.filepath) == basename
530
+ )
531
+ )
532
+ ]
533
+ if not matching:
534
+ return f"No pre-computed static findings for '{file_path}'"
535
+
536
+ lines = [f"Static findings for {file_path} ({len(matching)} findings):"]
537
+ for f in matching:
538
+ lines.append(
539
+ f" [{f.severity.upper()}] {f.tool.upper()} rule {f.rule_id} "
540
+ f"line {f.line}: {f.message}"
541
+ )
542
+ if f.evidence:
543
+ lines.append(
544
+ f" Evidence: {anonymize_secrets(f.evidence[:120].strip())}"
545
+ )
546
+ return "\n".join(lines)
547
+
548
+ def _list_files(self, pattern: str | None = None) -> str:
549
+ """List all fetched file paths, optionally filtered by glob pattern."""
550
+ paths = sorted(self._files.keys())
551
+ if pattern:
552
+ paths = [
553
+ p
554
+ for p in paths
555
+ if fnmatch.fnmatch(p, pattern)
556
+ or fnmatch.fnmatch(os.path.basename(p), pattern)
557
+ ]
558
+ if not paths:
559
+ return (
560
+ f"No files match pattern '{pattern}'"
561
+ if pattern
562
+ else "No files in repository."
563
+ )
564
+ return f"Files ({len(paths)}):\n" + "\n".join(f" {p}" for p in paths)
565
+
566
+ def compute_cvss(
567
+ self,
568
+ vuln_type: str,
569
+ exposed_over_network: bool = False,
570
+ requires_auth: bool = False,
571
+ user_interaction: bool = False,
572
+ affects_remote_system: bool = False,
573
+ ) -> str:
574
+ """Compute a deterministic CVSS v4 base score for a confirmed vulnerability.
575
+
576
+ ALWAYS call this before calling report_finding(). Use the score and
577
+ vector it returns in your finding. Do not estimate severity yourself.
578
+ vuln_type must exactly match a key in the OWASP ASI taxonomy
579
+ (e.g. 'hardcoded_credentials', 'arbitrary_code_execution').
580
+
581
+ Args:
582
+ vuln_type: Subcategory key from the OWASP ASI taxonomy.
583
+ exposed_over_network: True if the component is network-reachable.
584
+ requires_auth: True if exploitation requires authentication.
585
+ user_interaction: True if a user must act for exploitation.
586
+ affects_remote_system: True if impact extends to downstream systems.
587
+
588
+ Returns:
589
+ JSON string with vuln_type, vector, score, and severity.
590
+ """
591
+ self._call_counts["compute_cvss"] = self._call_counts.get("compute_cvss", 0) + 1
592
+ mapping = _load_cvss_mapping()
593
+
594
+ if vuln_type not in mapping:
595
+ available = ", ".join(sorted(mapping.keys())[:10])
596
+ return (
597
+ f"ERROR: vuln_type '{vuln_type}' not found in CVSS mapping.\n"
598
+ f"Available types (first 10): {available}\n"
599
+ f"Check config/agentic_cvss_mapping.yaml for the full list."
600
+ )
601
+
602
+ base_vector = mapping[vuln_type].get("vector", "")
603
+ if not base_vector:
604
+ return f"ERROR: No base CVSS vector found for '{vuln_type}'"
605
+
606
+ # Parse vector components into a mutable dict
607
+ metrics = dict(re.findall(r"([A-Z]{1,2}):([A-Z]{1,2})", base_vector))
608
+
609
+ # Apply context flag overrides
610
+ if exposed_over_network:
611
+ metrics["AV"] = "N"
612
+ if requires_auth:
613
+ metrics["PR"] = "L"
614
+ if user_interaction:
615
+ metrics["UI"] = "P"
616
+ if affects_remote_system:
617
+ metrics["SC"] = "H"
618
+ metrics["SI"] = "H"
619
+ metrics["SA"] = "H"
620
+
621
+ # Reconstruct vector string
622
+ new_vector = "CVSS:4.0/" + "/".join(f"{k}:{v}" for k, v in metrics.items())
623
+
624
+ # Compute score using the cvss library
625
+ try:
626
+ cvss_obj = CVSS4(new_vector)
627
+ score = round(cvss_obj.base_score, 1)
628
+ severity = cvss_obj.severities()[
629
+ 0
630
+ ] # 'Critical' | 'High' | 'Medium' | 'Low' | 'None'
631
+ except Exception as e:
632
+ return f"ERROR: CVSS computation failed for vector '{new_vector}': {e}"
633
+
634
+ result = {
635
+ "vuln_type": vuln_type,
636
+ "vector": new_vector,
637
+ "score": score,
638
+ "severity": severity,
639
+ "overrides": {
640
+ "exposed_over_network": exposed_over_network,
641
+ "requires_auth": requires_auth,
642
+ "user_interaction": user_interaction,
643
+ "affects_remote_system": affects_remote_system,
644
+ },
645
+ }
646
+ return json.dumps(result)
647
+
648
+ def report_finding(
649
+ self,
650
+ category: str,
651
+ subcategory: str,
652
+ title: str,
653
+ description: str,
654
+ impact: str,
655
+ remediation: str,
656
+ affected_component: str,
657
+ evidence: str,
658
+ confidence: str,
659
+ hallucination_flag: bool,
660
+ evidence_file: str = "",
661
+ evidence_line: int = 0,
662
+ agent_name: str = "",
663
+ ) -> str:
664
+ """Record a confirmed security finding.
665
+
666
+ Call compute_cvss() first to get the correct score and vector, then
667
+ call this function to record the finding. Do not call this with
668
+ speculative findings — only report what you can directly evidence.
669
+
670
+ Args:
671
+ category: ASI category key (e.g. 'asi01_agent_goal_hijack').
672
+ subcategory: Specific vulnerability type (e.g. 'direct_prompt_injection').
673
+ title: Short human-readable title.
674
+ description: Technical description of the issue.
675
+ impact: What an attacker could achieve.
676
+ remediation: Concrete fix guidance.
677
+ affected_component: File, agent ID, or tool name.
678
+ evidence: Direct code snippet or pattern (max 200 chars).
679
+ confidence: One of 'high', 'medium', 'low'.
680
+ hallucination_flag: True if you suspect but cannot fully prove the issue.
681
+ evidence_file: Relative file path where the vulnerability was found.
682
+ evidence_line: Line number in the file (use 0 if unknown).
683
+ agent_name: The Agent name from the AGENT PROFILES.
684
+
685
+ Returns:
686
+ Confirmation string with the finding number.
687
+ """
688
+ self._call_counts["report_finding"] = (
689
+ self._call_counts.get("report_finding", 0) + 1
690
+ )
691
+ finding = {
692
+ "category": category,
693
+ "subcategory": subcategory,
694
+ "title": title,
695
+ "description": description,
696
+ "impact": impact,
697
+ "remediation": remediation,
698
+ "affected_component": affected_component,
699
+ "evidence": anonymize_secrets(evidence[:200]),
700
+ "confidence": confidence,
701
+ "hallucination_flag": bool(hallucination_flag),
702
+ "evidence_file": evidence_file,
703
+ "evidence_line": int(evidence_line),
704
+ "agent_name": agent_name,
705
+ }
706
+ self._session_findings.append(finding)
707
+ return (
708
+ f"Finding #{len(self._session_findings)} recorded: "
709
+ f"[{category}/{subcategory}] {title} (confidence={confidence})"
710
+ )
711
+
712
+ def get_adk_tools(self, tracer=None) -> list:
713
+ """Return ADK-compatible tool callables for this dispatcher.
714
+
715
+ When tracer is provided, wraps each tool call with tracer.record_call()
716
+ so the JSONL trace file records the agent's investigation.
717
+
718
+ Resolves deferred type annotations (from __future__ import annotations)
719
+ so ADK's function parameter parser gets real types, not strings.
720
+ """
721
+ tools = [
722
+ self.read_source,
723
+ self.analyze_code,
724
+ self.get_findings,
725
+ self.compute_cvss,
726
+ self.report_finding,
727
+ ]
728
+ for tool in tools:
729
+ fn = tool.__func__ if hasattr(tool, "__func__") else tool
730
+ try:
731
+ fn.__annotations__ = typing.get_type_hints(fn)
732
+ except Exception:
733
+ pass
734
+
735
+ if tracer is None:
736
+ return tools
737
+
738
+ def _wrap(tool_name: str, fn):
739
+ @functools.wraps(fn)
740
+ def traced(*args, **kwargs):
741
+ with tracer.record_call(tool_name, kwargs, tracer._iterations) as ctx:
742
+ result = fn(*args, **kwargs)
743
+ ctx.set_result(result)
744
+ return result
745
+
746
+ return traced
747
+
748
+ return [
749
+ _wrap("read_source", self.read_source),
750
+ _wrap("analyze_code", self.analyze_code),
751
+ _wrap("get_findings", self.get_findings),
752
+ _wrap("compute_cvss", self.compute_cvss),
753
+ _wrap("report_finding", self.report_finding),
754
+ ]