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
openhack/agents/llm.py ADDED
@@ -0,0 +1,334 @@
1
+ """
2
+ LLM client for OpenHack.
3
+ """
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ from typing import Any, Callable, Optional
9
+ from dataclasses import dataclass, field
10
+
11
+ import openai
12
+
13
+ from openhack.config import settings
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ @dataclass
19
+ class Message:
20
+ role: str
21
+ content: Optional[str] = None
22
+ tool_calls: Optional[list[dict]] = None
23
+ tool_call_id: Optional[str] = None
24
+ name: Optional[str] = None
25
+ reasoning_content: Optional[str] = None
26
+
27
+ def to_dict(self) -> dict:
28
+ d = {"role": self.role}
29
+ if self.content is not None:
30
+ d["content"] = self.content
31
+ if self.tool_calls is not None:
32
+ d["tool_calls"] = self.tool_calls
33
+ if self.tool_call_id is not None:
34
+ d["tool_call_id"] = self.tool_call_id
35
+ if self.name is not None:
36
+ d["name"] = self.name
37
+ if self.reasoning_content is not None:
38
+ d["reasoning_content"] = self.reasoning_content
39
+ return d
40
+
41
+
42
+ @dataclass
43
+ class ToolCall:
44
+ id: str
45
+ name: str
46
+ arguments: dict
47
+
48
+
49
+ @dataclass
50
+ class ToolResult:
51
+ tool_call_id: str
52
+ content: str
53
+
54
+ def to_message(self) -> Message:
55
+ return Message(role="tool", content=self.content, tool_call_id=self.tool_call_id)
56
+
57
+
58
+ @dataclass
59
+ class LLMResponse:
60
+ content: Optional[str] = None
61
+ tool_calls: list[ToolCall] = field(default_factory=list)
62
+ usage: Optional[dict] = None
63
+ cost: float = 0.0
64
+ reasoning_content: Optional[str] = None
65
+
66
+
67
+ class LLMClient:
68
+ """LLM client for OpenHack."""
69
+
70
+ PRICING = {
71
+ "kimi-k2.5": {"input": 0.50, "output": 2.80},
72
+ }
73
+
74
+ def __init__(
75
+ self,
76
+ model: Optional[str] = None,
77
+ temperature: float = 0.0,
78
+ max_tokens: int = 8192,
79
+ provider: Optional[str] = None,
80
+ prompt_cache_key: Optional[str] = None,
81
+ ):
82
+ self.provider = provider or settings.llm_provider
83
+ self.model = model or settings.openhack_model_id
84
+
85
+ self.temperature = temperature
86
+ self.max_tokens = max_tokens
87
+ self.prompt_cache_key = prompt_cache_key
88
+ self.total_cost: float = 0.0
89
+ self.total_tokens: int = 0
90
+ self.total_input_tokens: int = 0
91
+ self.total_output_tokens: int = 0
92
+
93
+ self._init_client()
94
+
95
+ def _init_client(self):
96
+ if not settings.openhack_api_key:
97
+ raise ValueError(
98
+ "OPENHACK_API_KEY is required.\n"
99
+ f"Sign up at: {settings.openhack_app_url}/signup\n"
100
+ "Then run: openhack /setup"
101
+ )
102
+ self.client = openai.AsyncOpenAI(
103
+ api_key=settings.openhack_api_key,
104
+ base_url=settings.openhack_base_url,
105
+ timeout=settings.openhack_read_timeout,
106
+ max_retries=0,
107
+ )
108
+
109
+ def _calculate_cost(self, input_tokens: int, output_tokens: int) -> float:
110
+ pricing = self.PRICING.get(self.model, {"input": 0.50, "output": 2.80})
111
+ return (input_tokens / 1_000_000) * pricing["input"] + (output_tokens / 1_000_000) * pricing["output"]
112
+
113
+ def _convert_tools_to_openai_format(self, tools: list[dict]) -> list[dict]:
114
+ return [
115
+ {
116
+ "type": "function",
117
+ "function": {
118
+ "name": tool["name"],
119
+ "description": tool.get("description", ""),
120
+ "parameters": tool.get("parameters", {"type": "object", "properties": {}}),
121
+ },
122
+ }
123
+ for tool in tools
124
+ ]
125
+
126
+ def _convert_messages_to_openai(self, messages: list[Message], system: Optional[str]) -> list[dict]:
127
+ openai_messages = []
128
+ if system:
129
+ openai_messages.append({"role": "system", "content": system})
130
+
131
+ for msg in messages:
132
+ if msg.role == "system":
133
+ openai_messages.append({"role": "system", "content": msg.content or ""})
134
+ elif msg.role == "tool":
135
+ openai_messages.append({
136
+ "role": "tool",
137
+ "tool_call_id": msg.tool_call_id,
138
+ "content": msg.content or "",
139
+ })
140
+ elif msg.role == "assistant" and msg.tool_calls:
141
+ openai_messages.append({
142
+ "role": "assistant",
143
+ "content": msg.content,
144
+ "tool_calls": msg.tool_calls,
145
+ })
146
+ else:
147
+ openai_messages.append({"role": msg.role, "content": msg.content or ""})
148
+
149
+ return openai_messages
150
+
151
+ async def chat(
152
+ self,
153
+ messages: list[Message],
154
+ tools: Optional[list[dict]] = None,
155
+ system: Optional[str] = None,
156
+ tool_choice: Optional[str] = None,
157
+ on_chunk: Optional[Callable] = None,
158
+ ) -> LLMResponse:
159
+ return await self._chat(messages, tools, system, tool_choice=tool_choice, on_chunk=on_chunk)
160
+
161
+ async def _chat(
162
+ self,
163
+ messages: list[Message],
164
+ tools: Optional[list[dict]] = None,
165
+ system: Optional[str] = None,
166
+ tool_choice: Optional[str] = None,
167
+ on_chunk: Optional[Callable] = None,
168
+ ) -> LLMResponse:
169
+ openai_messages = self._convert_messages_to_openai(messages, system)
170
+
171
+ kwargs: dict[str, Any] = {
172
+ "model": self.model,
173
+ "messages": openai_messages,
174
+ "max_tokens": self.max_tokens,
175
+ "temperature": self.temperature,
176
+ "stream": True,
177
+ "stream_options": {"include_usage": True},
178
+ }
179
+ if tools:
180
+ kwargs["tools"] = self._convert_tools_to_openai_format(tools)
181
+ kwargs["tool_choice"] = tool_choice or "auto"
182
+ if self.prompt_cache_key:
183
+ kwargs["prompt_cache_key"] = self.prompt_cache_key
184
+
185
+ max_retries = settings.openhack_max_retries
186
+ last_exception = None
187
+
188
+ for attempt in range(max_retries + 1):
189
+ stream = None
190
+ try:
191
+ if attempt > 0:
192
+ wait_time = 5 * (2 ** (attempt - 1))
193
+ print(f" Retrying API call (attempt {attempt + 1}/{max_retries + 1}) after {wait_time}s...")
194
+ await asyncio.sleep(wait_time)
195
+
196
+ stream = await self.client.chat.completions.create(**kwargs)
197
+
198
+ content_parts: list[str] = []
199
+ reasoning_parts: list[str] = []
200
+ tool_call_acc: dict[int, dict] = {}
201
+ input_tokens = 0
202
+ output_tokens = 0
203
+
204
+ async for chunk in stream:
205
+ if chunk.usage:
206
+ input_tokens = chunk.usage.prompt_tokens or 0
207
+ output_tokens = chunk.usage.completion_tokens or 0
208
+
209
+ if not chunk.choices:
210
+ continue
211
+
212
+ delta = chunk.choices[0].delta
213
+
214
+ if delta.content:
215
+ content_parts.append(delta.content)
216
+ if on_chunk:
217
+ on_chunk("content", delta.content)
218
+
219
+ rc = getattr(delta, "reasoning_content", None)
220
+ if rc:
221
+ reasoning_parts.append(rc)
222
+ if on_chunk:
223
+ on_chunk("reasoning", rc)
224
+
225
+ if delta.tool_calls:
226
+ for tc_delta in delta.tool_calls:
227
+ idx = tc_delta.index
228
+ if idx not in tool_call_acc:
229
+ tool_call_acc[idx] = {
230
+ "id": tc_delta.id or "",
231
+ "name": (tc_delta.function.name if tc_delta.function else "") or "",
232
+ "arguments_parts": [],
233
+ }
234
+ acc = tool_call_acc[idx]
235
+ if tc_delta.id:
236
+ acc["id"] = tc_delta.id
237
+ if tc_delta.function:
238
+ if tc_delta.function.name:
239
+ acc["name"] = tc_delta.function.name
240
+ if tc_delta.function.arguments:
241
+ acc["arguments_parts"].append(tc_delta.function.arguments)
242
+
243
+ content = "".join(content_parts) or None
244
+ reasoning_content = "".join(reasoning_parts) or None
245
+
246
+ tool_calls = []
247
+ for idx in sorted(tool_call_acc.keys()):
248
+ acc = tool_call_acc[idx]
249
+ raw_args = "".join(acc["arguments_parts"])
250
+ try:
251
+ args = json.loads(raw_args) if raw_args else {}
252
+ except json.JSONDecodeError:
253
+ logger.warning(f"Failed to parse tool call arguments: {raw_args[:200]}")
254
+ args = {}
255
+ tool_calls.append(ToolCall(id=acc["id"], name=acc["name"], arguments=args))
256
+
257
+ if input_tokens == 0 and output_tokens == 0:
258
+ logger.debug("No usage data in stream — cost will be zero for this call")
259
+
260
+ cost = self._calculate_cost(input_tokens, output_tokens)
261
+ self.total_cost += cost
262
+ self.total_tokens += input_tokens + output_tokens
263
+ self.total_input_tokens += input_tokens
264
+ self.total_output_tokens += output_tokens
265
+
266
+ llm_response = LLMResponse(
267
+ content=content,
268
+ tool_calls=tool_calls,
269
+ usage={"input_tokens": input_tokens, "output_tokens": output_tokens, "total_tokens": input_tokens + output_tokens},
270
+ cost=cost,
271
+ )
272
+ llm_response.reasoning_content = reasoning_content
273
+ return llm_response
274
+
275
+ except openai.RateLimitError as e:
276
+ last_exception = e
277
+ if stream:
278
+ try: await stream.close()
279
+ except Exception: pass
280
+ if attempt == max_retries:
281
+ raise
282
+ except openai.AuthenticationError as e:
283
+ detail = getattr(e, "message", str(e))
284
+ raise ValueError(
285
+ f"Authentication failed (401): {detail}\n"
286
+ f"If this is your OpenHack token, run: openhack /login\n"
287
+ f"Check that your API key is valid and has not expired."
288
+ ) from e
289
+ except openai.PermissionDeniedError as e:
290
+ detail = getattr(e, "message", str(e))
291
+ if "credits" in detail.lower() or "insufficient" in detail.lower():
292
+ raise ValueError(
293
+ f"Insufficient credits. Purchase more at: {settings.openhack_app_url}/settings/billing"
294
+ ) from e
295
+ raise ValueError(
296
+ f"Access denied by OpenHack API: {detail}\n"
297
+ f"Check your API key at: {settings.openhack_app_url}/settings/api-keys"
298
+ ) from e
299
+ except openai.APIStatusError as e:
300
+ if stream:
301
+ try: await stream.close()
302
+ except Exception: pass
303
+ if e.status_code >= 500:
304
+ last_exception = e
305
+ if attempt == max_retries:
306
+ raise
307
+ else:
308
+ raise
309
+ except openai.APITimeoutError as e:
310
+ last_exception = e
311
+ if stream:
312
+ try: await stream.close()
313
+ except Exception: pass
314
+ if attempt == max_retries:
315
+ raise
316
+ except openai.APIConnectionError as e:
317
+ last_exception = e
318
+ if stream:
319
+ try: await stream.close()
320
+ except Exception: pass
321
+ if attempt == max_retries:
322
+ raise
323
+ wait_time = 10 * (2 ** attempt)
324
+ await asyncio.sleep(wait_time)
325
+ continue
326
+ except Exception as e:
327
+ logger.debug(f"OpenHack API error: {e}", exc_info=True)
328
+ if stream:
329
+ try: await stream.close()
330
+ except Exception: pass
331
+ raise
332
+
333
+ if last_exception:
334
+ raise last_exception
@@ -0,0 +1,19 @@
1
+ """
2
+ Reconnaissance agent for mapping application structure.
3
+ """
4
+
5
+ from .base import BaseAgent
6
+ from openhack.prompts import RECON_PROMPT, format_project_context
7
+
8
+
9
+ class ReconAgent(BaseAgent):
10
+ name = "recon"
11
+ description = "Reconnaissance - mapping application structure"
12
+
13
+ def get_system_prompt(self, context: dict) -> str:
14
+ project_context = context.get("project_context", {})
15
+ project_context_str = format_project_context(project_context)
16
+ return RECON_PROMPT.format(project_context=project_context_str)
17
+
18
+ def _parse_final_response(self, content: str) -> dict:
19
+ return {"summary": content, "type": "recon_complete"}