openhack 0.1.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 (113) hide show
  1. openhack/__init__.py +2 -0
  2. openhack/__main__.py +225 -0
  3. openhack/agents/__init__.py +30 -0
  4. openhack/agents/base.py +230 -0
  5. openhack/agents/browser_verifier.py +679 -0
  6. openhack/agents/browser_verifier_swarm.py +256 -0
  7. openhack/agents/checkpoint.py +89 -0
  8. openhack/agents/context_manager.py +356 -0
  9. openhack/agents/coordinator.py +1105 -0
  10. openhack/agents/endpoint_analyst.py +307 -0
  11. openhack/agents/feature_hunter.py +93 -0
  12. openhack/agents/hunter.py +481 -0
  13. openhack/agents/hunter_swarm.py +385 -0
  14. openhack/agents/llm.py +334 -0
  15. openhack/agents/recon.py +19 -0
  16. openhack/agents/sandbox_verifier.py +396 -0
  17. openhack/agents/sandbox_verifier_swarm.py +250 -0
  18. openhack/agents/session.py +286 -0
  19. openhack/agents/validator.py +217 -0
  20. openhack/agents/validator_swarm.py +106 -0
  21. openhack/auth.py +175 -0
  22. openhack/browser/__init__.py +12 -0
  23. openhack/browser/runner.py +385 -0
  24. openhack/categories.py +130 -0
  25. openhack/config.py +201 -0
  26. openhack/deterministic_recon.py +464 -0
  27. openhack/entry_points.py +745 -0
  28. openhack/framework_classifier.py +515 -0
  29. openhack/framework_detection.py +269 -0
  30. openhack/headless_scan.py +179 -0
  31. openhack/prompts/__init__.py +108 -0
  32. openhack/prompts/browser_verifier.py +171 -0
  33. openhack/prompts/coordinator.py +31 -0
  34. openhack/prompts/django/__init__.py +32 -0
  35. openhack/prompts/django/auth_bypass.py +76 -0
  36. openhack/prompts/django/csrf.py +62 -0
  37. openhack/prompts/django/data_exposure.py +67 -0
  38. openhack/prompts/django/idor.py +74 -0
  39. openhack/prompts/django/injection.py +67 -0
  40. openhack/prompts/django/misconfiguration.py +70 -0
  41. openhack/prompts/django/ssrf.py +64 -0
  42. openhack/prompts/endpoint_analyst.py +122 -0
  43. openhack/prompts/express/__init__.py +29 -0
  44. openhack/prompts/express/auth_bypass.py +71 -0
  45. openhack/prompts/express/data_exposure.py +77 -0
  46. openhack/prompts/express/idor.py +69 -0
  47. openhack/prompts/express/injection.py +75 -0
  48. openhack/prompts/express/misconfiguration.py +72 -0
  49. openhack/prompts/express/ssrf.py +63 -0
  50. openhack/prompts/feature_hunter.py +140 -0
  51. openhack/prompts/flask/__init__.py +29 -0
  52. openhack/prompts/flask/auth_bypass.py +86 -0
  53. openhack/prompts/flask/data_exposure.py +78 -0
  54. openhack/prompts/flask/idor.py +83 -0
  55. openhack/prompts/flask/injection.py +77 -0
  56. openhack/prompts/flask/misconfiguration.py +73 -0
  57. openhack/prompts/flask/ssrf.py +65 -0
  58. openhack/prompts/hunter.py +362 -0
  59. openhack/prompts/hunter_continuation_loop.py +12 -0
  60. openhack/prompts/hunter_continuation_no_findings.py +19 -0
  61. openhack/prompts/hunter_continuation_no_progress.py +22 -0
  62. openhack/prompts/hunter_tool_instructions.py +55 -0
  63. openhack/prompts/nextjs/__init__.py +42 -0
  64. openhack/prompts/nextjs/auth_bypass.py +80 -0
  65. openhack/prompts/nextjs/csrf.py +71 -0
  66. openhack/prompts/nextjs/data_exposure.py +88 -0
  67. openhack/prompts/nextjs/idor.py +64 -0
  68. openhack/prompts/nextjs/injection.py +65 -0
  69. openhack/prompts/nextjs/middleware_bypass.py +75 -0
  70. openhack/prompts/nextjs/misconfiguration.py +92 -0
  71. openhack/prompts/nextjs/server_actions.py +97 -0
  72. openhack/prompts/nextjs/ssrf.py +66 -0
  73. openhack/prompts/nextjs/xss.py +69 -0
  74. openhack/prompts/pr_analysis_system.py +80 -0
  75. openhack/prompts/pr_analysis_user.py +11 -0
  76. openhack/prompts/project_context.py +89 -0
  77. openhack/prompts/recon.py +199 -0
  78. openhack/prompts/reporter.py +88 -0
  79. openhack/prompts/researchers.py +434 -0
  80. openhack/prompts/sandbox_verifier.py +128 -0
  81. openhack/prompts/supabase/__init__.py +39 -0
  82. openhack/prompts/supabase/auth_tokens.py +131 -0
  83. openhack/prompts/supabase/edge_functions.py +150 -0
  84. openhack/prompts/supabase/graphql.py +102 -0
  85. openhack/prompts/supabase/postgrest.py +99 -0
  86. openhack/prompts/supabase/realtime.py +93 -0
  87. openhack/prompts/supabase/rls.py +110 -0
  88. openhack/prompts/supabase/rpc_functions.py +127 -0
  89. openhack/prompts/supabase/storage.py +110 -0
  90. openhack/prompts/supabase/tenant_isolation.py +118 -0
  91. openhack/prompts/validator.py +319 -0
  92. openhack/prompts/validator_continuation_incomplete.py +12 -0
  93. openhack/prompts/validator_tool_instructions.py +29 -0
  94. openhack/quality.py +231 -0
  95. openhack/sandbox/__init__.py +12 -0
  96. openhack/sandbox/orchestrator.py +517 -0
  97. openhack/sandbox/runner.py +177 -0
  98. openhack/scan_session.py +245 -0
  99. openhack/setup.py +452 -0
  100. openhack/static_validator.py +612 -0
  101. openhack/tools/__init__.py +1 -0
  102. openhack/tools/ast_tools.py +307 -0
  103. openhack/tools/coverage.py +1078 -0
  104. openhack/tools/filesystem.py +404 -0
  105. openhack/tools/nextjs.py +258 -0
  106. openhack/tools/registry.py +52 -0
  107. openhack/tui.py +3450 -0
  108. openhack/updates.py +170 -0
  109. openhack-0.1.0.dist-info/METADATA +189 -0
  110. openhack-0.1.0.dist-info/RECORD +113 -0
  111. openhack-0.1.0.dist-info/WHEEL +4 -0
  112. openhack-0.1.0.dist-info/entry_points.txt +2 -0
  113. openhack-0.1.0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,396 @@
1
+ """
2
+ Sandbox verifier agent.
3
+
4
+ Takes a confirmed finding and iteratively develops a working exploit
5
+ by executing requests against a live sandboxed instance of the target app.
6
+ The agent adapts its approach based on responses, trying multiple strategies
7
+ before concluding whether a vulnerability is exploitable.
8
+ """
9
+
10
+ import json
11
+ import logging
12
+ from typing import Optional
13
+
14
+ from .base import BaseAgent
15
+ from .llm import Message, ToolResult
16
+ from ..sandbox.runner import ExploitRunner, ExploitResult
17
+ from openhack.prompts import SANDBOX_VERIFIER_PROMPT, SANDBOX_VERIFIER_TOOL_INSTRUCTIONS
18
+ from openhack.prompts import format_project_context
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ # ── Tool definitions for the sandbox verifier ──────────────────────
24
+
25
+ SANDBOX_HTTP_REQUEST_TOOL = {
26
+ "name": "sandbox_http_request",
27
+ "description": (
28
+ "Execute an HTTP request against the live sandboxed application. "
29
+ "Use this to test exploit payloads. Returns full response including "
30
+ "status code, headers, and body."
31
+ ),
32
+ "parameters": {
33
+ "type": "object",
34
+ "properties": {
35
+ "method": {
36
+ "type": "string",
37
+ "enum": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
38
+ "description": "HTTP method",
39
+ },
40
+ "path": {
41
+ "type": "string",
42
+ "description": "URL path (e.g., /api/users/1). Will be prefixed with the sandbox base URL.",
43
+ },
44
+ "headers": {
45
+ "type": "object",
46
+ "description": "HTTP headers as key-value pairs",
47
+ "additionalProperties": {"type": "string"},
48
+ },
49
+ "body": {
50
+ "type": "string",
51
+ "description": "Raw request body (for form data, XML, etc.)",
52
+ },
53
+ "json_body": {
54
+ "type": "object",
55
+ "description": "JSON request body (automatically sets Content-Type: application/json)",
56
+ },
57
+ "follow_redirects": {
58
+ "type": "boolean",
59
+ "description": "Whether to follow HTTP redirects (default: false)",
60
+ "default": False,
61
+ },
62
+ },
63
+ "required": ["method", "path"],
64
+ },
65
+ }
66
+
67
+ SANDBOX_MULTI_STEP_TOOL = {
68
+ "name": "sandbox_multi_step",
69
+ "description": (
70
+ "Execute a chain of HTTP requests for multi-step exploits. "
71
+ "Use this for exploits that need setup steps (e.g., register user → login → exploit). "
72
+ "Each step's response body is available to later steps via {step_N_body} placeholders."
73
+ ),
74
+ "parameters": {
75
+ "type": "object",
76
+ "properties": {
77
+ "steps": {
78
+ "type": "array",
79
+ "items": {
80
+ "type": "object",
81
+ "properties": {
82
+ "method": {"type": "string"},
83
+ "path": {"type": "string"},
84
+ "headers": {"type": "object", "additionalProperties": {"type": "string"}},
85
+ "body": {"type": "string"},
86
+ "json_body": {"type": "object"},
87
+ "follow_redirects": {"type": "boolean"},
88
+ },
89
+ "required": ["method", "path"],
90
+ },
91
+ "description": "Ordered list of HTTP requests to execute",
92
+ },
93
+ },
94
+ "required": ["steps"],
95
+ },
96
+ }
97
+
98
+ SANDBOX_GET_LOGS_TOOL = {
99
+ "name": "sandbox_get_logs",
100
+ "description": (
101
+ "Get the Docker container logs from the sandboxed application. "
102
+ "Useful for debugging when requests fail unexpectedly or to see "
103
+ "server-side errors triggered by your exploit attempts."
104
+ ),
105
+ "parameters": {
106
+ "type": "object",
107
+ "properties": {
108
+ "tail": {
109
+ "type": "integer",
110
+ "description": "Number of log lines to return (default: 50)",
111
+ "default": 50,
112
+ },
113
+ },
114
+ },
115
+ }
116
+
117
+ REPORT_EXPLOIT_RESULT_TOOL = {
118
+ "name": "report_exploit_result",
119
+ "description": (
120
+ "Report the final result of your exploit verification. "
121
+ "Call this when you have either confirmed the exploit works "
122
+ "or determined it is not exploitable after multiple attempts."
123
+ ),
124
+ "parameters": {
125
+ "type": "object",
126
+ "properties": {
127
+ "status": {
128
+ "type": "string",
129
+ "enum": ["exploitable", "not_exploitable"],
130
+ "description": "Whether the vulnerability was successfully exploited",
131
+ },
132
+ "confidence": {
133
+ "type": "string",
134
+ "enum": ["high", "medium", "low"],
135
+ "description": "Confidence in the result",
136
+ },
137
+ "working_poc": {
138
+ "type": "string",
139
+ "description": "The working Python exploit script (for exploitable findings)",
140
+ },
141
+ "evidence": {
142
+ "type": "string",
143
+ "description": "Evidence: response data proving exploitation, or explanation of why it failed",
144
+ },
145
+ "attempts_made": {
146
+ "type": "integer",
147
+ "description": "How many exploit attempts were made",
148
+ },
149
+ "exploit_request": {
150
+ "type": "object",
151
+ "description": "The exact HTTP request that worked (method, path, headers, body)",
152
+ },
153
+ "reason": {
154
+ "type": "string",
155
+ "description": "For not_exploitable: why the exploit cannot work in practice",
156
+ },
157
+ },
158
+ "required": ["status", "confidence", "evidence", "attempts_made"],
159
+ },
160
+ }
161
+
162
+
163
+ SANDBOX_TOOLS = [
164
+ SANDBOX_HTTP_REQUEST_TOOL,
165
+ SANDBOX_MULTI_STEP_TOOL,
166
+ SANDBOX_GET_LOGS_TOOL,
167
+ REPORT_EXPLOIT_RESULT_TOOL,
168
+ ]
169
+
170
+
171
+ class SandboxVerifierAgent(BaseAgent):
172
+ """Agent that verifies vulnerabilities by exploiting them in a sandbox."""
173
+
174
+ name = "sandbox_verifier"
175
+ description = "Verifying exploit in sandbox"
176
+
177
+ def __init__(
178
+ self,
179
+ *args,
180
+ sandbox_url: str = "",
181
+ exploit_runner: Optional[ExploitRunner] = None,
182
+ sandbox_orchestrator=None,
183
+ finding_index: int = 0,
184
+ max_attempts: int = 7,
185
+ **kwargs,
186
+ ):
187
+ super().__init__(*args, **kwargs)
188
+ self.sandbox_url = sandbox_url
189
+ self.exploit_runner = exploit_runner
190
+ self.sandbox_orchestrator = sandbox_orchestrator
191
+ self.finding_index = finding_index
192
+ self.max_attempts = max_attempts
193
+ self.exploit_result: Optional[dict] = None
194
+ self.attempt_count = 0
195
+
196
+ self.name = f"sandbox_verifier:finding_{finding_index}"
197
+ self.description = f"Exploiting finding {finding_index} in sandbox"
198
+
199
+ def get_system_prompt(self, context: dict) -> str:
200
+ finding = context.get("finding", {})
201
+ project_context = context.get("project_context", {})
202
+ project_context_str = format_project_context(project_context)
203
+
204
+ finding_details = f"""
205
+ ### Vulnerability: {finding.get('category', 'Unknown')}
206
+ - **Severity**: {finding.get('severity', 'Unknown')}
207
+ - **File**: {finding.get('file_path', 'Unknown')}
208
+ - **Line**: {finding.get('line_number', 'Unknown')}
209
+ - **Description**: {finding.get('description', 'No description')}
210
+ - **Code**:
211
+ ```
212
+ {finding.get('code_snippet', 'No code snippet')}
213
+ ```
214
+ - **Original PoC**:
215
+ ```
216
+ {finding.get('poc', 'No PoC provided')}
217
+ ```
218
+ - **Confidence**: {finding.get('confidence', 'Unknown')}
219
+ - **CVSS Score**: {finding.get('cvss_score', 'N/A')}
220
+ """
221
+
222
+ prompt = SANDBOX_VERIFIER_PROMPT.format(
223
+ project_context=project_context_str,
224
+ sandbox_url=self.sandbox_url,
225
+ finding_details=finding_details,
226
+ max_attempts=self.max_attempts,
227
+ )
228
+ prompt += SANDBOX_VERIFIER_TOOL_INSTRUCTIONS
229
+ return prompt
230
+
231
+ def get_tools(self) -> list[dict]:
232
+ # Include filesystem tools (read_file, grep) plus sandbox tools
233
+ return super().get_tools() + SANDBOX_TOOLS
234
+
235
+ async def _handle_sandbox_http_request(self, args: dict) -> dict:
236
+ """Execute a single HTTP request against the sandbox."""
237
+ self.attempt_count += 1
238
+
239
+ result = await self.exploit_runner.execute_request(
240
+ method=args.get("method", "GET"),
241
+ path=args.get("path", "/"),
242
+ headers=args.get("headers"),
243
+ body=args.get("body"),
244
+ json_body=args.get("json_body"),
245
+ follow_redirects=args.get("follow_redirects", False),
246
+ attempt=self.attempt_count,
247
+ )
248
+
249
+ return result.to_dict()
250
+
251
+ async def _handle_sandbox_multi_step(self, args: dict) -> dict:
252
+ """Execute a multi-step exploit chain."""
253
+ steps = args.get("steps", [])
254
+ if not steps:
255
+ return {"error": "No steps provided"}
256
+
257
+ self.attempt_count += 1
258
+ results = await self.exploit_runner.execute_multi_step(steps)
259
+
260
+ return {
261
+ "steps_executed": len(results),
262
+ "results": [r.to_dict() for r in results],
263
+ }
264
+
265
+ async def _handle_sandbox_get_logs(self, args: dict) -> dict:
266
+ """Get container logs."""
267
+ if not self.sandbox_orchestrator:
268
+ return {"error": "No sandbox orchestrator available"}
269
+
270
+ tail = args.get("tail", 50)
271
+ logs = await self.sandbox_orchestrator.get_logs(tail=tail)
272
+ return {"logs": logs}
273
+
274
+ def _handle_report_exploit_result(self, args: dict) -> dict:
275
+ """Record the final exploit result."""
276
+ self.exploit_result = {
277
+ "finding_index": self.finding_index,
278
+ "status": args.get("status", "not_exploitable"),
279
+ "confidence": args.get("confidence", "medium"),
280
+ "working_poc": args.get("working_poc"),
281
+ "evidence": args.get("evidence", ""),
282
+ "attempts_made": args.get("attempts_made", self.attempt_count),
283
+ "exploit_request": args.get("exploit_request"),
284
+ "reason": args.get("reason"),
285
+ }
286
+ return {"status": "recorded", "finding_index": self.finding_index}
287
+
288
+ async def run(self, task: str, context: Optional[dict] = None) -> dict:
289
+ context = context or {}
290
+ self.session.current_agent = self.name
291
+ self.exploit_result = None
292
+ self.attempt_count = 0
293
+
294
+ system_prompt = self.get_system_prompt(context)
295
+ self.messages = [Message(role="user", content=task)]
296
+ self._seed_existing_instructions()
297
+
298
+ max_iterations = self.max_attempts * 4 # Allow multiple tool calls per attempt
299
+ iteration = 0
300
+
301
+ while iteration < max_iterations:
302
+ if self.session.cancelled:
303
+ break
304
+ iteration += 1
305
+
306
+ self._inject_pending_instructions()
307
+
308
+ response = await self.llm.chat(
309
+ messages=self.messages, tools=self.get_tools(), system=system_prompt,
310
+ )
311
+
312
+ self.session.total_cost += response.cost
313
+ if response.usage:
314
+ self.session.total_tokens += response.usage.get("total_tokens", 0)
315
+ self.context_manager.update_usage(response.usage.get("input_tokens", 0))
316
+
317
+ if response.content:
318
+ self.session.add_trace(
319
+ agent=self.name, event_type="thinking", content=response.content,
320
+ )
321
+
322
+ if not response.tool_calls:
323
+ return self._build_result(response.content or "")
324
+
325
+ assistant_msg = Message(
326
+ role="assistant", content=response.content,
327
+ tool_calls=[
328
+ {"id": tc.id, "type": "function", "function": {"name": tc.name, "arguments": json.dumps(tc.arguments)}}
329
+ for tc in response.tool_calls
330
+ ],
331
+ reasoning_content=getattr(response, 'reasoning_content', None),
332
+ )
333
+ self.messages.append(assistant_msg)
334
+
335
+ should_finish = False
336
+ for tool_call in response.tool_calls:
337
+ self.session.add_trace(
338
+ agent=self.name, event_type="tool_call",
339
+ content=f"Calling {tool_call.name}",
340
+ tool_name=tool_call.name, tool_input=tool_call.arguments,
341
+ )
342
+
343
+ if tool_call.name == "sandbox_http_request":
344
+ result = await self._handle_sandbox_http_request(tool_call.arguments)
345
+ elif tool_call.name == "sandbox_multi_step":
346
+ result = await self._handle_sandbox_multi_step(tool_call.arguments)
347
+ elif tool_call.name == "sandbox_get_logs":
348
+ result = await self._handle_sandbox_get_logs(tool_call.arguments)
349
+ elif tool_call.name == "report_exploit_result":
350
+ result = self._handle_report_exploit_result(tool_call.arguments)
351
+ should_finish = True
352
+ else:
353
+ # Filesystem tools (read_file, grep, etc.)
354
+ result = self.tools.execute_tool(tool_call.name, tool_call.arguments)
355
+
356
+ self.session.add_trace(
357
+ agent=self.name, event_type="tool_result",
358
+ content=f"Result from {tool_call.name}",
359
+ tool_name=tool_call.name, tool_output=result,
360
+ )
361
+
362
+ raw_content = json.dumps(result) if isinstance(result, dict) else str(result)
363
+ truncated_content = self.context_manager.truncate_tool_result(tool_call.name, raw_content)
364
+ tool_result = ToolResult(tool_call_id=tool_call.id, content=truncated_content)
365
+ self.messages.append(tool_result.to_message())
366
+
367
+ if should_finish:
368
+ return self._build_result(response.content or "")
369
+
370
+ if self.context_manager.needs_compaction():
371
+ self.messages = self.context_manager.compact_messages(self.messages)
372
+ logger.info(f"[{self.name}] Compacted message history")
373
+
374
+ # Max iterations reached without explicit report
375
+ if not self.exploit_result:
376
+ self.exploit_result = {
377
+ "finding_index": self.finding_index,
378
+ "status": "not_exploitable",
379
+ "confidence": "low",
380
+ "evidence": "Max iterations reached without confirming exploit",
381
+ "attempts_made": self.attempt_count,
382
+ "reason": "Agent exhausted iteration budget",
383
+ }
384
+
385
+ return self._build_result("Max iterations reached")
386
+
387
+ def _build_result(self, summary: str) -> dict:
388
+ return {
389
+ "raw_output": summary,
390
+ "exploit_result": self.exploit_result,
391
+ "attempts_made": self.attempt_count,
392
+ "type": "sandbox_verification_complete",
393
+ }
394
+
395
+ def _parse_final_response(self, content: str) -> dict:
396
+ return self._build_result(content)
@@ -0,0 +1,250 @@
1
+ """
2
+ Sandbox verifier swarm agent.
3
+
4
+ Spawns one sandbox verifier per confirmed finding and runs them concurrently
5
+ against the live sandboxed application. All verifiers share the same sandbox
6
+ instance — the app is started once and torn down after all verifiers finish.
7
+ """
8
+
9
+ import asyncio
10
+ import logging
11
+ from typing import Optional
12
+
13
+ from .sandbox_verifier import SandboxVerifierAgent
14
+ from .llm import LLMClient
15
+ from .session import Session
16
+ from ..sandbox.orchestrator import SandboxOrchestrator, SandboxConfig
17
+ from ..sandbox.runner import ExploitRunner
18
+ from openhack.tools.registry import ToolRegistry
19
+ from openhack.config import settings
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class SandboxVerifierSwarmAgent:
25
+ """Runs sandbox verification for all confirmed findings concurrently."""
26
+
27
+ name = "sandbox_verifier_swarm"
28
+ description = "Sandbox exploit verification swarm"
29
+
30
+ def __init__(
31
+ self,
32
+ llm: LLMClient,
33
+ tools: ToolRegistry,
34
+ session: Session,
35
+ sandbox_config: Optional[SandboxConfig] = None,
36
+ ):
37
+ self.llm = llm
38
+ self.tools = tools
39
+ self.session = session
40
+ self.sandbox_config = sandbox_config
41
+ self.total_cost: float = 0.0
42
+ self.total_tokens: int = 0
43
+ self.total_input_tokens: int = 0
44
+ self.total_output_tokens: int = 0
45
+
46
+ def _create_llm_for_verifier(self) -> LLMClient:
47
+ model = self.llm.model
48
+ return LLMClient(model=model, temperature=0.0, max_tokens=8192, provider=self.llm.provider, prompt_cache_key=self.llm.prompt_cache_key)
49
+
50
+ async def run(self, task: str, context: Optional[dict] = None) -> dict:
51
+ context = context or {}
52
+ findings = context.get("confirmed_findings", [])
53
+
54
+ if not findings:
55
+ return {
56
+ "raw_output": "No findings to verify in sandbox",
57
+ "exploitable": [],
58
+ "not_exploitable": [],
59
+ "type": "sandbox_verification_complete",
60
+ }
61
+
62
+ self.session.add_trace(
63
+ agent=self.name, event_type="swarm_start",
64
+ content={"findings_count": len(findings)},
65
+ )
66
+
67
+ target_dir = self.tools.target_dir
68
+ orchestrator = SandboxOrchestrator(target_dir, self.sandbox_config)
69
+
70
+ self.session.add_trace(
71
+ agent=self.name, event_type="sandbox_starting",
72
+ content="Building and starting sandbox containers…",
73
+ )
74
+
75
+ try:
76
+ sandbox_status = await orchestrator.start()
77
+ sandbox_url = sandbox_status.base_url
78
+
79
+ self.session.add_trace(
80
+ agent=self.name, event_type="sandbox_ready",
81
+ content={"base_url": sandbox_url, "host_port": sandbox_status.host_port},
82
+ )
83
+
84
+ # Create exploit runner
85
+ async with ExploitRunner(sandbox_url) as runner:
86
+ # Spawn verifiers
87
+ semaphore = asyncio.Semaphore(settings.max_concurrent_validators)
88
+ # Fail-fast: if N consecutive verifiers crash with the same
89
+ # error (e.g. "Insufficient credits"), abort the remaining
90
+ # ones rather than burning through all findings silently.
91
+ FAIL_FAST_THRESHOLD = 3
92
+ abort_event = asyncio.Event()
93
+ error_streak: list[str] = []
94
+ fatal_error: Optional[str] = None
95
+
96
+ async def run_verifier(idx: int, finding: dict) -> tuple[int, dict]:
97
+ nonlocal fatal_error
98
+ verifier_name = f"sandbox_verifier:finding_{idx}"
99
+ self.session.add_trace(
100
+ agent=verifier_name, event_type="queued",
101
+ content={"finding_index": idx, "title": finding.get("title", "")},
102
+ )
103
+
104
+ if abort_event.is_set():
105
+ self.session.add_trace(
106
+ agent=verifier_name, event_type="skipped",
107
+ content="Skipped — swarm aborted due to repeated failures",
108
+ )
109
+ llm = self._create_llm_for_verifier()
110
+ return idx, {
111
+ "exploit_result": {
112
+ "finding_index": idx, "status": "skipped",
113
+ "confidence": "none", "evidence": "Aborted",
114
+ "attempts_made": 0, "reason": fatal_error or "Aborted",
115
+ },
116
+ "type": "sandbox_verification_skipped",
117
+ }, llm
118
+
119
+ async with semaphore:
120
+ if abort_event.is_set():
121
+ self.session.add_trace(
122
+ agent=verifier_name, event_type="skipped",
123
+ content="Skipped — swarm aborted due to repeated failures",
124
+ )
125
+ llm = self._create_llm_for_verifier()
126
+ return idx, {
127
+ "exploit_result": {
128
+ "finding_index": idx, "status": "skipped",
129
+ "confidence": "none", "evidence": "Aborted",
130
+ "attempts_made": 0, "reason": fatal_error or "Aborted",
131
+ },
132
+ "type": "sandbox_verification_skipped",
133
+ }, llm
134
+
135
+ llm = self._create_llm_for_verifier()
136
+ verifier = SandboxVerifierAgent(
137
+ llm, self.tools, self.session,
138
+ sandbox_url=sandbox_url,
139
+ exploit_runner=runner,
140
+ sandbox_orchestrator=orchestrator,
141
+ finding_index=idx,
142
+ max_attempts=settings.sandbox_max_exploit_attempts,
143
+ )
144
+ try:
145
+ sub_context = {
146
+ "finding": finding,
147
+ "project_context": context.get("project_context", {}),
148
+ }
149
+ result = await verifier.run(
150
+ "Verify this vulnerability by exploiting it in the sandbox.",
151
+ context=sub_context,
152
+ )
153
+ error_streak.clear()
154
+ return idx, result, llm
155
+ except Exception as e:
156
+ error_msg = str(e)
157
+ logger.error(f"Sandbox verifier for finding {idx} failed: {e}")
158
+ self.session.add_trace(
159
+ agent=verifier_name, event_type="error",
160
+ content=f"Verifier crashed: {e}",
161
+ )
162
+ error_streak.append(error_msg)
163
+ if (
164
+ len(error_streak) >= FAIL_FAST_THRESHOLD
165
+ and len(set(error_streak[-FAIL_FAST_THRESHOLD:])) == 1
166
+ ):
167
+ fatal_error = error_msg
168
+ abort_event.set()
169
+ self.session.add_trace(
170
+ agent=self.name, event_type="swarm_aborted",
171
+ content=(
172
+ f"Aborting: {FAIL_FAST_THRESHOLD} consecutive "
173
+ f"verifiers failed with: {error_msg}"
174
+ ),
175
+ )
176
+ return idx, {
177
+ "exploit_result": {
178
+ "finding_index": idx,
179
+ "status": "not_exploitable",
180
+ "confidence": "low",
181
+ "evidence": f"Verifier crashed: {error_msg}",
182
+ "attempts_made": 0,
183
+ "reason": "Internal error",
184
+ },
185
+ "type": "sandbox_verification_failed",
186
+ }, llm
187
+
188
+ tasks = [
189
+ asyncio.create_task(run_verifier(idx, finding))
190
+ for idx, finding in enumerate(findings)
191
+ ]
192
+
193
+ try:
194
+ results = await asyncio.gather(*tasks)
195
+ except asyncio.CancelledError:
196
+ for t in tasks:
197
+ t.cancel()
198
+ await asyncio.gather(*tasks, return_exceptions=True)
199
+ raise
200
+
201
+ # Collect results
202
+ exploitable = []
203
+ not_exploitable = []
204
+
205
+ for idx, result, llm_client in results:
206
+ self.total_cost += llm_client.total_cost
207
+ self.total_tokens += llm_client.total_tokens
208
+ self.total_input_tokens += llm_client.total_input_tokens
209
+ self.total_output_tokens += llm_client.total_output_tokens
210
+
211
+ exploit_result = result.get("exploit_result") if result else None
212
+ if not exploit_result:
213
+ not_exploitable.append({"finding_index": idx, "status": "error", "confidence": "low"})
214
+ continue
215
+ if exploit_result.get("status") == "exploitable":
216
+ exploitable.append(exploit_result)
217
+ else:
218
+ not_exploitable.append(exploit_result)
219
+
220
+ self.session.add_trace(
221
+ agent=self.name, event_type="swarm_complete",
222
+ content={
223
+ "total_exploitable": len(exploitable),
224
+ "total_not_exploitable": len(not_exploitable),
225
+ "total_cost": self.total_cost,
226
+ "total_tokens": self.total_tokens,
227
+ "fatal_error": fatal_error,
228
+ },
229
+ )
230
+
231
+ result_dict = {
232
+ "raw_output": (
233
+ f"Sandbox verification complete: {len(exploitable)} exploitable, "
234
+ f"{len(not_exploitable)} not exploitable out of {len(findings)} findings"
235
+ ),
236
+ "exploitable": exploitable,
237
+ "not_exploitable": not_exploitable,
238
+ "type": "sandbox_verification_complete",
239
+ }
240
+ if fatal_error:
241
+ result_dict["fatal_error"] = fatal_error
242
+ return result_dict
243
+
244
+ finally:
245
+ # Always tear down the sandbox
246
+ self.session.add_trace(
247
+ agent=self.name, event_type="sandbox_teardown",
248
+ content="Stopping sandbox containers",
249
+ )
250
+ await orchestrator.stop()