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.
- openhack/__init__.py +2 -0
- openhack/__main__.py +225 -0
- openhack/agents/__init__.py +30 -0
- openhack/agents/base.py +230 -0
- openhack/agents/browser_verifier.py +679 -0
- openhack/agents/browser_verifier_swarm.py +256 -0
- openhack/agents/checkpoint.py +89 -0
- openhack/agents/context_manager.py +356 -0
- openhack/agents/coordinator.py +1105 -0
- openhack/agents/endpoint_analyst.py +307 -0
- openhack/agents/feature_hunter.py +93 -0
- openhack/agents/hunter.py +481 -0
- openhack/agents/hunter_swarm.py +385 -0
- openhack/agents/llm.py +334 -0
- openhack/agents/recon.py +19 -0
- openhack/agents/sandbox_verifier.py +396 -0
- openhack/agents/sandbox_verifier_swarm.py +250 -0
- openhack/agents/session.py +286 -0
- openhack/agents/validator.py +217 -0
- openhack/agents/validator_swarm.py +106 -0
- openhack/auth.py +175 -0
- openhack/browser/__init__.py +12 -0
- openhack/browser/runner.py +385 -0
- openhack/categories.py +130 -0
- openhack/config.py +201 -0
- openhack/deterministic_recon.py +464 -0
- openhack/entry_points.py +745 -0
- openhack/framework_classifier.py +515 -0
- openhack/framework_detection.py +269 -0
- openhack/headless_scan.py +179 -0
- openhack/prompts/__init__.py +108 -0
- openhack/prompts/browser_verifier.py +171 -0
- openhack/prompts/coordinator.py +31 -0
- openhack/prompts/django/__init__.py +32 -0
- openhack/prompts/django/auth_bypass.py +76 -0
- openhack/prompts/django/csrf.py +62 -0
- openhack/prompts/django/data_exposure.py +67 -0
- openhack/prompts/django/idor.py +74 -0
- openhack/prompts/django/injection.py +67 -0
- openhack/prompts/django/misconfiguration.py +70 -0
- openhack/prompts/django/ssrf.py +64 -0
- openhack/prompts/endpoint_analyst.py +122 -0
- openhack/prompts/express/__init__.py +29 -0
- openhack/prompts/express/auth_bypass.py +71 -0
- openhack/prompts/express/data_exposure.py +77 -0
- openhack/prompts/express/idor.py +69 -0
- openhack/prompts/express/injection.py +75 -0
- openhack/prompts/express/misconfiguration.py +72 -0
- openhack/prompts/express/ssrf.py +63 -0
- openhack/prompts/feature_hunter.py +140 -0
- openhack/prompts/flask/__init__.py +29 -0
- openhack/prompts/flask/auth_bypass.py +86 -0
- openhack/prompts/flask/data_exposure.py +78 -0
- openhack/prompts/flask/idor.py +83 -0
- openhack/prompts/flask/injection.py +77 -0
- openhack/prompts/flask/misconfiguration.py +73 -0
- openhack/prompts/flask/ssrf.py +65 -0
- openhack/prompts/hunter.py +362 -0
- openhack/prompts/hunter_continuation_loop.py +12 -0
- openhack/prompts/hunter_continuation_no_findings.py +19 -0
- openhack/prompts/hunter_continuation_no_progress.py +22 -0
- openhack/prompts/hunter_tool_instructions.py +55 -0
- openhack/prompts/nextjs/__init__.py +42 -0
- openhack/prompts/nextjs/auth_bypass.py +80 -0
- openhack/prompts/nextjs/csrf.py +71 -0
- openhack/prompts/nextjs/data_exposure.py +88 -0
- openhack/prompts/nextjs/idor.py +64 -0
- openhack/prompts/nextjs/injection.py +65 -0
- openhack/prompts/nextjs/middleware_bypass.py +75 -0
- openhack/prompts/nextjs/misconfiguration.py +92 -0
- openhack/prompts/nextjs/server_actions.py +97 -0
- openhack/prompts/nextjs/ssrf.py +66 -0
- openhack/prompts/nextjs/xss.py +69 -0
- openhack/prompts/pr_analysis_system.py +80 -0
- openhack/prompts/pr_analysis_user.py +11 -0
- openhack/prompts/project_context.py +89 -0
- openhack/prompts/recon.py +199 -0
- openhack/prompts/reporter.py +88 -0
- openhack/prompts/researchers.py +434 -0
- openhack/prompts/sandbox_verifier.py +128 -0
- openhack/prompts/supabase/__init__.py +39 -0
- openhack/prompts/supabase/auth_tokens.py +131 -0
- openhack/prompts/supabase/edge_functions.py +150 -0
- openhack/prompts/supabase/graphql.py +102 -0
- openhack/prompts/supabase/postgrest.py +99 -0
- openhack/prompts/supabase/realtime.py +93 -0
- openhack/prompts/supabase/rls.py +110 -0
- openhack/prompts/supabase/rpc_functions.py +127 -0
- openhack/prompts/supabase/storage.py +110 -0
- openhack/prompts/supabase/tenant_isolation.py +118 -0
- openhack/prompts/validator.py +319 -0
- openhack/prompts/validator_continuation_incomplete.py +12 -0
- openhack/prompts/validator_tool_instructions.py +29 -0
- openhack/quality.py +231 -0
- openhack/sandbox/__init__.py +12 -0
- openhack/sandbox/orchestrator.py +517 -0
- openhack/sandbox/runner.py +177 -0
- openhack/scan_session.py +245 -0
- openhack/setup.py +452 -0
- openhack/static_validator.py +612 -0
- openhack/tools/__init__.py +1 -0
- openhack/tools/ast_tools.py +307 -0
- openhack/tools/coverage.py +1078 -0
- openhack/tools/filesystem.py +404 -0
- openhack/tools/nextjs.py +258 -0
- openhack/tools/registry.py +52 -0
- openhack/tui.py +3450 -0
- openhack/updates.py +170 -0
- openhack-0.1.0.dist-info/METADATA +189 -0
- openhack-0.1.0.dist-info/RECORD +113 -0
- openhack-0.1.0.dist-info/WHEEL +4 -0
- openhack-0.1.0.dist-info/entry_points.txt +2 -0
- openhack-0.1.0.dist-info/licenses/LICENSE +661 -0
openhack/tui.py
ADDED
|
@@ -0,0 +1,3450 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Interactive TUI for OpenHack.
|
|
3
|
+
|
|
4
|
+
Full-screen prompt_toolkit Application with two modes:
|
|
5
|
+
|
|
6
|
+
- LANDING: ground-symbol logo + "OpenHack" wordmark + centered input. Tip and
|
|
7
|
+
account footer below. Type a slash command or a path/URL to scan.
|
|
8
|
+
- SCANNING: pinned status bar (target, elapsed, cost) + VSplit pane layout
|
|
9
|
+
(agents on the left, findings on the right) + input bar at the bottom.
|
|
10
|
+
|
|
11
|
+
Scan execution still uses CoordinatorAgent/Session under the hood; trace
|
|
12
|
+
events from the session are translated into agent/finding pane state and the
|
|
13
|
+
layout re-renders on every update.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import signal
|
|
21
|
+
import time
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Callable, Optional
|
|
25
|
+
|
|
26
|
+
from prompt_toolkit import HTML
|
|
27
|
+
from prompt_toolkit.data_structures import Point
|
|
28
|
+
from prompt_toolkit.application import Application
|
|
29
|
+
from prompt_toolkit.buffer import Buffer
|
|
30
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
31
|
+
from prompt_toolkit.document import Document
|
|
32
|
+
from prompt_toolkit.filters import Condition
|
|
33
|
+
from prompt_toolkit.formatted_text import to_formatted_text
|
|
34
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
35
|
+
from prompt_toolkit.layout import Layout
|
|
36
|
+
from prompt_toolkit.layout.containers import (
|
|
37
|
+
ConditionalContainer,
|
|
38
|
+
Float,
|
|
39
|
+
FloatContainer,
|
|
40
|
+
HSplit,
|
|
41
|
+
VSplit,
|
|
42
|
+
Window,
|
|
43
|
+
WindowAlign,
|
|
44
|
+
)
|
|
45
|
+
from prompt_toolkit.layout.scrollable_pane import ScrollablePane
|
|
46
|
+
from prompt_toolkit.widgets import Frame
|
|
47
|
+
from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
|
|
48
|
+
from prompt_toolkit.formatted_text import split_lines
|
|
49
|
+
from prompt_toolkit.layout.dimension import Dimension as D
|
|
50
|
+
from prompt_toolkit.layout.menus import CompletionsMenu
|
|
51
|
+
from prompt_toolkit.layout.processors import BeforeInput
|
|
52
|
+
from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
|
|
53
|
+
from prompt_toolkit.styles import Style
|
|
54
|
+
|
|
55
|
+
from openhack.agents.coordinator import CoordinatorAgent
|
|
56
|
+
from openhack.agents.llm import LLMClient, Message, LLMResponse
|
|
57
|
+
from openhack.agents.session import Session, SessionStatus, Finding, TraceEntry
|
|
58
|
+
from openhack.config import (
|
|
59
|
+
settings,
|
|
60
|
+
save_user_config,
|
|
61
|
+
load_user_config,
|
|
62
|
+
resolve_provider,
|
|
63
|
+
reload_settings,
|
|
64
|
+
_PROVIDER_KEY_FIELDS,
|
|
65
|
+
)
|
|
66
|
+
from openhack.setup import run_setup_command
|
|
67
|
+
from openhack.tools.registry import ToolRegistry
|
|
68
|
+
from openhack.prompts.project_context import build_project_context
|
|
69
|
+
from openhack.updates import Announcement, UpdateInfo, fetch_updates, save_dismissed
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ── Brand ─────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
# OpenHack ground-symbol logo (chunky pixel blocks). All lines are padded to
|
|
75
|
+
# 26 cols so the line's geometric midpoint matches the blocks' visual midpoint
|
|
76
|
+
# (col 13.5) — keeps the "OpenHack" wordmark aligned with the vertical bar.
|
|
77
|
+
_LOGO_WIDTH = 26
|
|
78
|
+
_LOGO_LINES = [line.ljust(_LOGO_WIDTH) for line in [
|
|
79
|
+
" ██",
|
|
80
|
+
" ██",
|
|
81
|
+
" ██",
|
|
82
|
+
" ██",
|
|
83
|
+
" ██",
|
|
84
|
+
" ████████████████████",
|
|
85
|
+
"",
|
|
86
|
+
" ████████████████",
|
|
87
|
+
"",
|
|
88
|
+
" ████████████",
|
|
89
|
+
]]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
PROVIDER_DEFAULTS = {"openhack": "kimi-k2.5"}
|
|
94
|
+
|
|
95
|
+
CHAT_SYSTEM_PROMPT = (
|
|
96
|
+
"You are OpenHack, a security-focused AI assistant embedded in the OpenHack CLI. "
|
|
97
|
+
"You help users understand vulnerability scan results, explain security concepts, "
|
|
98
|
+
"and advise on remediation. Be concise and direct. "
|
|
99
|
+
"If the user asks you to scan, tell them to use /full-scan or /scan <path>."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _esc(text: str) -> str:
|
|
104
|
+
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ── Severity coloring ─────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
def _sev_style(severity: str) -> str:
|
|
110
|
+
s = (severity or "").lower()
|
|
111
|
+
if s == "critical":
|
|
112
|
+
return "class:sev.critical"
|
|
113
|
+
if s == "high":
|
|
114
|
+
return "class:sev.high"
|
|
115
|
+
if s == "medium":
|
|
116
|
+
return "class:sev.medium"
|
|
117
|
+
if s == "low":
|
|
118
|
+
return "class:sev.low"
|
|
119
|
+
return "class:sev.info"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _sev_label(severity: str) -> str:
|
|
123
|
+
s = (severity or "").lower()
|
|
124
|
+
return {
|
|
125
|
+
"critical": "CRIT",
|
|
126
|
+
"high": "HIGH",
|
|
127
|
+
"medium": "MED ",
|
|
128
|
+
"low": "LOW ",
|
|
129
|
+
}.get(s, "INFO")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── Slash command registry ────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
_SLASH_COMMANDS = [
|
|
135
|
+
("/copy", "Copy the selected finding for Codex / Claude Code / OpenCode"),
|
|
136
|
+
("/logout", "Sign out (clears the saved token — requires confirmation)"),
|
|
137
|
+
("/verify", "Run sandbox/browser verification on loaded findings (`/verify sandbox` or `/verify browser`)"),
|
|
138
|
+
("/mouse", "Toggle mouse capture — off lets you drag-to-select text natively"),
|
|
139
|
+
("/discord", "Open the OpenHack Discord in your browser"),
|
|
140
|
+
("/scan", "Full scan on a specific directory (defaults to current)"),
|
|
141
|
+
("/pause", "Pause the running scan (Ctrl+C also pauses)"),
|
|
142
|
+
("/resume", "Resume a paused scan"),
|
|
143
|
+
("/cancel", "Cancel the running scan permanently"),
|
|
144
|
+
("/sessions", "Browse and re-load past scan results"),
|
|
145
|
+
("/findings", "Re-display findings from last scan"),
|
|
146
|
+
("/sidebar", "Show/hide the Findings list sidebar (Ctrl+B)"),
|
|
147
|
+
("/login", "Re-login with OpenHack account (browser flow)"),
|
|
148
|
+
("/setup", "Interactive setup wizard"),
|
|
149
|
+
("/config", "Show or set configuration"),
|
|
150
|
+
("/provider", "Switch provider"),
|
|
151
|
+
("/model", "Override model ID"),
|
|
152
|
+
("/cost", "Show cost breakdown from last scan"),
|
|
153
|
+
("/clear", "Clear scan history (returns to landing)"),
|
|
154
|
+
("/help", "Show available commands"),
|
|
155
|
+
("/test", "Run a simulated scan (no LLM)"),
|
|
156
|
+
("/quit", "Exit"),
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
_CONFIG_KEYS = [
|
|
160
|
+
("provider", "LLM provider"),
|
|
161
|
+
("model", "Model ID override"),
|
|
162
|
+
("openhack_api_key", "OpenHack API key"),
|
|
163
|
+
("openhack_model_id", "OpenHack model ID"),
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
_CANCEL_PHRASES = {
|
|
167
|
+
"cancel", "cancel scan", "cancel the scan",
|
|
168
|
+
"stop", "stop scan", "stop the scan",
|
|
169
|
+
"abort", "abort scan",
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class OpenHackCompleter(Completer):
|
|
174
|
+
def get_completions(self, document: Document, complete_event):
|
|
175
|
+
text = document.text_before_cursor
|
|
176
|
+
words = text.split()
|
|
177
|
+
|
|
178
|
+
if not text or (len(words) == 1 and not text.endswith(" ")):
|
|
179
|
+
prefix = text.lstrip()
|
|
180
|
+
if not prefix or prefix.startswith("/"):
|
|
181
|
+
for cmd, desc in _SLASH_COMMANDS:
|
|
182
|
+
if cmd.startswith(prefix):
|
|
183
|
+
yield Completion(cmd, start_position=-len(prefix), display_meta=desc)
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
# Whitespace-only input (no actual words) — nothing to complete against.
|
|
187
|
+
if not words:
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
cmd = words[0].lower()
|
|
191
|
+
|
|
192
|
+
if cmd == "/config":
|
|
193
|
+
if len(words) == 1 and text.endswith(" "):
|
|
194
|
+
for key, desc in _CONFIG_KEYS:
|
|
195
|
+
yield Completion(key, display_meta=desc)
|
|
196
|
+
elif len(words) == 2 and not text.endswith(" "):
|
|
197
|
+
partial = words[1]
|
|
198
|
+
for key, desc in _CONFIG_KEYS:
|
|
199
|
+
if key.startswith(partial):
|
|
200
|
+
yield Completion(key, start_position=-len(partial), display_meta=desc)
|
|
201
|
+
elif cmd == "/scan":
|
|
202
|
+
partial = words[-1] if len(words) > 1 and not text.endswith(" ") else ""
|
|
203
|
+
base = partial or "."
|
|
204
|
+
try:
|
|
205
|
+
base_path = Path(base)
|
|
206
|
+
if base_path.is_dir():
|
|
207
|
+
parent = base_path
|
|
208
|
+
prefix = ""
|
|
209
|
+
else:
|
|
210
|
+
parent = base_path.parent if base_path.parent.is_dir() else Path(".")
|
|
211
|
+
prefix = base_path.name
|
|
212
|
+
for child in sorted(parent.iterdir()):
|
|
213
|
+
if child.name.startswith(".") or not child.is_dir():
|
|
214
|
+
continue
|
|
215
|
+
if prefix and not child.name.startswith(prefix):
|
|
216
|
+
continue
|
|
217
|
+
yield Completion(str(child) + "/", start_position=-len(partial))
|
|
218
|
+
except OSError:
|
|
219
|
+
pass
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ── Agent/finding state derived from trace entries ────────────────
|
|
223
|
+
|
|
224
|
+
# Status icons:
|
|
225
|
+
# ◌ pending (not yet started)
|
|
226
|
+
# ● running (current step is this agent)
|
|
227
|
+
# ▸ working (mid-task)
|
|
228
|
+
# ✓ complete
|
|
229
|
+
# ✗ failed / cancelled
|
|
230
|
+
|
|
231
|
+
_STATUS_PENDING = ("◌", "class:status.pending")
|
|
232
|
+
_STATUS_RUNNING = ("●", "class:status.running")
|
|
233
|
+
_STATUS_WORKING = ("▸", "class:status.working")
|
|
234
|
+
_STATUS_DONE = ("✓", "class:status.done")
|
|
235
|
+
_STATUS_FAIL = ("✗", "class:status.fail")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class _AgentRow:
|
|
239
|
+
__slots__ = ("name", "status", "detail")
|
|
240
|
+
|
|
241
|
+
def __init__(self, name: str, status: tuple[str, str], detail: str = ""):
|
|
242
|
+
self.name = name
|
|
243
|
+
self.status = status
|
|
244
|
+
self.detail = detail
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class ScanState:
|
|
248
|
+
"""Derived UI state for an in-progress scan."""
|
|
249
|
+
|
|
250
|
+
def __init__(self, target: str):
|
|
251
|
+
self.target = target
|
|
252
|
+
self.start_time = time.time()
|
|
253
|
+
self.end_time: Optional[float] = None # set when the scan terminates
|
|
254
|
+
self.cost: float = 0.0
|
|
255
|
+
self.current_step: Optional[str] = None
|
|
256
|
+
self.agents: dict[str, _AgentRow] = {}
|
|
257
|
+
self.findings: list[Finding] = []
|
|
258
|
+
self.agent_order: list[str] = []
|
|
259
|
+
self.last_message: str = ""
|
|
260
|
+
# Each rendered trace line carries its source agent so the Trace tab
|
|
261
|
+
# can filter to "show only this agent's events".
|
|
262
|
+
self.trace_lines: list[tuple[str, list[tuple[str, str]]]] = []
|
|
263
|
+
# Unique agents in order of first appearance — drives the trace sidebar.
|
|
264
|
+
self.trace_agents: list[str] = []
|
|
265
|
+
|
|
266
|
+
def _append_trace(self, agent: str, fragments: list[tuple[str, str]]) -> None:
|
|
267
|
+
"""Internal: record a rendered trace line with its agent attribution."""
|
|
268
|
+
self.trace_lines.append((agent, fragments))
|
|
269
|
+
if agent and agent not in self.trace_agents:
|
|
270
|
+
self.trace_agents.append(agent)
|
|
271
|
+
|
|
272
|
+
def finish(self) -> None:
|
|
273
|
+
"""Freeze the elapsed clock — call when the scan completes/cancels/fails."""
|
|
274
|
+
if self.end_time is None:
|
|
275
|
+
self.end_time = time.time()
|
|
276
|
+
|
|
277
|
+
def elapsed_str(self) -> str:
|
|
278
|
+
endpoint = self.end_time if self.end_time is not None else time.time()
|
|
279
|
+
seconds = int(endpoint - self.start_time)
|
|
280
|
+
m, s = divmod(seconds, 60)
|
|
281
|
+
return f"{m}:{s:02d}" if m else f"0:{s:02d}"
|
|
282
|
+
|
|
283
|
+
def upsert_agent(self, name: str, status: tuple[str, str], detail: str = "") -> None:
|
|
284
|
+
row = self.agents.get(name)
|
|
285
|
+
if row is None:
|
|
286
|
+
self.agents[name] = _AgentRow(name, status, detail)
|
|
287
|
+
self.agent_order.append(name)
|
|
288
|
+
else:
|
|
289
|
+
row.status = status
|
|
290
|
+
if detail:
|
|
291
|
+
row.detail = detail
|
|
292
|
+
|
|
293
|
+
def update_from_trace(self, entry: TraceEntry) -> None:
|
|
294
|
+
agent = entry.agent
|
|
295
|
+
etype = entry.event_type
|
|
296
|
+
|
|
297
|
+
ts = self._ts(entry.timestamp)
|
|
298
|
+
|
|
299
|
+
if etype == "step_start":
|
|
300
|
+
self.current_step = str(entry.content)
|
|
301
|
+
self.last_message = f"step start · {entry.content}"
|
|
302
|
+
self._append_trace(agent, [
|
|
303
|
+
("class:trace.time", ts),
|
|
304
|
+
("class:trace.step", f" ── {entry.content} ──"),
|
|
305
|
+
])
|
|
306
|
+
return
|
|
307
|
+
|
|
308
|
+
if etype == "step_complete":
|
|
309
|
+
data = entry.content if isinstance(entry.content, dict) else {}
|
|
310
|
+
self.cost += float(data.get("cost", 0) or 0)
|
|
311
|
+
self.last_message = f"step complete · {data.get('step', '')}"
|
|
312
|
+
self._append_trace(agent, [
|
|
313
|
+
("class:trace.time", ts),
|
|
314
|
+
("class:trace.dim", f" {data.get('step', 'step')} complete · "
|
|
315
|
+
f"${float(data.get('cost', 0)):.4f} · {data.get('tokens', 0):,} tok"),
|
|
316
|
+
])
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
if etype == "swarm_start":
|
|
320
|
+
data = entry.content if isinstance(entry.content, dict) else {}
|
|
321
|
+
groups = data.get("groups", [])
|
|
322
|
+
base = agent.replace("_swarm", "")
|
|
323
|
+
for g in groups:
|
|
324
|
+
self.upsert_agent(f"{base}:{g}", _STATUS_PENDING, "queued")
|
|
325
|
+
self.last_message = f"{agent} · spawned {len(groups)} sub-agents"
|
|
326
|
+
count = data.get("group_count") or data.get("findings_count") or len(groups)
|
|
327
|
+
self._append_trace(agent, [
|
|
328
|
+
("class:trace.time", ts),
|
|
329
|
+
("class:trace.agent", f" {agent}"),
|
|
330
|
+
("class:trace.dim", f" spawned {count} sub-agents"),
|
|
331
|
+
])
|
|
332
|
+
return
|
|
333
|
+
|
|
334
|
+
if etype == "swarm_complete":
|
|
335
|
+
data = entry.content if isinstance(entry.content, dict) else {}
|
|
336
|
+
base = agent.replace("_swarm", "")
|
|
337
|
+
for name in list(self.agents):
|
|
338
|
+
if name.startswith(f"{base}:") and self.agents[name].status[0] != "✓":
|
|
339
|
+
self.upsert_agent(name, _STATUS_DONE, "complete")
|
|
340
|
+
cost = data.get("total_cost", 0)
|
|
341
|
+
self.cost += float(cost or 0)
|
|
342
|
+
n = data.get("total_findings") or data.get("total_confirmed") or 0
|
|
343
|
+
self.last_message = f"{agent} · complete"
|
|
344
|
+
self._append_trace(agent, [
|
|
345
|
+
("class:trace.time", ts),
|
|
346
|
+
("class:trace.agent", f" {agent}"),
|
|
347
|
+
("class:trace.dim", f" complete · {n} findings · ${float(cost):.4f}"),
|
|
348
|
+
])
|
|
349
|
+
return
|
|
350
|
+
|
|
351
|
+
if etype == "tool_call":
|
|
352
|
+
tool = entry.tool_name or "tool"
|
|
353
|
+
args = entry.tool_input or {}
|
|
354
|
+
detail = _short_tool_label(tool, args)
|
|
355
|
+
self.upsert_agent(agent, _STATUS_WORKING, detail)
|
|
356
|
+
self.last_message = f"{agent} · {detail}"
|
|
357
|
+
self._append_trace(agent, [
|
|
358
|
+
("class:trace.time", ts),
|
|
359
|
+
("class:trace.agent", f" {agent:>24}"),
|
|
360
|
+
("class:trace.arrow", " → "),
|
|
361
|
+
("class:trace.tool", tool),
|
|
362
|
+
("class:trace.dim", f" {detail}" if detail and detail != tool else ""),
|
|
363
|
+
])
|
|
364
|
+
return
|
|
365
|
+
|
|
366
|
+
if etype == "tool_result":
|
|
367
|
+
row = self.agents.get(agent)
|
|
368
|
+
if row and row.status[0] == "▸":
|
|
369
|
+
row.status = _STATUS_RUNNING
|
|
370
|
+
return
|
|
371
|
+
|
|
372
|
+
if etype == "thinking":
|
|
373
|
+
self.upsert_agent(agent, _STATUS_RUNNING, "thinking…")
|
|
374
|
+
content_str = str(entry.content or "").strip()
|
|
375
|
+
if content_str:
|
|
376
|
+
# Truncate hard at the source level so a 5-page chain-of-thought
|
|
377
|
+
# doesn't blow up the pane. Render the (possibly truncated)
|
|
378
|
+
# content as markdown — headers, bold, bullets, inline code.
|
|
379
|
+
if len(content_str) > 2000:
|
|
380
|
+
content_str = content_str[:1997] + "…"
|
|
381
|
+
line: list[tuple[str, str]] = [
|
|
382
|
+
("class:trace.time", ts),
|
|
383
|
+
("class:trace.agent", f" {agent:>24}"),
|
|
384
|
+
("class:trace.arrow", " ⋯ "),
|
|
385
|
+
]
|
|
386
|
+
line.extend(_render_md_prose(content_str))
|
|
387
|
+
self._append_trace(agent, line)
|
|
388
|
+
return
|
|
389
|
+
|
|
390
|
+
if etype == "finding_added":
|
|
391
|
+
data = entry.content if isinstance(entry.content, dict) else {}
|
|
392
|
+
sev = (data.get("severity") or "info").lower()
|
|
393
|
+
title = data.get("title", "")
|
|
394
|
+
file_path = data.get("file_path", "")
|
|
395
|
+
self.last_message = f"finding · {title}"
|
|
396
|
+
self._append_trace(agent, [
|
|
397
|
+
("class:trace.time", ts),
|
|
398
|
+
("", " "),
|
|
399
|
+
(_sev_style(sev), f"★ {_sev_label(sev)}"),
|
|
400
|
+
("", " "),
|
|
401
|
+
("class:finding.title", title),
|
|
402
|
+
("class:finding.path", f" {file_path}" if file_path else ""),
|
|
403
|
+
])
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
if etype == "queued":
|
|
407
|
+
data = entry.content if isinstance(entry.content, dict) else {}
|
|
408
|
+
title = data.get("title", "")
|
|
409
|
+
self.upsert_agent(agent, _STATUS_PENDING, "queued")
|
|
410
|
+
self.last_message = f"{agent} · queued"
|
|
411
|
+
self._append_trace(agent, [
|
|
412
|
+
("class:trace.time", ts),
|
|
413
|
+
("class:trace.agent", f" {agent:>24}"),
|
|
414
|
+
("class:trace.dim", f" queued · {title}" if title else " queued"),
|
|
415
|
+
])
|
|
416
|
+
return
|
|
417
|
+
|
|
418
|
+
if etype == "sandbox_starting":
|
|
419
|
+
msg = str(entry.content or "starting sandbox…")
|
|
420
|
+
self.last_message = f"{agent} · starting sandbox"
|
|
421
|
+
self._append_trace(agent, [
|
|
422
|
+
("class:trace.time", ts),
|
|
423
|
+
("class:trace.agent", f" {agent}"),
|
|
424
|
+
("class:trace.dim", f" {msg}"),
|
|
425
|
+
])
|
|
426
|
+
return
|
|
427
|
+
|
|
428
|
+
if etype == "sandbox_ready":
|
|
429
|
+
data = entry.content if isinstance(entry.content, dict) else {}
|
|
430
|
+
url = data.get("base_url", "")
|
|
431
|
+
self.last_message = f"sandbox ready · {url}"
|
|
432
|
+
self._append_trace(agent, [
|
|
433
|
+
("class:trace.time", ts),
|
|
434
|
+
("class:trace.agent", f" {agent}"),
|
|
435
|
+
("class:trace.dim", f" sandbox ready · {url}"),
|
|
436
|
+
])
|
|
437
|
+
return
|
|
438
|
+
|
|
439
|
+
if etype == "error":
|
|
440
|
+
msg = str(entry.content or "error")
|
|
441
|
+
self.upsert_agent(agent, _STATUS_FAIL, msg[:60])
|
|
442
|
+
self.last_message = f"{agent} · error"
|
|
443
|
+
self._append_trace(agent, [
|
|
444
|
+
("class:trace.time", ts),
|
|
445
|
+
("class:trace.agent", f" {agent:>24}"),
|
|
446
|
+
("class:trace.arrow", " ✗ "),
|
|
447
|
+
("class:status.fail", msg[:200]),
|
|
448
|
+
])
|
|
449
|
+
return
|
|
450
|
+
|
|
451
|
+
if etype == "skipped":
|
|
452
|
+
msg = str(entry.content or "skipped")
|
|
453
|
+
self.upsert_agent(agent, _STATUS_FAIL, "skipped")
|
|
454
|
+
self._append_trace(agent, [
|
|
455
|
+
("class:trace.time", ts),
|
|
456
|
+
("class:trace.agent", f" {agent:>24}"),
|
|
457
|
+
("class:trace.dim", f" skipped · {msg[:120]}"),
|
|
458
|
+
])
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
if etype == "swarm_aborted":
|
|
462
|
+
msg = str(entry.content or "aborted")
|
|
463
|
+
self.last_message = f"{agent} · aborted"
|
|
464
|
+
self._append_trace(agent, [
|
|
465
|
+
("class:trace.time", ts),
|
|
466
|
+
("class:trace.agent", f" {agent}"),
|
|
467
|
+
("class:trace.arrow", " ✗ "),
|
|
468
|
+
("class:status.fail", msg[:200]),
|
|
469
|
+
])
|
|
470
|
+
return
|
|
471
|
+
|
|
472
|
+
if etype == "sandbox_teardown":
|
|
473
|
+
msg = str(entry.content or "stopping sandbox")
|
|
474
|
+
self.last_message = f"{agent} · teardown"
|
|
475
|
+
self._append_trace(agent, [
|
|
476
|
+
("class:trace.time", ts),
|
|
477
|
+
("class:trace.agent", f" {agent}"),
|
|
478
|
+
("class:trace.dim", f" {msg}"),
|
|
479
|
+
])
|
|
480
|
+
return
|
|
481
|
+
|
|
482
|
+
def _ts(self, t: float) -> str:
|
|
483
|
+
rel = max(0, int(t - self.start_time))
|
|
484
|
+
m, s = divmod(rel, 60)
|
|
485
|
+
return f"[{m}:{s:02d}]"
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
# ── Syntax highlighting ───────────────────────────────────────────
|
|
489
|
+
|
|
490
|
+
def _highlight_code(code: str, file_path: str = "") -> list[tuple[str, str]]:
|
|
491
|
+
"""Tokenize *code* with Pygments and return prompt_toolkit fragments."""
|
|
492
|
+
if not code:
|
|
493
|
+
return []
|
|
494
|
+
try:
|
|
495
|
+
from pygments.lexers import get_lexer_for_filename, guess_lexer
|
|
496
|
+
from pygments.token import Token
|
|
497
|
+
from pygments.util import ClassNotFound
|
|
498
|
+
except ImportError:
|
|
499
|
+
return [("class:code", code)]
|
|
500
|
+
|
|
501
|
+
lexer = None
|
|
502
|
+
if file_path:
|
|
503
|
+
try:
|
|
504
|
+
lexer = get_lexer_for_filename(file_path)
|
|
505
|
+
except ClassNotFound:
|
|
506
|
+
lexer = None
|
|
507
|
+
if lexer is None:
|
|
508
|
+
try:
|
|
509
|
+
lexer = guess_lexer(code)
|
|
510
|
+
except Exception:
|
|
511
|
+
return [("class:code", code)]
|
|
512
|
+
|
|
513
|
+
def style_for(token) -> str:
|
|
514
|
+
if token in Token.Comment:
|
|
515
|
+
return "class:syntax.comment"
|
|
516
|
+
if token in Token.String:
|
|
517
|
+
return "class:syntax.string"
|
|
518
|
+
if token in Token.Keyword:
|
|
519
|
+
return "class:syntax.keyword"
|
|
520
|
+
if token in Token.Name.Builtin:
|
|
521
|
+
return "class:syntax.builtin"
|
|
522
|
+
if token in Token.Name.Function:
|
|
523
|
+
return "class:syntax.function"
|
|
524
|
+
if token in Token.Name.Class:
|
|
525
|
+
return "class:syntax.class"
|
|
526
|
+
if token in Token.Name.Decorator:
|
|
527
|
+
return "class:syntax.decorator"
|
|
528
|
+
if token in Token.Number:
|
|
529
|
+
return "class:syntax.number"
|
|
530
|
+
if token in Token.Operator:
|
|
531
|
+
return "class:syntax.operator"
|
|
532
|
+
return "class:code"
|
|
533
|
+
|
|
534
|
+
return [(style_for(tok), text) for tok, text in lexer.get_tokens(code)]
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
class _ScrollableFormattedTextControl(FormattedTextControl):
|
|
538
|
+
"""A FormattedTextControl that *always* catches scroll-wheel events and
|
|
539
|
+
forwards them to a callback. Used by the details pane so mouse wheel
|
|
540
|
+
scrolling fires reliably regardless of which fragment is hovered.
|
|
541
|
+
"""
|
|
542
|
+
|
|
543
|
+
def __init__(self, *args, on_scroll=None, on_event=None, **kwargs):
|
|
544
|
+
super().__init__(*args, **kwargs)
|
|
545
|
+
self._on_scroll = on_scroll
|
|
546
|
+
self._on_event = on_event # called for *any* event — used for debug
|
|
547
|
+
|
|
548
|
+
def mouse_handler(self, mouse_event: MouseEvent): # type: ignore[override]
|
|
549
|
+
if self._on_event is not None:
|
|
550
|
+
try:
|
|
551
|
+
self._on_event(mouse_event)
|
|
552
|
+
except Exception:
|
|
553
|
+
pass
|
|
554
|
+
if self._on_scroll is not None:
|
|
555
|
+
if mouse_event.event_type == MouseEventType.SCROLL_DOWN:
|
|
556
|
+
self._on_scroll(+3)
|
|
557
|
+
return None
|
|
558
|
+
if mouse_event.event_type == MouseEventType.SCROLL_UP:
|
|
559
|
+
self._on_scroll(-3)
|
|
560
|
+
return None
|
|
561
|
+
return super().mouse_handler(mouse_event)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _section_header(label: str) -> list[tuple[str, str]]:
|
|
565
|
+
"""An open-bottom 'box top' that visually demarcates a section."""
|
|
566
|
+
width = 78
|
|
567
|
+
prefix = f"┌─ {label} "
|
|
568
|
+
pad = max(0, width - len(prefix) - 1)
|
|
569
|
+
return [
|
|
570
|
+
("class:section.box", prefix),
|
|
571
|
+
("class:section.box", "─" * pad),
|
|
572
|
+
("class:section.box", "┐\n"),
|
|
573
|
+
]
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _highlight_code_by_lang(code: str, lang: str, fallback_file: str = "") -> list[tuple[str, str]]:
|
|
577
|
+
"""Tokenize code with Pygments using a language name; fall back to file-based detection."""
|
|
578
|
+
try:
|
|
579
|
+
from pygments.lexers import get_lexer_by_name
|
|
580
|
+
from pygments.token import Token
|
|
581
|
+
from pygments.util import ClassNotFound
|
|
582
|
+
except ImportError:
|
|
583
|
+
return [("class:code", code)]
|
|
584
|
+
try:
|
|
585
|
+
lexer = get_lexer_by_name(lang)
|
|
586
|
+
except ClassNotFound:
|
|
587
|
+
return _highlight_code(code, fallback_file)
|
|
588
|
+
|
|
589
|
+
def style_for(tok):
|
|
590
|
+
if tok in Token.Comment: return "class:syntax.comment"
|
|
591
|
+
if tok in Token.String: return "class:syntax.string"
|
|
592
|
+
if tok in Token.Keyword: return "class:syntax.keyword"
|
|
593
|
+
if tok in Token.Name.Builtin: return "class:syntax.builtin"
|
|
594
|
+
if tok in Token.Name.Function: return "class:syntax.function"
|
|
595
|
+
if tok in Token.Name.Class: return "class:syntax.class"
|
|
596
|
+
if tok in Token.Name.Decorator: return "class:syntax.decorator"
|
|
597
|
+
if tok in Token.Number: return "class:syntax.number"
|
|
598
|
+
if tok in Token.Operator: return "class:syntax.operator"
|
|
599
|
+
return "class:code"
|
|
600
|
+
|
|
601
|
+
return [(style_for(tok), t) for tok, t in lexer.get_tokens(code)]
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
# Inline markdown patterns: **bold**, *italic*, _italic_, `code`, [link](url)
|
|
605
|
+
_MD_INLINE_RE = __import__("re").compile(
|
|
606
|
+
r"(\*\*[^*\n]+\*\*)"
|
|
607
|
+
r"|(`[^`\n]+`)"
|
|
608
|
+
r"|(\*(?!\s)[^*\n]+?\*)"
|
|
609
|
+
r"|(_(?!\s)[^_\n]+?_)"
|
|
610
|
+
r"|(\[[^\]\n]+\]\([^)\s]+\))"
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _render_md_inline(text: str) -> list[tuple[str, str]]:
|
|
615
|
+
"""Render inline markdown — bold/italic/code/links — into styled fragments."""
|
|
616
|
+
import re as _re
|
|
617
|
+
out: list[tuple[str, str]] = []
|
|
618
|
+
pos = 0
|
|
619
|
+
for m in _MD_INLINE_RE.finditer(text):
|
|
620
|
+
if m.start() > pos:
|
|
621
|
+
out.append(("", text[pos:m.start()]))
|
|
622
|
+
token = m.group(0)
|
|
623
|
+
if token.startswith("**") and token.endswith("**"):
|
|
624
|
+
out.append(("class:md.bold", token[2:-2]))
|
|
625
|
+
elif token.startswith("`") and token.endswith("`"):
|
|
626
|
+
out.append(("class:md.code", token[1:-1]))
|
|
627
|
+
elif token.startswith("*") and token.endswith("*"):
|
|
628
|
+
out.append(("class:md.italic", token[1:-1]))
|
|
629
|
+
elif token.startswith("_") and token.endswith("_"):
|
|
630
|
+
out.append(("class:md.italic", token[1:-1]))
|
|
631
|
+
elif token.startswith("["):
|
|
632
|
+
link_m = _re.match(r"\[([^\]]+)\]\(([^)]+)\)", token)
|
|
633
|
+
if link_m:
|
|
634
|
+
out.append(("class:md.link", link_m.group(1)))
|
|
635
|
+
else:
|
|
636
|
+
out.append(("", token))
|
|
637
|
+
pos = m.end()
|
|
638
|
+
if pos < len(text):
|
|
639
|
+
out.append(("", text[pos:]))
|
|
640
|
+
return out
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _render_md_prose(text: str) -> list[tuple[str, str]]:
|
|
644
|
+
"""Render a chunk of markdown prose (no code fences) into styled fragments."""
|
|
645
|
+
import re as _re
|
|
646
|
+
out: list[tuple[str, str]] = []
|
|
647
|
+
lines = text.split("\n")
|
|
648
|
+
for idx, line in enumerate(lines):
|
|
649
|
+
# ATX headers: #, ##, ###, ...
|
|
650
|
+
m_h = _re.match(r"^(#{1,6})\s+(.*)$", line)
|
|
651
|
+
if m_h:
|
|
652
|
+
level = len(m_h.group(1))
|
|
653
|
+
content = m_h.group(2).strip()
|
|
654
|
+
style = (
|
|
655
|
+
"class:md.h1" if level == 1
|
|
656
|
+
else "class:md.h2" if level == 2
|
|
657
|
+
else "class:md.h3"
|
|
658
|
+
)
|
|
659
|
+
out.append((style, content))
|
|
660
|
+
# Horizontal rule
|
|
661
|
+
elif _re.match(r"^[-*_]{3,}\s*$", line):
|
|
662
|
+
out.append(("class:rule", "─" * 60))
|
|
663
|
+
# Bullet list
|
|
664
|
+
elif (m_b := _re.match(r"^(\s*)[-*+]\s+(.*)$", line)):
|
|
665
|
+
out.append(("", m_b.group(1)))
|
|
666
|
+
out.append(("class:md.bullet", "• "))
|
|
667
|
+
out.extend(_render_md_inline(m_b.group(2)))
|
|
668
|
+
# Numbered list
|
|
669
|
+
elif (m_n := _re.match(r"^(\s*)(\d+)\.\s+(.*)$", line)):
|
|
670
|
+
out.append(("", m_n.group(1)))
|
|
671
|
+
out.append(("class:md.bullet", f"{m_n.group(2)}. "))
|
|
672
|
+
out.extend(_render_md_inline(m_n.group(3)))
|
|
673
|
+
# Blockquote
|
|
674
|
+
elif (m_q := _re.match(r"^>\s?(.*)$", line)):
|
|
675
|
+
out.append(("class:md.quote", "│ "))
|
|
676
|
+
out.extend(_render_md_inline(m_q.group(1)))
|
|
677
|
+
# Regular line
|
|
678
|
+
else:
|
|
679
|
+
out.extend(_render_md_inline(line))
|
|
680
|
+
# Preserve newlines between lines
|
|
681
|
+
if idx < len(lines) - 1:
|
|
682
|
+
out.append(("", "\n"))
|
|
683
|
+
return out
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _render_markdown_with_code(text: str, default_file: str = "") -> list[tuple[str, str]]:
|
|
687
|
+
"""Render markdown text. Code fences are syntax-highlighted; prose handles
|
|
688
|
+
headers, bold, italic, inline code, lists, blockquotes, and horizontal rules."""
|
|
689
|
+
import re as _re
|
|
690
|
+
if not text:
|
|
691
|
+
return []
|
|
692
|
+
fragments: list[tuple[str, str]] = []
|
|
693
|
+
pattern = _re.compile(r"```([a-zA-Z0-9_+\-]*)\n(.*?)```", _re.DOTALL)
|
|
694
|
+
last_end = 0
|
|
695
|
+
for m in pattern.finditer(text):
|
|
696
|
+
prose = text[last_end:m.start()]
|
|
697
|
+
if prose:
|
|
698
|
+
fragments.extend(_render_md_prose(prose))
|
|
699
|
+
lang = m.group(1).strip()
|
|
700
|
+
code = m.group(2)
|
|
701
|
+
if lang:
|
|
702
|
+
fragments.extend(_highlight_code_by_lang(code, lang, default_file))
|
|
703
|
+
else:
|
|
704
|
+
fragments.extend(_highlight_code(code, default_file))
|
|
705
|
+
last_end = m.end()
|
|
706
|
+
if last_end < len(text):
|
|
707
|
+
fragments.extend(_render_md_prose(text[last_end:]))
|
|
708
|
+
return fragments
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _short_tool_label(tool: str, args: dict) -> str:
|
|
712
|
+
path = args.get("path", "")
|
|
713
|
+
pattern = args.get("pattern", "")
|
|
714
|
+
# Paths are already relative to the project root (tools are rooted at
|
|
715
|
+
# target_dir), so we surface them verbatim in the trace.
|
|
716
|
+
if tool == "read_file" and path:
|
|
717
|
+
return f"read {path}"
|
|
718
|
+
if tool == "list_dir":
|
|
719
|
+
return f"ls {path}" if path else "ls ."
|
|
720
|
+
if tool == "glob" and pattern:
|
|
721
|
+
scope = f" in {path}" if path else ""
|
|
722
|
+
return f"glob {pattern}{scope}"
|
|
723
|
+
if tool == "grep":
|
|
724
|
+
p = pattern[:24] + "…" if len(pattern) > 24 else pattern
|
|
725
|
+
scope = f" in {path}" if path else ""
|
|
726
|
+
return f"grep /{p}/{scope}"
|
|
727
|
+
if tool == "get_project_info":
|
|
728
|
+
return "project info"
|
|
729
|
+
if tool == "get_route_map":
|
|
730
|
+
return "route map"
|
|
731
|
+
if tool == "extract_functions" and path:
|
|
732
|
+
return f"extract functions from {path}"
|
|
733
|
+
if tool == "find_dangerous_patterns" and path:
|
|
734
|
+
return f"find dangerous patterns in {path}"
|
|
735
|
+
if tool == "trace_variable":
|
|
736
|
+
var = args.get("variable_name", "")
|
|
737
|
+
return f"trace {var} in {path}" if var else f"trace variable in {path}"
|
|
738
|
+
if tool == "report_finding":
|
|
739
|
+
cat = args.get("category", "")
|
|
740
|
+
fp = args.get("file_path", "")
|
|
741
|
+
if cat and fp:
|
|
742
|
+
return f"report {cat} in {fp}"
|
|
743
|
+
return f"report {cat}" if cat else "report finding"
|
|
744
|
+
if tool == "validate_finding":
|
|
745
|
+
return f"validate {args.get('status', '')}"
|
|
746
|
+
if tool == "finish_hunt":
|
|
747
|
+
return "finish hunt"
|
|
748
|
+
if tool == "finish_validation":
|
|
749
|
+
return "finish validation"
|
|
750
|
+
if path:
|
|
751
|
+
return f"{tool} {path}"
|
|
752
|
+
return tool
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
# ── App ───────────────────────────────────────────────────────────
|
|
756
|
+
|
|
757
|
+
class OpenHackApp:
|
|
758
|
+
"""Full-screen prompt_toolkit application driving the OpenHack TUI."""
|
|
759
|
+
|
|
760
|
+
def __init__(self) -> None:
|
|
761
|
+
cfg = load_user_config()
|
|
762
|
+
self.provider = resolve_provider(cfg.get("provider", settings.llm_provider))
|
|
763
|
+
self.model = cfg.get("model") or PROVIDER_DEFAULTS.get(self.provider, settings.openhack_model_id)
|
|
764
|
+
self.org_name: str = cfg.get("openhack_org_name") or ""
|
|
765
|
+
self.user_email: str = "" # populated lazily
|
|
766
|
+
|
|
767
|
+
self.mode: str = "landing" # "landing" | "scanning" | "viewing" | "sessions"
|
|
768
|
+
self.previous_mode: Optional[str] = None # set when entering "sessions" so Esc can return
|
|
769
|
+
self.active_tab: str = "trace" # "trace" | "findings" — sessions is its own mode now
|
|
770
|
+
self.scan: Optional[ScanState] = None
|
|
771
|
+
self.session: Optional[Session] = None
|
|
772
|
+
self.scan_task: Optional[asyncio.Task] = None
|
|
773
|
+
self.last_status_line: str = ""
|
|
774
|
+
self.last_findings: list[Finding] = [] # findings from most recent scan
|
|
775
|
+
self.last_session: Optional[Session] = None
|
|
776
|
+
self.chat_history: list[Message] = []
|
|
777
|
+
self._cancel_armed = False
|
|
778
|
+
# Sessions tab state
|
|
779
|
+
self.sessions_index: list[dict] = []
|
|
780
|
+
self.sessions_selected: int = 0
|
|
781
|
+
self.viewing_target: str = "" # header label when in "viewing" mode
|
|
782
|
+
# Findings tab selection (split pane: list left, details right)
|
|
783
|
+
self.findings_selected: int = 0
|
|
784
|
+
self.findings_list_hidden: bool = False # toggle the left list via Ctrl+B / /sidebar
|
|
785
|
+
# macOS terminals sometimes emit BOTH a mouse SCROLL event AND an
|
|
786
|
+
# arrow-key event for a single trackpad gesture. Track when the last
|
|
787
|
+
# mouse scroll happened so the arrow-key handler can stand down.
|
|
788
|
+
self._last_scroll_at: float = 0.0
|
|
789
|
+
# /logout uses a two-press confirmation; flag is reset on any other action.
|
|
790
|
+
self._logout_armed: bool = False
|
|
791
|
+
# /verify also uses two-press confirmation for the "enable" path so the
|
|
792
|
+
# user reads the warning about prereqs. Stores which subject is armed.
|
|
793
|
+
self._verify_arm_subject: Optional[str] = None # 'sandbox' | 'browser' | 'all' | None
|
|
794
|
+
# Mouse capture state. When True, prompt_toolkit consumes every mouse
|
|
795
|
+
# event (so wheel-scroll + click-to-select work) but the terminal's
|
|
796
|
+
# native drag-to-select-text is blocked. /mouse toggles this.
|
|
797
|
+
self._mouse_enabled: bool = True
|
|
798
|
+
# Centered modal-dialog state. None = no modal; otherwise a key
|
|
799
|
+
# identifying which one to render (e.g. 'verify:sandbox', 'logout').
|
|
800
|
+
self._modal_kind: Optional[str] = None
|
|
801
|
+
self._modal_title: str = ""
|
|
802
|
+
self._modal_body: str = ""
|
|
803
|
+
self._modal_on_yes: Optional[Any] = None # callable invoked on 'y' / Enter
|
|
804
|
+
# Manual scroll offset for the details pane (in logical lines).
|
|
805
|
+
# We bypass Window.vertical_scroll because prompt_toolkit's render
|
|
806
|
+
# was clamping it back to 0 in our setup — instead we clip the
|
|
807
|
+
# fragment list ourselves in details_text().
|
|
808
|
+
self._details_scroll: int = 0
|
|
809
|
+
# Findings sidebar width as a percentage of the Findings tab width.
|
|
810
|
+
# The sibling Dimensions (built in _build_layout) hold weights that
|
|
811
|
+
# we mutate to resize live.
|
|
812
|
+
self._sidebar_pct: int = 35
|
|
813
|
+
# Trace pane scroll. _trace_follow=True means stick to the bottom as
|
|
814
|
+
# new events stream in; flipped off when the user scrolls up to read
|
|
815
|
+
# history, flipped back on when they scroll back to bottom.
|
|
816
|
+
self._trace_scroll: int = 0
|
|
817
|
+
self._trace_follow: bool = True
|
|
818
|
+
# Trace sidebar: 0 = "All", 1+ = scan.trace_agents[idx-1]
|
|
819
|
+
self._trace_agent_idx: int = 0
|
|
820
|
+
# Update/announcement state — populated asynchronously on startup.
|
|
821
|
+
self._update_info: Optional[UpdateInfo] = None
|
|
822
|
+
|
|
823
|
+
self.input_buffer = Buffer(
|
|
824
|
+
multiline=False,
|
|
825
|
+
completer=OpenHackCompleter(),
|
|
826
|
+
complete_while_typing=True,
|
|
827
|
+
accept_handler=self._on_buffer_accept,
|
|
828
|
+
)
|
|
829
|
+
|
|
830
|
+
self.kb = self._build_keybindings()
|
|
831
|
+
self.layout = self._build_layout()
|
|
832
|
+
self.style = self._build_style()
|
|
833
|
+
|
|
834
|
+
self.app: Application = Application(
|
|
835
|
+
layout=self.layout,
|
|
836
|
+
key_bindings=self.kb,
|
|
837
|
+
style=self.style,
|
|
838
|
+
full_screen=True,
|
|
839
|
+
# Filter-driven so /mouse can toggle native copy on demand.
|
|
840
|
+
# When False, the terminal's built-in drag-to-select works.
|
|
841
|
+
mouse_support=Condition(lambda: self._mouse_enabled),
|
|
842
|
+
erase_when_done=True,
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
# ── Keybindings ───────────────────────────────────────────────
|
|
846
|
+
|
|
847
|
+
def _build_keybindings(self) -> KeyBindings:
|
|
848
|
+
kb = KeyBindings()
|
|
849
|
+
|
|
850
|
+
@kb.add("c-c")
|
|
851
|
+
def _ctrl_c(event):
|
|
852
|
+
# Behavior:
|
|
853
|
+
# • Scan running, not paused → pause (state preserved)
|
|
854
|
+
# • Scan paused → exit TUI (scan stays as 'running'
|
|
855
|
+
# with dead PID → reclassified as
|
|
856
|
+
# 'aborted' next launch; resume with
|
|
857
|
+
# 'r' in /sessions)
|
|
858
|
+
# • No scan running → exit TUI
|
|
859
|
+
if self.mode == "scanning" and self.session is not None:
|
|
860
|
+
if self.session.paused:
|
|
861
|
+
event.app.exit()
|
|
862
|
+
else:
|
|
863
|
+
self.session.pause()
|
|
864
|
+
self.last_status_line = (
|
|
865
|
+
"scan paused · Ctrl+C again to exit · /resume to continue · /cancel to stop"
|
|
866
|
+
)
|
|
867
|
+
self._invalidate()
|
|
868
|
+
else:
|
|
869
|
+
event.app.exit()
|
|
870
|
+
|
|
871
|
+
@kb.add("c-d")
|
|
872
|
+
def _ctrl_d(event):
|
|
873
|
+
if not self.input_buffer.text:
|
|
874
|
+
event.app.exit()
|
|
875
|
+
|
|
876
|
+
# Modal-dialog keys (eager so they take priority over text input
|
|
877
|
+
# while a modal is open — typing 'y' goes to the dialog, not the
|
|
878
|
+
# input box).
|
|
879
|
+
modal_open = Condition(lambda: self._modal_kind is not None)
|
|
880
|
+
|
|
881
|
+
@kb.add("y", filter=modal_open, eager=True)
|
|
882
|
+
@kb.add("Y", filter=modal_open, eager=True)
|
|
883
|
+
@kb.add("enter", filter=modal_open, eager=True)
|
|
884
|
+
def _modal_yes(event):
|
|
885
|
+
cb = self._modal_on_yes
|
|
886
|
+
self._close_modal()
|
|
887
|
+
if cb is not None:
|
|
888
|
+
try:
|
|
889
|
+
cb()
|
|
890
|
+
except Exception as exc:
|
|
891
|
+
self.last_status_line = f"action failed: {exc}"
|
|
892
|
+
self._invalidate()
|
|
893
|
+
|
|
894
|
+
@kb.add("n", filter=modal_open, eager=True)
|
|
895
|
+
@kb.add("N", filter=modal_open, eager=True)
|
|
896
|
+
@kb.add("escape", filter=modal_open, eager=True)
|
|
897
|
+
def _modal_no(event):
|
|
898
|
+
self._close_modal()
|
|
899
|
+
self.last_status_line = "cancelled"
|
|
900
|
+
self._invalidate()
|
|
901
|
+
|
|
902
|
+
def _completion_open() -> bool:
|
|
903
|
+
return self.input_buffer.complete_state is not None
|
|
904
|
+
|
|
905
|
+
@kb.add("escape", eager=True, filter=Condition(_completion_open))
|
|
906
|
+
def _escape_completion(event):
|
|
907
|
+
event.current_buffer.cancel_completion()
|
|
908
|
+
|
|
909
|
+
@kb.add("escape", eager=False, filter=~Condition(_completion_open))
|
|
910
|
+
def _escape(event):
|
|
911
|
+
pass
|
|
912
|
+
|
|
913
|
+
# Option+Shift+Left/Right — select word (macOS sends Escape + ShiftLeft/Right)
|
|
914
|
+
@kb.add("escape", "s-left")
|
|
915
|
+
def _select_word_left(event):
|
|
916
|
+
buf = event.current_buffer
|
|
917
|
+
pos = buf.document.find_previous_word_beginning() or 0
|
|
918
|
+
buf.cursor_position += pos
|
|
919
|
+
buf.start_selection()
|
|
920
|
+
# Already moved — selection is from new pos to old pos
|
|
921
|
+
# Re-do: move back, start selection, then move
|
|
922
|
+
buf.cursor_position -= pos
|
|
923
|
+
buf.start_selection()
|
|
924
|
+
buf.cursor_position += pos
|
|
925
|
+
|
|
926
|
+
@kb.add("escape", "s-right")
|
|
927
|
+
def _select_word_right(event):
|
|
928
|
+
buf = event.current_buffer
|
|
929
|
+
pos = buf.document.find_next_word_ending() or 0
|
|
930
|
+
buf.start_selection()
|
|
931
|
+
buf.cursor_position += pos
|
|
932
|
+
|
|
933
|
+
# Tab navigation — only in scanning/viewing modes, and only when the
|
|
934
|
+
# input is empty so they don't conflict with typing.
|
|
935
|
+
def _in_tabs() -> bool:
|
|
936
|
+
return self.mode in ("scanning", "viewing")
|
|
937
|
+
|
|
938
|
+
def _in_sessions() -> bool:
|
|
939
|
+
return self.mode == "sessions"
|
|
940
|
+
|
|
941
|
+
def _input_empty() -> bool:
|
|
942
|
+
return not self.input_buffer.text
|
|
943
|
+
|
|
944
|
+
@kb.add("c-t")
|
|
945
|
+
def _ctrl_t(event):
|
|
946
|
+
if _in_tabs():
|
|
947
|
+
self._cycle_tab(+1)
|
|
948
|
+
|
|
949
|
+
@kb.add("right", filter=Condition(lambda: _in_tabs() and _input_empty()))
|
|
950
|
+
def _right(event):
|
|
951
|
+
self._cycle_tab(+1)
|
|
952
|
+
|
|
953
|
+
@kb.add("left", filter=Condition(lambda: _in_tabs() and _input_empty()))
|
|
954
|
+
def _left(event):
|
|
955
|
+
self._cycle_tab(-1)
|
|
956
|
+
|
|
957
|
+
for i, name in enumerate(("trace", "findings"), 1):
|
|
958
|
+
@kb.add(str(i), filter=Condition(lambda: _in_tabs() and _input_empty()))
|
|
959
|
+
def _digit(event, _name=name):
|
|
960
|
+
self.active_tab = _name
|
|
961
|
+
self._invalidate()
|
|
962
|
+
|
|
963
|
+
# Findings list navigation (Findings tab + empty input).
|
|
964
|
+
def _on_findings() -> bool:
|
|
965
|
+
return _in_tabs() and self.active_tab == "findings" and _input_empty()
|
|
966
|
+
|
|
967
|
+
def _reset_details_scroll() -> None:
|
|
968
|
+
self._details_scroll = 0
|
|
969
|
+
|
|
970
|
+
def _move_selection(delta: int) -> None:
|
|
971
|
+
# If a mouse scroll fired in the last 400ms, this arrow key is
|
|
972
|
+
# almost certainly the paired event from a Mac trackpad gesture —
|
|
973
|
+
# not a deliberate keyboard press to switch findings. Stand down.
|
|
974
|
+
if time.monotonic() - self._last_scroll_at < 0.4:
|
|
975
|
+
return
|
|
976
|
+
n = len(self._current_findings())
|
|
977
|
+
if n == 0:
|
|
978
|
+
return
|
|
979
|
+
new_idx = max(0, min(n - 1, self.findings_selected + delta))
|
|
980
|
+
if new_idx != self.findings_selected:
|
|
981
|
+
self.findings_selected = new_idx
|
|
982
|
+
_reset_details_scroll()
|
|
983
|
+
self._invalidate()
|
|
984
|
+
|
|
985
|
+
def _scroll_details(delta: int) -> None:
|
|
986
|
+
self._details_scroll = max(0, self._details_scroll + delta)
|
|
987
|
+
self._invalidate()
|
|
988
|
+
|
|
989
|
+
# Up / Down switch findings (keyboard nav). [ and ] are aliases.
|
|
990
|
+
@kb.add("up", filter=Condition(_on_findings))
|
|
991
|
+
def _f_up(event):
|
|
992
|
+
_move_selection(-1)
|
|
993
|
+
|
|
994
|
+
@kb.add("down", filter=Condition(_on_findings))
|
|
995
|
+
def _f_down(event):
|
|
996
|
+
_move_selection(+1)
|
|
997
|
+
|
|
998
|
+
@kb.add("[", filter=Condition(_on_findings))
|
|
999
|
+
def _f_prev(event):
|
|
1000
|
+
_move_selection(-1)
|
|
1001
|
+
|
|
1002
|
+
@kb.add("]", filter=Condition(_on_findings))
|
|
1003
|
+
def _f_next(event):
|
|
1004
|
+
_move_selection(+1)
|
|
1005
|
+
|
|
1006
|
+
# 'y' = yank the finding as an AI-agent prompt to the system clipboard.
|
|
1007
|
+
@kb.add("y", filter=Condition(_on_findings))
|
|
1008
|
+
def _f_yank(event):
|
|
1009
|
+
self._cmd_copy_fix()
|
|
1010
|
+
|
|
1011
|
+
# < / > resize the sidebar / details split by 5% steps.
|
|
1012
|
+
def _resize_sidebar(delta_pct: int) -> None:
|
|
1013
|
+
self._sidebar_pct = max(15, min(75, self._sidebar_pct + delta_pct))
|
|
1014
|
+
self._sidebar_dim.weight = self._sidebar_pct
|
|
1015
|
+
self._details_dim.weight = 100 - self._sidebar_pct
|
|
1016
|
+
self.last_status_line = f"sidebar {self._sidebar_pct}% · details {100 - self._sidebar_pct}%"
|
|
1017
|
+
self._invalidate()
|
|
1018
|
+
|
|
1019
|
+
@kb.add("<", filter=Condition(_on_findings))
|
|
1020
|
+
def _f_shrink(event):
|
|
1021
|
+
_resize_sidebar(-5)
|
|
1022
|
+
|
|
1023
|
+
@kb.add(">", filter=Condition(_on_findings))
|
|
1024
|
+
def _f_grow(event):
|
|
1025
|
+
_resize_sidebar(+5)
|
|
1026
|
+
|
|
1027
|
+
# Trace tab scrolling — keyboard fallbacks for the mouse wheel.
|
|
1028
|
+
def _on_trace() -> bool:
|
|
1029
|
+
return _in_tabs() and self.active_tab == "trace" and _input_empty()
|
|
1030
|
+
|
|
1031
|
+
# Up / Down → navigate the trace sidebar (agent picker).
|
|
1032
|
+
# PgUp / PgDn → scroll the trace content (was Up/Down before).
|
|
1033
|
+
def _move_trace_agent(delta: int) -> None:
|
|
1034
|
+
if self.scan is None:
|
|
1035
|
+
return
|
|
1036
|
+
# Total entries = "All" + however many agents the tree shows.
|
|
1037
|
+
# Compute by counting trace_agents (which is what the tree is built
|
|
1038
|
+
# from) — works for both the flat sidebar and the tree variant.
|
|
1039
|
+
n_entries = 1 + len(self.scan.trace_agents)
|
|
1040
|
+
if n_entries <= 1:
|
|
1041
|
+
return
|
|
1042
|
+
self._trace_agent_idx = max(0, min(n_entries - 1, self._trace_agent_idx + delta))
|
|
1043
|
+
self._trace_scroll = 0
|
|
1044
|
+
self._trace_follow = True
|
|
1045
|
+
self._invalidate()
|
|
1046
|
+
|
|
1047
|
+
@kb.add("up", filter=Condition(_on_trace))
|
|
1048
|
+
def _t_up(event):
|
|
1049
|
+
_move_trace_agent(-1)
|
|
1050
|
+
|
|
1051
|
+
@kb.add("down", filter=Condition(_on_trace))
|
|
1052
|
+
def _t_down(event):
|
|
1053
|
+
_move_trace_agent(+1)
|
|
1054
|
+
|
|
1055
|
+
@kb.add("[", filter=Condition(_on_trace))
|
|
1056
|
+
def _t_prev_agent(event):
|
|
1057
|
+
_move_trace_agent(-1)
|
|
1058
|
+
|
|
1059
|
+
@kb.add("]", filter=Condition(_on_trace))
|
|
1060
|
+
def _t_next_agent(event):
|
|
1061
|
+
_move_trace_agent(+1)
|
|
1062
|
+
|
|
1063
|
+
# PgUp / PgDn scroll the trace content (was Up/Down before).
|
|
1064
|
+
@kb.add("pageup", filter=Condition(_on_trace))
|
|
1065
|
+
def _t_pgup(event):
|
|
1066
|
+
self._scroll_trace_by(-12)
|
|
1067
|
+
|
|
1068
|
+
@kb.add("pagedown", filter=Condition(_on_trace))
|
|
1069
|
+
def _t_pgdn(event):
|
|
1070
|
+
self._scroll_trace_by(+12)
|
|
1071
|
+
|
|
1072
|
+
@kb.add("home", filter=Condition(_on_trace))
|
|
1073
|
+
def _t_home(event):
|
|
1074
|
+
# Home: jump to "All" + reset trace to top.
|
|
1075
|
+
self._trace_agent_idx = 0
|
|
1076
|
+
self._trace_follow = False
|
|
1077
|
+
self._trace_scroll = 0
|
|
1078
|
+
self._invalidate()
|
|
1079
|
+
|
|
1080
|
+
@kb.add("end", filter=Condition(_on_trace))
|
|
1081
|
+
def _t_end(event):
|
|
1082
|
+
# End: stay on current agent filter, jump trace to bottom.
|
|
1083
|
+
self._trace_follow = True
|
|
1084
|
+
self._invalidate()
|
|
1085
|
+
|
|
1086
|
+
# Mouse wheel over the right pane is the primary scroll mechanism.
|
|
1087
|
+
# Keyboard fallbacks below work when the input box is empty so they
|
|
1088
|
+
# don't conflict with typing.
|
|
1089
|
+
@kb.add("pageup", filter=Condition(_on_findings))
|
|
1090
|
+
def _details_pgup(event):
|
|
1091
|
+
_scroll_details(-12)
|
|
1092
|
+
|
|
1093
|
+
@kb.add("pagedown", filter=Condition(_on_findings))
|
|
1094
|
+
def _details_pgdn(event):
|
|
1095
|
+
_scroll_details(+12)
|
|
1096
|
+
|
|
1097
|
+
@kb.add("home", filter=Condition(_on_findings))
|
|
1098
|
+
def _f_home(event):
|
|
1099
|
+
_reset_details_scroll()
|
|
1100
|
+
self._invalidate()
|
|
1101
|
+
|
|
1102
|
+
@kb.add("end", filter=Condition(_on_findings))
|
|
1103
|
+
def _f_end(event):
|
|
1104
|
+
_scroll_details(+10_000)
|
|
1105
|
+
|
|
1106
|
+
# Ctrl+B toggles the sidebar (left findings list) — global shortcut,
|
|
1107
|
+
# only meaningful on the Findings tab but harmless elsewhere.
|
|
1108
|
+
@kb.add("c-b", filter=Condition(lambda: _in_tabs() and _input_empty()))
|
|
1109
|
+
def _toggle_sidebar(event):
|
|
1110
|
+
self.findings_list_hidden = not self.findings_list_hidden
|
|
1111
|
+
self.last_status_line = "sidebar hidden" if self.findings_list_hidden else "sidebar shown"
|
|
1112
|
+
self._invalidate()
|
|
1113
|
+
|
|
1114
|
+
# Sessions overlay keybindings.
|
|
1115
|
+
@kb.add("up", filter=Condition(lambda: _in_sessions() and _input_empty()))
|
|
1116
|
+
def _up(event):
|
|
1117
|
+
if self.sessions_index:
|
|
1118
|
+
self.sessions_selected = max(0, self.sessions_selected - 1)
|
|
1119
|
+
self._invalidate()
|
|
1120
|
+
|
|
1121
|
+
@kb.add("down", filter=Condition(lambda: _in_sessions() and _input_empty()))
|
|
1122
|
+
def _down(event):
|
|
1123
|
+
if self.sessions_index:
|
|
1124
|
+
self.sessions_selected = min(len(self.sessions_index) - 1, self.sessions_selected + 1)
|
|
1125
|
+
self._invalidate()
|
|
1126
|
+
|
|
1127
|
+
@kb.add("enter", filter=Condition(lambda: _in_sessions() and _input_empty()))
|
|
1128
|
+
def _enter(event):
|
|
1129
|
+
self._load_selected_session()
|
|
1130
|
+
|
|
1131
|
+
@kb.add("r", filter=Condition(lambda: _in_sessions() and _input_empty()))
|
|
1132
|
+
def _resume(event):
|
|
1133
|
+
self._resume_selected_session()
|
|
1134
|
+
|
|
1135
|
+
@kb.add("escape", eager=True, filter=Condition(lambda: _in_sessions() and _input_empty()))
|
|
1136
|
+
def _esc_sessions(event):
|
|
1137
|
+
self._close_sessions_overlay()
|
|
1138
|
+
|
|
1139
|
+
return kb
|
|
1140
|
+
|
|
1141
|
+
def _cycle_tab(self, direction: int) -> None:
|
|
1142
|
+
order = ["trace", "findings"]
|
|
1143
|
+
try:
|
|
1144
|
+
idx = order.index(self.active_tab)
|
|
1145
|
+
except ValueError:
|
|
1146
|
+
idx = 0
|
|
1147
|
+
self.active_tab = order[(idx + direction) % len(order)]
|
|
1148
|
+
self._invalidate()
|
|
1149
|
+
|
|
1150
|
+
# ── Style ─────────────────────────────────────────────────────
|
|
1151
|
+
|
|
1152
|
+
def _build_style(self) -> Style:
|
|
1153
|
+
return Style.from_dict({
|
|
1154
|
+
"logo": "bold ansiwhite",
|
|
1155
|
+
"wordmark": "bold ansiwhite",
|
|
1156
|
+
"tagline": "ansigray",
|
|
1157
|
+
"tip": "ansigray italic",
|
|
1158
|
+
"tip.label": "ansiyellow",
|
|
1159
|
+
"footer": "ansigray",
|
|
1160
|
+
"input.box": "",
|
|
1161
|
+
"input.prompt": "bold ansibrightgreen",
|
|
1162
|
+
"input.placeholder": "ansigray italic",
|
|
1163
|
+
"hint": "ansigray",
|
|
1164
|
+
"hint.key": "bold ansiwhite",
|
|
1165
|
+
"rule": "ansigray",
|
|
1166
|
+
"header.brand": "bold ansicyan",
|
|
1167
|
+
"header.sep": "ansigray",
|
|
1168
|
+
"header.target": "bold ansiwhite",
|
|
1169
|
+
"header.meta": "ansigray",
|
|
1170
|
+
"tab.active": "bold reverse ansicyan",
|
|
1171
|
+
"tab.inactive": "ansigray",
|
|
1172
|
+
"tab.key": "bold ansiwhite",
|
|
1173
|
+
"pane.title": "bold ansiwhite",
|
|
1174
|
+
"pane.empty": "ansigray italic",
|
|
1175
|
+
"pane.dim": "ansigray",
|
|
1176
|
+
"verified": "bold ansigreen",
|
|
1177
|
+
"status.pending": "ansigray",
|
|
1178
|
+
"status.running": "bold ansicyan",
|
|
1179
|
+
"status.working": "bold ansiyellow",
|
|
1180
|
+
"status.done": "bold ansigreen",
|
|
1181
|
+
"status.fail": "bold ansired",
|
|
1182
|
+
"agent.name": "ansiwhite",
|
|
1183
|
+
"agent.detail": "ansigray",
|
|
1184
|
+
"sev.critical": "bold reverse ansired",
|
|
1185
|
+
"sev.high": "bold ansired",
|
|
1186
|
+
"sev.medium": "bold ansiyellow",
|
|
1187
|
+
"sev.low": "bold ansiblue",
|
|
1188
|
+
"sev.info": "ansigray",
|
|
1189
|
+
"finding.title": "ansiwhite",
|
|
1190
|
+
"finding.path": "ansigray",
|
|
1191
|
+
"finding.cursor": "bold reverse ansicyan",
|
|
1192
|
+
"trace.time": "ansigray",
|
|
1193
|
+
"trace.agent": "ansicyan",
|
|
1194
|
+
"trace.arrow": "ansigray",
|
|
1195
|
+
"trace.tool": "bold ansiwhite",
|
|
1196
|
+
"trace.dim": "ansigray",
|
|
1197
|
+
"trace.step": "bold ansibrightblue",
|
|
1198
|
+
"session.row": "ansiwhite",
|
|
1199
|
+
"session.row.selected": "bold reverse ansicyan",
|
|
1200
|
+
"session.meta": "ansigray",
|
|
1201
|
+
"section.label": "bold ansicyan",
|
|
1202
|
+
"section.box": "ansicyan",
|
|
1203
|
+
"code": "ansiwhite",
|
|
1204
|
+
"md.h1": "bold ansibrightcyan underline",
|
|
1205
|
+
"md.h2": "bold ansicyan",
|
|
1206
|
+
"md.h3": "bold ansiwhite",
|
|
1207
|
+
"md.bold": "bold ansiwhite",
|
|
1208
|
+
"md.italic": "italic",
|
|
1209
|
+
"md.code": "bg:#222222 ansibrightgreen",
|
|
1210
|
+
"md.bullet": "ansicyan",
|
|
1211
|
+
"md.link": "underline ansibrightblue",
|
|
1212
|
+
"md.quote": "italic ansigray",
|
|
1213
|
+
"syntax.comment": "italic ansigray",
|
|
1214
|
+
"syntax.string": "ansigreen",
|
|
1215
|
+
"syntax.keyword": "bold ansimagenta",
|
|
1216
|
+
"syntax.builtin": "ansicyan",
|
|
1217
|
+
"syntax.function": "ansiyellow",
|
|
1218
|
+
"syntax.class": "bold ansiyellow",
|
|
1219
|
+
"syntax.decorator": "ansiyellow",
|
|
1220
|
+
"syntax.number": "ansicyan",
|
|
1221
|
+
"syntax.operator": "ansiwhite",
|
|
1222
|
+
"log": "ansigray",
|
|
1223
|
+
"modal.frame": "bg:#1a1a1a ansiwhite",
|
|
1224
|
+
"modal.title": "bg:#1a1a1a bold ansibrightcyan",
|
|
1225
|
+
"modal.body": "bg:#1a1a1a ansiwhite",
|
|
1226
|
+
"modal.hint": "bg:#1a1a1a ansigray",
|
|
1227
|
+
"modal.key": "bg:#1a1a1a bold ansibrightyellow",
|
|
1228
|
+
"completion-menu.completion": "bg:#222222 ansiwhite",
|
|
1229
|
+
"completion-menu.completion.current": "bg:ansibrightblue ansiwhite",
|
|
1230
|
+
})
|
|
1231
|
+
|
|
1232
|
+
# ── Layout ────────────────────────────────────────────────────
|
|
1233
|
+
|
|
1234
|
+
def _build_layout(self) -> Layout:
|
|
1235
|
+
is_landing = Condition(lambda: self.mode == "landing")
|
|
1236
|
+
is_sessions = Condition(lambda: self.mode == "sessions")
|
|
1237
|
+
is_scanning = Condition(lambda: self.mode in ("scanning", "viewing"))
|
|
1238
|
+
|
|
1239
|
+
landing = self._build_landing_container()
|
|
1240
|
+
scan = self._build_scan_container()
|
|
1241
|
+
sessions = self._build_sessions_container()
|
|
1242
|
+
|
|
1243
|
+
body = HSplit([
|
|
1244
|
+
ConditionalContainer(content=landing, filter=is_landing),
|
|
1245
|
+
ConditionalContainer(content=sessions, filter=is_sessions),
|
|
1246
|
+
ConditionalContainer(content=scan, filter=is_scanning),
|
|
1247
|
+
])
|
|
1248
|
+
|
|
1249
|
+
# ── Modal overlay: centered Frame shown when self._modal_kind set ──
|
|
1250
|
+
def _modal_text():
|
|
1251
|
+
return [
|
|
1252
|
+
("class:modal.title", self._modal_title),
|
|
1253
|
+
("", "\n\n"),
|
|
1254
|
+
("class:modal.body", self._modal_body),
|
|
1255
|
+
("", "\n\n"),
|
|
1256
|
+
("class:modal.key", "[Y]"),
|
|
1257
|
+
("class:modal.hint", " confirm "),
|
|
1258
|
+
("class:modal.key", "[N]"),
|
|
1259
|
+
("class:modal.hint", " cancel "),
|
|
1260
|
+
("class:modal.key", "[Esc]"),
|
|
1261
|
+
("class:modal.hint", " dismiss"),
|
|
1262
|
+
]
|
|
1263
|
+
|
|
1264
|
+
modal_body_window = Window(
|
|
1265
|
+
FormattedTextControl(_modal_text),
|
|
1266
|
+
wrap_lines=True,
|
|
1267
|
+
style="class:modal.frame",
|
|
1268
|
+
)
|
|
1269
|
+
modal_frame = Frame(
|
|
1270
|
+
body=modal_body_window,
|
|
1271
|
+
title="OpenHack",
|
|
1272
|
+
style="class:modal.frame",
|
|
1273
|
+
width=D(min=50, max=80, preferred=72),
|
|
1274
|
+
height=D(min=8, max=20, preferred=14),
|
|
1275
|
+
)
|
|
1276
|
+
# Center via weight-1 spacers on all four sides inside the full-screen Float.
|
|
1277
|
+
modal_centered = HSplit([
|
|
1278
|
+
Window(height=D(weight=1)),
|
|
1279
|
+
VSplit([
|
|
1280
|
+
Window(width=D(weight=1)),
|
|
1281
|
+
modal_frame,
|
|
1282
|
+
Window(width=D(weight=1)),
|
|
1283
|
+
]),
|
|
1284
|
+
Window(height=D(weight=1)),
|
|
1285
|
+
])
|
|
1286
|
+
modal_visible = Condition(lambda: self._modal_kind is not None)
|
|
1287
|
+
|
|
1288
|
+
root = FloatContainer(
|
|
1289
|
+
content=body,
|
|
1290
|
+
floats=[
|
|
1291
|
+
Float(
|
|
1292
|
+
xcursor=True,
|
|
1293
|
+
ycursor=True,
|
|
1294
|
+
content=CompletionsMenu(max_height=12, scroll_offset=1),
|
|
1295
|
+
),
|
|
1296
|
+
Float(
|
|
1297
|
+
top=0, left=0, right=0, bottom=0,
|
|
1298
|
+
content=ConditionalContainer(modal_centered, filter=modal_visible),
|
|
1299
|
+
),
|
|
1300
|
+
],
|
|
1301
|
+
)
|
|
1302
|
+
return Layout(root, focused_element=self._input_window)
|
|
1303
|
+
|
|
1304
|
+
def _build_landing_container(self) -> HSplit:
|
|
1305
|
+
# Centered logo + wordmark + input + hints + tip + footer.
|
|
1306
|
+
# Render each logo line as its own Window so WindowAlign.CENTER puts
|
|
1307
|
+
# them all at the same horizontal offset.
|
|
1308
|
+
logo_windows = [
|
|
1309
|
+
Window(
|
|
1310
|
+
FormattedTextControl(lambda line=line: [("class:logo", line)]),
|
|
1311
|
+
align=WindowAlign.CENTER,
|
|
1312
|
+
height=1,
|
|
1313
|
+
)
|
|
1314
|
+
for line in _LOGO_LINES
|
|
1315
|
+
]
|
|
1316
|
+
|
|
1317
|
+
def wordmark():
|
|
1318
|
+
return [("class:wordmark", "OpenHack")]
|
|
1319
|
+
|
|
1320
|
+
def tip():
|
|
1321
|
+
return [
|
|
1322
|
+
("class:tip.label", "• Tip "),
|
|
1323
|
+
("class:tip", "Type "),
|
|
1324
|
+
("class:hint.key", "/scan ."),
|
|
1325
|
+
("class:tip", " to scan the current directory, or "),
|
|
1326
|
+
("class:hint.key", "?"),
|
|
1327
|
+
("class:tip", " for help"),
|
|
1328
|
+
]
|
|
1329
|
+
|
|
1330
|
+
# "Get Started" box — four key commands a new user needs. Each row is
|
|
1331
|
+
# `command description`, column-aligned so the descriptions line up.
|
|
1332
|
+
_GETTING_STARTED = [
|
|
1333
|
+
("/login", "login to OpenHack"),
|
|
1334
|
+
("/scan <dir>", "begin a scan"),
|
|
1335
|
+
("/sessions", "browse past scans"),
|
|
1336
|
+
("/help", "list commands"),
|
|
1337
|
+
("/discord", "chat with the community"),
|
|
1338
|
+
]
|
|
1339
|
+
_GS_CMD_WIDTH = max(len(cmd) for cmd, _ in _GETTING_STARTED)
|
|
1340
|
+
|
|
1341
|
+
_GS_LPAD = " " # 2 spaces left padding inside the frame
|
|
1342
|
+
_GS_RPAD = " " # 2 spaces right padding inside the frame
|
|
1343
|
+
|
|
1344
|
+
def getting_started():
|
|
1345
|
+
out: list[tuple[str, str]] = [
|
|
1346
|
+
("", "\n"), # top vertical padding
|
|
1347
|
+
("", _GS_LPAD), ("class:pane.title", "Get Started"), ("", _GS_RPAD + "\n"),
|
|
1348
|
+
("", "\n"), # spacer under the title
|
|
1349
|
+
]
|
|
1350
|
+
for cmd, desc in _GETTING_STARTED:
|
|
1351
|
+
pad = " " * (_GS_CMD_WIDTH - len(cmd) + 3)
|
|
1352
|
+
out.append(("", _GS_LPAD))
|
|
1353
|
+
out.append(("class:hint.key", cmd))
|
|
1354
|
+
out.append(("", pad))
|
|
1355
|
+
out.append(("class:tip", desc))
|
|
1356
|
+
out.append(("", _GS_RPAD + "\n"))
|
|
1357
|
+
out.append(("", "\n")) # bottom vertical padding
|
|
1358
|
+
return out
|
|
1359
|
+
|
|
1360
|
+
# Width = lpad + command column + gap + longest description column + rpad.
|
|
1361
|
+
_GS_INNER_WIDTH = (
|
|
1362
|
+
len(_GS_LPAD) + _GS_CMD_WIDTH + 3
|
|
1363
|
+
+ max(len(d) for _, d in _GETTING_STARTED) + len(_GS_RPAD)
|
|
1364
|
+
)
|
|
1365
|
+
_GS_FRAME_WIDTH = _GS_INNER_WIDTH + 2 # +2 for the box borders
|
|
1366
|
+
# Body height: 1 top pad + 1 title + 1 blank + N rows + 1 bottom pad.
|
|
1367
|
+
_GS_INNER_HEIGHT = 3 + len(_GETTING_STARTED) + 1
|
|
1368
|
+
|
|
1369
|
+
def hints():
|
|
1370
|
+
return [
|
|
1371
|
+
("class:hint", " enter "),
|
|
1372
|
+
("class:hint.key", "submit"),
|
|
1373
|
+
("class:hint", " tab "),
|
|
1374
|
+
("class:hint.key", "complete"),
|
|
1375
|
+
("class:hint", " "),
|
|
1376
|
+
("class:hint.key", "?"),
|
|
1377
|
+
("class:hint", " help"),
|
|
1378
|
+
]
|
|
1379
|
+
|
|
1380
|
+
def footer():
|
|
1381
|
+
cfg = load_user_config()
|
|
1382
|
+
first = cfg.get("openhack_user_first_name") or ""
|
|
1383
|
+
last = cfg.get("openhack_user_last_name") or ""
|
|
1384
|
+
email = cfg.get("openhack_user_email") or self.user_email or ""
|
|
1385
|
+
org = cfg.get("openhack_org_name") or self.org_name or ""
|
|
1386
|
+
# Prefer full name → first name → email.
|
|
1387
|
+
display_name = " ".join(p for p in (first, last) if p).strip() or email
|
|
1388
|
+
parts: list[tuple[str, str]] = []
|
|
1389
|
+
if display_name:
|
|
1390
|
+
parts.append(("class:footer", display_name))
|
|
1391
|
+
if org:
|
|
1392
|
+
if parts:
|
|
1393
|
+
parts.append(("class:footer", " · "))
|
|
1394
|
+
parts.append(("class:footer", org))
|
|
1395
|
+
if not parts:
|
|
1396
|
+
parts.append(("class:footer", "not logged in — run /login"))
|
|
1397
|
+
return parts
|
|
1398
|
+
|
|
1399
|
+
# The input bar (used by both landing and scanning, but here it's
|
|
1400
|
+
# styled to feel like opencode's centered prompt).
|
|
1401
|
+
self._input_window = Window(
|
|
1402
|
+
content=BufferControl(
|
|
1403
|
+
buffer=self.input_buffer,
|
|
1404
|
+
input_processors=[BeforeInput("❯ ", style="class:input.prompt")],
|
|
1405
|
+
),
|
|
1406
|
+
height=1,
|
|
1407
|
+
)
|
|
1408
|
+
|
|
1409
|
+
return HSplit([
|
|
1410
|
+
Window(height=D(weight=1)), # top spacer
|
|
1411
|
+
*logo_windows,
|
|
1412
|
+
Window(height=1),
|
|
1413
|
+
Window(FormattedTextControl(wordmark), align=WindowAlign.CENTER, height=1),
|
|
1414
|
+
Window(height=2),
|
|
1415
|
+
# Input row, padded so it appears centered with consistent width.
|
|
1416
|
+
VSplit([
|
|
1417
|
+
Window(width=D(weight=1)),
|
|
1418
|
+
HSplit([
|
|
1419
|
+
Window(
|
|
1420
|
+
FormattedTextControl(lambda: [("class:rule", "─" * 64)]),
|
|
1421
|
+
height=1,
|
|
1422
|
+
),
|
|
1423
|
+
self._input_window,
|
|
1424
|
+
Window(
|
|
1425
|
+
FormattedTextControl(lambda: [("class:rule", "─" * 64)]),
|
|
1426
|
+
height=1,
|
|
1427
|
+
),
|
|
1428
|
+
], width=64),
|
|
1429
|
+
Window(width=D(weight=1)),
|
|
1430
|
+
]),
|
|
1431
|
+
Window(height=1),
|
|
1432
|
+
Window(FormattedTextControl(hints), align=WindowAlign.CENTER, height=1),
|
|
1433
|
+
Window(height=1),
|
|
1434
|
+
Window(FormattedTextControl(tip), align=WindowAlign.CENTER, height=1),
|
|
1435
|
+
Window(height=1),
|
|
1436
|
+
# "Get Started" box — fixed-width, horizontally centered with
|
|
1437
|
+
# flexible spacers on either side.
|
|
1438
|
+
VSplit([
|
|
1439
|
+
Window(width=D(weight=1)),
|
|
1440
|
+
Frame(
|
|
1441
|
+
Window(
|
|
1442
|
+
FormattedTextControl(getting_started),
|
|
1443
|
+
height=_GS_INNER_HEIGHT,
|
|
1444
|
+
),
|
|
1445
|
+
width=_GS_FRAME_WIDTH,
|
|
1446
|
+
),
|
|
1447
|
+
Window(width=D(weight=1)),
|
|
1448
|
+
]),
|
|
1449
|
+
Window(height=1),
|
|
1450
|
+
# Update notification + announcement banners (populated async on
|
|
1451
|
+
# startup via the /updates endpoint). Empty if nothing to show.
|
|
1452
|
+
Window(
|
|
1453
|
+
FormattedTextControl(self._update_banner_text),
|
|
1454
|
+
align=WindowAlign.CENTER,
|
|
1455
|
+
wrap_lines=True,
|
|
1456
|
+
),
|
|
1457
|
+
# Status-line slot: shows /verify warnings, /logout prompts, errors,
|
|
1458
|
+
# etc., on the landing screen. wrap_lines so long warnings stay
|
|
1459
|
+
# readable instead of getting truncated at the right edge.
|
|
1460
|
+
Window(
|
|
1461
|
+
FormattedTextControl(lambda: [
|
|
1462
|
+
("class:log", f" {self.last_status_line}" if self.last_status_line else "")
|
|
1463
|
+
]),
|
|
1464
|
+
wrap_lines=True,
|
|
1465
|
+
align=WindowAlign.CENTER,
|
|
1466
|
+
),
|
|
1467
|
+
Window(height=D(weight=1)),
|
|
1468
|
+
Window(FormattedTextControl(footer), align=WindowAlign.CENTER, height=1),
|
|
1469
|
+
Window(height=1),
|
|
1470
|
+
])
|
|
1471
|
+
|
|
1472
|
+
def _build_scan_container(self) -> HSplit:
|
|
1473
|
+
# ── Header bar ────────────────────────────────────────────
|
|
1474
|
+
def header_text():
|
|
1475
|
+
target = ""
|
|
1476
|
+
elapsed = ""
|
|
1477
|
+
cost = 0.0
|
|
1478
|
+
label = ""
|
|
1479
|
+
if self.scan is not None:
|
|
1480
|
+
target = self.scan.target or ""
|
|
1481
|
+
elapsed = self.scan.elapsed_str()
|
|
1482
|
+
cost = self.scan.cost
|
|
1483
|
+
if self.scan.end_time is not None and self.mode != "viewing":
|
|
1484
|
+
label = "complete"
|
|
1485
|
+
elif self.session is not None and self.session.paused:
|
|
1486
|
+
label = "⏸ paused"
|
|
1487
|
+
if self.mode == "viewing":
|
|
1488
|
+
target = self.viewing_target or target
|
|
1489
|
+
label = "viewing"
|
|
1490
|
+
short = self._short_target(target) if target else ""
|
|
1491
|
+
out: list[tuple[str, str]] = [("class:header.brand", "⏚ openhack")]
|
|
1492
|
+
if short:
|
|
1493
|
+
out.extend([
|
|
1494
|
+
("class:header.sep", " · "),
|
|
1495
|
+
("class:header.target", short),
|
|
1496
|
+
])
|
|
1497
|
+
if elapsed:
|
|
1498
|
+
out.extend([
|
|
1499
|
+
("class:header.sep", " "),
|
|
1500
|
+
("class:header.meta", elapsed),
|
|
1501
|
+
])
|
|
1502
|
+
if self.scan is not None and self.mode != "viewing":
|
|
1503
|
+
out.extend([
|
|
1504
|
+
("class:header.sep", " · "),
|
|
1505
|
+
("class:header.meta", f"${cost:.4f}"),
|
|
1506
|
+
])
|
|
1507
|
+
if label:
|
|
1508
|
+
out.extend([
|
|
1509
|
+
("class:header.sep", " · "),
|
|
1510
|
+
("class:header.meta", label),
|
|
1511
|
+
])
|
|
1512
|
+
return out
|
|
1513
|
+
|
|
1514
|
+
def account_text():
|
|
1515
|
+
# Mirror the landing-page footer: show "Name · Org" on the right
|
|
1516
|
+
# edge of the scan header so users always see who they're scanning
|
|
1517
|
+
# as. Falls back through full name → first name → email → blank.
|
|
1518
|
+
cfg = load_user_config()
|
|
1519
|
+
first = cfg.get("openhack_user_first_name") or ""
|
|
1520
|
+
last = cfg.get("openhack_user_last_name") or ""
|
|
1521
|
+
email = cfg.get("openhack_user_email") or self.user_email or ""
|
|
1522
|
+
org = cfg.get("openhack_org_name") or self.org_name or ""
|
|
1523
|
+
display_name = " ".join(p for p in (first, last) if p).strip() or email
|
|
1524
|
+
parts: list[tuple[str, str]] = []
|
|
1525
|
+
if display_name:
|
|
1526
|
+
parts.append(("class:header.meta", display_name))
|
|
1527
|
+
if org:
|
|
1528
|
+
if parts:
|
|
1529
|
+
parts.append(("class:header.sep", " · "))
|
|
1530
|
+
parts.append(("class:header.meta", org))
|
|
1531
|
+
if parts:
|
|
1532
|
+
# Trailing pad keeps the text off the right edge.
|
|
1533
|
+
parts.append(("", " "))
|
|
1534
|
+
return parts
|
|
1535
|
+
|
|
1536
|
+
header = VSplit([
|
|
1537
|
+
Window(FormattedTextControl(header_text), height=1),
|
|
1538
|
+
Window(
|
|
1539
|
+
FormattedTextControl(account_text),
|
|
1540
|
+
height=1,
|
|
1541
|
+
align=WindowAlign.RIGHT,
|
|
1542
|
+
),
|
|
1543
|
+
], height=1)
|
|
1544
|
+
rule = Window(FormattedTextControl(lambda: [("class:rule", "─" * 240)]), height=1)
|
|
1545
|
+
|
|
1546
|
+
# ── Tab bar ───────────────────────────────────────────────
|
|
1547
|
+
def tab_bar():
|
|
1548
|
+
findings = self._current_findings()
|
|
1549
|
+
count = len(findings)
|
|
1550
|
+
tabs = [("trace", "Trace"), ("findings", f"Findings ({count})")]
|
|
1551
|
+
out: list[tuple[str, str]] = [("", " ")]
|
|
1552
|
+
for i, (key, label) in enumerate(tabs, 1):
|
|
1553
|
+
active = self.active_tab == key
|
|
1554
|
+
cls = "class:tab.active" if active else "class:tab.inactive"
|
|
1555
|
+
out.append(("class:tab.key", f" {i} "))
|
|
1556
|
+
out.append((cls, f" {label} "))
|
|
1557
|
+
out.append(("", " "))
|
|
1558
|
+
out.append(("class:hint",
|
|
1559
|
+
" ←/→ tab · ↑↓ scroll · [ ] finding · < > resize · Ctrl+B hide · /sessions"))
|
|
1560
|
+
return out
|
|
1561
|
+
|
|
1562
|
+
tab_bar_window = Window(FormattedTextControl(tab_bar), height=1)
|
|
1563
|
+
|
|
1564
|
+
# ── Trace tab ─────────────────────────────────────────────
|
|
1565
|
+
def _agent_tree() -> list[tuple[int, str]]:
|
|
1566
|
+
"""Flatten scan.trace_agents into [(indent_level, agent_name), …].
|
|
1567
|
+
|
|
1568
|
+
Swarms like 'hunter_swarm' adopt their 'hunter:*' children as
|
|
1569
|
+
level-1 entries underneath. Other agents stay at level 0.
|
|
1570
|
+
"""
|
|
1571
|
+
if self.scan is None:
|
|
1572
|
+
return []
|
|
1573
|
+
agents = self.scan.trace_agents
|
|
1574
|
+
agent_set = set(agents)
|
|
1575
|
+
# Map of parent_swarm_name -> [child names in original order]
|
|
1576
|
+
children_map: dict[str, list[str]] = {}
|
|
1577
|
+
for a in agents:
|
|
1578
|
+
if ":" in a:
|
|
1579
|
+
base = a.split(":", 1)[0]
|
|
1580
|
+
parent = f"{base}_swarm"
|
|
1581
|
+
if parent in agent_set:
|
|
1582
|
+
children_map.setdefault(parent, []).append(a)
|
|
1583
|
+
|
|
1584
|
+
seen: set[str] = set()
|
|
1585
|
+
out: list[tuple[int, str]] = []
|
|
1586
|
+
for a in agents:
|
|
1587
|
+
if a in seen:
|
|
1588
|
+
continue
|
|
1589
|
+
if ":" in a:
|
|
1590
|
+
base = a.split(":", 1)[0]
|
|
1591
|
+
parent = f"{base}_swarm"
|
|
1592
|
+
if parent in agent_set:
|
|
1593
|
+
# Will be emitted under its parent when parent is visited.
|
|
1594
|
+
continue
|
|
1595
|
+
# Top-level entry.
|
|
1596
|
+
out.append((0, a))
|
|
1597
|
+
seen.add(a)
|
|
1598
|
+
# Children (if a is a known swarm parent).
|
|
1599
|
+
for c in children_map.get(a, []):
|
|
1600
|
+
if c not in seen:
|
|
1601
|
+
out.append((1, c))
|
|
1602
|
+
seen.add(c)
|
|
1603
|
+
# Orphans — any agent not yet emitted (parent wasn't actually seen).
|
|
1604
|
+
for a in agents:
|
|
1605
|
+
if a not in seen:
|
|
1606
|
+
out.append((0, a))
|
|
1607
|
+
seen.add(a)
|
|
1608
|
+
return out
|
|
1609
|
+
|
|
1610
|
+
def _selected_trace_agents() -> Optional[set[str]]:
|
|
1611
|
+
"""None = show all events; otherwise a set of agent names to include.
|
|
1612
|
+
|
|
1613
|
+
Selecting a swarm parent expands to include all its children, so
|
|
1614
|
+
'hunter_swarm' shows events from hunter_swarm AND every hunter:*.
|
|
1615
|
+
"""
|
|
1616
|
+
if self.scan is None:
|
|
1617
|
+
return None
|
|
1618
|
+
idx = self._trace_agent_idx
|
|
1619
|
+
if idx <= 0:
|
|
1620
|
+
return None
|
|
1621
|
+
tree = _agent_tree()
|
|
1622
|
+
if not tree:
|
|
1623
|
+
return None
|
|
1624
|
+
idx = min(idx - 1, len(tree) - 1)
|
|
1625
|
+
_, name = tree[idx]
|
|
1626
|
+
if name.endswith("_swarm"):
|
|
1627
|
+
base = name[: -len("_swarm")]
|
|
1628
|
+
return {name} | {
|
|
1629
|
+
a for a in self.scan.trace_agents
|
|
1630
|
+
if a.startswith(f"{base}:")
|
|
1631
|
+
}
|
|
1632
|
+
return {name}
|
|
1633
|
+
|
|
1634
|
+
def _trace_text_raw():
|
|
1635
|
+
if self.scan is None or not self.scan.trace_lines:
|
|
1636
|
+
return [("class:pane.empty", " no trace yet — start a scan with /scan <path>")]
|
|
1637
|
+
wanted = _selected_trace_agents()
|
|
1638
|
+
out: list[tuple[str, str]] = []
|
|
1639
|
+
matched = 0
|
|
1640
|
+
for agent, fragments in self.scan.trace_lines:
|
|
1641
|
+
if wanted is not None and agent not in wanted:
|
|
1642
|
+
continue
|
|
1643
|
+
for fragment in fragments:
|
|
1644
|
+
out.append(fragment)
|
|
1645
|
+
out.append(("", "\n"))
|
|
1646
|
+
matched += 1
|
|
1647
|
+
if matched == 0 and wanted is not None:
|
|
1648
|
+
label = next(iter(wanted)) if len(wanted) == 1 else f"{len(wanted)} agents"
|
|
1649
|
+
return [("class:pane.empty", f" no events from {label} (yet)")]
|
|
1650
|
+
return out
|
|
1651
|
+
|
|
1652
|
+
def trace_text():
|
|
1653
|
+
"""Manual viewport clipping. _trace_follow=True sticks to the
|
|
1654
|
+
bottom; otherwise show from _trace_scroll."""
|
|
1655
|
+
raw = _trace_text_raw()
|
|
1656
|
+
try:
|
|
1657
|
+
lines = list(split_lines(raw))
|
|
1658
|
+
except Exception:
|
|
1659
|
+
return raw
|
|
1660
|
+
if not lines:
|
|
1661
|
+
return raw
|
|
1662
|
+
info = self._trace_window.render_info if hasattr(self, '_trace_window') else None
|
|
1663
|
+
window_height = info.window_height if info is not None else 20
|
|
1664
|
+
max_scroll = max(0, len(lines) - window_height)
|
|
1665
|
+
if self._trace_follow:
|
|
1666
|
+
self._trace_scroll = max_scroll
|
|
1667
|
+
elif self._trace_scroll > max_scroll:
|
|
1668
|
+
self._trace_scroll = max_scroll
|
|
1669
|
+
visible = lines[self._trace_scroll:]
|
|
1670
|
+
out: list[tuple[str, str]] = []
|
|
1671
|
+
for i, line in enumerate(visible):
|
|
1672
|
+
out.extend(line)
|
|
1673
|
+
if i < len(visible) - 1:
|
|
1674
|
+
out.append(("", "\n"))
|
|
1675
|
+
return out
|
|
1676
|
+
|
|
1677
|
+
def _scroll_trace_by(delta: int) -> None:
|
|
1678
|
+
# If user scrolls up, break the auto-follow.
|
|
1679
|
+
if delta < 0:
|
|
1680
|
+
self._trace_follow = False
|
|
1681
|
+
# Bump the offset, then re-clamp on next render.
|
|
1682
|
+
self._trace_scroll = max(0, self._trace_scroll + delta)
|
|
1683
|
+
# If we scrolled down past the visible content end, re-enable follow.
|
|
1684
|
+
if delta > 0 and self.scan and self.scan.trace_lines:
|
|
1685
|
+
total_lines = sum(
|
|
1686
|
+
sum(frag[1].count("\n") for frag in line) + 1
|
|
1687
|
+
for line in self.scan.trace_lines
|
|
1688
|
+
)
|
|
1689
|
+
info = self._trace_window.render_info if hasattr(self, '_trace_window') else None
|
|
1690
|
+
window_height = info.window_height if info is not None else 20
|
|
1691
|
+
if self._trace_scroll >= max(0, total_lines - window_height):
|
|
1692
|
+
self._trace_follow = True
|
|
1693
|
+
self._invalidate()
|
|
1694
|
+
|
|
1695
|
+
trace_window = Window(
|
|
1696
|
+
content=_ScrollableFormattedTextControl(
|
|
1697
|
+
text=trace_text,
|
|
1698
|
+
focusable=False,
|
|
1699
|
+
on_scroll=_scroll_trace_by,
|
|
1700
|
+
),
|
|
1701
|
+
# Wrap so full relative paths in tool calls stay visible. Manual
|
|
1702
|
+
# scroll counts logical (\n-delimited) lines, not visual rows, so
|
|
1703
|
+
# wrap doesn't break the scroll offset.
|
|
1704
|
+
wrap_lines=True,
|
|
1705
|
+
always_hide_cursor=True,
|
|
1706
|
+
)
|
|
1707
|
+
self._trace_window = trace_window
|
|
1708
|
+
self._scroll_trace_by = _scroll_trace_by
|
|
1709
|
+
|
|
1710
|
+
# ── Trace sidebar: tree of agents that have produced events. ──
|
|
1711
|
+
# Sidebar entries (flat list, ordered):
|
|
1712
|
+
# index 0 = "All"
|
|
1713
|
+
# index 1..N = _agent_tree() entries (level 0 or 1 with indent)
|
|
1714
|
+
def trace_sidebar_text():
|
|
1715
|
+
tree = _agent_tree() if self.scan is not None else []
|
|
1716
|
+
n_entries = 1 + len(tree) # "All" + tree
|
|
1717
|
+
out: list = [("class:pane.title", " agents\n\n")]
|
|
1718
|
+
if len(tree) == 0:
|
|
1719
|
+
# "All" + waiting message.
|
|
1720
|
+
def _handler_all(event: MouseEvent):
|
|
1721
|
+
if event.event_type == MouseEventType.MOUSE_UP:
|
|
1722
|
+
self._trace_agent_idx = 0
|
|
1723
|
+
self._invalidate()
|
|
1724
|
+
cls0 = "class:finding.cursor" if self._trace_agent_idx == 0 else "class:trace.agent"
|
|
1725
|
+
pointer0 = "❯ " if self._trace_agent_idx == 0 else " "
|
|
1726
|
+
out.append((cls0, f" {pointer0}All", _handler_all))
|
|
1727
|
+
out.append(("", "\n", _handler_all))
|
|
1728
|
+
out.append(("class:pane.empty", "\n (waiting for events)\n"))
|
|
1729
|
+
return out
|
|
1730
|
+
|
|
1731
|
+
def _make_handler(idx: int):
|
|
1732
|
+
def _handler(event: MouseEvent):
|
|
1733
|
+
if event.event_type == MouseEventType.MOUSE_UP:
|
|
1734
|
+
self._trace_agent_idx = idx
|
|
1735
|
+
self._trace_scroll = 0
|
|
1736
|
+
self._trace_follow = True
|
|
1737
|
+
self._invalidate()
|
|
1738
|
+
return _handler
|
|
1739
|
+
|
|
1740
|
+
# Clamp selection if agents list shrank since last selection.
|
|
1741
|
+
if self._trace_agent_idx >= n_entries:
|
|
1742
|
+
self._trace_agent_idx = 0
|
|
1743
|
+
|
|
1744
|
+
# Entry 0: "All"
|
|
1745
|
+
sel = self._trace_agent_idx == 0
|
|
1746
|
+
cls = "class:finding.cursor" if sel else "class:trace.agent"
|
|
1747
|
+
pointer = "❯ " if sel else " "
|
|
1748
|
+
h0 = _make_handler(0)
|
|
1749
|
+
out.append((cls, f" {pointer}All", h0))
|
|
1750
|
+
out.append(("", "\n", h0))
|
|
1751
|
+
|
|
1752
|
+
# Tree entries
|
|
1753
|
+
for i, (level, name) in enumerate(tree, start=1):
|
|
1754
|
+
sel = i == self._trace_agent_idx
|
|
1755
|
+
handler = _make_handler(i)
|
|
1756
|
+
pointer = "❯ " if sel else " "
|
|
1757
|
+
if level == 0:
|
|
1758
|
+
indent = ""
|
|
1759
|
+
label_full = name
|
|
1760
|
+
cls = "class:finding.cursor" if sel else "class:trace.agent"
|
|
1761
|
+
else:
|
|
1762
|
+
indent = " ├─ "
|
|
1763
|
+
label_full = name
|
|
1764
|
+
cls = "class:finding.cursor" if sel else "class:trace.dim"
|
|
1765
|
+
shown = label_full if len(label_full) <= 24 else label_full[:23] + "…"
|
|
1766
|
+
out.append((cls, f" {pointer}{indent}{shown}", handler))
|
|
1767
|
+
out.append(("", "\n", handler))
|
|
1768
|
+
return out
|
|
1769
|
+
|
|
1770
|
+
def _trace_sidebar_cursor() -> Point:
|
|
1771
|
+
# Row 0 = title ("agents"), row 1 = blank, row 2+ = entries.
|
|
1772
|
+
# Each entry is 1 row. Selected index maps to row (2 + idx).
|
|
1773
|
+
return Point(x=0, y=2 + self._trace_agent_idx)
|
|
1774
|
+
|
|
1775
|
+
trace_sidebar_ctrl = FormattedTextControl(
|
|
1776
|
+
trace_sidebar_text, focusable=False,
|
|
1777
|
+
get_cursor_position=_trace_sidebar_cursor,
|
|
1778
|
+
)
|
|
1779
|
+
trace_sidebar = Window(
|
|
1780
|
+
content=trace_sidebar_ctrl,
|
|
1781
|
+
wrap_lines=False,
|
|
1782
|
+
always_hide_cursor=True,
|
|
1783
|
+
width=D(weight=25, preferred=10_000),
|
|
1784
|
+
)
|
|
1785
|
+
trace_sep = Window(
|
|
1786
|
+
FormattedTextControl(lambda: [("class:rule", "│\n") for _ in range(0, 200)]),
|
|
1787
|
+
width=1,
|
|
1788
|
+
)
|
|
1789
|
+
trace_pane = VSplit([
|
|
1790
|
+
trace_sidebar,
|
|
1791
|
+
trace_sep,
|
|
1792
|
+
VSplit([
|
|
1793
|
+
Window(width=1),
|
|
1794
|
+
trace_window,
|
|
1795
|
+
], width=D(weight=75, preferred=10_000)),
|
|
1796
|
+
])
|
|
1797
|
+
|
|
1798
|
+
# ── Findings tab (split: list on left, details on right) ──
|
|
1799
|
+
def findings_list_text():
|
|
1800
|
+
findings = self._current_findings()
|
|
1801
|
+
count = len(findings)
|
|
1802
|
+
# Per-finding verification summary: how many have been confirmed by
|
|
1803
|
+
# the sandbox / browser verifier. Source is a comma-joined string
|
|
1804
|
+
# like "sandbox,browser" — split and bucket.
|
|
1805
|
+
sb_n = sum(1 for f in findings if "sandbox" in (f.source or ""))
|
|
1806
|
+
br_n = sum(1 for f in findings if "browser" in (f.source or ""))
|
|
1807
|
+
out: list[tuple[str, str, "MouseEvent"] | tuple[str, str]] = [
|
|
1808
|
+
("class:pane.title", f" Findings ({count})\n"),
|
|
1809
|
+
]
|
|
1810
|
+
if sb_n or br_n:
|
|
1811
|
+
badge_parts: list[str] = []
|
|
1812
|
+
if sb_n:
|
|
1813
|
+
badge_parts.append(f"sandbox ✓ {sb_n}/{count}")
|
|
1814
|
+
if br_n:
|
|
1815
|
+
badge_parts.append(f"browser ✓ {br_n}/{count}")
|
|
1816
|
+
out.append(("class:pane.dim", f" {' · '.join(badge_parts)}\n"))
|
|
1817
|
+
out.append(("", "\n"))
|
|
1818
|
+
if not findings:
|
|
1819
|
+
out.append(("class:pane.empty", " none yet — start a scan with /scan <path>\n"))
|
|
1820
|
+
return out
|
|
1821
|
+
|
|
1822
|
+
def _make_handler(idx: int):
|
|
1823
|
+
def _handler(event: MouseEvent):
|
|
1824
|
+
# Only handle clicks for selection. Mouse wheel on the
|
|
1825
|
+
# sidebar is intentionally a no-op (consumed but ignored)
|
|
1826
|
+
# so it doesn't fight with the details pane's scrolling.
|
|
1827
|
+
if event.event_type == MouseEventType.MOUSE_UP:
|
|
1828
|
+
self.findings_selected = idx
|
|
1829
|
+
self._invalidate()
|
|
1830
|
+
return _handler
|
|
1831
|
+
|
|
1832
|
+
for i, f in enumerate(findings):
|
|
1833
|
+
selected = i == self.findings_selected
|
|
1834
|
+
handler = _make_handler(i)
|
|
1835
|
+
pointer = "❯ " if selected else " "
|
|
1836
|
+
row_cls = "class:finding.cursor" if selected else "class:finding.title"
|
|
1837
|
+
# Verified badge: green ✓ when sandbox- or browser-validated.
|
|
1838
|
+
src = f.source or ""
|
|
1839
|
+
if "sandbox" in src and "browser" in src:
|
|
1840
|
+
verified_mark = ("class:verified", "✓✓ ")
|
|
1841
|
+
elif "sandbox" in src or "browser" in src:
|
|
1842
|
+
verified_mark = ("class:verified", "✓ ")
|
|
1843
|
+
else:
|
|
1844
|
+
verified_mark = ("", " ")
|
|
1845
|
+
# The row itself — clickable
|
|
1846
|
+
out.append((row_cls, f" {pointer}", handler))
|
|
1847
|
+
out.append((verified_mark[0], verified_mark[1], handler))
|
|
1848
|
+
out.append((_sev_style(f.severity), f" {_sev_label(f.severity)} ", handler))
|
|
1849
|
+
out.append(("", " ", handler))
|
|
1850
|
+
# Truncate the title to keep the list pane scannable.
|
|
1851
|
+
title = f.title if len(f.title) <= 60 else f.title[:57] + "…"
|
|
1852
|
+
out.append((row_cls, title, handler))
|
|
1853
|
+
out.append(("", "\n", handler))
|
|
1854
|
+
if f.file_path:
|
|
1855
|
+
short_path = f.file_path
|
|
1856
|
+
if len(short_path) > 64:
|
|
1857
|
+
short_path = "…" + short_path[-63:]
|
|
1858
|
+
out.append(("class:finding.path", f" {short_path}\n", handler))
|
|
1859
|
+
out.append(("", "\n", handler))
|
|
1860
|
+
return out
|
|
1861
|
+
|
|
1862
|
+
# ── Details pane: a single scrollable Window ──
|
|
1863
|
+
def _selected_finding():
|
|
1864
|
+
findings = self._current_findings()
|
|
1865
|
+
if not findings:
|
|
1866
|
+
return None
|
|
1867
|
+
if self.findings_selected >= len(findings):
|
|
1868
|
+
self.findings_selected = max(0, len(findings) - 1)
|
|
1869
|
+
return findings[self.findings_selected]
|
|
1870
|
+
|
|
1871
|
+
def _scroll_details_by(delta: int) -> None:
|
|
1872
|
+
self._details_scroll = max(0, self._details_scroll + delta)
|
|
1873
|
+
self._last_scroll_at = time.monotonic()
|
|
1874
|
+
self._invalidate()
|
|
1875
|
+
|
|
1876
|
+
def _details_text_raw():
|
|
1877
|
+
f = _selected_finding()
|
|
1878
|
+
if f is None:
|
|
1879
|
+
return [("class:pane.empty", " no findings to inspect")]
|
|
1880
|
+
out: list[tuple[str, str]] = []
|
|
1881
|
+
|
|
1882
|
+
out.append(("class:pane.title", f"{f.title}\n"))
|
|
1883
|
+
out.append(("", "\n"))
|
|
1884
|
+
out.append((_sev_style(f.severity), f" {_sev_label(f.severity)} "))
|
|
1885
|
+
if f.category:
|
|
1886
|
+
out.append(("", " "))
|
|
1887
|
+
out.append(("class:finding.path", f.category))
|
|
1888
|
+
if getattr(f, "cvss_score", None):
|
|
1889
|
+
out.append(("", " "))
|
|
1890
|
+
out.append(("class:trace.dim", f"CVSS {f.cvss_score:.1f}"))
|
|
1891
|
+
src = f.source or ""
|
|
1892
|
+
verifiers = [v for v in ("sandbox", "browser") if v in src]
|
|
1893
|
+
if verifiers:
|
|
1894
|
+
out.append(("", " "))
|
|
1895
|
+
out.append(("class:verified", "✓ verified via " + ", ".join(verifiers)))
|
|
1896
|
+
out.append(("", "\n"))
|
|
1897
|
+
if f.file_path:
|
|
1898
|
+
loc = f.file_path
|
|
1899
|
+
if getattr(f, "line_number", None):
|
|
1900
|
+
loc += f":{f.line_number}"
|
|
1901
|
+
out.append(("class:finding.path", f"{loc}\n"))
|
|
1902
|
+
out.append(("", "\n"))
|
|
1903
|
+
|
|
1904
|
+
if f.description:
|
|
1905
|
+
out.extend(_section_header("Description"))
|
|
1906
|
+
out.append(("", "\n"))
|
|
1907
|
+
out.append(("", f.description))
|
|
1908
|
+
out.append(("", "\n\n"))
|
|
1909
|
+
|
|
1910
|
+
snippet = getattr(f, "code_snippet", None)
|
|
1911
|
+
if snippet:
|
|
1912
|
+
out.extend(_section_header("Vulnerable code"))
|
|
1913
|
+
out.append(("", "\n"))
|
|
1914
|
+
out.extend(_highlight_code(snippet, f.file_path or ""))
|
|
1915
|
+
out.append(("", "\n\n"))
|
|
1916
|
+
|
|
1917
|
+
fix = getattr(f, "fix", None)
|
|
1918
|
+
if fix:
|
|
1919
|
+
out.extend(_section_header("Recommended fix"))
|
|
1920
|
+
out.append(("", "\n"))
|
|
1921
|
+
out.extend(_render_markdown_with_code(fix, f.file_path or ""))
|
|
1922
|
+
out.append(("", "\n\n"))
|
|
1923
|
+
else:
|
|
1924
|
+
out.append(("class:trace.dim", "No fix saved for this finding.\n"))
|
|
1925
|
+
|
|
1926
|
+
return out
|
|
1927
|
+
|
|
1928
|
+
def details_text():
|
|
1929
|
+
"""Manual viewport-clipping scroll: drop the first N logical
|
|
1930
|
+
lines from the rendered fragments based on self._details_scroll."""
|
|
1931
|
+
raw = _details_text_raw()
|
|
1932
|
+
try:
|
|
1933
|
+
lines = list(split_lines(raw))
|
|
1934
|
+
except Exception:
|
|
1935
|
+
return raw
|
|
1936
|
+
if not lines:
|
|
1937
|
+
return raw
|
|
1938
|
+
# Clamp scroll so that the last line lands at the bottom of the
|
|
1939
|
+
# viewport — no scrolling past the end into blank space.
|
|
1940
|
+
info = self._details_window.render_info
|
|
1941
|
+
window_height = info.window_height if info is not None else 20
|
|
1942
|
+
max_scroll = max(0, len(lines) - window_height)
|
|
1943
|
+
if self._details_scroll > max_scroll:
|
|
1944
|
+
self._details_scroll = max_scroll
|
|
1945
|
+
visible = lines[self._details_scroll:]
|
|
1946
|
+
out: list[tuple[str, str]] = []
|
|
1947
|
+
for i, line in enumerate(visible):
|
|
1948
|
+
out.extend(line)
|
|
1949
|
+
if i < len(visible) - 1:
|
|
1950
|
+
out.append(("", "\n"))
|
|
1951
|
+
return out
|
|
1952
|
+
|
|
1953
|
+
# The custom control catches SCROLL_UP/SCROLL_DOWN at the control
|
|
1954
|
+
# level — guaranteed to fire on wheel events anywhere over this
|
|
1955
|
+
# Window, regardless of which fragment is under the cursor.
|
|
1956
|
+
details_window = Window(
|
|
1957
|
+
content=_ScrollableFormattedTextControl(
|
|
1958
|
+
text=details_text,
|
|
1959
|
+
focusable=False,
|
|
1960
|
+
on_scroll=_scroll_details_by,
|
|
1961
|
+
),
|
|
1962
|
+
wrap_lines=True,
|
|
1963
|
+
always_hide_cursor=True,
|
|
1964
|
+
)
|
|
1965
|
+
self._details_window = details_window
|
|
1966
|
+
|
|
1967
|
+
# Resizable split: the two Dimensions are stored on self so the
|
|
1968
|
+
# < / > keybindings can mutate their `weight` to change the ratio.
|
|
1969
|
+
self._sidebar_dim = D(weight=self._sidebar_pct, preferred=10_000)
|
|
1970
|
+
self._details_dim = D(weight=100 - self._sidebar_pct, preferred=10_000)
|
|
1971
|
+
|
|
1972
|
+
# Findings list pane (left)
|
|
1973
|
+
findings_list_pane = Window(
|
|
1974
|
+
content=FormattedTextControl(findings_list_text, focusable=False),
|
|
1975
|
+
wrap_lines=False,
|
|
1976
|
+
always_hide_cursor=True,
|
|
1977
|
+
width=self._sidebar_dim,
|
|
1978
|
+
)
|
|
1979
|
+
details_sep = Window(
|
|
1980
|
+
FormattedTextControl(lambda: [("class:rule", "│\n") for _ in range(0, 200)]),
|
|
1981
|
+
width=1,
|
|
1982
|
+
)
|
|
1983
|
+
# Right pane — symmetric horizontal padding so content sits balanced.
|
|
1984
|
+
findings_details_pane = VSplit([
|
|
1985
|
+
Window(width=2),
|
|
1986
|
+
details_window,
|
|
1987
|
+
Window(width=2),
|
|
1988
|
+
], width=self._details_dim)
|
|
1989
|
+
sidebar_visible = Condition(lambda: not self.findings_list_hidden)
|
|
1990
|
+
findings_pane = VSplit([
|
|
1991
|
+
ConditionalContainer(findings_list_pane, filter=sidebar_visible),
|
|
1992
|
+
ConditionalContainer(details_sep, filter=sidebar_visible),
|
|
1993
|
+
findings_details_pane,
|
|
1994
|
+
])
|
|
1995
|
+
|
|
1996
|
+
# ── Body: one of the two tabs ─────────────────────────────
|
|
1997
|
+
body = HSplit([
|
|
1998
|
+
ConditionalContainer(content=trace_pane,
|
|
1999
|
+
filter=Condition(lambda: self.active_tab == "trace")),
|
|
2000
|
+
ConditionalContainer(content=findings_pane,
|
|
2001
|
+
filter=Condition(lambda: self.active_tab == "findings")),
|
|
2002
|
+
])
|
|
2003
|
+
|
|
2004
|
+
# ── Bottom status line + input ────────────────────────────
|
|
2005
|
+
def status_line():
|
|
2006
|
+
msg = self.last_status_line or (self.scan.last_message if self.scan else "")
|
|
2007
|
+
return [("class:log", f" {msg}" if msg else "")]
|
|
2008
|
+
|
|
2009
|
+
return HSplit([
|
|
2010
|
+
Window(height=1), # top padding
|
|
2011
|
+
header,
|
|
2012
|
+
rule,
|
|
2013
|
+
tab_bar_window,
|
|
2014
|
+
rule,
|
|
2015
|
+
body,
|
|
2016
|
+
rule,
|
|
2017
|
+
Window(FormattedTextControl(status_line), height=1),
|
|
2018
|
+
VSplit([
|
|
2019
|
+
Window(width=2),
|
|
2020
|
+
self._input_window,
|
|
2021
|
+
Window(FormattedTextControl(lambda: [("class:hint", " /cancel /clear")]),
|
|
2022
|
+
width=20, height=1, align=WindowAlign.RIGHT),
|
|
2023
|
+
]),
|
|
2024
|
+
Window(height=1), # bottom padding
|
|
2025
|
+
])
|
|
2026
|
+
|
|
2027
|
+
def _build_sessions_container(self) -> HSplit:
|
|
2028
|
+
"""Standalone sessions overlay — full-screen picker, no tab bar."""
|
|
2029
|
+
def header_text():
|
|
2030
|
+
return [
|
|
2031
|
+
("class:header.brand", "openhack"),
|
|
2032
|
+
("class:header.sep", " · "),
|
|
2033
|
+
("class:header.target", "sessions"),
|
|
2034
|
+
("class:header.sep", " "),
|
|
2035
|
+
("class:header.meta",
|
|
2036
|
+
f"{len(self.sessions_index)} saved scan(s)" if self.sessions_index else "no saved scans"),
|
|
2037
|
+
]
|
|
2038
|
+
|
|
2039
|
+
def sessions_text():
|
|
2040
|
+
out: list[tuple[str, str]] = [("", "\n")]
|
|
2041
|
+
if not self.sessions_index:
|
|
2042
|
+
out.append((
|
|
2043
|
+
"class:pane.empty",
|
|
2044
|
+
" no saved scans yet — completed scans are saved to ~/.openhack/scans/\n",
|
|
2045
|
+
))
|
|
2046
|
+
return out
|
|
2047
|
+
for i, row in enumerate(self.sessions_index):
|
|
2048
|
+
selected = i == self.sessions_selected
|
|
2049
|
+
cls = "class:session.row.selected" if selected else "class:session.row"
|
|
2050
|
+
pointer = "❯ " if selected else " "
|
|
2051
|
+
out.append((cls, f" {pointer}{row.get('label', '')}"))
|
|
2052
|
+
out.append(("", "\n"))
|
|
2053
|
+
out.append(("class:session.meta", f" {row.get('meta', '')}"))
|
|
2054
|
+
out.append(("", "\n\n"))
|
|
2055
|
+
return out
|
|
2056
|
+
|
|
2057
|
+
def hint_text():
|
|
2058
|
+
return [
|
|
2059
|
+
("class:hint", " ↑/↓ "),
|
|
2060
|
+
("class:hint.key", "navigate"),
|
|
2061
|
+
("class:hint", " enter "),
|
|
2062
|
+
("class:hint.key", "load"),
|
|
2063
|
+
("class:hint", " esc "),
|
|
2064
|
+
("class:hint.key", "back"),
|
|
2065
|
+
]
|
|
2066
|
+
|
|
2067
|
+
header = Window(FormattedTextControl(header_text), height=1)
|
|
2068
|
+
rule = Window(FormattedTextControl(lambda: [("class:rule", "─" * 240)]), height=1)
|
|
2069
|
+
def _sessions_cursor() -> Point:
|
|
2070
|
+
# Row 0 = leading blank. Each session = 3 rows (label, meta, blank).
|
|
2071
|
+
return Point(x=0, y=1 + self.sessions_selected * 3)
|
|
2072
|
+
|
|
2073
|
+
body = Window(
|
|
2074
|
+
FormattedTextControl(
|
|
2075
|
+
sessions_text, focusable=False,
|
|
2076
|
+
get_cursor_position=_sessions_cursor,
|
|
2077
|
+
),
|
|
2078
|
+
wrap_lines=False,
|
|
2079
|
+
always_hide_cursor=True,
|
|
2080
|
+
)
|
|
2081
|
+
hint = Window(FormattedTextControl(hint_text), height=1)
|
|
2082
|
+
|
|
2083
|
+
return HSplit([
|
|
2084
|
+
Window(height=1),
|
|
2085
|
+
header,
|
|
2086
|
+
rule,
|
|
2087
|
+
body,
|
|
2088
|
+
rule,
|
|
2089
|
+
hint,
|
|
2090
|
+
VSplit([Window(width=2), self._input_window]),
|
|
2091
|
+
Window(height=1),
|
|
2092
|
+
])
|
|
2093
|
+
|
|
2094
|
+
_SEV_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
|
|
2095
|
+
|
|
2096
|
+
def _current_findings(self) -> list[Finding]:
|
|
2097
|
+
if self.scan is not None and self.mode == "scanning":
|
|
2098
|
+
findings = self.scan.findings
|
|
2099
|
+
elif self.mode == "viewing":
|
|
2100
|
+
findings = self.last_findings
|
|
2101
|
+
elif self.scan is not None and self.scan.findings:
|
|
2102
|
+
findings = self.scan.findings
|
|
2103
|
+
else:
|
|
2104
|
+
findings = self.last_findings
|
|
2105
|
+
# Sort by severity (critical first), stable so equal-severity findings
|
|
2106
|
+
# keep their discovery order.
|
|
2107
|
+
return sorted(
|
|
2108
|
+
findings,
|
|
2109
|
+
key=lambda f: self._SEV_RANK.get((f.severity or "info").lower(), 99),
|
|
2110
|
+
)
|
|
2111
|
+
|
|
2112
|
+
@staticmethod
|
|
2113
|
+
def _short_target(target: str) -> str:
|
|
2114
|
+
try:
|
|
2115
|
+
home = str(Path.home())
|
|
2116
|
+
if target.startswith(home):
|
|
2117
|
+
return "~" + target[len(home):]
|
|
2118
|
+
except Exception:
|
|
2119
|
+
pass
|
|
2120
|
+
return target
|
|
2121
|
+
|
|
2122
|
+
# ── Update banner ────────────────────────────────────────────
|
|
2123
|
+
|
|
2124
|
+
_ANN_LEVEL_STYLE = {
|
|
2125
|
+
"info": "class:tip",
|
|
2126
|
+
"warning": "class:sev.medium",
|
|
2127
|
+
"critical": "class:sev.critical",
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
def _update_banner_text(self) -> list[tuple[str, str]]:
|
|
2131
|
+
"""Render update + announcement banners for the landing screen."""
|
|
2132
|
+
info = self._update_info
|
|
2133
|
+
if info is None:
|
|
2134
|
+
return []
|
|
2135
|
+
out: list[tuple[str, str]] = []
|
|
2136
|
+
|
|
2137
|
+
# Update available notification.
|
|
2138
|
+
if info.has_update and info.latest:
|
|
2139
|
+
from openhack import __version__ as cur
|
|
2140
|
+
out.append(("class:sev.medium", f" ⬆ Update available: {cur} → {info.latest.version}"))
|
|
2141
|
+
out.append(("class:tip", " · pipx upgrade openhack"))
|
|
2142
|
+
if info.latest.download_url:
|
|
2143
|
+
out.append(("class:tip", f" · {info.latest.download_url}"))
|
|
2144
|
+
out.append(("", "\n"))
|
|
2145
|
+
|
|
2146
|
+
# Banner-placement announcements.
|
|
2147
|
+
for ann in info.announcements:
|
|
2148
|
+
if "banner" not in ann.placement:
|
|
2149
|
+
continue
|
|
2150
|
+
style = self._ANN_LEVEL_STYLE.get(ann.level, "class:tip")
|
|
2151
|
+
out.append((style, f" {ann.title}"))
|
|
2152
|
+
if ann.body:
|
|
2153
|
+
# Show first line of body as a subtitle.
|
|
2154
|
+
first_line = ann.body.split("\n")[0].strip()
|
|
2155
|
+
if first_line:
|
|
2156
|
+
out.append(("class:tip", f" — {first_line}"))
|
|
2157
|
+
out.append(("", "\n"))
|
|
2158
|
+
|
|
2159
|
+
return out
|
|
2160
|
+
|
|
2161
|
+
# ── Invalidate / refresh ──────────────────────────────────────
|
|
2162
|
+
|
|
2163
|
+
def _invalidate(self) -> None:
|
|
2164
|
+
try:
|
|
2165
|
+
self.app.invalidate()
|
|
2166
|
+
except Exception:
|
|
2167
|
+
pass
|
|
2168
|
+
|
|
2169
|
+
# ── Input handling ────────────────────────────────────────────
|
|
2170
|
+
|
|
2171
|
+
def _on_buffer_accept(self, buf: Buffer) -> bool:
|
|
2172
|
+
text = buf.text.strip()
|
|
2173
|
+
buf.reset()
|
|
2174
|
+
if not text:
|
|
2175
|
+
return False
|
|
2176
|
+
if text == "?":
|
|
2177
|
+
text = "/help"
|
|
2178
|
+
asyncio.create_task(self._dispatch_input(text))
|
|
2179
|
+
return False # keep buffer alive
|
|
2180
|
+
|
|
2181
|
+
async def _dispatch_input(self, text: str) -> None:
|
|
2182
|
+
try:
|
|
2183
|
+
await self._handle_input(text)
|
|
2184
|
+
finally:
|
|
2185
|
+
self._invalidate()
|
|
2186
|
+
|
|
2187
|
+
async def _handle_input(self, text: str) -> None:
|
|
2188
|
+
# Cancel any pending confirmations when an unrelated input arrives.
|
|
2189
|
+
if self._logout_armed and not text.startswith("/logout"):
|
|
2190
|
+
self._logout_armed = False
|
|
2191
|
+
if self._verify_arm_subject is not None and not text.startswith("/verify"):
|
|
2192
|
+
self._verify_arm_subject = None
|
|
2193
|
+
|
|
2194
|
+
# Non-slash input.
|
|
2195
|
+
if not text.startswith("/"):
|
|
2196
|
+
if self.mode == "scanning" and self.session:
|
|
2197
|
+
low = text.lstrip("-").strip().lower()
|
|
2198
|
+
if low in _CANCEL_PHRASES:
|
|
2199
|
+
self._cancel_scan()
|
|
2200
|
+
return
|
|
2201
|
+
self.session.add_user_instruction(text)
|
|
2202
|
+
self.last_status_line = "instruction queued for scan agents"
|
|
2203
|
+
return
|
|
2204
|
+
# Landing: chat about findings.
|
|
2205
|
+
await self._chat(text)
|
|
2206
|
+
return
|
|
2207
|
+
|
|
2208
|
+
parts = text.split(None, 1)
|
|
2209
|
+
cmd = parts[0].lower()
|
|
2210
|
+
arg = parts[1] if len(parts) > 1 else ""
|
|
2211
|
+
|
|
2212
|
+
if cmd == "/help":
|
|
2213
|
+
self._show_help()
|
|
2214
|
+
elif cmd in ("/quit", "/exit"):
|
|
2215
|
+
if self.mode == "scanning":
|
|
2216
|
+
self._cancel_scan()
|
|
2217
|
+
self.app.exit()
|
|
2218
|
+
elif cmd == "/cancel":
|
|
2219
|
+
self._cancel_scan()
|
|
2220
|
+
elif cmd == "/pause":
|
|
2221
|
+
self._pause_scan()
|
|
2222
|
+
elif cmd == "/resume":
|
|
2223
|
+
self._resume_scan()
|
|
2224
|
+
elif cmd == "/clear":
|
|
2225
|
+
self.mode = "landing"
|
|
2226
|
+
self.scan = None
|
|
2227
|
+
self.active_tab = "trace"
|
|
2228
|
+
self.viewing_target = ""
|
|
2229
|
+
self.last_status_line = ""
|
|
2230
|
+
elif cmd == "/login":
|
|
2231
|
+
await self._cmd_login()
|
|
2232
|
+
elif cmd == "/logout":
|
|
2233
|
+
self._cmd_logout()
|
|
2234
|
+
elif cmd == "/setup":
|
|
2235
|
+
await self._cmd_setup()
|
|
2236
|
+
elif cmd == "/provider":
|
|
2237
|
+
self._cmd_provider(arg)
|
|
2238
|
+
elif cmd == "/model":
|
|
2239
|
+
self._cmd_model(arg)
|
|
2240
|
+
elif cmd == "/scan":
|
|
2241
|
+
target = (arg.strip() or os.getcwd())
|
|
2242
|
+
target_path = Path(target).resolve()
|
|
2243
|
+
if not target_path.exists():
|
|
2244
|
+
self.last_status_line = f"error: directory not found: {target_path}"
|
|
2245
|
+
else:
|
|
2246
|
+
self._start_scan(str(target_path))
|
|
2247
|
+
elif cmd == "/cost":
|
|
2248
|
+
self._cmd_cost()
|
|
2249
|
+
elif cmd == "/findings":
|
|
2250
|
+
self._cmd_findings()
|
|
2251
|
+
elif cmd == "/config":
|
|
2252
|
+
self._cmd_config(arg)
|
|
2253
|
+
elif cmd == "/test":
|
|
2254
|
+
self._start_test_scan()
|
|
2255
|
+
elif cmd == "/sessions":
|
|
2256
|
+
self._open_sessions_overlay()
|
|
2257
|
+
elif cmd == "/sidebar":
|
|
2258
|
+
self.findings_list_hidden = not self.findings_list_hidden
|
|
2259
|
+
self.last_status_line = "sidebar hidden" if self.findings_list_hidden else "sidebar shown"
|
|
2260
|
+
elif cmd == "/copy":
|
|
2261
|
+
self._cmd_copy_fix()
|
|
2262
|
+
elif cmd == "/verify":
|
|
2263
|
+
self._cmd_verify(arg)
|
|
2264
|
+
elif cmd == "/mouse":
|
|
2265
|
+
self._cmd_mouse(arg)
|
|
2266
|
+
elif cmd == "/discord":
|
|
2267
|
+
self._cmd_discord()
|
|
2268
|
+
else:
|
|
2269
|
+
self.last_status_line = f"unknown command: {cmd} — try /help"
|
|
2270
|
+
|
|
2271
|
+
# ── Commands that just update status ──────────────────────────
|
|
2272
|
+
|
|
2273
|
+
def _show_help(self) -> None:
|
|
2274
|
+
lines = ["commands: " + ", ".join(c for c, _ in _SLASH_COMMANDS)]
|
|
2275
|
+
self.last_status_line = lines[0]
|
|
2276
|
+
|
|
2277
|
+
def _cmd_provider(self, name: str) -> None:
|
|
2278
|
+
name = name.lower().strip()
|
|
2279
|
+
if name not in PROVIDER_DEFAULTS:
|
|
2280
|
+
self.last_status_line = f"unknown provider: {name}"
|
|
2281
|
+
return
|
|
2282
|
+
self.provider = resolve_provider(name)
|
|
2283
|
+
self.model = PROVIDER_DEFAULTS[name]
|
|
2284
|
+
save_user_config({"provider": self.provider, "model": self.model})
|
|
2285
|
+
self.last_status_line = f"switched to {name} ({self.model})"
|
|
2286
|
+
|
|
2287
|
+
def _cmd_model(self, arg: str) -> None:
|
|
2288
|
+
if arg:
|
|
2289
|
+
self.model = arg
|
|
2290
|
+
save_user_config({"model": arg})
|
|
2291
|
+
self.last_status_line = f"model set to {arg}"
|
|
2292
|
+
else:
|
|
2293
|
+
self.last_status_line = f"current model: {self.model}"
|
|
2294
|
+
|
|
2295
|
+
# ── Copy finding for AI agent ─────────────────────────────────
|
|
2296
|
+
|
|
2297
|
+
@staticmethod
|
|
2298
|
+
def _clipboard_write(text: str) -> tuple[bool, str]:
|
|
2299
|
+
"""Write *text* to the system clipboard. Returns (success, tool_used)."""
|
|
2300
|
+
import subprocess
|
|
2301
|
+
import shutil
|
|
2302
|
+
|
|
2303
|
+
for tool, args in (
|
|
2304
|
+
("pbcopy", ["pbcopy"]), # macOS
|
|
2305
|
+
("wl-copy", ["wl-copy"]), # Wayland
|
|
2306
|
+
("xclip", ["xclip", "-selection", "clipboard"]), # X11
|
|
2307
|
+
("xsel", ["xsel", "--clipboard", "--input"]), # X11 alt
|
|
2308
|
+
("clip", ["clip"]), # Windows
|
|
2309
|
+
):
|
|
2310
|
+
if shutil.which(args[0]) is None:
|
|
2311
|
+
continue
|
|
2312
|
+
try:
|
|
2313
|
+
proc = subprocess.run(
|
|
2314
|
+
args, input=text.encode("utf-8"),
|
|
2315
|
+
timeout=2, check=False,
|
|
2316
|
+
)
|
|
2317
|
+
if proc.returncode == 0:
|
|
2318
|
+
return True, tool
|
|
2319
|
+
except Exception:
|
|
2320
|
+
continue
|
|
2321
|
+
return False, ""
|
|
2322
|
+
|
|
2323
|
+
@staticmethod
|
|
2324
|
+
def _format_finding_for_agent(f: Finding) -> str:
|
|
2325
|
+
"""Format the finding as a self-contained prompt for an AI coding agent."""
|
|
2326
|
+
lines: list[str] = [
|
|
2327
|
+
"Please fix this security vulnerability in my codebase.",
|
|
2328
|
+
"",
|
|
2329
|
+
f"# {f.title}",
|
|
2330
|
+
"",
|
|
2331
|
+
]
|
|
2332
|
+
meta_bits = [f"**Severity:** {f.severity.upper()}"]
|
|
2333
|
+
if f.category:
|
|
2334
|
+
meta_bits.append(f"**Category:** {f.category}")
|
|
2335
|
+
if getattr(f, "cvss_score", None):
|
|
2336
|
+
meta_bits.append(f"**CVSS:** {f.cvss_score:.1f}")
|
|
2337
|
+
lines.append(" • ".join(meta_bits))
|
|
2338
|
+
if f.file_path:
|
|
2339
|
+
loc = f.file_path
|
|
2340
|
+
if getattr(f, "line_number", None):
|
|
2341
|
+
loc += f":{f.line_number}"
|
|
2342
|
+
lines.append(f"**Location:** `{loc}`")
|
|
2343
|
+
lines.append("")
|
|
2344
|
+
|
|
2345
|
+
if f.description:
|
|
2346
|
+
lines += ["## Description", "", f.description, ""]
|
|
2347
|
+
|
|
2348
|
+
snippet = getattr(f, "code_snippet", None)
|
|
2349
|
+
if snippet:
|
|
2350
|
+
# Try to infer the fence language from the file extension.
|
|
2351
|
+
lang = ""
|
|
2352
|
+
if f.file_path:
|
|
2353
|
+
ext = f.file_path.rsplit(".", 1)[-1].lower() if "." in f.file_path else ""
|
|
2354
|
+
lang = {
|
|
2355
|
+
"ts": "typescript", "tsx": "typescript",
|
|
2356
|
+
"js": "javascript", "jsx": "javascript",
|
|
2357
|
+
"py": "python", "rb": "ruby", "go": "go",
|
|
2358
|
+
"rs": "rust", "java": "java", "kt": "kotlin",
|
|
2359
|
+
"c": "c", "cpp": "cpp", "cs": "csharp",
|
|
2360
|
+
"php": "php", "swift": "swift",
|
|
2361
|
+
}.get(ext, ext)
|
|
2362
|
+
lines += ["## Vulnerable code", "", f"```{lang}", snippet, "```", ""]
|
|
2363
|
+
|
|
2364
|
+
fix = getattr(f, "fix", None)
|
|
2365
|
+
if fix:
|
|
2366
|
+
lines += ["## Recommended fix", "", fix, ""]
|
|
2367
|
+
|
|
2368
|
+
if f.file_path:
|
|
2369
|
+
lines.append(f"Apply the recommended fix to `{f.file_path}`.")
|
|
2370
|
+
|
|
2371
|
+
return "\n".join(lines)
|
|
2372
|
+
|
|
2373
|
+
def _cmd_copy_fix(self) -> None:
|
|
2374
|
+
findings = self._current_findings()
|
|
2375
|
+
if not findings:
|
|
2376
|
+
self.last_status_line = "no finding selected"
|
|
2377
|
+
return
|
|
2378
|
+
if self.findings_selected >= len(findings):
|
|
2379
|
+
self.last_status_line = "no finding selected"
|
|
2380
|
+
return
|
|
2381
|
+
f = findings[self.findings_selected]
|
|
2382
|
+
text = self._format_finding_for_agent(f)
|
|
2383
|
+
ok, tool = self._clipboard_write(text)
|
|
2384
|
+
if ok:
|
|
2385
|
+
self.last_status_line = (
|
|
2386
|
+
f"copied {len(text):,} chars to clipboard via {tool} · "
|
|
2387
|
+
f"paste into Codex / Claude Code / OpenCode"
|
|
2388
|
+
)
|
|
2389
|
+
else:
|
|
2390
|
+
self.last_status_line = (
|
|
2391
|
+
"couldn't find a clipboard tool (pbcopy/xclip/wl-copy/clip)"
|
|
2392
|
+
)
|
|
2393
|
+
|
|
2394
|
+
# ── Sessions overlay ──────────────────────────────────────────
|
|
2395
|
+
|
|
2396
|
+
@staticmethod
|
|
2397
|
+
def _pid_alive(pid: int) -> bool:
|
|
2398
|
+
"""Cheap liveness check — `kill -0` doesn't actually signal."""
|
|
2399
|
+
try:
|
|
2400
|
+
os.kill(pid, 0)
|
|
2401
|
+
return True
|
|
2402
|
+
except (OSError, ProcessLookupError):
|
|
2403
|
+
return False
|
|
2404
|
+
|
|
2405
|
+
def _resume_selected_session(self) -> None:
|
|
2406
|
+
"""Resume an aborted scan by kicking off a fresh scan against the
|
|
2407
|
+
same target. The prior aborted scan's data stays preserved as a
|
|
2408
|
+
separate session — this is coarse resume (re-scan the target),
|
|
2409
|
+
not mid-scan resume from a step. Findings from the old scan can
|
|
2410
|
+
still be viewed via Enter on the aborted row.
|
|
2411
|
+
"""
|
|
2412
|
+
if not self.sessions_index:
|
|
2413
|
+
return
|
|
2414
|
+
row = self.sessions_index[self.sessions_selected]
|
|
2415
|
+
target = row.get("target") or ""
|
|
2416
|
+
if not target or not Path(target).exists():
|
|
2417
|
+
self.last_status_line = f"target no longer exists: {target}"
|
|
2418
|
+
return
|
|
2419
|
+
status = (row.get("status") or "").lower()
|
|
2420
|
+
if status not in ("aborted", "failed", "cancelled"):
|
|
2421
|
+
self.last_status_line = f"can only resume aborted/failed scans (this one is {status})"
|
|
2422
|
+
return
|
|
2423
|
+
self._close_sessions_overlay()
|
|
2424
|
+
self._start_scan(target)
|
|
2425
|
+
self.last_status_line = f"resuming: re-scanning {self._short_target(target)}"
|
|
2426
|
+
|
|
2427
|
+
|
|
2428
|
+
def _open_sessions_overlay(self) -> None:
|
|
2429
|
+
"""Open the sessions picker as a full-screen overlay."""
|
|
2430
|
+
self._refresh_sessions_index()
|
|
2431
|
+
self.previous_mode = self.mode # remember where to go back on Esc
|
|
2432
|
+
self.mode = "sessions"
|
|
2433
|
+
if not self.sessions_index:
|
|
2434
|
+
self.last_status_line = "no saved scans yet — completed scans are saved to ~/.openhack/scans/"
|
|
2435
|
+
else:
|
|
2436
|
+
self.last_status_line = (
|
|
2437
|
+
f"{len(self.sessions_index)} session(s) · ↑/↓ navigate · enter load · r resume (aborted) · esc back"
|
|
2438
|
+
)
|
|
2439
|
+
|
|
2440
|
+
def _close_sessions_overlay(self) -> None:
|
|
2441
|
+
"""Return from the sessions overlay to whatever screen the user was on."""
|
|
2442
|
+
target_mode = self.previous_mode or "landing"
|
|
2443
|
+
self.mode = target_mode
|
|
2444
|
+
self.previous_mode = None
|
|
2445
|
+
self.last_status_line = ""
|
|
2446
|
+
|
|
2447
|
+
def _refresh_sessions_index(self) -> None:
|
|
2448
|
+
scans_dir = Path.home() / ".openhack" / "scans"
|
|
2449
|
+
self.sessions_index = []
|
|
2450
|
+
if not scans_dir.exists():
|
|
2451
|
+
return
|
|
2452
|
+
rows: list[tuple[float, dict]] = []
|
|
2453
|
+
for p in scans_dir.glob("*.json"):
|
|
2454
|
+
try:
|
|
2455
|
+
with open(p) as fp:
|
|
2456
|
+
data = json.load(fp)
|
|
2457
|
+
except (OSError, json.JSONDecodeError):
|
|
2458
|
+
continue
|
|
2459
|
+
findings = data.get("findings", []) or []
|
|
2460
|
+
sev_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
|
2461
|
+
for f in findings:
|
|
2462
|
+
sev = (f.get("severity") or "info").lower()
|
|
2463
|
+
sev_counts[sev] = sev_counts.get(sev, 0) + 1
|
|
2464
|
+
top_sev = next((s.upper() for s in ("critical", "high", "medium", "low", "info")
|
|
2465
|
+
if sev_counts.get(s, 0) > 0), "—")
|
|
2466
|
+
target = data.get("target_dir") or "(unknown)"
|
|
2467
|
+
started = data.get("started_at") or ""
|
|
2468
|
+
try:
|
|
2469
|
+
started_display = datetime.fromisoformat(started).strftime("%Y-%m-%d %H:%M")
|
|
2470
|
+
except ValueError:
|
|
2471
|
+
started_display = started[:16]
|
|
2472
|
+
duration = data.get("duration_seconds") or 0
|
|
2473
|
+
dur_m, dur_s = divmod(int(duration), 60)
|
|
2474
|
+
duration_display = f"{dur_m}:{dur_s:02d}"
|
|
2475
|
+
scan_id = data.get("scan_id") or p.stem
|
|
2476
|
+
short_id = scan_id[:8]
|
|
2477
|
+
|
|
2478
|
+
# Resolve true status: a "running" report whose PID is no longer
|
|
2479
|
+
# alive means the terminal closed mid-scan — reclassify as aborted.
|
|
2480
|
+
raw_status = (data.get("status") or "completed").lower()
|
|
2481
|
+
status = raw_status
|
|
2482
|
+
if raw_status == "running":
|
|
2483
|
+
pid = data.get("pid")
|
|
2484
|
+
if not (isinstance(pid, int) and self._pid_alive(pid)):
|
|
2485
|
+
status = "aborted"
|
|
2486
|
+
|
|
2487
|
+
label = f"{short_id} {self._short_target(target)}"
|
|
2488
|
+
meta = (
|
|
2489
|
+
f"[{status}] {started_display} · {len(findings)} findings · "
|
|
2490
|
+
f"top {top_sev} · {duration_display}"
|
|
2491
|
+
)
|
|
2492
|
+
row = {
|
|
2493
|
+
"path": p,
|
|
2494
|
+
"scan_id": scan_id,
|
|
2495
|
+
"label": label,
|
|
2496
|
+
"meta": meta,
|
|
2497
|
+
"target": target,
|
|
2498
|
+
"status": status,
|
|
2499
|
+
"data": data,
|
|
2500
|
+
}
|
|
2501
|
+
rows.append((p.stat().st_mtime, row))
|
|
2502
|
+
rows.sort(key=lambda x: x[0], reverse=True)
|
|
2503
|
+
self.sessions_index = [r for _, r in rows]
|
|
2504
|
+
if self.sessions_selected >= len(self.sessions_index):
|
|
2505
|
+
self.sessions_selected = max(0, len(self.sessions_index) - 1)
|
|
2506
|
+
|
|
2507
|
+
def _load_selected_session(self) -> None:
|
|
2508
|
+
if not self.sessions_index:
|
|
2509
|
+
return
|
|
2510
|
+
row = self.sessions_index[self.sessions_selected]
|
|
2511
|
+
data = row.get("data") or {}
|
|
2512
|
+
findings_raw = data.get("findings", []) or []
|
|
2513
|
+
loaded: list[Finding] = []
|
|
2514
|
+
for fd in findings_raw:
|
|
2515
|
+
try:
|
|
2516
|
+
# JSON uses camelCase keys (via Finding.to_dict). Accept either.
|
|
2517
|
+
loaded.append(Finding(
|
|
2518
|
+
category=fd.get("category", "") or "",
|
|
2519
|
+
severity=(fd.get("severity") or "info"),
|
|
2520
|
+
title=fd.get("title", "") or "",
|
|
2521
|
+
description=fd.get("description", "") or "",
|
|
2522
|
+
file_path=fd.get("file_path") or fd.get("filePath") or "",
|
|
2523
|
+
line_number=fd.get("line_number") or fd.get("lineNumber"),
|
|
2524
|
+
code_snippet=fd.get("code_snippet") or fd.get("relevantCode"),
|
|
2525
|
+
poc=fd.get("poc"),
|
|
2526
|
+
fix=fd.get("fix") or fd.get("recommendation"),
|
|
2527
|
+
cvss_score=fd.get("cvss_score") or fd.get("cvssScore"),
|
|
2528
|
+
confidence=fd.get("confidence", "medium"),
|
|
2529
|
+
validated=bool(fd.get("validated", False)),
|
|
2530
|
+
))
|
|
2531
|
+
except Exception:
|
|
2532
|
+
continue
|
|
2533
|
+
self.last_findings = loaded
|
|
2534
|
+
# Build a placeholder scan with a frozen clock. start_time / end_time
|
|
2535
|
+
# must share the same time base — we anchor on the first trace
|
|
2536
|
+
# event's epoch timestamp so per-event [m:ss] offsets read sanely,
|
|
2537
|
+
# then set end_time = start_time + duration so the header shows the
|
|
2538
|
+
# actual duration (not start-epoch arithmetic).
|
|
2539
|
+
scan = ScanState(target=row.get("target") or "")
|
|
2540
|
+
scan.cost = float((data.get("cost") or {}).get("total_cost") or 0.0)
|
|
2541
|
+
duration = float(data.get("duration_seconds") or 0)
|
|
2542
|
+
|
|
2543
|
+
# Hydrate saved trace events (version 2+ reports). Older reports
|
|
2544
|
+
# have no trace field — Trace tab will show "no trace yet" for those.
|
|
2545
|
+
trace_raw = data.get("trace") or []
|
|
2546
|
+
first_ts: Optional[float] = None
|
|
2547
|
+
for entry_data in trace_raw:
|
|
2548
|
+
try:
|
|
2549
|
+
entry = TraceEntry(
|
|
2550
|
+
timestamp=float(entry_data.get("timestamp") or 0),
|
|
2551
|
+
agent=entry_data.get("agent", "") or "",
|
|
2552
|
+
event_type=entry_data.get("event_type", "") or "",
|
|
2553
|
+
content=entry_data.get("content"),
|
|
2554
|
+
tool_name=entry_data.get("tool_name"),
|
|
2555
|
+
tool_input=entry_data.get("tool_input"),
|
|
2556
|
+
tool_output=entry_data.get("tool_output"),
|
|
2557
|
+
)
|
|
2558
|
+
if first_ts is None and entry.timestamp > 0:
|
|
2559
|
+
first_ts = entry.timestamp
|
|
2560
|
+
scan.start_time = first_ts
|
|
2561
|
+
scan.update_from_trace(entry)
|
|
2562
|
+
except Exception:
|
|
2563
|
+
continue
|
|
2564
|
+
|
|
2565
|
+
# Anchor end_time relative to start_time so elapsed_str() reports
|
|
2566
|
+
# the actual duration. If no trace events were saved (older reports),
|
|
2567
|
+
# fall back to start_time=0 + end_time=duration.
|
|
2568
|
+
if first_ts is not None:
|
|
2569
|
+
scan.end_time = first_ts + duration
|
|
2570
|
+
else:
|
|
2571
|
+
scan.start_time = 0
|
|
2572
|
+
scan.end_time = duration
|
|
2573
|
+
|
|
2574
|
+
self.scan = scan
|
|
2575
|
+
self.viewing_target = row.get("target") or ""
|
|
2576
|
+
self.mode = "viewing"
|
|
2577
|
+
self.previous_mode = None
|
|
2578
|
+
self.active_tab = "findings"
|
|
2579
|
+
self.last_status_line = (
|
|
2580
|
+
f"loaded {row.get('scan_id', '')[:8]} · "
|
|
2581
|
+
f"{len(loaded)} findings · {row.get('meta', '')}"
|
|
2582
|
+
)
|
|
2583
|
+
|
|
2584
|
+
def _cmd_cost(self) -> None:
|
|
2585
|
+
sess = self.last_session or self.session
|
|
2586
|
+
if not sess:
|
|
2587
|
+
self.last_status_line = "no scan has been run yet"
|
|
2588
|
+
return
|
|
2589
|
+
b = sess.get_cost_breakdown()
|
|
2590
|
+
self.last_status_line = (
|
|
2591
|
+
f"cost: ${b['total_cost']:.4f} · tokens: {b['total_tokens']:,}"
|
|
2592
|
+
)
|
|
2593
|
+
|
|
2594
|
+
def _cmd_findings(self) -> None:
|
|
2595
|
+
findings = (self.last_session.findings if self.last_session else None) or self.last_findings
|
|
2596
|
+
if not findings:
|
|
2597
|
+
self.last_status_line = "no findings to display"
|
|
2598
|
+
return
|
|
2599
|
+
self.last_findings = list(findings)
|
|
2600
|
+
self.last_status_line = f"{len(findings)} finding(s)"
|
|
2601
|
+
|
|
2602
|
+
def _cmd_config(self, arg: str) -> None:
|
|
2603
|
+
if not arg.strip():
|
|
2604
|
+
cfg = load_user_config()
|
|
2605
|
+
self.last_status_line = "config: " + ", ".join(
|
|
2606
|
+
f"{k}={'***' if 'api_key' in k and v else v}" for k, v in cfg.items() if v
|
|
2607
|
+
)
|
|
2608
|
+
return
|
|
2609
|
+
parts = arg.strip().split(None, 1)
|
|
2610
|
+
key = parts[0].lower()
|
|
2611
|
+
value = parts[1] if len(parts) > 1 else ""
|
|
2612
|
+
valid = {"provider", "model", "openhack_api_key", "openhack_model_id"}
|
|
2613
|
+
if key not in valid:
|
|
2614
|
+
self.last_status_line = f"unknown config key: {key}"
|
|
2615
|
+
return
|
|
2616
|
+
if not value:
|
|
2617
|
+
cfg = load_user_config()
|
|
2618
|
+
current = cfg.get(key, "")
|
|
2619
|
+
self.last_status_line = f"{key} = {'***' if 'api_key' in key and current else current or '(not set)'}"
|
|
2620
|
+
return
|
|
2621
|
+
save_user_config({key: value})
|
|
2622
|
+
if key == "provider":
|
|
2623
|
+
self.provider = resolve_provider(value)
|
|
2624
|
+
self.model = PROVIDER_DEFAULTS.get(value, self.model)
|
|
2625
|
+
save_user_config({"provider": self.provider})
|
|
2626
|
+
elif key == "model":
|
|
2627
|
+
self.model = value
|
|
2628
|
+
self.last_status_line = f"saved {key}"
|
|
2629
|
+
|
|
2630
|
+
# ── Setup / login (delegate to setup.py / auth.py) ────────────
|
|
2631
|
+
|
|
2632
|
+
async def _cmd_setup(self) -> None:
|
|
2633
|
+
if self.mode == "scanning":
|
|
2634
|
+
self.last_status_line = "cannot run setup while a scan is in progress"
|
|
2635
|
+
return
|
|
2636
|
+
# Setup wizard is a separate full-screen flow; we need to suspend our
|
|
2637
|
+
# app to let it use the terminal.
|
|
2638
|
+
await self._run_external(run_setup_command())
|
|
2639
|
+
reload_settings()
|
|
2640
|
+
cfg = load_user_config()
|
|
2641
|
+
self.provider = resolve_provider(cfg.get("provider", settings.llm_provider))
|
|
2642
|
+
self.model = cfg.get("model") or PROVIDER_DEFAULTS.get(self.provider, settings.openhack_model_id)
|
|
2643
|
+
self.org_name = cfg.get("openhack_org_name") or self.org_name
|
|
2644
|
+
self.last_status_line = f"active: {self.provider} · {self.model}"
|
|
2645
|
+
|
|
2646
|
+
async def _cmd_login(self) -> None:
|
|
2647
|
+
if self.mode == "scanning":
|
|
2648
|
+
self.last_status_line = "cannot log in while a scan is in progress"
|
|
2649
|
+
return
|
|
2650
|
+
from openhack.auth import (
|
|
2651
|
+
DeviceLoginCancelled,
|
|
2652
|
+
DeviceLoginError,
|
|
2653
|
+
DeviceLoginExpired,
|
|
2654
|
+
device_login,
|
|
2655
|
+
)
|
|
2656
|
+
cfg = load_user_config()
|
|
2657
|
+
app_url = cfg.get("openhack_app_url") or settings.openhack_app_url
|
|
2658
|
+
|
|
2659
|
+
async def _do_login():
|
|
2660
|
+
try:
|
|
2661
|
+
return await device_login(app_url)
|
|
2662
|
+
except DeviceLoginCancelled:
|
|
2663
|
+
self.last_status_line = "login cancelled"
|
|
2664
|
+
except DeviceLoginExpired as exc:
|
|
2665
|
+
self.last_status_line = f"login expired: {exc}"
|
|
2666
|
+
except DeviceLoginError as exc:
|
|
2667
|
+
self.last_status_line = f"login failed: {exc}"
|
|
2668
|
+
return None
|
|
2669
|
+
|
|
2670
|
+
result = await self._run_external(_do_login())
|
|
2671
|
+
if not result:
|
|
2672
|
+
return
|
|
2673
|
+
new_cfg: dict = {"provider": "openhack", "openhack_api_key": result.token}
|
|
2674
|
+
if result.org_id: new_cfg["openhack_org_id"] = result.org_id
|
|
2675
|
+
if result.org_slug: new_cfg["openhack_org_slug"] = result.org_slug
|
|
2676
|
+
if result.org_name: new_cfg["openhack_org_name"] = result.org_name
|
|
2677
|
+
if result.user_email: new_cfg["openhack_user_email"] = result.user_email
|
|
2678
|
+
if result.user_first_name: new_cfg["openhack_user_first_name"] = result.user_first_name
|
|
2679
|
+
if result.user_last_name: new_cfg["openhack_user_last_name"] = result.user_last_name
|
|
2680
|
+
save_user_config(new_cfg)
|
|
2681
|
+
reload_settings()
|
|
2682
|
+
self.org_name = result.org_name or self.org_name
|
|
2683
|
+
self.last_status_line = f"logged in · {result.org_name or ''}"
|
|
2684
|
+
|
|
2685
|
+
def _cmd_logout(self) -> None:
|
|
2686
|
+
cfg = load_user_config()
|
|
2687
|
+
if not cfg.get("openhack_api_key"):
|
|
2688
|
+
self.last_status_line = "not signed in"
|
|
2689
|
+
return
|
|
2690
|
+
first = cfg.get("openhack_user_first_name") or ""
|
|
2691
|
+
last = cfg.get("openhack_user_last_name") or ""
|
|
2692
|
+
email = cfg.get("openhack_user_email") or ""
|
|
2693
|
+
who = " ".join(p for p in (first, last) if p).strip() or email or "current user"
|
|
2694
|
+
org = cfg.get("openhack_org_name") or ""
|
|
2695
|
+
target = f"{who} · {org}" if org else who
|
|
2696
|
+
self._open_modal(
|
|
2697
|
+
"logout",
|
|
2698
|
+
"Sign out?",
|
|
2699
|
+
f"You're about to sign out from {target}.\n\n"
|
|
2700
|
+
f"The saved API token will be cleared from ~/.openhack/config. "
|
|
2701
|
+
f"You can sign back in any time with /login.",
|
|
2702
|
+
self._do_logout,
|
|
2703
|
+
)
|
|
2704
|
+
|
|
2705
|
+
def _do_logout(self) -> None:
|
|
2706
|
+
cleared = {
|
|
2707
|
+
"openhack_api_key": None,
|
|
2708
|
+
"openhack_org_id": None,
|
|
2709
|
+
"openhack_org_slug": None,
|
|
2710
|
+
"openhack_org_name": None,
|
|
2711
|
+
"openhack_user_email": None,
|
|
2712
|
+
"openhack_user_first_name": None,
|
|
2713
|
+
"openhack_user_last_name": None,
|
|
2714
|
+
}
|
|
2715
|
+
# `save_user_config` merges into the existing JSON, so None values
|
|
2716
|
+
# would just be ignored. We need to physically remove them.
|
|
2717
|
+
try:
|
|
2718
|
+
existing = load_user_config()
|
|
2719
|
+
for k in cleared:
|
|
2720
|
+
existing.pop(k, None)
|
|
2721
|
+
from openhack.config import CONFIG_PATH
|
|
2722
|
+
import json as _json, os as _os
|
|
2723
|
+
with open(CONFIG_PATH, "w") as fp:
|
|
2724
|
+
_json.dump(existing, fp, indent=2)
|
|
2725
|
+
fp.write("\n")
|
|
2726
|
+
try:
|
|
2727
|
+
_os.chmod(CONFIG_PATH, 0o600)
|
|
2728
|
+
except OSError:
|
|
2729
|
+
pass
|
|
2730
|
+
except Exception as exc:
|
|
2731
|
+
self.last_status_line = f"sign-out failed: {exc}"
|
|
2732
|
+
self._logout_armed = False
|
|
2733
|
+
return
|
|
2734
|
+
|
|
2735
|
+
reload_settings()
|
|
2736
|
+
self.org_name = ""
|
|
2737
|
+
self.user_email = ""
|
|
2738
|
+
self.scan = None
|
|
2739
|
+
self.session = None
|
|
2740
|
+
self.mode = "landing"
|
|
2741
|
+
self.active_tab = "trace"
|
|
2742
|
+
self._logout_armed = False
|
|
2743
|
+
self.last_status_line = "signed out · run /login to sign back in"
|
|
2744
|
+
self._invalidate()
|
|
2745
|
+
|
|
2746
|
+
# ── Modal helpers ─────────────────────────────────────────────
|
|
2747
|
+
|
|
2748
|
+
def _open_modal(self, kind: str, title: str, body: str,
|
|
2749
|
+
on_yes: Callable[[], None]) -> None:
|
|
2750
|
+
self._modal_kind = kind
|
|
2751
|
+
self._modal_title = title
|
|
2752
|
+
self._modal_body = body
|
|
2753
|
+
self._modal_on_yes = on_yes
|
|
2754
|
+
self._invalidate()
|
|
2755
|
+
|
|
2756
|
+
def _close_modal(self) -> None:
|
|
2757
|
+
self._modal_kind = None
|
|
2758
|
+
self._modal_title = ""
|
|
2759
|
+
self._modal_body = ""
|
|
2760
|
+
self._modal_on_yes = None
|
|
2761
|
+
|
|
2762
|
+
def _show_announcement_modal(self, ann: Announcement) -> None:
|
|
2763
|
+
"""Display an announcement as a modal dialog. On dismiss, persist
|
|
2764
|
+
the announcement ID so it won't appear again (unless critical)."""
|
|
2765
|
+
def _dismiss():
|
|
2766
|
+
save_dismissed(ann.id)
|
|
2767
|
+
|
|
2768
|
+
# Critical announcements can't be dismissed without acknowledging.
|
|
2769
|
+
title = ann.title or "Announcement"
|
|
2770
|
+
body = ann.body or ""
|
|
2771
|
+
self._open_modal(f"announcement:{ann.id}", title, body, _dismiss)
|
|
2772
|
+
self._invalidate()
|
|
2773
|
+
|
|
2774
|
+
def _cmd_discord(self) -> None:
|
|
2775
|
+
url = "https://openhack.com/discord"
|
|
2776
|
+
try:
|
|
2777
|
+
import webbrowser
|
|
2778
|
+
webbrowser.open(url)
|
|
2779
|
+
self.last_status_line = f"opened {url} in your browser"
|
|
2780
|
+
except Exception as exc:
|
|
2781
|
+
self.last_status_line = f"couldn't open browser: {exc} · visit {url}"
|
|
2782
|
+
self._invalidate()
|
|
2783
|
+
|
|
2784
|
+
def _cmd_mouse(self, arg: str) -> None:
|
|
2785
|
+
"""Toggle mouse capture. When off, native terminal drag-to-select works
|
|
2786
|
+
(so users can copy text), at the cost of mouse-wheel scrolling and
|
|
2787
|
+
click-to-select inside the TUI. Keyboard nav still works either way.
|
|
2788
|
+
"""
|
|
2789
|
+
a = arg.strip().lower()
|
|
2790
|
+
if a in ("on", "true", "1"):
|
|
2791
|
+
self._mouse_enabled = True
|
|
2792
|
+
elif a in ("off", "false", "0"):
|
|
2793
|
+
self._mouse_enabled = False
|
|
2794
|
+
else:
|
|
2795
|
+
self._mouse_enabled = not self._mouse_enabled
|
|
2796
|
+
if self._mouse_enabled:
|
|
2797
|
+
self.last_status_line = (
|
|
2798
|
+
"mouse ON · wheel scroll & click work · /mouse off to enable drag-to-copy"
|
|
2799
|
+
)
|
|
2800
|
+
else:
|
|
2801
|
+
self.last_status_line = (
|
|
2802
|
+
"mouse OFF · drag to select & copy text · /mouse on to re-enable"
|
|
2803
|
+
)
|
|
2804
|
+
self._invalidate()
|
|
2805
|
+
|
|
2806
|
+
# ── Verify (sandbox / browser) ────────────────────────────────
|
|
2807
|
+
|
|
2808
|
+
_VERIFY_PREREQS = {
|
|
2809
|
+
"sandbox": (
|
|
2810
|
+
"SANDBOX needs: (1) Docker Desktop or daemon running · "
|
|
2811
|
+
"(2) Dockerfile OR docker-compose.yml at the scan target's root · "
|
|
2812
|
+
"(3) the app must start and respond to a health check on / · "
|
|
2813
|
+
"(4) a free localhost port the sandbox can bind to."
|
|
2814
|
+
),
|
|
2815
|
+
"browser": (
|
|
2816
|
+
"BROWSER needs: (1) the browser extra installed → "
|
|
2817
|
+
"`uv sync --extra browser` · "
|
|
2818
|
+
"(2) Chromium installed → `uv run playwright install chromium` · "
|
|
2819
|
+
"(3) the target app reachable over HTTP — usually means sandbox "
|
|
2820
|
+
"verification is also on (so the app is running)."
|
|
2821
|
+
),
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
def _cmd_verify(self, arg: str) -> None:
|
|
2825
|
+
"""Run sandbox or browser verification against the currently-loaded
|
|
2826
|
+
session's findings. /verify is an *action*, not a settings toggle —
|
|
2827
|
+
the user loads a session via /sessions or finishes a scan, then runs
|
|
2828
|
+
/verify sandbox or /verify browser to add verification evidence to
|
|
2829
|
+
the existing findings.
|
|
2830
|
+
"""
|
|
2831
|
+
parts = arg.strip().split()
|
|
2832
|
+
logging.getLogger("openhack.tui").info("/verify dispatched: arg=%r", arg)
|
|
2833
|
+
|
|
2834
|
+
if not parts:
|
|
2835
|
+
self.last_status_line = (
|
|
2836
|
+
"usage: /verify <sandbox|browser> "
|
|
2837
|
+
"— runs verification against the loaded session's findings"
|
|
2838
|
+
)
|
|
2839
|
+
return
|
|
2840
|
+
|
|
2841
|
+
kind = parts[0].lower()
|
|
2842
|
+
if kind not in ("sandbox", "browser"):
|
|
2843
|
+
self.last_status_line = f"unknown subject: {kind} (use sandbox/browser)"
|
|
2844
|
+
return
|
|
2845
|
+
|
|
2846
|
+
if self.mode == "scanning" and self.scan_task is not None:
|
|
2847
|
+
self.last_status_line = "a scan is already running · wait for it to finish first"
|
|
2848
|
+
return
|
|
2849
|
+
|
|
2850
|
+
findings = self._current_findings()
|
|
2851
|
+
if not findings:
|
|
2852
|
+
self.last_status_line = "no findings loaded — finish a scan or load a session from /sessions first"
|
|
2853
|
+
return
|
|
2854
|
+
|
|
2855
|
+
# Resolve the target directory: viewing mode stores it on viewing_target,
|
|
2856
|
+
# otherwise pull from the currently-loaded session.
|
|
2857
|
+
target_dir = (
|
|
2858
|
+
self.viewing_target
|
|
2859
|
+
or (self.scan.target if self.scan and self.scan.target else "")
|
|
2860
|
+
)
|
|
2861
|
+
if not target_dir or not Path(target_dir).exists():
|
|
2862
|
+
self.last_status_line = (
|
|
2863
|
+
f"target directory not accessible: {target_dir or '(unknown)'}"
|
|
2864
|
+
)
|
|
2865
|
+
return
|
|
2866
|
+
|
|
2867
|
+
title = f"Run {kind} verification on {len(findings)} finding(s)?"
|
|
2868
|
+
body = (
|
|
2869
|
+
f"{self._VERIFY_PREREQS[kind]}\n\n"
|
|
2870
|
+
f"Target: {target_dir}\n"
|
|
2871
|
+
f"This will spin up the verification swarm against the loaded findings, "
|
|
2872
|
+
f"stream events into the Trace tab, and write a new report to "
|
|
2873
|
+
f"~/.openhack/scans/ when it finishes."
|
|
2874
|
+
)
|
|
2875
|
+
|
|
2876
|
+
def _apply():
|
|
2877
|
+
task = asyncio.create_task(self._run_verification(kind, target_dir, list(findings)))
|
|
2878
|
+
self.scan_task = task
|
|
2879
|
+
|
|
2880
|
+
self._open_modal(f"verify:{kind}", title, body, _apply)
|
|
2881
|
+
|
|
2882
|
+
async def _run_verification(self, kind: str, target_dir: str,
|
|
2883
|
+
findings: list[Finding]) -> None:
|
|
2884
|
+
"""Spin up the sandbox/browser verifier swarm against an existing
|
|
2885
|
+
findings set. Streams trace events live and writes a new report when done.
|
|
2886
|
+
"""
|
|
2887
|
+
reload_settings()
|
|
2888
|
+
|
|
2889
|
+
# Preserve the loaded scan's existing trace and findings — verification
|
|
2890
|
+
# is an *extension* of an existing scan, not a fresh run. We mutate the
|
|
2891
|
+
# current ScanState (created either by the previous scan or by
|
|
2892
|
+
# _open_session) so:
|
|
2893
|
+
# • trace_lines / trace_agents from the original scan stay intact
|
|
2894
|
+
# • the new sandbox/browser swarms append to that same trace
|
|
2895
|
+
# • scan.findings already has every finding ready for the Findings tab
|
|
2896
|
+
if self.scan is None:
|
|
2897
|
+
self.scan = ScanState(target=target_dir)
|
|
2898
|
+
# Reset the clock so the elapsed counter reflects this verification run.
|
|
2899
|
+
self.scan.start_time = time.time()
|
|
2900
|
+
self.scan.end_time = None
|
|
2901
|
+
# Ensure scan.findings holds the findings we're about to verify so the
|
|
2902
|
+
# Findings tab reads them in scanning mode. (When loaded from /sessions
|
|
2903
|
+
# the trace got hydrated but findings live on self.last_findings — we
|
|
2904
|
+
# mirror them onto scan.findings here.) Use the *same* objects so the
|
|
2905
|
+
# verifier's mutations show up on the rendered list.
|
|
2906
|
+
if not self.scan.findings:
|
|
2907
|
+
self.scan.findings = list(findings)
|
|
2908
|
+
self.last_findings = list(findings)
|
|
2909
|
+
self.mode = "scanning"
|
|
2910
|
+
self.active_tab = "trace"
|
|
2911
|
+
self._invalidate()
|
|
2912
|
+
|
|
2913
|
+
session: Optional[Session] = None
|
|
2914
|
+
try:
|
|
2915
|
+
session = Session(
|
|
2916
|
+
target_dir=target_dir,
|
|
2917
|
+
on_trace=self._on_trace,
|
|
2918
|
+
)
|
|
2919
|
+
# Seed the verifier with the findings being verified — same Finding
|
|
2920
|
+
# *objects* as scan.findings so the swarm's mutations are visible
|
|
2921
|
+
# in the rendered list without copying.
|
|
2922
|
+
for f in findings:
|
|
2923
|
+
session.findings.append(f)
|
|
2924
|
+
self.session = session
|
|
2925
|
+
|
|
2926
|
+
tools = ToolRegistry(target_dir=Path(target_dir))
|
|
2927
|
+
llm = LLMClient(
|
|
2928
|
+
model=self.model, temperature=0.0, max_tokens=8192,
|
|
2929
|
+
provider=self.provider, prompt_cache_key=session.id,
|
|
2930
|
+
)
|
|
2931
|
+
|
|
2932
|
+
if kind == "sandbox":
|
|
2933
|
+
from openhack.agents.sandbox_verifier_swarm import SandboxVerifierSwarmAgent
|
|
2934
|
+
from openhack.sandbox.orchestrator import SandboxConfig
|
|
2935
|
+
sandbox_cfg = SandboxConfig(
|
|
2936
|
+
health_check_path=settings.sandbox_health_check_path,
|
|
2937
|
+
health_check_timeout=settings.sandbox_health_check_timeout,
|
|
2938
|
+
teardown_on_complete=settings.sandbox_teardown_on_complete,
|
|
2939
|
+
)
|
|
2940
|
+
swarm = SandboxVerifierSwarmAgent(
|
|
2941
|
+
llm, tools, session, sandbox_config=sandbox_cfg,
|
|
2942
|
+
)
|
|
2943
|
+
else:
|
|
2944
|
+
from openhack.agents.browser_verifier_swarm import BrowserVerifierSwarmAgent
|
|
2945
|
+
from openhack.sandbox.orchestrator import SandboxConfig
|
|
2946
|
+
sandbox_cfg = SandboxConfig(
|
|
2947
|
+
health_check_path=settings.sandbox_health_check_path,
|
|
2948
|
+
health_check_timeout=settings.sandbox_health_check_timeout,
|
|
2949
|
+
teardown_on_complete=settings.sandbox_teardown_on_complete,
|
|
2950
|
+
)
|
|
2951
|
+
swarm = BrowserVerifierSwarmAgent(
|
|
2952
|
+
llm, tools, session, sandbox_config=sandbox_cfg,
|
|
2953
|
+
)
|
|
2954
|
+
|
|
2955
|
+
# The swarm reads findings from context["confirmed_findings"] as dicts.
|
|
2956
|
+
findings_dicts = [f.to_dict() for f in findings]
|
|
2957
|
+
result = await swarm.run(
|
|
2958
|
+
f"Run {kind} verification on the loaded findings.",
|
|
2959
|
+
context={"confirmed_findings": findings_dicts},
|
|
2960
|
+
)
|
|
2961
|
+
|
|
2962
|
+
# The swarm returns lists of {finding_index, status, evidence, ...}.
|
|
2963
|
+
# Stamp the matching Finding objects so the Findings tab can render
|
|
2964
|
+
# a ✓ next to verified ones. Findings are mutated in place, which
|
|
2965
|
+
# `self.scan.findings` shares — the UI picks up the changes on the
|
|
2966
|
+
# next invalidate.
|
|
2967
|
+
exploitable = (result or {}).get("exploitable") or []
|
|
2968
|
+
verified_by = "sandbox" if kind == "sandbox" else "browser"
|
|
2969
|
+
verified_count = 0
|
|
2970
|
+
for item in exploitable:
|
|
2971
|
+
idx = item.get("finding_index")
|
|
2972
|
+
if idx is None or idx >= len(findings):
|
|
2973
|
+
continue
|
|
2974
|
+
f = findings[idx]
|
|
2975
|
+
# source is a comma-joined string when multiple verifiers have
|
|
2976
|
+
# validated the same finding (e.g., "sandbox,browser").
|
|
2977
|
+
existing = {s.strip() for s in (f.source or "").split(",") if s.strip()}
|
|
2978
|
+
existing.add(verified_by)
|
|
2979
|
+
f.source = ",".join(sorted(existing))
|
|
2980
|
+
evidence = item.get("evidence")
|
|
2981
|
+
if evidence and not f.poc:
|
|
2982
|
+
f.poc = evidence
|
|
2983
|
+
verified_count += 1
|
|
2984
|
+
|
|
2985
|
+
# Persist the now-annotated findings so the user can find them in /sessions.
|
|
2986
|
+
fatal = (result or {}).get("fatal_error")
|
|
2987
|
+
status = "failed" if fatal else "completed"
|
|
2988
|
+
self._write_report(session, target_dir, status=status)
|
|
2989
|
+
self.last_findings = list(session.findings)
|
|
2990
|
+
self.last_session = session
|
|
2991
|
+
if fatal:
|
|
2992
|
+
self.last_status_line = (
|
|
2993
|
+
f"{kind} verification aborted · {fatal}"
|
|
2994
|
+
)
|
|
2995
|
+
else:
|
|
2996
|
+
self.last_status_line = (
|
|
2997
|
+
f"{kind} verification complete · "
|
|
2998
|
+
f"{verified_count}/{len(findings)} verified · "
|
|
2999
|
+
f"report saved to ~/.openhack/scans/{session.id[:8]}.json"
|
|
3000
|
+
)
|
|
3001
|
+
|
|
3002
|
+
except asyncio.CancelledError:
|
|
3003
|
+
self.last_status_line = f"{kind} verification cancelled"
|
|
3004
|
+
if session is not None:
|
|
3005
|
+
self._write_report(session, target_dir, status="cancelled")
|
|
3006
|
+
raise
|
|
3007
|
+
except Exception as exc:
|
|
3008
|
+
self.last_status_line = f"{kind} verification failed: {exc}"
|
|
3009
|
+
if session is not None:
|
|
3010
|
+
self._write_report(session, target_dir, status="failed")
|
|
3011
|
+
finally:
|
|
3012
|
+
if self.scan is not None:
|
|
3013
|
+
self.scan.finish()
|
|
3014
|
+
self.scan_task = None
|
|
3015
|
+
self.active_tab = "findings"
|
|
3016
|
+
self.findings_selected = 0
|
|
3017
|
+
self._invalidate()
|
|
3018
|
+
|
|
3019
|
+
async def _run_external(self, awaitable):
|
|
3020
|
+
"""Suspend the full-screen app, run an external async flow, then resume."""
|
|
3021
|
+
# Prompt_toolkit's run_in_terminal lets us yield the terminal to a
|
|
3022
|
+
# non-app process. The 'in_executor=False' default suits async work.
|
|
3023
|
+
from prompt_toolkit.application.run_in_terminal import in_terminal
|
|
3024
|
+
result_holder = {}
|
|
3025
|
+
|
|
3026
|
+
async def _runner():
|
|
3027
|
+
try:
|
|
3028
|
+
result_holder["v"] = await awaitable
|
|
3029
|
+
except Exception as exc: # surface any exception
|
|
3030
|
+
result_holder["err"] = exc
|
|
3031
|
+
|
|
3032
|
+
async with in_terminal():
|
|
3033
|
+
await _runner()
|
|
3034
|
+
if "err" in result_holder:
|
|
3035
|
+
self.last_status_line = f"error: {result_holder['err']}"
|
|
3036
|
+
return None
|
|
3037
|
+
return result_holder.get("v")
|
|
3038
|
+
|
|
3039
|
+
# ── Scan kickoff ──────────────────────────────────────────────
|
|
3040
|
+
|
|
3041
|
+
def _start_scan(self, target_dir: str) -> None:
|
|
3042
|
+
if self.mode == "scanning":
|
|
3043
|
+
self.last_status_line = "a scan is already in progress"
|
|
3044
|
+
return
|
|
3045
|
+
self.scan = ScanState(target=target_dir)
|
|
3046
|
+
self.mode = "scanning"
|
|
3047
|
+
self.active_tab = "trace"
|
|
3048
|
+
self.viewing_target = ""
|
|
3049
|
+
self._cancel_armed = False
|
|
3050
|
+
self.scan_task = asyncio.create_task(self._run_scan(target_dir))
|
|
3051
|
+
|
|
3052
|
+
def _start_test_scan(self) -> None:
|
|
3053
|
+
if self.mode == "scanning":
|
|
3054
|
+
self.last_status_line = "a scan is already in progress"
|
|
3055
|
+
return
|
|
3056
|
+
self.scan = ScanState(target=os.getcwd() + " (test)")
|
|
3057
|
+
self.mode = "scanning"
|
|
3058
|
+
self.active_tab = "trace"
|
|
3059
|
+
self.viewing_target = ""
|
|
3060
|
+
self._cancel_armed = False
|
|
3061
|
+
self.scan_task = asyncio.create_task(self._run_test_scan())
|
|
3062
|
+
|
|
3063
|
+
def _cancel_scan(self) -> None:
|
|
3064
|
+
if self.mode != "scanning":
|
|
3065
|
+
self.last_status_line = "no scan is running"
|
|
3066
|
+
return
|
|
3067
|
+
self.last_status_line = "cancelling…"
|
|
3068
|
+
if self.session:
|
|
3069
|
+
self.session.cancel()
|
|
3070
|
+
if self.scan_task and not self.scan_task.done():
|
|
3071
|
+
self.scan_task.cancel()
|
|
3072
|
+
|
|
3073
|
+
def _pause_scan(self) -> None:
|
|
3074
|
+
if self.mode != "scanning" or self.session is None:
|
|
3075
|
+
self.last_status_line = "no scan is running"
|
|
3076
|
+
return
|
|
3077
|
+
if self.session.paused:
|
|
3078
|
+
self.last_status_line = "scan is already paused · /resume to continue"
|
|
3079
|
+
return
|
|
3080
|
+
self.session.pause()
|
|
3081
|
+
self.last_status_line = "scan paused · /resume to continue · /cancel to stop"
|
|
3082
|
+
self._invalidate()
|
|
3083
|
+
|
|
3084
|
+
def _resume_scan(self) -> None:
|
|
3085
|
+
if self.mode != "scanning" or self.session is None:
|
|
3086
|
+
self.last_status_line = "no scan is running"
|
|
3087
|
+
return
|
|
3088
|
+
if not self.session.paused:
|
|
3089
|
+
self.last_status_line = "scan is not paused"
|
|
3090
|
+
return
|
|
3091
|
+
self.session.resume()
|
|
3092
|
+
self.last_status_line = "scan resumed"
|
|
3093
|
+
self._invalidate()
|
|
3094
|
+
|
|
3095
|
+
def _on_trace(self, entry: TraceEntry) -> None:
|
|
3096
|
+
if self.scan is None:
|
|
3097
|
+
return
|
|
3098
|
+
self.scan.update_from_trace(entry)
|
|
3099
|
+
# Live-tick the elapsed clock by invalidating.
|
|
3100
|
+
self._invalidate()
|
|
3101
|
+
|
|
3102
|
+
async def _run_scan(self, target_dir: str) -> None:
|
|
3103
|
+
reload_settings()
|
|
3104
|
+
session: Optional[Session] = None
|
|
3105
|
+
try:
|
|
3106
|
+
project_context = build_project_context(target_dir)
|
|
3107
|
+
session = Session(
|
|
3108
|
+
target_dir=target_dir,
|
|
3109
|
+
on_trace=self._on_trace,
|
|
3110
|
+
project_context=project_context,
|
|
3111
|
+
)
|
|
3112
|
+
self.session = session
|
|
3113
|
+
|
|
3114
|
+
# Wrap on_trace to also persist on key milestones (step_complete,
|
|
3115
|
+
# finding_added) so a crashed scan still leaves a readable report.
|
|
3116
|
+
def _checkpoint(entry: TraceEntry) -> None:
|
|
3117
|
+
self._on_trace(entry)
|
|
3118
|
+
if entry.event_type in ("step_complete", "swarm_complete", "finding_added"):
|
|
3119
|
+
self._write_report(session, target_dir, status="running")
|
|
3120
|
+
|
|
3121
|
+
session._on_trace = _checkpoint # type: ignore[attr-defined]
|
|
3122
|
+
|
|
3123
|
+
# Wrap add_finding to bubble findings into ScanState + persist.
|
|
3124
|
+
original_add_finding = session.add_finding
|
|
3125
|
+
|
|
3126
|
+
def _patched_add_finding(f: Finding) -> None:
|
|
3127
|
+
original_add_finding(f)
|
|
3128
|
+
if self.scan is not None:
|
|
3129
|
+
self.scan.findings.append(f)
|
|
3130
|
+
self._write_report(session, target_dir, status="running")
|
|
3131
|
+
self._invalidate()
|
|
3132
|
+
|
|
3133
|
+
session.add_finding = _patched_add_finding # type: ignore[method-assign]
|
|
3134
|
+
|
|
3135
|
+
# Write an initial 'running' report so /sessions sees it immediately.
|
|
3136
|
+
self._write_report(session, target_dir, status="running")
|
|
3137
|
+
|
|
3138
|
+
tools = ToolRegistry(target_dir=Path(target_dir))
|
|
3139
|
+
llm = LLMClient(
|
|
3140
|
+
model=self.model, temperature=0.0, max_tokens=8192,
|
|
3141
|
+
provider=self.provider, prompt_cache_key=session.id,
|
|
3142
|
+
)
|
|
3143
|
+
coordinator = CoordinatorAgent(llm, tools, session)
|
|
3144
|
+
await coordinator.run_full_scan()
|
|
3145
|
+
|
|
3146
|
+
self.last_session = session
|
|
3147
|
+
self.last_findings = list(session.findings)
|
|
3148
|
+
self._write_report(session, target_dir, status="completed")
|
|
3149
|
+
self.last_status_line = (
|
|
3150
|
+
f"scan complete · {len(session.findings)} findings · "
|
|
3151
|
+
f"${session.total_cost:.4f}"
|
|
3152
|
+
)
|
|
3153
|
+
|
|
3154
|
+
except asyncio.CancelledError:
|
|
3155
|
+
if session is not None:
|
|
3156
|
+
self._write_report(session, target_dir, status="cancelled")
|
|
3157
|
+
self.last_status_line = (
|
|
3158
|
+
f"scan cancelled · resume with: openhack resume {session.id}"
|
|
3159
|
+
)
|
|
3160
|
+
else:
|
|
3161
|
+
self.last_status_line = "scan cancelled"
|
|
3162
|
+
raise
|
|
3163
|
+
except Exception as exc:
|
|
3164
|
+
if session is not None:
|
|
3165
|
+
self._write_report(session, target_dir, status="failed")
|
|
3166
|
+
self.last_status_line = (
|
|
3167
|
+
f"scan failed: {exc} · retry with: openhack resume {session.id}"
|
|
3168
|
+
)
|
|
3169
|
+
else:
|
|
3170
|
+
self.last_status_line = f"scan failed: {exc}"
|
|
3171
|
+
finally:
|
|
3172
|
+
if self.scan is not None:
|
|
3173
|
+
self.scan.finish()
|
|
3174
|
+
self.scan_task = None
|
|
3175
|
+
# On scan completion, jump from Trace → Findings so the user
|
|
3176
|
+
# lands on the results without having to switch tabs.
|
|
3177
|
+
self.active_tab = "findings"
|
|
3178
|
+
self.findings_selected = 0
|
|
3179
|
+
self._invalidate()
|
|
3180
|
+
|
|
3181
|
+
def _write_report(
|
|
3182
|
+
self,
|
|
3183
|
+
session: Session,
|
|
3184
|
+
target_dir: str,
|
|
3185
|
+
status: Optional[str] = None,
|
|
3186
|
+
) -> None:
|
|
3187
|
+
"""Atomically write the scan report. Called incrementally during a scan
|
|
3188
|
+
(status='running') and at end (status='completed'/'cancelled'/'failed').
|
|
3189
|
+
"""
|
|
3190
|
+
try:
|
|
3191
|
+
report_dir = Path.home() / ".openhack" / "scans"
|
|
3192
|
+
report_dir.mkdir(parents=True, exist_ok=True)
|
|
3193
|
+
report_path = report_dir / f"{session.id}.json"
|
|
3194
|
+
elapsed = time.time() - (self.scan.start_time if self.scan else session.created_at)
|
|
3195
|
+
|
|
3196
|
+
# Serialize trace entries so the Trace tab can re-render later.
|
|
3197
|
+
def _trace_dict(e: TraceEntry) -> dict:
|
|
3198
|
+
tool_output = e.tool_output
|
|
3199
|
+
# Tool outputs can be enormous; cap so reports stay sane.
|
|
3200
|
+
if tool_output is not None and not isinstance(tool_output, (dict, list, int, float, bool)):
|
|
3201
|
+
s = str(tool_output)
|
|
3202
|
+
tool_output = s if len(s) <= 2000 else s[:2000] + "…"
|
|
3203
|
+
return {
|
|
3204
|
+
"timestamp": e.timestamp,
|
|
3205
|
+
"agent": e.agent,
|
|
3206
|
+
"event_type": e.event_type,
|
|
3207
|
+
"content": e.content,
|
|
3208
|
+
"tool_name": e.tool_name,
|
|
3209
|
+
"tool_input": e.tool_input,
|
|
3210
|
+
"tool_output": tool_output,
|
|
3211
|
+
}
|
|
3212
|
+
|
|
3213
|
+
report = {
|
|
3214
|
+
"version": 2,
|
|
3215
|
+
"scan_id": session.id,
|
|
3216
|
+
"target_dir": target_dir,
|
|
3217
|
+
"provider": self.provider,
|
|
3218
|
+
"model": self.model,
|
|
3219
|
+
"status": status or session.status.value,
|
|
3220
|
+
"pid": os.getpid(),
|
|
3221
|
+
"started_at": datetime.fromtimestamp(session.created_at).isoformat(),
|
|
3222
|
+
"duration_seconds": round(elapsed, 2),
|
|
3223
|
+
"cost": session.get_cost_breakdown(),
|
|
3224
|
+
"findings": [f.to_dict() for f in session.findings],
|
|
3225
|
+
"trace": [_trace_dict(e) for e in session.trace],
|
|
3226
|
+
}
|
|
3227
|
+
# Atomic write: temp file + rename to avoid corrupting on crash.
|
|
3228
|
+
tmp_path = report_path.with_suffix(".json.tmp")
|
|
3229
|
+
with open(tmp_path, "w") as fp:
|
|
3230
|
+
json.dump(report, fp, indent=2, default=str, ensure_ascii=False)
|
|
3231
|
+
os.replace(tmp_path, report_path)
|
|
3232
|
+
except Exception:
|
|
3233
|
+
pass
|
|
3234
|
+
|
|
3235
|
+
async def _run_test_scan(self) -> None:
|
|
3236
|
+
import random
|
|
3237
|
+
from openhack.agents.session import Session as _S
|
|
3238
|
+
|
|
3239
|
+
session = _S(target_dir=os.getcwd(), on_trace=self._on_trace)
|
|
3240
|
+
self.session = session
|
|
3241
|
+
|
|
3242
|
+
# Hook live add_finding.
|
|
3243
|
+
original_add_finding = session.add_finding
|
|
3244
|
+
|
|
3245
|
+
def _patched_add_finding(f: Finding) -> None:
|
|
3246
|
+
original_add_finding(f)
|
|
3247
|
+
if self.scan is not None:
|
|
3248
|
+
self.scan.findings.append(f)
|
|
3249
|
+
self._invalidate()
|
|
3250
|
+
|
|
3251
|
+
session.add_finding = _patched_add_finding # type: ignore[method-assign]
|
|
3252
|
+
|
|
3253
|
+
def _d():
|
|
3254
|
+
return random.uniform(0.05, 0.25)
|
|
3255
|
+
|
|
3256
|
+
try:
|
|
3257
|
+
session.add_trace("coordinator", "step_start", "Step 1: Reconnaissance")
|
|
3258
|
+
await asyncio.sleep(_d())
|
|
3259
|
+
for tool in ["get_project_info", "list_dir", "read_file", "get_route_map",
|
|
3260
|
+
"check_dependencies", "grep", "find_dangerous_patterns"]:
|
|
3261
|
+
session.add_trace("recon", "tool_call", "",
|
|
3262
|
+
tool_name=tool, tool_input={"path": "src"})
|
|
3263
|
+
await asyncio.sleep(_d())
|
|
3264
|
+
session.add_trace("recon", "tool_result", "", tool_name=tool,
|
|
3265
|
+
tool_output={"ok": True})
|
|
3266
|
+
session.add_trace("coordinator", "step_complete",
|
|
3267
|
+
{"step": "recon", "cost": 0.04, "tokens": 85000})
|
|
3268
|
+
|
|
3269
|
+
groups = ["input_validation", "access_control", "data_handling"]
|
|
3270
|
+
session.add_trace("hunter_swarm", "swarm_start",
|
|
3271
|
+
{"groups": groups, "group_count": len(groups)})
|
|
3272
|
+
for g in groups:
|
|
3273
|
+
a = f"hunter:{g}"
|
|
3274
|
+
for tool in ["read_file", "grep", "trace_variable"]:
|
|
3275
|
+
session.add_trace(a, "tool_call", "",
|
|
3276
|
+
tool_name=tool, tool_input={"path": "src/lib/auth.ts"})
|
|
3277
|
+
await asyncio.sleep(_d())
|
|
3278
|
+
session.add_trace(a, "tool_result", "", tool_name=tool)
|
|
3279
|
+
|
|
3280
|
+
findings = [
|
|
3281
|
+
("IDOR", "critical", "src/app/dashboard/[id]/page.tsx",
|
|
3282
|
+
"IDOR in workspace page — no ownership check"),
|
|
3283
|
+
("SQL Injection", "critical", "src/lib/db.ts",
|
|
3284
|
+
"SQL Injection via queryRawUnsafe"),
|
|
3285
|
+
("XSS", "high", "src/components/note-card.tsx",
|
|
3286
|
+
"Stored XSS via dangerouslySetInnerHTML"),
|
|
3287
|
+
("Auth Bypass", "high", "src/app/api/users/route.ts",
|
|
3288
|
+
"Missing auth check on user list endpoint"),
|
|
3289
|
+
("Open Redirect", "medium", "src/app/api/auth/callback/route.ts",
|
|
3290
|
+
"Unvalidated redirect URL in OAuth callback"),
|
|
3291
|
+
]
|
|
3292
|
+
for cat, sev, fp, title in findings:
|
|
3293
|
+
session.add_finding(Finding(
|
|
3294
|
+
category=cat, severity=sev, title=title,
|
|
3295
|
+
description=title, file_path=fp,
|
|
3296
|
+
))
|
|
3297
|
+
await asyncio.sleep(_d())
|
|
3298
|
+
|
|
3299
|
+
session.add_trace("hunter_swarm", "swarm_complete",
|
|
3300
|
+
{"total_findings": len(findings), "total_cost": 0.18})
|
|
3301
|
+
session.add_trace("coordinator", "step_complete",
|
|
3302
|
+
{"step": "hunters", "cost": 0.18, "tokens": 320000})
|
|
3303
|
+
|
|
3304
|
+
session.total_cost = 0.22
|
|
3305
|
+
session.status = SessionStatus.COMPLETED
|
|
3306
|
+
self.last_session = session
|
|
3307
|
+
self.last_findings = list(session.findings)
|
|
3308
|
+
self.last_status_line = (
|
|
3309
|
+
f"test scan complete · {len(session.findings)} findings"
|
|
3310
|
+
)
|
|
3311
|
+
except asyncio.CancelledError:
|
|
3312
|
+
self.last_status_line = "test scan cancelled"
|
|
3313
|
+
raise
|
|
3314
|
+
finally:
|
|
3315
|
+
if self.scan is not None:
|
|
3316
|
+
self.scan.finish()
|
|
3317
|
+
self.scan_task = None
|
|
3318
|
+
self.active_tab = "findings"
|
|
3319
|
+
self.findings_selected = 0
|
|
3320
|
+
self._invalidate()
|
|
3321
|
+
|
|
3322
|
+
# ── Chat ──────────────────────────────────────────────────────
|
|
3323
|
+
|
|
3324
|
+
async def _chat(self, user_message: str) -> None:
|
|
3325
|
+
self.chat_history.append(Message(role="user", content=user_message))
|
|
3326
|
+
reload_settings()
|
|
3327
|
+
try:
|
|
3328
|
+
llm = LLMClient(
|
|
3329
|
+
model=self.model, temperature=0.3, max_tokens=4096,
|
|
3330
|
+
provider=self.provider,
|
|
3331
|
+
)
|
|
3332
|
+
except Exception as exc:
|
|
3333
|
+
self.last_status_line = f"llm error: {exc}"
|
|
3334
|
+
self.chat_history.pop()
|
|
3335
|
+
return
|
|
3336
|
+
|
|
3337
|
+
context_parts = [CHAT_SYSTEM_PROMPT]
|
|
3338
|
+
if self.last_session and self.last_session.findings:
|
|
3339
|
+
summary = []
|
|
3340
|
+
for i, f in enumerate(self.last_session.findings, 1):
|
|
3341
|
+
summary.append(
|
|
3342
|
+
f"{i}. [{f.severity.upper()}] {f.category} - {f.title}"
|
|
3343
|
+
+ (f" ({f.file_path})" if f.file_path else "")
|
|
3344
|
+
)
|
|
3345
|
+
context_parts.append("\n\nCurrent scan findings:\n" + "\n".join(summary))
|
|
3346
|
+
|
|
3347
|
+
self.last_status_line = "thinking…"
|
|
3348
|
+
self._invalidate()
|
|
3349
|
+
try:
|
|
3350
|
+
response: LLMResponse = await llm.chat(
|
|
3351
|
+
messages=self.chat_history, system="".join(context_parts),
|
|
3352
|
+
)
|
|
3353
|
+
except Exception as exc:
|
|
3354
|
+
self.last_status_line = f"llm error: {exc}"
|
|
3355
|
+
self.chat_history.pop()
|
|
3356
|
+
return
|
|
3357
|
+
|
|
3358
|
+
reply = (response.content or "").strip() or "(no response)"
|
|
3359
|
+
self.chat_history.append(Message(role="assistant", content=reply))
|
|
3360
|
+
if len(self.chat_history) > 40:
|
|
3361
|
+
self.chat_history = self.chat_history[-30:]
|
|
3362
|
+
# Show the reply as a short status line; full reply truncated for
|
|
3363
|
+
# the status bar — better display will come in v2.
|
|
3364
|
+
self.last_status_line = reply if len(reply) <= 200 else reply[:197] + "…"
|
|
3365
|
+
|
|
3366
|
+
# ── Run ───────────────────────────────────────────────────────
|
|
3367
|
+
|
|
3368
|
+
async def run(self) -> None:
|
|
3369
|
+
# Tick the clock every second while scanning.
|
|
3370
|
+
async def _ticker():
|
|
3371
|
+
while True:
|
|
3372
|
+
await asyncio.sleep(1.0)
|
|
3373
|
+
if self.mode == "scanning":
|
|
3374
|
+
self._invalidate()
|
|
3375
|
+
|
|
3376
|
+
async def _check_updates():
|
|
3377
|
+
info = await fetch_updates()
|
|
3378
|
+
if info is None:
|
|
3379
|
+
return
|
|
3380
|
+
self._update_info = info
|
|
3381
|
+
self._invalidate()
|
|
3382
|
+
# If there are modal-placement announcements, queue the first one
|
|
3383
|
+
# as a modal dialog after a short delay (so it doesn't fight the
|
|
3384
|
+
# landing screen initial render).
|
|
3385
|
+
modal_anns = [a for a in info.announcements if "modal" in a.placement]
|
|
3386
|
+
if modal_anns:
|
|
3387
|
+
await asyncio.sleep(0.5)
|
|
3388
|
+
self._show_announcement_modal(modal_anns[0])
|
|
3389
|
+
|
|
3390
|
+
tick_task = asyncio.create_task(_ticker())
|
|
3391
|
+
asyncio.create_task(_check_updates())
|
|
3392
|
+
try:
|
|
3393
|
+
await self.app.run_async()
|
|
3394
|
+
finally:
|
|
3395
|
+
tick_task.cancel()
|
|
3396
|
+
if self.scan_task and not self.scan_task.done():
|
|
3397
|
+
self.scan_task.cancel()
|
|
3398
|
+
try:
|
|
3399
|
+
await self.scan_task
|
|
3400
|
+
except (asyncio.CancelledError, Exception):
|
|
3401
|
+
pass
|
|
3402
|
+
|
|
3403
|
+
|
|
3404
|
+
def _configure_logging() -> None:
|
|
3405
|
+
"""Route all logging to a file so messages don't corrupt the full-screen UI.
|
|
3406
|
+
|
|
3407
|
+
Anything that calls `logger.warning(...)` / `logger.error(..., exc_info=True)`
|
|
3408
|
+
(e.g. LLMClient retries, upstream errors) would otherwise hit stderr and
|
|
3409
|
+
overlap the layout. The log file lives at ~/.openhack/logs/openhack.log.
|
|
3410
|
+
"""
|
|
3411
|
+
log_dir = Path.home() / ".openhack" / "logs"
|
|
3412
|
+
try:
|
|
3413
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
3414
|
+
except OSError:
|
|
3415
|
+
return
|
|
3416
|
+
log_path = log_dir / "openhack.log"
|
|
3417
|
+
|
|
3418
|
+
root = logging.getLogger()
|
|
3419
|
+
# Remove any existing StreamHandlers that would write to the terminal.
|
|
3420
|
+
for h in list(root.handlers):
|
|
3421
|
+
if isinstance(h, logging.StreamHandler) and not isinstance(h, logging.FileHandler):
|
|
3422
|
+
root.removeHandler(h)
|
|
3423
|
+
# Add our file handler if not already there.
|
|
3424
|
+
have_file = any(
|
|
3425
|
+
isinstance(h, logging.FileHandler) and Path(getattr(h, "baseFilename", "")) == log_path
|
|
3426
|
+
for h in root.handlers
|
|
3427
|
+
)
|
|
3428
|
+
if not have_file:
|
|
3429
|
+
fh = logging.FileHandler(log_path)
|
|
3430
|
+
fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
|
|
3431
|
+
root.addHandler(fh)
|
|
3432
|
+
if root.level == logging.NOTSET or root.level > logging.INFO:
|
|
3433
|
+
root.setLevel(logging.INFO)
|
|
3434
|
+
|
|
3435
|
+
|
|
3436
|
+
def main():
|
|
3437
|
+
signal.signal(signal.SIGHUP, lambda *_: os._exit(1))
|
|
3438
|
+
signal.signal(signal.SIGTERM, lambda *_: os._exit(1))
|
|
3439
|
+
_configure_logging()
|
|
3440
|
+
|
|
3441
|
+
app = OpenHackApp()
|
|
3442
|
+
try:
|
|
3443
|
+
asyncio.run(app.run())
|
|
3444
|
+
except KeyboardInterrupt:
|
|
3445
|
+
pass
|
|
3446
|
+
|
|
3447
|
+
|
|
3448
|
+
# ── Back-compat aliases for existing imports ──────────────────────
|
|
3449
|
+
|
|
3450
|
+
OpenHackCLI = OpenHackApp # legacy name used by __main__.py
|