Compare commits

...
Author SHA1 Message Date
yoni 7303c27435 Reject OTP verify responses lacking a usable expiry 2026-07-21 00:06:40 +00:00
yoni ab840c31ae Accept epoch-seconds relay expiry so valid verification is not misread 2026-07-21 00:00:43 +00:00
yoni a1e0f20d6e Session-gate historical run reads; fail closed on missing expiry 2026-07-20 23:55:04 +00:00
7b412ac3b1 Harden local viewer authorization (session capability, history gating, expiry) (#818)
* Harden viewer authz: session capability, history gating, expiry

* Set session cookie whenever index.html is served

* Gate session capability behind bootstrap token; sync auth status with expiry

---------

Co-authored-by: yoni <yoni@usestrix.com>
2026-07-20 23:45:11 +00:00
Jonathan Singer 4be5d7716c Add a run details panel and move the privacy note to a dismissable toast 2026-07-20 19:06:40 -04:00
Jonathan Singer 68f1d19504 Show live steering messages in the agent transcript 2026-07-20 17:47:50 -04:00
Jonathan Singer dd1dab5d95 Bring the pro live-prompt composer to the local viewer
Replace the ad-hoc steer input with a faithful port of the pro app's
ScanPromptComposer: a collapsible Guide the agent pill that opens into
the Live prompt card with an auto-resizing textarea, a target picker,
and a rounded send button. Drops the Enterprise lock and bridge states
since local steering is immediate. Used under the agent graph with a
target dropdown, and docked in the agent detail modal with a static
target.
2026-07-20 17:30:38 -04:00
Jonathan Singer 09ff9aa783 Steer each agent from its modal; drop the Pro upsell there 2026-07-20 17:20:09 -04:00
Jonathan Singer 47ded9b93c Add live agent steering from the web viewer
Let the in-TUI viewer send a steering message to a running agent during a
live scan, reusing the same delivery path the TUI uses. Standalone strix
view reports steering unavailable, so the web composer only shows when a
live scan is in process.
2026-07-20 16:54:29 -04:00
Jonathan Singer 01c02d6d97 Rebuild the viewer bundle 2026-07-20 16:48:55 -04:00
Jonathan Singer 4465617134 Polish the local viewer: inline verify, clean titles, smart default view
- Verify your email right on the Past runs page instead of being sent off
  to the Email report page; the list unlocks in place once verified.
- Show a clean target-based title (like arch.co) and a relative time
  instead of the raw run folder name everywhere runs are listed.
- Land on Agents while a scan is still live and on Overview once it is
  done, without ever overriding where you navigate yourself.
2026-07-20 16:48:52 -04:00
Jonathan Singer 8f4ca2aedc Add a collapsible sidebar to the local viewer
Toggle it to a narrow icon-only rail; the choice is remembered in
localStorage and each icon keeps a hover tooltip so labels stay findable.
2026-07-20 16:48:41 -04:00
Jonathan Singer de1c36d7e6 Update viewer event test for the cta surface and funnel events 2026-07-20 15:15:47 -04:00
Jonathan Singer 8264eee2f5 Add surfaces to the empty-state and agents CTAs 2026-07-20 15:05:46 -04:00
Jonathan Singer 2351bf9323 Track attribution, cta surfaces, and the email funnel in the viewer 2026-07-20 15:04:29 -04:00
Jonathan Singer 10d6136352 Forward viewer email funnel events and cta surface to PostHog 2026-07-20 15:04:25 -04:00
Jonathan Singer 8b98457041 Keep report code blocks from overlapping the field labels 2026-07-20 14:51:50 -04:00
Jonathan Singer 4505ef451c Render markdown in the report PDF (bold, code, lists, headings) 2026-07-20 14:48:06 -04:00
Jonathan Singer 05e8848332 Restyle the report PDF to match the cloud pentest report 2026-07-20 14:38:17 -04:00
Jonathan Singer f3a956f5df Reword the platform CTA and the linked-email chip 2026-07-20 14:26:03 -04:00
Jonathan Singer ea0c107559 Start the email flow directly from the Overview CTA 2026-07-20 13:46:35 -04:00
Jonathan Singer cfe1073650 Center the email report page in the content area 2026-07-20 13:43:35 -04:00
Jonathan Singer 2a85e27dc0 Make the email report its own page instead of a modal 2026-07-20 13:41:34 -04:00
Jonathan Singer f91a831761 Hide the trust banner on the Pro feature pages 2026-07-20 13:36:07 -04:00
Jonathan Singer 5600650e26 Rebuild the viewer bundle 2026-07-20 13:31:19 -04:00
Jonathan Singer 9d4033d950 Pass the relay's work_email_required error through the viewer server
Classify the relay's 400 work_email_required separately from a malformed
address so the browser can show the "use your work email" message.
2026-07-20 13:31:19 -04:00
Jonathan Singer d73f319be5 Rework the viewer nav, add in-app feature pages, and verify-only past runs
- Collapse the sidebar into one ordered list of uniform two-line rows (icon +
  label + short one-liner), no tier sections.
- Sidebar Pro/Enterprise rows now open an in-app FeatureDetail upsell page
  instead of linking out; the page's primary CTA is the sign-up link.
- Past runs "View runs" runs a verify-only flow that never sends a report.
- Ask for a work email: helper text, an instant common-domain check, and a
  friendly message when the relay rejects a personal domain.
2026-07-20 13:31:13 -04:00
Jonathan Singer 2cd953b54e Tighten the email report CTA subtext 2026-07-20 12:42:13 -04:00
Jonathan Singer 7710a970b2 Reframe the email report CTA around security, not unlocking
The subtext now leads with the zero-knowledge assurance (we cannot read the
report) and frames the email step as verifying who we send to via a one-time
code, rather than unlocking a feature.
2026-07-20 12:40:53 -04:00
Jonathan Singer 54e9c0239b Show Pro descriptions under each sidebar item and widen the rail
The hover tooltips read as too subtle, so the one-liners now sit as secondary
text under each title and the left menu is a little wider to fit them.
2026-07-20 12:36:45 -04:00
Jonathan Singer dfc672c947 Label the top-bar CTA 'Run in the cloud' instead of a second 'Start free' 2026-07-20 12:35:14 -04:00
Jonathan Singer e32bfcde4a Bundle Pillow so the frozen binary can build PDF reports
reportlab imports PIL lazily and it was in the PyInstaller excludes list,
so report generation crashed in the packaged binary.
2026-07-20 12:23:06 -04:00
Jonathan Singer a12b9c634c Rebuild the viewer bundle 2026-07-20 12:02:16 -04:00
Jonathan Singer 4c337f93ba Add the email report dialog, past runs view, and run switcher 2026-07-20 12:02:13 -04:00
Jonathan Singer 8d9f785dc7 Add the persistent sidebar and shared Pro upsell tiles 2026-07-20 12:02:10 -04:00
Jonathan Singer 5dee36d30a Wire the viewer to run history, email auth, and report-send endpoints 2026-07-20 12:02:06 -04:00
Jonathan Singer f9a966df59 Add tests for PDF reports, viewer auth, and run history gating 2026-07-20 11:52:54 -04:00
Jonathan Singer 391df1b38c Add reportlab, pypdf, and cryptography deps and packaging 2026-07-20 11:52:51 -04:00
Jonathan Singer f7cfd0d47d Add run history list, run-scoping, and email/report endpoints to the viewer server 2026-07-20 11:52:48 -04:00
Jonathan Singer 98c2e0be4e Add viewer email auth state, relay client, and encrypted PDF reports 2026-07-20 11:52:46 -04:00
Jonathan Singer da9c606c67 Add STRIX_APP_URL viewer setting for the relay base URL 2026-07-20 11:52:43 -04:00
Jonathan Singer 11c7eef082 Add a "View in web" link to the run summary, and make the logo clickable 2026-07-20 10:04:40 -04:00
Jonathan Singer 8bbcafcf1a Fix the viewer build command 2026-07-20 09:51:56 -04:00
Jonathan Singer 1e4db0098b Fix .gitignore skipping some viewer source files 2026-07-20 09:51:56 -04:00
Jonathan Singer 1ce37d7b12 Refresh the live view faster and follow new activity 2026-07-20 09:51:56 -04:00
Jonathan Singer b41abc9e58 Clean up the agent transcript view 2026-07-20 09:51:56 -04:00
Jonathan Singer 9a9b5cc1c2 Tidy up the agent modal sizing 2026-07-20 09:51:56 -04:00
Jonathan Singer db29c77b30 Open agent details in a modal 2026-07-20 09:51:56 -04:00
Jonathan Singer 46e4b16167 Add a local web viewer for runs
`strix view` opens a run in the browser, served locally from the run files
(nothing is uploaded). There's also a quick link to it from the TUI and the
end-of-run summary.
2026-07-20 09:51:56 -04:00
92 changed files with 17000 additions and 1482 deletions
+12 -4
View File
@@ -1,17 +1,25 @@
# Node / local-viewer SPA source (the built bundle in
# strix/viewer/viewer_dist/ is committed and shipped; do not ignore it)
node_modules/
strix/viewer_src/node_modules/
strix/viewer_src/.vite/
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
# Anchored to the repo root: these are Python build-artifact dir names, but
# unanchored they also match nested source dirs (e.g. the viewer's src/lib).
/build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
/lib/
/lib64/
parts/
sdist/
var/
@@ -46,7 +54,7 @@ pip-delete-this-directory.txt
.env.production.local
# MongoDB
data/
/data/
mongod.log
*.mongodb
*.mongorc.js
+14
View File
@@ -99,6 +99,20 @@ We welcome feature ideas! Please:
- Consider implementation approach
- Be open to discussion
## 🖥️ Local viewer SPA
`strix view` serves a prebuilt web UI whose source lives in `strix/viewer_src/`
(a Vite + React project) and whose built output is committed to
`strix/viewer/viewer_dist/` and shipped in the package. End users never run a
JS build. If you change anything under `strix/viewer_src/`, rebuild and commit
the output:
```bash
make viewer # or: cd strix/viewer_src && npm ci && npm run build
```
Commit both the source change and the regenerated `strix/viewer/viewer_dist/`.
## 🤝 Community
- **Discord**: [Join our community](https://discord.gg/strix-ai)
+7 -1
View File
@@ -1,4 +1,4 @@
.PHONY: help install dev-install format lint type-check security check-all clean pre-commit setup-dev dev
.PHONY: help install dev-install format lint type-check security check-all clean pre-commit setup-dev dev viewer
help:
@echo "Available commands:"
@@ -15,6 +15,7 @@ help:
@echo ""
@echo "Development:"
@echo " pre-commit - Run pre-commit hooks on all files"
@echo " viewer - Rebuild the local-viewer SPA (commit the output)"
@echo " clean - Clean up cache files and artifacts"
install:
@@ -66,5 +67,10 @@ clean:
find . -name "*.pyc" -delete 2>/dev/null || true
@echo "✅ Cleanup complete!"
viewer:
@echo "🖥️ Building the local-viewer SPA..."
cd strix/viewer_src && npm ci && npm run build
@echo "✅ Viewer built to strix/viewer/viewer_dist/ (commit the changes)."
dev: format lint type-check
@echo "✅ Development cycle complete!"
+18
View File
@@ -44,6 +44,9 @@ dependencies = [
"requests>=2.32.0",
"cvss>=3.2",
"caido-sdk-client>=0.2.0",
"reportlab>=4.0",
"pypdf>=5.0",
"cryptography>=42",
]
[project.optional-dependencies]
@@ -74,6 +77,10 @@ build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["strix"]
# The prebuilt viewer bundle under strix/viewer/viewer_dist/ ships automatically
# (hatchling includes non-.py files under the package). The Vite SOURCE lives
# under the package dir too but must never ship in the wheel.
exclude = ["strix/viewer_src", "strix/viewer_src/**"]
# ============================================================================
# Type Checking Configuration
@@ -111,6 +118,8 @@ module = [
"docker.*",
"caido_sdk_client.*",
"pydantic_settings.*",
"reportlab.*",
"pypdf.*",
]
ignore_missing_imports = true
disable_error_code = ["import-untyped"]
@@ -201,6 +210,15 @@ ignore = [
]
[tool.ruff.lint.per-file-ignores]
# Test doubles use fixture tokens/passwords and match a callee signature whose
# args they intentionally ignore.
"tests/test_viewer_auth.py" = ["S105", "S106", "ARG001"]
"tests/test_report_pdf.py" = ["S105", "S106"]
# Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a
# circular dependency with strix.telemetry / strix.viewer.report_pdf.
"strix/viewer/server.py" = ["N802", "PLC0415"]
# Lazy telemetry import to avoid importing PostHog before the viewer starts.
"strix/viewer/cli.py" = ["PLC0415"]
# Lazy imports inside functions to avoid circular dependency with
# strix.telemetry / strix.report.dedupe / cvss.
"strix/tools/notes/tools.py" = ["PLC0415", "TC002"]
+32 -1
View File
@@ -25,6 +25,13 @@ for tcss_file in strix_root.rglob('*.tcss'):
rel_path = tcss_file.relative_to(project_root)
datas.append((str(tcss_file), str(rel_path.parent)))
# Prebuilt local-viewer SPA (served by `strix view`).
viewer_dist = strix_root / 'viewer' / 'viewer_dist'
for asset in viewer_dist.rglob('*'):
if asset.is_file():
rel_path = asset.relative_to(project_root)
datas.append((str(asset), str(rel_path.parent)))
datas += collect_data_files('textual')
datas += collect_data_files('tiktoken')
@@ -151,6 +158,21 @@ hiddenimports = [
'strix.report.dedupe',
'strix.report.state',
'strix.report.writer',
'strix.viewer',
'strix.viewer.auth',
'strix.viewer.cli',
'strix.viewer.report_pdf',
'strix.viewer.server',
'strix.viewer.transcript',
# PDF report generation + encryption
'reportlab',
'reportlab.pdfgen',
'reportlab.pdfbase',
'reportlab.lib',
'reportlab.platypus',
'pypdf',
'cryptography',
'strix.runtime',
'strix.runtime.backends',
'strix.runtime.caido_bootstrap',
@@ -178,6 +200,16 @@ hiddenimports += collect_submodules('textual')
hiddenimports += collect_submodules('rich')
hiddenimports += collect_submodules('pydantic')
hiddenimports += collect_submodules('pygments')
# reportlab loads renderers/fonts dynamically, so pull its whole tree in.
hiddenimports += collect_submodules('reportlab')
# reportlab ships bundled fonts (.pfb/.afm) it needs at runtime.
datas += collect_data_files('reportlab')
# reportlab imports PIL (pillow) lazily for image handling, so it must be
# bundled explicitly and kept out of the excludes list below.
hiddenimports += collect_submodules('PIL')
datas += collect_data_files('PIL')
excludes = [
# Sandbox-only packages
@@ -225,7 +257,6 @@ excludes = [
'numpy',
'pandas',
'scipy',
'PIL',
'cv2',
]
+10
View File
@@ -72,6 +72,15 @@ class IntegrationSettings(BaseSettings):
perplexity_api_key: str | None = Field(default=None, alias="PERPLEXITY_API_KEY")
class ViewerSettings(BaseSettings):
model_config = _BASE_CONFIG
# Base URL of the Strix relay the local viewer proxies to for email
# verification and encrypted report delivery. The browser never talks to
# the relay directly; the local server is the only caller.
app_url: str = Field(default="https://app.strix.ai", alias="STRIX_APP_URL")
class Settings(BaseSettings):
model_config = _BASE_CONFIG
@@ -79,3 +88,4 @@ class Settings(BaseSettings):
runtime: RuntimeSettings = Field(default_factory=RuntimeSettings)
telemetry: TelemetrySettings = Field(default_factory=TelemetrySettings)
integrations: IntegrationSettings = Field(default_factory=IntegrationSettings)
viewer: ViewerSettings = Field(default_factory=ViewerSettings)
+17
View File
@@ -21,3 +21,20 @@ def runtime_state_dir(run_dir: Path) -> Path:
def run_record_path(run_dir: Path) -> Path:
return run_dir / RUN_RECORD_FILENAME
def runs_base_dir(*, cwd: Path | None = None) -> Path:
base = cwd or Path.cwd()
return base / RUNS_DIR_NAME
def latest_run_dir(*, cwd: Path | None = None) -> Path | None:
base = runs_base_dir(cwd=cwd)
if not base.is_dir():
return None
candidates = [child for child in base.iterdir() if run_record_path(child).is_file()]
if not candidates:
return None
# run.json is rewritten on status/end changes, so its mtime tracks activity
# more reliably than the directory mtime (a live run sorts to the top).
return max(candidates, key=lambda child: run_record_path(child).stat().st_mtime)
+10
View File
@@ -67,6 +67,16 @@ Toast.-information .toast--title {
display: none;
}
#viewer_cta {
height: auto;
background: transparent;
border: round #333333;
color: #60a5fa;
padding: 0 1;
margin-bottom: 1;
text-align: center;
}
#agents_tree {
height: 1fr;
background: transparent;
+69 -2
View File
@@ -7,6 +7,7 @@ import argparse
import asyncio
import shutil
import sys
import time
from datetime import UTC, datetime
from pathlib import Path
@@ -720,7 +721,9 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser
args.scan_mode = persisted_scan_mode
def display_completion_message(args: argparse.Namespace, results_path: Path) -> None:
def display_completion_message(
args: argparse.Namespace, results_path: Path, web_url: str | None = None
) -> None:
console = Console()
report_state = get_global_report_state()
@@ -759,6 +762,29 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) ->
results_text.append(str(results_path), style="#60a5fa")
panel_parts.extend(["\n", results_text])
if web_url:
web_text = Text()
web_text.append("\n")
web_text.append("View in web", style="dim")
web_text.append(" ")
# OSC-8 hyperlink: clickable in modern terminals, falls back to the URL.
web_text.append(web_url, style=f"#60a5fa link {web_url}")
panel_parts.extend(["\n", web_text])
reopen_text = Text()
reopen_text.append("\n")
reopen_text.append("Reopen", style="dim")
reopen_text.append(" ")
reopen_text.append(f"strix view {args.run_name}", style="#22c55e")
panel_parts.extend(["\n", reopen_text])
else:
view_text = Text()
view_text.append("\n")
view_text.append("View", style="dim")
view_text.append(" ")
view_text.append(f"strix view {args.run_name}", style="#22c55e")
panel_parts.extend(["\n", view_text])
if not scan_completed:
resume_text = Text()
resume_text.append("\n")
@@ -846,6 +872,14 @@ def main() -> None:
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
# `strix view [<run>]` is a viewer-only subcommand, dispatched before the
# scan argument parser (which requires a target) and before any scan setup.
if len(sys.argv) > 1 and sys.argv[1] == "view":
from strix.viewer.cli import run_view
run_view(sys.argv[2:])
return
args = parse_arguments()
if args.config:
@@ -940,7 +974,40 @@ def main() -> None:
scarf.end(report_state, exit_reason=exit_reason)
results_path = run_dir_for(args.run_name)
display_completion_message(args, results_path)
# For an interactive run, host the local viewer so the completion panel can
# show a clickable "View in web" link. Skipped in non-interactive/CI runs
# (no TTY to serve and it would block the process).
viewer_httpd = None
web_url = None
if not args.non_interactive and sys.stdout.isatty():
from strix.viewer.server import authorized_url, bundle_is_built, serve
if bundle_is_built():
try:
viewer_httpd, base_url, token = serve(results_path, open_browser=False)
# The completion panel's "View in web" link must authorize the
# browser, so hand it the tokened URL rather than the bare host.
web_url = authorized_url(base_url, token)
posthog.viewer_opened(source="post_scan", live=False)
except Exception:
logger.debug("could not start local viewer", exc_info=True)
viewer_httpd, web_url = None, None
display_completion_message(args, results_path, web_url=web_url)
if viewer_httpd is not None:
console = Console()
console.print("[dim]Hosting the local viewer. Press Ctrl-C to stop.[/]")
console.print()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
console.print("\n[dim]Viewer stopped.[/]")
finally:
viewer_httpd.shutdown()
viewer_httpd.server_close()
if args.non_interactive:
report_state = get_global_report_state()
+75 -1
View File
@@ -6,6 +6,7 @@ import logging
import signal
import sys
import threading
import webbrowser
from collections.abc import Callable
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as pkg_version
@@ -768,6 +769,7 @@ class StrixTUIApp(App): # type: ignore[misc]
Binding("ctrl+q", "request_quit", "Quit", priority=True),
Binding("ctrl+c", "request_quit", "Quit", priority=True),
Binding("escape", "stop_selected_agent", "Stop Agent", priority=True),
Binding("ctrl+o", "open_viewer", "Open Viewer", priority=True),
]
def __init__(self, args: argparse.Namespace):
@@ -794,6 +796,8 @@ class StrixTUIApp(App): # type: ignore[misc]
self._displayed_events: list[str] = []
self._scan_thread: threading.Thread | None = None
self._viewer_httpd: Any = None
self._viewer_url: str | None = None
self._scan_loop: asyncio.AbstractEventLoop | None = None
self._scan_stop_event = threading.Event()
self._scan_completed = threading.Event()
@@ -903,7 +907,12 @@ class StrixTUIApp(App): # type: ignore[misc]
vulnerabilities_panel = VulnerabilitiesPanel(id="vulnerabilities_panel")
sidebar = Vertical(agents_tree, vulnerabilities_panel, stats_scroll, id="sidebar")
viewer_cta = Static(self._viewer_cta_markup(), id="viewer_cta")
viewer_cta.ALLOW_SELECT = False
sidebar = Vertical(
viewer_cta, agents_tree, vulnerabilities_panel, stats_scroll, id="sidebar"
)
content_container.mount(chat_area_container)
content_container.mount(sidebar)
@@ -1805,6 +1814,7 @@ class StrixTUIApp(App): # type: ignore[misc]
async def action_custom_quit(self) -> None:
self._fire_sandbox_cleanup()
self._shutdown_viewer()
if self._scan_thread and self._scan_thread.is_alive():
self._scan_stop_event.set()
@@ -1813,6 +1823,70 @@ class StrixTUIApp(App): # type: ignore[misc]
self.exit()
def _viewer_cta_markup(self, url: str | None = None) -> str:
if url:
return f"[@click=app.open_viewer][#22c55e]● Viewer running[/][/]\n[dim]{url}[/]"
return "[@click=app.open_viewer]▶ Watch live in browser[/]"
def _set_viewer_cta(self, markup: str) -> None:
with contextlib.suppress(Exception):
self.query_one("#viewer_cta", Static).update(markup)
def action_open_viewer(self) -> None:
if self._viewer_url:
with contextlib.suppress(Exception):
webbrowser.open(self._viewer_url)
return
try:
from strix.viewer.server import authorized_url, bundle_is_built, serve
if not bundle_is_built():
self._set_viewer_cta("[#eab308]Viewer UI not built[/]")
return
run_dir = self.report_state.get_run_dir()
def _viewer_steer(agent_id: str, message: str) -> bool:
# Reuse the exact TUI delivery path, but target the agent the
# web graph selected (not the TUI's current selection).
return send_user_message_to_agent(
coordinator=self.coordinator,
loop=self._scan_loop,
live_view=self.live_view,
target_agent_id=agent_id,
message=message,
)
httpd, url, token = serve(run_dir, open_browser=True, steer_handler=_viewer_steer)
except Exception:
logger.debug("failed to start local viewer", exc_info=True)
self._set_viewer_cta("[red]Viewer failed to start[/]")
return
self._viewer_httpd = httpd
# Store the tokened URL so reopening the CTA re-authorizes the browser
# (this viewer carries a steer handler, so the session is required).
self._viewer_url = authorized_url(url, token)
self._set_viewer_cta(self._viewer_cta_markup(self._viewer_url))
with contextlib.suppress(Exception):
from strix.telemetry import posthog
live = self.report_state.run_record.get("status") not in {
"completed",
"stopped",
"failed",
"interrupted",
}
posthog.viewer_opened(source="tui", live=live)
def _shutdown_viewer(self) -> None:
httpd = self._viewer_httpd
if httpd is None:
return
self._viewer_httpd = None
with contextlib.suppress(Exception):
httpd.shutdown()
httpd.server_close()
def _fire_sandbox_cleanup(self) -> None:
self.coordinator.mark_shutting_down()
loop = self._scan_loop
+16 -4
View File
@@ -24,14 +24,26 @@ def load_session_history(run_dir: Path, agent_ids: Any) -> list[tuple[str, dict[
if not agents_db.exists() or not session_ids:
return []
session_id_set = set(session_ids)
# Open read-only: the scan process may be actively writing this WAL database
# from another process (the local viewer tails it live), and a reader must
# never lock or mutate it. mode=ro (not immutable=1) still reads the latest
# committed WAL state; WAL permits concurrent readers alongside the writer.
conn: sqlite3.Connection | None = None
try:
with sqlite3.connect(agents_db) as conn:
rows = conn.execute(
"select id, session_id, message_data, created_at from agent_messages order by id"
).fetchall()
conn = sqlite3.connect(
f"file:{agents_db}?mode=ro",
uri=True,
check_same_thread=False,
)
rows = conn.execute(
"select id, session_id, message_data, created_at from agent_messages order by id"
).fetchall()
except sqlite3.Error:
logger.exception("Failed to hydrate TUI history from %s", agents_db)
return []
finally:
if conn is not None:
conn.close()
items: list[tuple[str, dict[str, Any], str]] = []
for row_id, agent_id, message_data, created_at in rows:
+38
View File
@@ -142,6 +142,44 @@ def end(report_state: "ReportState", exit_reason: str = "completed") -> None:
)
def viewer_opened(source: str, live: bool) -> None:
_send(
"viewer_opened",
{
**base_props(),
"source": source,
"live": live,
},
)
def viewer_cta_clicked(cta: str, surface: str | None = None) -> None:
props = {
**base_props(),
"cta": cta[:64],
}
if surface:
props["surface"] = surface[:64]
_send("viewer_cta_clicked", props)
_VIEWER_EMAIL_STEPS = frozenset(
{"email_submitted", "email_verified", "report_sent", "work_email_required"}
)
def viewer_email_event(step: str, purpose: str | None = None) -> None:
if step not in _VIEWER_EMAIL_STEPS:
return
_send(
f"viewer_{step}",
{
**base_props(),
**({"purpose": purpose} if purpose else {}),
},
)
def error(error_type: str) -> None:
props = {**base_props(), "error_type": error_type}
_send("error", props)
+12
View File
@@ -0,0 +1,12 @@
"""Local web viewer for Strix runs.
Serves a prebuilt single-page app that renders a run (live or finished) read
directly from the run's on-disk files. No cloud dependency, no file picker.
"""
from __future__ import annotations
from strix.viewer.server import serve
__all__ = ["serve"]
+251
View File
@@ -0,0 +1,251 @@
"""Viewer email verification state and the relay client.
The local viewer proxies email verification and encrypted-report delivery to
the Strix relay (``STRIX_APP_URL``). The browser never talks to the relay
directly, and the report password generated locally is never sent to it.
State lives in ``~/.strix/viewer-auth.json`` (0600). ``is_verified`` is a local
flag that unlocks browsing the run history list; the relay still enforces token
expiry when a report is actually sent.
"""
from __future__ import annotations
import base64
import contextlib
import json
import logging
import urllib.error
import urllib.request
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from strix.config.loader import load_settings
logger = logging.getLogger(__name__)
AUTH_PATH = Path.home() / ".strix" / "viewer-auth.json"
_OTP_TIMEOUT = 15
_SEND_TIMEOUT = 30
class RelayError(Exception):
"""A relay call failed. ``code`` is a stable, machine-readable reason."""
def __init__(self, code: str, message: str | None = None) -> None:
self.code = code
super().__init__(message or code)
# --- local state ------------------------------------------------------------
def read_auth() -> dict[str, Any] | None:
"""Return the stored ``{email, token, verified_at}`` record, or None."""
try:
data = json.loads(AUTH_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(data, dict):
return None
email = data.get("email")
token = data.get("token")
if not isinstance(email, str) or not email or not isinstance(token, str) or not token:
return None
return data
def parse_expiry(raw: object) -> datetime | None:
"""Parse a relay ``expires_at`` value into an aware UTC datetime.
Accepts both ISO 8601 strings and epoch seconds (as a number or numeric
string) so a valid relay expiry is not misread as missing. Returns None only
when it is genuinely absent or unparseable; both the local gate (see
``is_verified``) and OTP verification (see ``otp_verify``) fail closed on such
values, matching the relay, which rejects a token with no valid expiry.
"""
if isinstance(raw, bool):
return None
if isinstance(raw, int | float):
return _from_epoch(raw)
if not isinstance(raw, str) or not raw:
return None
try:
return _from_epoch(float(raw))
except ValueError:
pass
try:
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
except ValueError:
return None
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
def _expiry(record: dict[str, Any]) -> datetime | None:
"""The stored ``verified_at`` parsed to a datetime, or None if unusable."""
return parse_expiry(record.get("verified_at"))
def _from_epoch(seconds: float) -> datetime | None:
"""Epoch seconds → aware UTC datetime, or None if out of range."""
try:
return datetime.fromtimestamp(seconds, tz=UTC)
except (OverflowError, OSError, ValueError):
return None
def is_verified() -> bool:
"""True when a usable email + token record with a valid future expiry exists.
The expiry returned by OTP verification is enforced here so history stops
unlocking once the token lapses. It fails closed: a record whose expiry is
absent, blank, or unparseable requires re-verification rather than unlocking
forever, keeping the local gate in step with the relay (which rejects an
expired token on report send).
"""
record = read_auth()
if record is None:
return False
expiry = _expiry(record)
return expiry is not None and expiry > datetime.now(UTC)
def write_auth(email: str, token: str, verified_at: str) -> None:
"""Atomically persist the auth record with 0600 permissions."""
AUTH_PATH.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps({"email": email, "token": token, "verified_at": verified_at})
tmp = AUTH_PATH.with_suffix(".json.tmp")
tmp.write_text(payload, encoding="utf-8")
with contextlib.suppress(OSError):
tmp.chmod(0o600)
tmp.replace(AUTH_PATH)
with contextlib.suppress(OSError):
AUTH_PATH.chmod(0o600)
def forget() -> None:
"""Delete the stored auth record. No-op if it is absent."""
with contextlib.suppress(OSError):
AUTH_PATH.unlink()
# --- relay client -----------------------------------------------------------
def _app_url() -> str:
return load_settings().viewer.app_url.rstrip("/")
def _post_json(path: str, payload: dict[str, Any], *, timeout: int) -> tuple[int, dict[str, Any]]:
"""POST JSON to the relay. Returns (status, parsed body).
Raises RelayError("unavailable") for network/transport failures. HTTP
error responses (4xx/5xx) are returned as (status, body) for the caller to
map, not raised.
"""
url = f"{_app_url()}{path}"
body = json.dumps(payload).encode("utf-8")
request = urllib.request.Request( # noqa: S310 - fixed https relay URL
url,
data=body,
headers={"Content-Type": "application/json", "Accept": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
return response.status, _parse_body(response.read())
except urllib.error.HTTPError as exc:
return exc.code, _parse_body(exc.read())
except (urllib.error.URLError, TimeoutError, OSError) as exc:
logger.warning("relay request to %s failed: %s", path, exc)
raise RelayError("unavailable") from exc
def _parse_body(raw: bytes) -> dict[str, Any]:
try:
data = json.loads(raw or b"{}")
except json.JSONDecodeError:
return {}
return data if isinstance(data, dict) else {}
def otp_start(email: str) -> None:
"""Ask the relay to email a verification code. Raises RelayError on failure."""
status, data = _post_json("/api/oss/otp/start", {"email": email}, timeout=_OTP_TIMEOUT)
if status == 200:
return
if status == 429:
raise RelayError("rate_limited")
if status == 400:
# The relay uses 400 both for a malformed address and, separately, to
# reject a free/personal email domain (it wants a work email).
if data.get("error") == "work_email_required":
raise RelayError("work_email_required")
raise RelayError("invalid_email")
raise RelayError("unavailable")
def otp_verify(email: str, code: str) -> dict[str, Any]:
"""Verify a code. Returns ``{token, email, expires_at}`` or raises RelayError."""
status, data = _post_json(
"/api/oss/otp/verify",
{"email": email, "code": code},
timeout=_OTP_TIMEOUT,
)
if status == 200 and isinstance(data.get("token"), str):
# A token with no usable expiry cannot unlock history locally (the gate
# fails closed), so treat such a response as a failed verification rather
# than reporting success and then leaving the user stuck unverified.
if parse_expiry(data.get("expires_at")) is None:
raise RelayError("unavailable")
return data
if status == 403:
raise RelayError("invalid_code")
raise RelayError("unavailable")
def report_send(
token: str,
pdf_bytes: bytes,
filename: str,
run_name: str,
target: str,
) -> None:
"""Forward the encrypted PDF to the relay for delivery.
The report password is NEVER part of this payload; only the encrypted PDF
bytes travel to the relay.
"""
payload = {
"token": token,
"pdf_base64": base64.b64encode(pdf_bytes).decode("ascii"),
"filename": filename,
"run_name": run_name,
"target": target,
}
status, _ = _post_json("/api/oss/report/send", payload, timeout=_SEND_TIMEOUT)
if status == 200:
return
if status == 401:
raise RelayError("reverify")
if status == 413:
raise RelayError("too_large")
if status == 403:
raise RelayError("forbidden")
raise RelayError("unavailable")
__all__ = [
"AUTH_PATH",
"RelayError",
"forget",
"is_verified",
"otp_start",
"otp_verify",
"read_auth",
"report_send",
"write_auth",
]
+139
View File
@@ -0,0 +1,139 @@
"""`strix view [<run>]` command: serve a run's viewer UI locally."""
from __future__ import annotations
import argparse
import logging
import time
from typing import TYPE_CHECKING
from rich.console import Console
from strix.core.paths import (
RUNS_DIR_NAME,
latest_run_dir,
run_dir_for,
run_record_path,
runs_base_dir,
)
from strix.viewer.server import authorized_url, bundle_is_built, serve
from strix.viewer.transcript import read_run_summary
if TYPE_CHECKING:
from pathlib import Path
from typing import NoReturn
logger = logging.getLogger(__name__)
def run_view(argv: list[str]) -> None:
parser = argparse.ArgumentParser(
prog="strix view",
description="Open a local web view of a Strix run (live or finished).",
)
parser.add_argument(
"run",
nargs="?",
default=None,
help=f"Run name under ./{RUNS_DIR_NAME} (defaults to the most recent run).",
)
parser.add_argument(
"--port",
type=int,
default=0,
help="Port to serve on (default: an available ephemeral port).",
)
parser.add_argument("--host", default="127.0.0.1", help=argparse.SUPPRESS)
parser.add_argument(
"--no-open",
action="store_true",
help="Do not open the browser automatically.",
)
args = parser.parse_args(argv)
console = Console()
if not bundle_is_built():
console.print(
"[bold red]Viewer UI is not built.[/]\n"
"Build it with: [cyan]cd strix/viewer_src && npm ci && npm run build[/]"
)
raise SystemExit(1)
run_dir = _resolve_run_dir(args.run, console)
httpd, url, token = serve(
run_dir,
host=args.host,
port=args.port,
open_browser=not args.no_open,
)
# The tokened URL is what authorizes the browser (steering, report sending,
# history). Print it rather than the bare URL so the operator -- and only
# the operator -- can open or share an authorized link.
open_url = authorized_url(url, token)
run_name = run_dir.name
summary = read_run_summary(run_dir)
live = not summary.get("finished", False)
from strix.telemetry import posthog
posthog.viewer_opened(source="cli", live=live)
state_label = "[#eab308]live[/]" if live else "[#22c55e]finished[/]"
console.print()
console.print(f"Serving [bold white]{run_name}[/] ({state_label}) at [#60a5fa]{open_url}[/]")
console.print("[dim]This link authorizes the browser; anyone you share it with can steer[/]")
console.print("[dim]a live scan and browse history. Press Ctrl-C to stop the viewer.[/]")
console.print()
try:
while True:
time.sleep(1.0)
except KeyboardInterrupt:
console.print("\n[dim]Viewer stopped.[/]")
finally:
httpd.shutdown()
httpd.server_close()
def _resolve_run_dir(run: str | None, console: Console) -> Path:
if run:
run_dir = run_dir_for(run)
if not run_record_path(run_dir).is_file():
_fail_no_run(console, requested=run)
return run_dir
latest = latest_run_dir()
if latest is None:
_fail_no_run(console, requested=None)
return latest
def _fail_no_run(console: Console, *, requested: str | None) -> NoReturn:
base = runs_base_dir()
available = (
sorted(
(child.name for child in base.iterdir() if run_record_path(child).is_file()),
reverse=True,
)
if base.is_dir()
else []
)
if requested:
console.print(f"[bold red]No run named '{requested}' under ./{RUNS_DIR_NAME}.[/]")
else:
console.print(f"[bold red]No runs found under ./{RUNS_DIR_NAME}.[/]")
if available:
console.print("Available runs:")
for name in available[:20]:
console.print(f" [cyan]{name}[/]")
raise SystemExit(1)
__all__ = ["run_view"]
+618
View File
@@ -0,0 +1,618 @@
"""Build and encrypt a branded PDF report for a run.
The layout mirrors the Strix cloud pentest report (cover page, executive
severity grid, per-finding detail with colored severity badges) but is rendered
entirely locally with reportlab, so it ships without a browser or heavy system
deps and keeps the report on the user's machine.
The PDF carries FULL finding detail, including proof-of-concept scripts, so it
is encrypted end to end with AES-256. The password is generated locally with a
CSPRNG, shown only to the local browser, and never leaves the machine except in
the user's own hands. Strix cannot read the delivered report.
"""
from __future__ import annotations
import html
import re
import secrets
from datetime import datetime
from io import BytesIO
from typing import TYPE_CHECKING, Any
from pypdf import PdfReader, PdfWriter
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.units import mm
from reportlab.pdfgen import canvas as pdfcanvas
from reportlab.platypus import (
Flowable,
KeepTogether,
PageBreak,
Paragraph,
SimpleDocTemplate,
Spacer,
Table,
TableStyle,
)
from strix.viewer.transcript import (
primary_target,
read_run_summary,
read_vulnerabilities,
severity_counts,
)
if TYPE_CHECKING:
from pathlib import Path
# Palette lifted from the cloud report theme (styles/base.ts, docx/theme.ts).
_INK = colors.HexColor("#000000")
_TEXT = colors.HexColor("#1a1a1a")
_MUTED = colors.HexColor("#666666")
_FAINT = colors.HexColor("#999999")
_BORDER = colors.HexColor("#e5e5e5")
_LIGHT_BG = colors.HexColor("#f7f7f7")
_SEVERITY_ORDER = ("critical", "high", "medium", "low")
_SEVERITY_COLORS = {
"critical": colors.HexColor("#dc2626"),
"high": colors.HexColor("#ea580c"),
"medium": colors.HexColor("#ca8a04"),
"low": colors.HexColor("#2563eb"),
}
# Helvetica stands in for Geist: a clean sans with no font file to ship.
_SANS = "Helvetica"
_SANS_BOLD = "Helvetica-Bold"
_MONO = "Courier"
_PAGE_W, _PAGE_H = A4
def _esc(value: Any) -> str:
"""Escape a value for reportlab's Paragraph markup."""
return html.escape(str(value)).replace("\n", "<br/>")
class _NumberedCanvas(pdfcanvas.Canvas): # type: ignore[misc] # reportlab base is untyped
"""Two-pass canvas that prints 'Page X of Y' on every page after the cover."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._saved_states: list[dict[str, Any]] = []
def showPage(self) -> None: # noqa: N802 - reportlab API
self._saved_states.append(dict(self.__dict__))
self._startPage()
def save(self) -> None:
total = len(self._saved_states)
for index, state in enumerate(self._saved_states):
self.__dict__.update(state)
if index > 0: # skip the cover page
self._draw_footer(index + 1, total)
super().showPage()
super().save()
def _draw_footer(self, page: int, total: int) -> None:
self.setFont(_SANS, 8)
self.setFillColor(_FAINT)
self.drawCentredString(_PAGE_W / 2, 14 * mm, f"Page {page} of {total}")
class _LogoMark(Flowable): # type: ignore[misc] # reportlab base is untyped
"""The rounded-square Strix mark drawn inline (no raster asset to ship)."""
def __init__(self, size: float = 30) -> None:
super().__init__()
self.size = size
self.width = size
self.height = size
def draw(self) -> None:
c = self.canv
s = self.size
c.setFillColor(_INK)
c.roundRect(0, 0, s, s, s * 0.28, fill=1, stroke=0)
c.setFillColor(colors.white)
c.setFont(_SANS_BOLD, s * 0.56)
c.drawCentredString(s / 2, s * 0.27, "S")
def _styles() -> dict[str, ParagraphStyle]:
styles: dict[str, ParagraphStyle] = {}
styles["wordmark"] = ParagraphStyle(
"Wordmark", fontName=_SANS_BOLD, fontSize=17, leading=20, textColor=_INK
)
styles["badge_label"] = ParagraphStyle(
"BadgeLabel", fontName=_SANS_BOLD, fontSize=9, leading=12, textColor=_MUTED
)
styles["cover_title"] = ParagraphStyle(
"CoverTitle", fontName=_SANS_BOLD, fontSize=34, leading=38, textColor=_INK
)
styles["cover_org"] = ParagraphStyle(
"CoverOrg", fontName=_SANS, fontSize=13, leading=18, textColor=_MUTED
)
styles["meta_label"] = ParagraphStyle(
"MetaLabel", fontName=_SANS_BOLD, fontSize=8, leading=12, textColor=_MUTED
)
styles["meta_value"] = ParagraphStyle(
"MetaValue", fontName=_SANS, fontSize=10.5, leading=14, textColor=_TEXT
)
styles["section"] = ParagraphStyle(
"Section", fontName=_SANS_BOLD, fontSize=18, leading=22, textColor=_INK, spaceAfter=6
)
styles["finding"] = ParagraphStyle(
"Finding", fontName=_SANS_BOLD, fontSize=13, leading=17, textColor=_INK, spaceBefore=6
)
styles["field_label"] = ParagraphStyle(
"FieldLabel", fontName=_SANS_BOLD, fontSize=8.5, leading=12, textColor=_MUTED,
spaceBefore=10, spaceAfter=2,
)
styles["body"] = ParagraphStyle(
"Body", fontName=_SANS, fontSize=10, leading=15, textColor=_TEXT, spaceAfter=8
)
styles["md_heading"] = ParagraphStyle(
"MdHeading", fontName=_SANS_BOLD, fontSize=11, leading=15, textColor=_INK,
spaceBefore=10, spaceAfter=4,
)
styles["bullet"] = ParagraphStyle(
"Bullet", fontName=_SANS, fontSize=10, leading=15, textColor=_TEXT,
leftIndent=16, firstLineIndent=-11, spaceAfter=3,
)
styles["meta_inline"] = ParagraphStyle(
"MetaInline", fontName=_SANS, fontSize=9, leading=13, textColor=_MUTED, spaceBefore=4
)
# spaceBefore/spaceAfter must exceed borderPadding: reportlab does not reserve
# a bordered paragraph's top padding, so too small a gap lets the background
# box bleed up over the field label above it.
styles["code"] = ParagraphStyle(
"Code", fontName=_MONO, fontSize=8, leading=11, textColor=_TEXT,
backColor=_LIGHT_BG, borderColor=_BORDER, borderWidth=0.5, borderPadding=8,
spaceBefore=12, spaceAfter=12,
)
styles["count"] = ParagraphStyle(
"Count", fontName=_SANS_BOLD, fontSize=30, leading=32, alignment=TA_CENTER
)
styles["count_label"] = ParagraphStyle(
"CountLabel", fontName=_SANS_BOLD, fontSize=8, leading=12, textColor=_MUTED,
alignment=TA_CENTER, spaceBefore=4,
)
styles["badge"] = ParagraphStyle(
"Badge", fontName=_SANS_BOLD, fontSize=9, leading=11, textColor=colors.white,
alignment=TA_CENTER,
)
styles["confidential"] = ParagraphStyle(
"Confidential", fontName=_SANS_BOLD, fontSize=9, leading=12, textColor=colors.white,
alignment=TA_CENTER,
)
return styles
def _parse_time(raw: Any) -> datetime | None:
if not isinstance(raw, str) or not raw:
return None
text = raw.strip().replace(" UTC", "Z").replace(" ", "T")
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
return datetime.fromisoformat(text)
except ValueError:
return None
def _fmt_time(raw: Any) -> str:
parsed = _parse_time(raw)
return parsed.strftime("%Y-%m-%d %H:%M UTC") if parsed else "n/a"
def _duration(start: Any, end: Any) -> str:
start_dt = _parse_time(start)
end_dt = _parse_time(end)
if not start_dt or not end_dt:
return "n/a"
seconds = int((end_dt - start_dt).total_seconds())
if seconds < 0:
return "n/a"
hours, remainder = divmod(seconds, 3600)
minutes, secs = divmod(remainder, 60)
if hours:
return f"{hours}h {minutes}m {secs}s"
if minutes:
return f"{minutes}m {secs}s"
return f"{secs}s"
def _severity_badge(styles: dict[str, ParagraphStyle], severity: str) -> Table:
"""A colored pill matching .severity-badge in the cloud report."""
color = _SEVERITY_COLORS.get(severity, _MUTED)
cell = Paragraph(severity.upper(), styles["badge"])
table = Table([[cell]], colWidths=[len(severity) * 6.5 + 20])
table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, -1), color),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
("LEFTPADDING", (0, 0), (-1, -1), 8),
("RIGHTPADDING", (0, 0), (-1, -1), 8),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
]
)
)
table.hAlign = "LEFT"
return table
def _severity_grid(styles: dict[str, ParagraphStyle], counts: dict[str, int]) -> Table:
"""The four-card severity grid from the executive summary."""
cells: list[list[Flowable]] = []
for name in _SEVERITY_ORDER:
color = _SEVERITY_COLORS[name]
count_style = ParagraphStyle(f"Count{name}", parent=styles["count"], textColor=color)
cells.append(
[Paragraph(str(counts.get(name, 0)), count_style),
Paragraph(name.upper(), styles["count_label"])]
)
col = (_PAGE_W - 40 * mm) / 4
table = Table([cells], colWidths=[col] * 4)
style = [
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 16),
("BOTTOMPADDING", (0, 0), (-1, -1), 16),
("GRID", (0, 0), (-1, -1), 0.5, _BORDER),
]
for index, name in enumerate(_SEVERITY_ORDER):
style.append(("LINEABOVE", (index, 0), (index, 0), 3, _SEVERITY_COLORS[name]))
table.setStyle(TableStyle(style))
return table
def _section(styles: dict[str, ParagraphStyle], title: str) -> Table:
"""Section title with the underline rule from h2.section-title."""
table = Table([[Paragraph(_esc(title), styles["section"])]], colWidths=[_PAGE_W - 40 * mm])
table.setStyle(
TableStyle(
[
("LINEBELOW", (0, 0), (-1, -1), 1, _BORDER),
("BOTTOMPADDING", (0, 0), (-1, -1), 10),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (0, 0), (-1, -1), 0),
("TOPPADDING", (0, 0), (-1, -1), 0),
]
)
)
return table
def _cover(
styles: dict[str, ParagraphStyle], record: dict[str, Any], run_name: str
) -> list[Flowable]:
header = Table(
[[_LogoMark(30), Paragraph("Strix", styles["wordmark"])]],
colWidths=[38, _PAGE_W - 40 * mm - 38],
)
header.setStyle(
TableStyle(
[
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (0, 0), (-1, -1), 0),
("TOPPADDING", (0, 0), (-1, -1), 0),
("BOTTOMPADDING", (0, 0), (-1, -1), 0),
]
)
)
target = primary_target(record) or "Target"
meta_rows = [
("TARGET", primary_target(record) or "unknown target"),
("RUN", run_name),
("SCAN MODE", str(record.get("scan_mode") or "n/a")),
("STATUS", str(record.get("status") or "n/a")),
("STARTED", _fmt_time(record.get("start_time"))),
("COMPLETED", _fmt_time(record.get("end_time"))),
("DURATION", _duration(record.get("start_time"), record.get("end_time"))),
]
meta_table = Table(
[[Paragraph(label, styles["meta_label"]), Paragraph(_esc(value), styles["meta_value"])]
for label, value in meta_rows],
colWidths=[38 * mm, _PAGE_W - 40 * mm - 38 * mm],
)
meta_table.setStyle(
TableStyle(
[
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LINEBELOW", (0, 0), (-1, -2), 0.5, _BORDER),
]
)
)
confidential = Table([[Paragraph("CONFIDENTIAL", styles["confidential"])]], colWidths=[120])
confidential.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, -1), _INK),
("TOPPADDING", (0, 0), (-1, -1), 8),
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
]
)
)
confidential.hAlign = "CENTER"
return [
header,
Spacer(1, 150),
Paragraph("PENETRATION TEST REPORT", styles["badge_label"]),
Spacer(1, 20),
Paragraph("Security Assessment", styles["cover_title"]),
Paragraph(_esc(target), styles["cover_org"]),
Spacer(1, 28),
meta_table,
Spacer(1, 90),
confidential,
PageBreak(),
]
def _inline_md(text: str) -> str:
"""Convert inline markdown (bold, italic, `code`) to reportlab markup.
Code spans are stashed as placeholders before bold/italic run, so bold that
wraps a code span (``**`x`**``) works and code contents are never mangled.
"""
codes: list[str] = []
def _stash(match: re.Match[str]) -> str:
codes.append(match.group(1))
return f"\x00{len(codes) - 1}\x00"
seg = html.escape(re.sub(r"`([^`]+)`", _stash, text))
seg = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", seg)
seg = re.sub(r"__(.+?)__", r"<b>\1</b>", seg)
seg = re.sub(r"\*(.+?)\*", r"<i>\1</i>", seg)
def _restore(match: re.Match[str]) -> str:
inner = html.escape(codes[int(match.group(1))])
return f'<font face="{_MONO}" color="#b31d28">{inner}</font>'
return re.sub(r"\x00(\d+)\x00", _restore, seg)
def _strip_leading_heading(md: str) -> str:
"""Drop a single leading markdown heading (each section adds its own title)."""
lines = md.lstrip("\n").split("\n")
if lines and re.match(r"^#{1,6}\s+", lines[0].strip()):
return "\n".join(lines[1:]).lstrip("\n")
return md
def _markdown_flowables( # noqa: PLR0915 - cohesive block parser, splitting hurts clarity
md: str, styles: dict[str, ParagraphStyle]
) -> list[Flowable]:
"""Render a markdown block (headings, lists, fenced code, prose) to flowables."""
flow: list[Flowable] = []
para: list[str] = []
bullets: list[tuple[str, str]] = []
def flush_para() -> None:
if para:
flow.append(Paragraph(_inline_md(" ".join(para)), styles["body"]))
para.clear()
def flush_bullets() -> None:
for marker, item in bullets:
flow.append(Paragraph(f"{marker}&nbsp;{_inline_md(item)}", styles["bullet"]))
bullets.clear()
lines = md.replace("\r\n", "\n").split("\n")
i = 0
while i < len(lines):
stripped = lines[i].strip()
if stripped.startswith("```"):
flush_para()
flush_bullets()
i += 1
code: list[str] = []
while i < len(lines) and not lines[i].strip().startswith("```"):
code.append(lines[i])
i += 1
i += 1 # closing fence
flow.append(Paragraph(_esc("\n".join(code)) or "&nbsp;", styles["code"]))
continue
if not stripped:
flush_para()
flush_bullets()
i += 1
continue
heading = re.match(r"^(#{1,6})\s+(.*)$", stripped)
if heading:
flush_para()
flush_bullets()
flow.append(Paragraph(_inline_md(heading.group(2)), styles["md_heading"]))
i += 1
continue
ordered = re.match(r"^(\d+)\.\s+(.*)$", stripped)
unordered = re.match(r"^[-*+]\s+(.*)$", stripped)
if ordered:
flush_para()
bullets.append((f"{ordered.group(1)}.", ordered.group(2)))
i += 1
continue
if unordered:
flush_para()
bullets.append(("", unordered.group(1)))
i += 1
continue
flush_bullets()
para.append(stripped)
i += 1
flush_para()
flush_bullets()
return flow
def _field_block(
styles: dict[str, ParagraphStyle], label: str, value: Any, *, code: bool = False
) -> list[Flowable]:
if value is None or (isinstance(value, str) and not value.strip()):
return []
flow: list[Flowable] = [Paragraph(label.upper(), styles["field_label"])]
if code:
flow.append(Paragraph(_esc(value), styles["code"]))
else:
flow.extend(_markdown_flowables(str(value), styles))
return flow
def _finding_flowables(
styles: dict[str, ParagraphStyle], index: int, vuln: dict[str, Any]
) -> list[Flowable]:
title = vuln.get("title") or "Untitled finding"
severity = str(vuln.get("severity") or "").lower().strip() or "low"
meta_bits = []
if vuln.get("cvss") is not None:
meta_bits.append(f"<b>CVSS</b> {_esc(vuln.get('cvss'))}")
meta_bits.extend(
f"<b>{key.title()}</b> {_esc(vuln.get(key))}"
for key in ("target", "endpoint", "method")
if vuln.get(key)
)
header: list[Flowable] = [
Paragraph(f"{index}. {_esc(title)}", styles["finding"]),
Spacer(1, 4),
_severity_badge(styles, severity),
]
if meta_bits:
header.append(Paragraph("&nbsp;&nbsp;".join(meta_bits), styles["meta_inline"]))
story: list[Flowable] = [KeepTogether(header)]
story.extend(_field_block(styles, "Description", vuln.get("description")))
story.extend(_field_block(styles, "Impact", vuln.get("impact")))
story.extend(_field_block(styles, "Technical analysis", vuln.get("technical_analysis")))
story.extend(_field_block(styles, "Proof of concept", vuln.get("poc_description")))
story.extend(_field_block(styles, "PoC script", vuln.get("poc_script_code"), code=True))
story.extend(_field_block(styles, "Evidence", vuln.get("evidence"), code=True))
remediation = vuln.get("remediation_steps")
if isinstance(remediation, list):
remediation = "\n".join(str(step) for step in remediation)
story.extend(_field_block(styles, "Remediation", remediation))
story.append(Spacer(1, 22))
return story
def _overview_flowables(
styles: dict[str, ParagraphStyle], record: dict[str, Any], total: int, counts: dict[str, int]
) -> list[Flowable]:
story: list[Flowable] = [
_section(styles, "Executive Summary"),
Spacer(1, 16),
_severity_grid(styles, counts),
Spacer(1, 10),
Paragraph(f"<b>{total}</b> total findings across this assessment.", styles["body"]),
]
scan_results = record.get("scan_results")
if not isinstance(scan_results, dict):
return story
summary = scan_results.get("executive_summary")
if isinstance(summary, str) and summary.strip():
story.append(Spacer(1, 16))
story.extend(_markdown_flowables(_strip_leading_heading(summary), styles))
for label, key in (
("Methodology", "methodology"),
("Technical Analysis", "technical_analysis"),
("Recommendations", "recommendations"),
):
value = scan_results.get(key)
if isinstance(value, str) and value.strip():
story.append(Spacer(1, 20))
story.append(_section(styles, label))
story.append(Spacer(1, 12))
story.extend(_markdown_flowables(_strip_leading_heading(value), styles))
return story
def generate_report_pdf(run_dir: Path) -> bytes:
"""Render a branded, full-detail PDF report for the run at ``run_dir``."""
record = read_run_summary(run_dir)
vulns = [v for v in read_vulnerabilities(run_dir) if isinstance(v, dict)]
counts = severity_counts(vulns)
run_name = str(record.get("run_name") or run_dir.name)
styles = _styles()
buffer = BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=A4,
title="Strix Security Report",
author="Strix",
leftMargin=20 * mm,
rightMargin=20 * mm,
topMargin=22 * mm,
bottomMargin=24 * mm,
)
story: list[Flowable] = []
story.extend(_cover(styles, record, run_name))
story.extend(_overview_flowables(styles, record, len(vulns), counts))
story.append(PageBreak())
story.append(_section(styles, "Findings"))
story.append(Spacer(1, 16))
if vulns:
for index, vuln in enumerate(vulns, start=1):
story.extend(_finding_flowables(styles, index, vuln))
else:
story.append(Paragraph("No findings were recorded for this run.", styles["body"]))
doc.build(story, canvasmaker=_NumberedCanvas)
return buffer.getvalue()
def generate_password() -> str:
"""Return a >=20 character URL-safe password from a CSPRNG."""
return secrets.token_urlsafe(16)
def encrypt_pdf(pdf_bytes: bytes, password: str) -> bytes:
"""Encrypt a PDF with AES-256 using ``password`` as the user password."""
reader = PdfReader(BytesIO(pdf_bytes))
writer = PdfWriter()
writer.append(reader)
writer.encrypt(user_password=password, algorithm="AES-256")
out = BytesIO()
writer.write(out)
return out.getvalue()
def build_encrypted_report(run_dir: Path) -> tuple[bytes, str, str]:
"""Build, encrypt, and name the report. Returns (pdf_bytes, password, filename)."""
record = read_run_summary(run_dir)
run_name = str(record.get("run_name") or run_dir.name)
pdf_bytes = generate_report_pdf(run_dir)
password = generate_password()
encrypted = encrypt_pdf(pdf_bytes, password)
filename = f"strix-report-{run_name}.pdf"
return encrypted, password, filename
__all__ = [
"build_encrypted_report",
"encrypt_pdf",
"generate_password",
"generate_report_pdf",
]
+537
View File
@@ -0,0 +1,537 @@
"""Local HTTP server that serves the viewer SPA and a run's data from disk.
Design notes:
- Uses only the standard library (no new runtime dependency). The workload is
serving static files plus a handful of JSON reads off disk, so an async stack
buys nothing here.
- The browser polls the JSON endpoints (~1s) rather than using SSE: a finished
run stops polling, and short-lived polls survive sleep/network blips without
server-side connection state, which suits a stdlib ThreadingHTTPServer.
- All reads happen per-request straight from disk, so the same server serves a
live in-progress run and a finished one identically; the SPA distinguishes
them via the ``finished`` flag on /api/run.
"""
from __future__ import annotations
import json
import logging
import mimetypes
import secrets
import threading
import webbrowser
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import TYPE_CHECKING, Any
from urllib.parse import parse_qs, unquote, urlencode, urlsplit
from strix.core.paths import run_record_path
from strix.viewer import auth
from strix.viewer.transcript import (
build_run_state,
primary_target,
read_report_markdown,
read_run_summary,
read_vulnerabilities,
severity_counts,
)
if TYPE_CHECKING:
from collections.abc import Callable
logger = logging.getLogger(__name__)
def bundle_dir() -> Path:
"""Directory holding the committed, prebuilt SPA (index.html + assets)."""
return Path(__file__).resolve().parent / "viewer_dist"
def bundle_is_built() -> bool:
return (bundle_dir() / "index.html").is_file()
def _iter_run_dirs(base_dir: Path) -> list[Path]:
"""Every run directory under ``base_dir``, newest first by record mtime."""
if not base_dir.is_dir():
return []
run_dirs = [child for child in base_dir.iterdir() if run_record_path(child).is_file()]
run_dirs.sort(key=lambda child: run_record_path(child).stat().st_mtime, reverse=True)
return run_dirs
def run_list_entry(run_dir: Path) -> dict[str, Any]:
"""Compact summary of a single run for the history list."""
record = read_run_summary(run_dir)
return {
"name": record.get("run_name") or run_dir.name,
"target": primary_target(record),
"scan_mode": record.get("scan_mode"),
"status": record.get("status"),
"start_time": record.get("start_time"),
"end_time": record.get("end_time"),
"finished": bool(record.get("finished")),
"severity_counts": severity_counts(read_vulnerabilities(run_dir)),
}
def build_runs_payload(base_dir: Path, *, verified: bool) -> dict[str, Any]:
"""The /api/runs payload. Gates the run list behind email verification.
The count is always advertised so the UI can tease the history, but the
entries only appear once the viewer is verified.
"""
run_dirs = _iter_run_dirs(base_dir)
count = len(run_dirs)
if not verified:
return {"locked": True, "count": count, "runs": []}
return {"locked": False, "count": count, "runs": [run_list_entry(d) for d in run_dirs]}
def resolve_run_dir(base_dir: Path, run_param: str | None, default_run_dir: Path) -> Path | None:
"""Resolve a ``?run=`` value to a real run directory under ``base_dir``.
Returns ``default_run_dir`` when no run is requested. Rejects traversal and
unknown runs (returns None) so the caller can answer 404.
"""
if not run_param:
return default_run_dir
base = base_dir.resolve()
candidate = (base / run_param).resolve()
# Only direct children of the runs base that actually hold a run record.
if candidate.parent != base or not run_record_path(candidate).is_file():
return None
return candidate
# Name of the cookie carrying the per-process session capability.
SESSION_COOKIE = "strix_viewer_session"
class _ViewerState:
def __init__(
self,
run_dir: Path,
assets_dir: Path,
steer_handler: Callable[[str, str], bool] | None = None,
) -> None:
self.run_dir = run_dir
self.assets_dir = assets_dir
# The strix_runs directory that holds the launched run; used to
# enumerate and resolve other runs for the history list.
self.base_dir = run_dir.parent
# Set only when the viewer runs inside a live scan process (the TUI
# launcher), which can deliver a message to a running agent. Absent for
# standalone ``strix view`` / finished runs, so steering is unavailable.
self.steer_handler = steer_handler
# Unguessable per-process capability. It is minted here, printed/opened
# for the operator who started the server (see ``authorized_url``), and
# exchanged for a session cookie only when presented on the initial page
# load. It is the request-level authorization the review asked for:
# reachability of the port (e.g. when bound with ``--host``) is not
# enough to steer a live scan, trigger a report, or browse history --
# the token is never handed to a caller who merely reaches ``/``.
self.session_token = secrets.token_urlsafe(32)
def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
class ViewerHandler(BaseHTTPRequestHandler):
server_version = "StrixViewer/1.0"
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
logger.debug("viewer %s - %s", self.address_string(), format % args)
def do_GET(self) -> None:
parts = urlsplit(self.path)
path = parts.path
try:
if path.startswith("/api/"):
self._handle_api(path, parse_qs(parts.query))
else:
self._handle_static(path, parse_qs(parts.query))
except BrokenPipeError:
# The browser closed the connection mid-response (e.g. it
# navigated away between polls). Not an error.
logger.debug("viewer client disconnected during %s", path)
except Exception:
# A bad request must never kill the worker thread.
logger.exception("viewer request failed: %s", path)
self._send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "internal error"})
def do_POST(self) -> None:
path = urlsplit(self.path).path
try:
if path == "/api/event":
self._handle_event()
elif path == "/api/auth/otp/start":
self._handle_otp_start()
elif path == "/api/auth/otp/verify":
self._handle_otp_verify()
elif path == "/api/auth/forget":
self._handle_forget()
elif path == "/api/report/send":
self._handle_report_send()
elif path == "/api/agents/steer":
self._handle_steer()
else:
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown endpoint"})
except BrokenPipeError:
logger.debug("viewer client disconnected during POST %s", path)
except Exception:
# A bad request must never kill the worker thread.
logger.exception("viewer request failed: POST %s", path)
self._send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "internal error"})
def _read_body(self) -> dict[str, Any]:
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length) if length else b""
try:
body = json.loads(raw or b"{}")
except json.JSONDecodeError:
return {}
return body if isinstance(body, dict) else {}
# Funnel events the viewer is allowed to forward. This handler is the
# trust boundary: only these event names, with only their known props,
# ever reach PostHog. Everything else (including any PII) is dropped.
_EMAIL_EVENTS = frozenset(
{"email_submitted", "email_verified", "report_sent", "work_email_required"}
)
def _handle_event(self) -> None:
body = self._read_body()
# Forwarded as anonymous PostHog events that respect the global
# telemetry opt-out. Never forward the email, code, or report body:
# only the whitelisted event names and their known props are passed.
event = body.get("event")
if event == "cta_clicked":
from strix.telemetry import posthog
cta = str(body.get("cta") or "unknown")
surface = body.get("surface")
posthog.viewer_cta_clicked(cta, surface=str(surface) if surface else None)
elif event in self._EMAIL_EVENTS:
from strix.telemetry import posthog
purpose = body.get("purpose")
posthog.viewer_email_event(str(event), purpose=str(purpose) if purpose else None)
self.send_response(HTTPStatus.NO_CONTENT)
self.end_headers()
def _handle_api(self, path: str, query: dict[str, list[str]]) -> None:
# The launched run is always viewable with no verification. Only the
# cross-run history list (/api/runs) is gated.
if path == "/api/runs":
payload = build_runs_payload(state.base_dir, verified=auth.is_verified())
self._send_json(HTTPStatus.OK, payload)
return
if path == "/api/capabilities":
# Steering is only possible when the viewer shares a live scan's
# coordinator + event loop (the TUI launcher wires a handler).
self._send_json(HTTPStatus.OK, {"can_steer": state.steer_handler is not None})
return
if path == "/api/auth/status":
# Report verification through is_verified() so an expired record
# is advertised as unverified -- otherwise the SPA would suppress
# re-verification while history stays locked, stranding the user.
record = auth.read_auth()
self._send_json(
HTTPStatus.OK,
{
"verified": auth.is_verified(),
"email": record.get("email") if record else None,
},
)
return
run_values = query.get("run")
run_param = run_values[0] if run_values else None
run_dir = resolve_run_dir(state.base_dir, run_param, state.run_dir)
if run_dir is None:
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown run"})
return
# The launched run is always viewable. Any *other* run's data is part
# of the gated history: it needs this process's session capability
# (so merely reaching an exposed --host port is not enough) *and*
# email verification -- otherwise knowing a run name would leak its
# metadata, vulnerabilities, report, and transcript.
if run_dir.resolve() != state.run_dir.resolve():
if not self._has_session():
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
return
if not auth.is_verified():
self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "unverified"})
return
if path == "/api/run":
self._send_json(HTTPStatus.OK, read_run_summary(run_dir))
elif path == "/api/vulnerabilities":
self._send_json(HTTPStatus.OK, read_vulnerabilities(run_dir))
elif path == "/api/report":
self._send_json(HTTPStatus.OK, {"markdown": read_report_markdown(run_dir)})
elif path == "/api/transcript":
self._send_json(HTTPStatus.OK, build_run_state(run_dir))
else:
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown endpoint"})
def _handle_otp_start(self) -> None:
email = str(self._read_body().get("email") or "").strip()
if not email:
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_email"})
return
try:
auth.otp_start(email)
except auth.RelayError as exc:
self._send_relay_error(exc)
return
self._send_json(HTTPStatus.OK, {"ok": True})
def _handle_otp_verify(self) -> None:
body = self._read_body()
email = str(body.get("email") or "").strip()
code = str(body.get("code") or "").strip()
if not email or not code:
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_code"})
return
try:
result = auth.otp_verify(email, code)
except auth.RelayError as exc:
self._send_relay_error(exc)
return
auth.write_auth(
email=result.get("email") or email,
token=result["token"],
verified_at=result.get("expires_at") or "",
)
verified_email = result.get("email") or email
self._send_json(HTTPStatus.OK, {"verified": True, "email": verified_email})
def _handle_forget(self) -> None:
auth.forget()
self._send_json(HTTPStatus.OK, {"ok": True})
def _handle_report_send(self) -> None:
if not self._has_session():
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
return
record = auth.read_auth()
if record is None:
self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "unverified"})
return
run_param = str(self._read_body().get("run") or "") or None
run_dir = resolve_run_dir(state.base_dir, run_param, state.run_dir)
if run_dir is None:
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown run"})
return
from strix.viewer.report_pdf import build_encrypted_report
pdf_bytes, password, filename = build_encrypted_report(run_dir)
summary = read_run_summary(run_dir)
run_name = str(summary.get("run_name") or run_dir.name)
target = primary_target(summary) or "unknown target"
try:
# The password is intentionally NOT passed here; only the
# encrypted PDF bytes reach the relay.
auth.report_send(record["token"], pdf_bytes, filename, run_name, target)
except auth.RelayError as exc:
self._send_relay_error(exc)
return
# The password is returned only to the local (127.0.0.1) browser.
self._send_json(
HTTPStatus.OK,
{"ok": True, "password": password, "filename": filename},
)
# Cap on a steering message so a runaway client cannot flood the agent.
_STEER_MESSAGE_MAX = 4000
def _handle_steer(self) -> None:
if not self._has_session():
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
return
body = self._read_body()
agent_id = body.get("agent_id")
message = body.get("message")
if not isinstance(agent_id, str) or not agent_id.strip():
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_agent_id"})
return
if (
not isinstance(message, str)
or not message.strip()
or len(message) > self._STEER_MESSAGE_MAX
):
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_message"})
return
if state.steer_handler is None:
# Standalone / finished-run viewing has no live scan to steer.
self._send_json(HTTPStatus.FORBIDDEN, {"error": "steering_unavailable"})
return
delivered = state.steer_handler(agent_id, message)
if delivered:
self._send_json(HTTPStatus.OK, {"ok": True})
else:
self._send_json(HTTPStatus.OK, {"ok": False, "error": "not_delivered"})
def _send_relay_error(self, exc: auth.RelayError) -> None:
status_by_code = {
"rate_limited": HTTPStatus.TOO_MANY_REQUESTS,
"invalid_email": HTTPStatus.BAD_REQUEST,
"work_email_required": HTTPStatus.BAD_REQUEST,
"invalid_code": HTTPStatus.FORBIDDEN,
"reverify": HTTPStatus.UNAUTHORIZED,
"forbidden": HTTPStatus.FORBIDDEN,
"too_large": HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
"unavailable": HTTPStatus.BAD_GATEWAY,
}
status = status_by_code.get(exc.code, HTTPStatus.BAD_GATEWAY)
self._send_json(status, {"error": exc.code})
def _cookies(self) -> dict[str, str]:
jar: dict[str, str] = {}
for chunk in (self.headers.get("Cookie") or "").split(";"):
name, sep, value = chunk.strip().partition("=")
if sep:
jar[name] = value
return jar
def _has_session(self) -> bool:
"""True when the request carries this process's session capability.
The cookie is set only when the SPA is served (index.html), so only
the browser this process handed the page to can pass. A direct
caller on an exposed port has no cookie and is rejected.
"""
supplied = self._cookies().get(SESSION_COOKIE, "")
return bool(supplied) and secrets.compare_digest(supplied, state.session_token)
def _token_presented(self, query: dict[str, list[str]]) -> bool:
"""True when the request carries the correct bootstrap token.
The token reaches the operator's browser through the URL printed /
opened by the process that started the server, a channel an
arbitrary network caller on an exposed port cannot observe.
"""
supplied = (query.get("token") or [""])[0]
return bool(supplied) and secrets.compare_digest(supplied, state.session_token)
def _handle_static(self, path: str, query: dict[str, list[str]]) -> None:
target = self._resolve_asset(path)
if target is None:
# SPA fallback: unknown non-asset routes render index.html so
# client-side deep links work.
target = state.assets_dir / "index.html"
is_index = target.name == "index.html"
if not target.is_file():
self._send_json(HTTPStatus.NOT_FOUND, {"error": "not found"})
return
content = target.read_bytes()
content_type, _ = mimetypes.guess_type(str(target))
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", content_type or "application/octet-stream")
self.send_header("Content-Length", str(len(content)))
if is_index and self._token_presented(query):
# Exchange the bootstrap token for the per-process session
# capability. Issued only when the correct token is presented,
# so a caller who merely reaches ``/`` never obtains it.
# HttpOnly (JS never needs it; fetch sends it automatically) and
# SameSite=Strict (never sent from a cross-site context).
self.send_header(
"Set-Cookie",
f"{SESSION_COOKIE}={state.session_token}; Path=/; HttpOnly; SameSite=Strict",
)
self.end_headers()
self.wfile.write(content)
def _resolve_asset(self, path: str) -> Path | None:
rel = unquote(path).lstrip("/")
if not rel or rel.endswith("/"):
return None
root = state.assets_dir.resolve()
candidate = (root / rel).resolve()
# Path-traversal guard: never serve outside the bundle root.
if root != candidate and root not in candidate.parents:
logger.warning("viewer rejected traversal attempt: %s", path)
return None
return candidate if candidate.is_file() else None
def _send_json(self, status: HTTPStatus, payload: Any) -> None:
body = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return ViewerHandler
def authorized_url(base_url: str, token: str) -> str:
"""URL that bootstraps the viewer session for the operator.
Presenting ``token`` on the initial page load is what mints the session
cookie, so this URL is printed / opened only for the operator who started
the server. Sharing it (rather than the bare ``base_url``) is what lets a
trusted remote user authorize when the viewer is exposed with ``--host``.
"""
return f"{base_url}/?{urlencode({'token': token})}"
def serve(
run_dir: Path,
*,
host: str = "127.0.0.1",
port: int = 0,
open_browser: bool = True,
steer_handler: Callable[[str, str], bool] | None = None,
) -> tuple[ThreadingHTTPServer, str, str]:
"""Start the viewer server on a background thread; return (server, url, token).
``url`` is the bare base; pass it through ``authorized_url(url, token)`` to
build the operator link that authorizes the browser.
Binds an ephemeral port by default. If a fixed ``port`` is requested but in
use, falls back to an ephemeral port. Reused by both the ``strix view``
command and the in-TUI launcher; callers own the server's lifetime.
``steer_handler`` is supplied only by the in-TUI launcher, which runs inside
the live scan process and can forward a message to a running agent. Left
``None`` (standalone ``strix view``), steering is reported unavailable.
"""
assets_dir = bundle_dir()
state = _ViewerState(run_dir=run_dir, assets_dir=assets_dir, steer_handler=steer_handler)
handler = _make_handler(state)
try:
httpd = ThreadingHTTPServer((host, port), handler)
except OSError:
if port == 0:
raise
logger.info("viewer port %s unavailable, falling back to an ephemeral port", port)
httpd = ThreadingHTTPServer((host, 0), handler)
httpd.daemon_threads = True
bound_port = int(httpd.server_address[1])
url = f"http://{host}:{bound_port}"
thread = threading.Thread(target=httpd.serve_forever, name="strix-viewer", daemon=True)
thread.start()
if open_browser:
_open_browser(authorized_url(url, state.session_token))
return httpd, url, state.session_token
def _open_browser(url: str) -> None:
try:
webbrowser.open(url)
except Exception: # noqa: BLE001 - launching the browser is best-effort
logger.debug("could not open browser for %s", url, exc_info=True)
__all__ = ["authorized_url", "bundle_dir", "bundle_is_built", "serve"]
+106
View File
@@ -0,0 +1,106 @@
"""Build the JSON payloads the viewer SPA consumes from a run directory."""
from __future__ import annotations
import json
import logging
from typing import TYPE_CHECKING, Any
from strix.core.paths import run_record_path
if TYPE_CHECKING:
from pathlib import Path
logger = logging.getLogger(__name__)
_TERMINAL_STATUSES = {"completed", "stopped", "failed", "interrupted"}
_KNOWN_SEVERITIES = ("critical", "high", "medium", "low")
def severity_counts(vulns: list[Any]) -> dict[str, int]:
"""Bucket vulnerabilities into critical/high/medium/low counts.
Mirrors the SPA's ``severityCounts``: severities are lowercased and
trimmed, and anything outside the four known buckets (``info``,
``informational``, ``unknown``, missing, ...) folds into ``low`` so the
shared UI renders cleanly.
"""
counts = dict.fromkeys(_KNOWN_SEVERITIES, 0)
for vuln in vulns:
raw = vuln.get("severity") if isinstance(vuln, dict) else None
severity = str(raw or "").lower().strip()
if severity not in counts:
severity = "low"
counts[severity] += 1
return counts
def build_run_state(run_dir: Path) -> dict[str, Any]:
"""Agent graph + full per-agent event/message stream.
Reuses the Textual-free ``TuiLiveView`` projection so the viewer and the TUI
share one parser for ``agents.json`` + ``agents.db`` and never drift.
"""
# Imported lazily so importing strix.viewer does not eagerly pull the TUI.
from strix.interface.tui.live_view import TuiLiveView # noqa: PLC0415
view = TuiLiveView()
view.hydrate_from_run_dir(run_dir)
return {"agents": list(view.agents.values()), "events": view.events}
def read_run_summary(run_dir: Path) -> dict[str, Any]:
"""The ``run.json`` record plus a computed ``finished`` flag."""
record = _load_json(run_record_path(run_dir), default={})
if not isinstance(record, dict):
record = {}
status = record.get("status")
finished = status in _TERMINAL_STATUSES and bool(record.get("end_time"))
return {**record, "finished": finished}
def primary_target(record: dict[str, Any]) -> str | None:
"""The first target's original string from a run record, or None."""
targets = record.get("targets_info")
if isinstance(targets, list):
for entry in targets:
if isinstance(entry, dict):
original = entry.get("original")
if isinstance(original, str) and original:
return original
return None
def read_vulnerabilities(run_dir: Path) -> list[Any]:
"""The ``vulnerabilities.json`` list (empty until a scan writes it)."""
data = _load_json(run_dir / "vulnerabilities.json", default=[])
return data if isinstance(data, list) else []
def read_report_markdown(run_dir: Path) -> str:
"""The executive report markdown (empty until a scan writes it)."""
report_path = run_dir / "penetration_test_report.md"
try:
return report_path.read_text(encoding="utf-8")
except OSError:
return ""
def _load_json(path: Path, *, default: Any) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return default
__all__ = [
"build_run_state",
"primary_target",
"read_report_markdown",
"read_run_summary",
"read_vulnerabilities",
"severity_counts",
]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="./logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>Strix Results</title>
<script type="module" crossorigin src="./assets/index-C6BkTz7a.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-C0NveaV7.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="./logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>Strix Results</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4272
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "strix-viewer",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@dagrejs/dagre": "^2.0.4",
"@xyflow/react": "^12.10.1",
"clsx": "^2.1.1",
"diff": "^8.0.3",
"highlight.js": "^11.11.1",
"lucide-react": "^0.563.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.0",
"@types/diff": "^7.0.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"tailwindcss": "^4.1.0",
"typescript": "^5.6.0",
"vite": "^6.0.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

+805
View File
@@ -0,0 +1,805 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ArrowLeft,
AlertCircle,
Waypoints,
Mail,
ChevronDown,
Wrench,
FileCheck2,
CalendarClock,
Radar,
GitPullRequest,
Rocket,
ArrowUpRight,
History,
} from "lucide-react";
import type { Vulnerability, VulnerabilitySeverity } from "@/types/issues";
import { SEVERITY_COLORS } from "@/types/issues";
import { getSeverityDot } from "@/lib/vulnerability-utils";
import VulnerabilityDetail from "@/components/vulnerability/VulnerabilityDetail";
import { ContentSection } from "@/components/vulnerability/ContentSection";
import { IssueSeveritySummary } from "@/components/IssueSeveritySummary";
import AgentGraph from "@/components/live/AgentGraph";
import { buildGraphAgents } from "@/components/live/AgentTranscript";
import AgentDetailModal from "@/components/live/AgentDetailModal";
import { ScanPromptComposer } from "@/components/live/ScanPromptComposer";
import { severityCounts, type ParsedRunSummary } from "@/lib/local-run-parser";
import {
fetchAll,
fetchAuthStatus,
fetchCapabilities,
fetchRunSummary,
fetchRuns,
fetchTranscript,
fetchVulnerabilities,
forgetAuth,
type AuthStatus,
type LoadedRun,
type RunsPayload,
} from "@/data/serverSource";
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
import { runTitle } from "@/lib/target-utils";
import Sidebar from "@/components/Sidebar";
import PastRunsView from "@/components/PastRunsView";
import EmailReportView from "@/components/EmailReportView";
import { RunDetails } from "@/components/RunDetails";
import { TrustToast } from "@/components/TrustToast";
import FeatureDetail from "@/components/FeatureDetail";
import { ProTile, ProInlineCta, type ProItem } from "@/components/ProCta";
import { FEATURES } from "@/lib/pro-features";
export type View = "overview" | "issues" | "agents" | "history" | "feature" | "email";
const TRUST_BANNER =
"Your findings stay on your machine. They're rendered here locally in your browser and never uploaded or stored by Strix. Emailing a report is an explicit opt-in that sends an encrypted copy only you can open.";
const SEVERITY_ORDER: VulnerabilitySeverity[] = ["critical", "high", "medium", "low"];
const POLL_MS = 500;
// Curated inline CTAs. Continuous-coverage row on Overview (the restyled upsell
// tiles), plus the recommendations pairing.
const RECOMMENDATION_CTAS: ProItem[] = [
{ title: "One-click autofix + open a fix PR", desc: "Fix it for you and open a PR, retested.", slug: "autofix", icon: Wrench },
{ title: "Export SOC 2 / ISO 27001 report", desc: "Share an auditor-ready report with your team.", slug: "compliance", icon: FileCheck2 },
];
const COVERAGE_CTAS: ProItem[] = [
{ title: "Scheduled pentesting", desc: "Continuous coverage for your whole org.", slug: "scheduled", icon: CalendarClock },
{ title: "Attack surface monitoring", desc: "Continuous coverage for your whole org.", slug: "asm", icon: Radar },
{ title: "PR reviews", desc: "Pentest every pull request your team opens.", slug: "pr_reviews", icon: GitPullRequest },
];
export default function App() {
const [activeRun, setActiveRun] = useState<string | null>(null);
const [run, setRun] = useState<LoadedRun | null>(null);
const [error, setError] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [view, setView] = useState<View>("overview");
const [activeFeature, setActiveFeature] = useState<string | null>(null);
const [auth, setAuth] = useState<AuthStatus | null>(null);
const [runs, setRuns] = useState<RunsPayload | null>(null);
const [emailPurpose, setEmailPurpose] = useState<"report" | "verify">("report");
const [emailSkipDisclosure, setEmailSkipDisclosure] = useState(false);
// Whether this viewer can steer a live scan (true only inside the in-TUI
// launcher that shares the running scan's coordinator + event loop).
const [canSteer, setCanSteer] = useState(false);
const refreshAuth = useCallback(async () => {
try {
setAuth(await fetchAuthStatus());
} catch {
/* auth status is best-effort; the launched run stays viewable */
}
}, []);
const refreshRuns = useCallback(async () => {
try {
setRuns(await fetchRuns());
} catch {
/* history list is best-effort */
}
}, []);
useEffect(() => {
void refreshAuth();
void refreshRuns();
// Capabilities never change over a session, so fetch once on mount.
fetchCapabilities()
.then((caps) => setCanSteer(caps.can_steer))
.catch(() => {
/* absence of steering is the safe default */
});
}, [refreshAuth, refreshRuns]);
// Live polling, scoped to the active run. Re-runs when the active run changes
// so switching to a past run (?run=<name>) reloads its data; a finished run
// does a single full fetch and stops.
const finishedRef = useRef(false);
useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
finishedRef.current = false;
const schedule = () => {
timer = setTimeout(tick, POLL_MS);
};
const tick = async () => {
if (cancelled) return;
try {
const { summary, raw, finished } = await fetchRunSummary(activeRun);
if (cancelled) return;
if (finished && !finishedRef.current) {
finishedRef.current = true;
const full = await fetchAll(activeRun);
if (!cancelled) setRun(full);
return; // stop polling
}
const [transcript, vulnerabilities] = await Promise.all([
fetchTranscript(activeRun).catch(() => ({ agents: [], events: [] })),
fetchVulnerabilities(summary.runId, activeRun).catch(() => [] as Vulnerability[]),
]);
if (cancelled) return;
setRun((prev) => ({
summary,
raw,
finished,
transcript,
vulnerabilities,
reportMarkdown: prev?.reportMarkdown ?? null,
}));
schedule();
} catch (e) {
if (cancelled) return;
setError(e instanceof Error ? e.message : "Could not load run data.");
schedule();
}
};
(async () => {
try {
const full = await fetchAll(activeRun);
if (cancelled) return;
setRun(full);
if (full.finished) {
finishedRef.current = true;
} else {
schedule();
}
} catch (e) {
if (cancelled) return;
setError(e instanceof Error ? e.message : "Could not load run data.");
schedule();
}
})();
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, [activeRun]);
const counts = useMemo(
() => (run ? severityCounts(run.vulnerabilities) : null),
[run]
);
const selected = run?.vulnerabilities.find((v) => v.id === selectedId) ?? null;
const agentCount = run?.transcript.agents.length ?? 0;
const verified = auth?.verified === true;
// Per-run guard for the default view: land on Agents while a scan is live,
// Overview once it finishes. Applied at most once per run and never once the
// user has navigated manually (userSetView flips the guard).
const initialViewAppliedRef = useRef(false);
// Reset the guard whenever the active run changes so the newly selected run
// gets its own default.
useEffect(() => {
initialViewAppliedRef.current = false;
}, [activeRun]);
useEffect(() => {
if (initialViewAppliedRef.current || !run) return;
if (run.finished) {
initialViewAppliedRef.current = true;
setView("overview");
} else if (agentCount > 0) {
// Live and agents have appeared: default to the agent graph. If it is
// live but no agents exist yet, wait (do not apply, do not set the flag).
initialViewAppliedRef.current = true;
setView("agents");
}
}, [run, agentCount]);
// User-initiated navigation: mark the default guard applied so the per-run
// default effect never yanks the user off the view they chose.
const userSetView = useCallback((v: View) => {
initialViewAppliedRef.current = true;
setView(v);
}, []);
const selectRun = useCallback((name: string) => {
setActiveRun(name);
setSelectedId(null);
setRun(null);
setError(null);
// Reset the guard so the per-run default applies to the newly selected run.
initialViewAppliedRef.current = false;
}, []);
const goEmail = useCallback((skipDisclosure: boolean, surface: string) => {
trackCta("email_report", surface);
setEmailPurpose("report");
setEmailSkipDisclosure(skipDisclosure);
userSetView("email");
}, [userSetView]);
// Sidebar entry keeps the disclosure (first place those users see it);
const openEmail = useCallback(() => goEmail(false, "sidebar"), [goEmail]);
// the Overview CTA already states the tradeoff, so it starts the flow directly.
const openEmailFromOverview = useCallback(() => goEmail(true, "overview"), [goEmail]);
const openHistory = useCallback(() => {
void refreshRuns();
userSetView("history");
}, [refreshRuns, userSetView]);
const onPastRunsVerified = useCallback(async () => {
await refreshAuth();
await refreshRuns();
}, [refreshAuth, refreshRuns]);
const selectFeature = useCallback((slug: string) => {
trackCta(slug, "sidebar_nav");
setActiveFeature(slug);
userSetView("feature");
}, [userSetView]);
const onForget = useCallback(async () => {
await forgetAuth();
await refreshAuth();
await refreshRuns();
}, [refreshAuth, refreshRuns]);
return (
<div className="min-h-screen bg-black text-white flex">
<Sidebar
view={view}
onSelectView={(v) => {
if (v === "history") openHistory();
else userSetView(v);
}}
activeFeature={activeFeature}
onSelectFeature={selectFeature}
issuesCount={run?.vulnerabilities.length ?? 0}
agentCount={agentCount}
runCount={runs?.count ?? 0}
verified={verified}
email={auth?.email ?? null}
onOpenEmail={openEmail}
onOpenHistory={openHistory}
onForget={() => void onForget()}
/>
<div className="flex-1 min-w-0">
{/* Top bar */}
<div className="border-b border-[#222]">
<div className="max-w-[72rem] mx-auto px-6 py-4 flex items-center gap-1.5">
<a
href={ctaUrl("https://app.strix.ai", "logo")}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackCta("logo", "topbar")}
className="flex items-center gap-1.5 opacity-90 transition-opacity hover:opacity-100 lg:hidden"
title="Open Strix Cloud"
>
<img src="./logo.png" alt="Strix" className="w-10 h-8 object-cover" />
<div className="text-base text-white font-medium tracking-tight">Strix</div>
</a>
<span className="text-xs text-[#666]">Local results</span>
{run && <LiveIndicator finished={run.finished} />}
<div className="ml-auto flex items-center gap-3">
{verified && runs && !runs.locked && runs.runs.length > 0 && (
<RunSwitcher
runs={runs}
activeRun={activeRun}
launchedName={runTitle(run?.summary.targets[0] ?? null, run?.summary.runName ?? run?.summary.runId ?? "Current run")}
onSelect={selectRun}
/>
)}
<a
href={ctaUrl(SIGNUP_URL, "run_in_cloud")}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackCta("run_in_cloud", "topbar")}
className="inline-flex items-center gap-1 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity hover:opacity-90"
>
Run in the cloud
<ArrowUpRight className="w-3 h-3" aria-hidden="true" />
</a>
</div>
</div>
</div>
<div className="max-w-[72rem] mx-auto px-6 py-8 space-y-6">
{error && !run && view !== "history" && view !== "email" && view !== "feature" && (
<div className="rounded-lg px-4 py-3 flex gap-3 items-start border border-red-500/30 bg-red-500/5">
<AlertCircle className="w-5 h-5 flex-shrink-0 mt-0.5 text-red-400" aria-hidden="true" />
<p className="text-sm text-red-300">{error}</p>
</div>
)}
{view === "email" ? (
<EmailReportView
activeRun={activeRun}
auth={auth}
purpose={emailPurpose}
skipDisclosure={emailSkipDisclosure}
onAuthChanged={() => {
void refreshAuth();
void refreshRuns();
}}
onExit={(dest) => setView(dest === "history" ? "history" : "overview")}
/>
) : view === "feature" && activeFeature && FEATURES[activeFeature] ? (
<FeatureDetail feature={FEATURES[activeFeature]} />
) : view === "history" ? (
<div className="space-y-4">
<div className="flex items-center gap-2">
<History className="w-5 h-5 text-[#888]" aria-hidden="true" />
<h1 className="text-2xl font-semibold text-white">Past runs</h1>
</div>
<PastRunsView
runs={runs}
activeRun={activeRun}
onSelectRun={selectRun}
onVerified={() => void onPastRunsVerified()}
/>
</div>
) : !run && !error ? (
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-10 text-center">
<div className="w-6 h-6 mx-auto mb-3 rounded-full border-2 border-[#333] border-t-white animate-spin" />
<p className="text-sm text-[#888]">Loading run data</p>
</div>
) : run && counts ? (
<>
<SummaryHeader summary={run.summary} />
{/* Tab strip: shown on small screens where the sidebar is hidden. */}
<div className="flex gap-5 border-b border-[#2a2a2a] lg:hidden">
<TabButton active={view === "overview"} onClick={() => userSetView("overview")}>
Overview
</TabButton>
<TabButton active={view === "issues"} onClick={() => userSetView("issues")}>
Issues{run.vulnerabilities.length > 0 ? ` (${run.vulnerabilities.length})` : ""}
</TabButton>
{agentCount > 0 && (
<TabButton active={view === "agents"} onClick={() => userSetView("agents")}>
Agents ({agentCount})
</TabButton>
)}
</div>
{view === "overview" ? (
<OverviewTab
summary={run.summary}
counts={counts}
total={run.vulnerabilities.length}
reportMarkdown={run.reportMarkdown}
raw={run.raw}
onOpenEmail={openEmailFromOverview}
/>
) : view === "agents" && agentCount > 0 ? (
<AgentsTab run={run} canSteer={canSteer} />
) : selected ? (
<div className="space-y-4">
<button
onClick={() => setSelectedId(null)}
className="cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] hover:text-white transition-colors"
>
<ArrowLeft className="w-4 h-4" /> Back to all findings
</button>
<VulnerabilityDetail vulnerability={selected} />
</div>
) : (
<FindingsList
vulnerabilities={run.vulnerabilities}
finished={run.finished}
onSelect={(id) => setSelectedId(id)}
/>
)}
</>
) : null}
</div>
</div>
<TrustToast message={TRUST_BANNER} />
</div>
);
}
function RunSwitcher({
runs,
activeRun,
launchedName,
onSelect,
}: {
runs: RunsPayload;
activeRun: string | null;
launchedName: string;
onSelect: (name: string) => void;
}) {
const [open, setOpen] = useState(false);
const activeEntry = runs.runs.find((r) => r.name === activeRun);
const current = activeEntry ? runTitle(activeEntry.target, activeEntry.name) : launchedName;
return (
<div className="relative">
<button
onClick={() => setOpen((o) => !o)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs text-[#aaa] transition-colors hover:text-white"
style={{ border: "1px solid #2a2a2a" }}
>
<History className="w-3.5 h-3.5" aria-hidden="true" />
<span className="max-w-[160px] truncate">{current}</span>
<ChevronDown className="w-3.5 h-3.5" aria-hidden="true" />
</button>
{open && (
<div
className="absolute right-0 z-50 mt-1.5 max-h-80 w-64 overflow-y-auto rounded-lg py-1 shadow-xl"
style={{ border: "1px solid #2a2a2a", background: "#0a0a0a" }}
>
{runs.runs.map((r) => {
const active = r.name === activeRun;
return (
<button
key={r.name}
onMouseDown={() => onSelect(r.name)}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-[rgba(255,255,255,0.06)] ${
active ? "text-white" : "text-[#aaa]"
}`}
>
<span className="min-w-0 flex-1">
<span className="block truncate">{runTitle(r.target, r.name)}</span>
{r.target && <span className="block truncate font-mono text-[#666]">{r.target}</span>}
</span>
{active && <span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-emerald-400" />}
</button>
);
})}
</div>
)}
</div>
);
}
function LiveIndicator({ finished }: { finished: boolean }) {
if (finished) {
return (
<span className="ml-3 inline-flex items-center gap-1.5 text-xs text-[#888]">
<span className="w-1.5 h-1.5 rounded-full bg-[#555]" />
Complete
</span>
);
}
return (
<span className="ml-3 inline-flex items-center gap-1.5 text-xs text-emerald-400">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75 animate-ping" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-400" />
</span>
Live
</span>
);
}
function formatDuration(seconds: number | null): string | null {
if (seconds == null) return null;
if (seconds < 60) return `${seconds}s`;
const m = Math.floor(seconds / 60);
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
}
function SummaryHeader({ summary }: { summary: ParsedRunSummary }) {
const duration = formatDuration(summary.durationSeconds);
return (
<div>
<h1 className="text-2xl font-semibold text-white">
{runTitle(summary.targets[0] ?? null, summary.runName ?? summary.runId ?? "Scan results")}
</h1>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-[#888]">
{summary.targets.length > 0 && (
<span className="font-mono text-[#aaa]">{summary.targets.join(", ")}</span>
)}
{summary.scanMode && <Meta label={summary.scanMode} />}
{duration && <Meta label={duration} />}
{summary.status && <Meta label={summary.status} />}
</div>
</div>
);
}
function Meta({ label }: { label: string }) {
return (
<>
<span className="text-[#333]">·</span>
<span className="capitalize">{label}</span>
</>
);
}
function FindingsList({
vulnerabilities,
finished,
onSelect,
}: {
vulnerabilities: Vulnerability[];
finished: boolean;
onSelect: (id: string) => void;
}) {
const sorted = [...vulnerabilities].sort(
(a, b) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity)
);
if (sorted.length === 0) {
return (
<div className="space-y-4">
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center text-sm text-[#888]">
{finished ? "No findings in this run." : "No findings yet. The scan is still running…"}
</div>
{finished && (
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
<p className="text-sm font-medium text-white">Stay ahead of new exposures</p>
<p className="mt-0.5 mb-3 text-xs text-[#666]">
Attack surface monitoring catches new exposures for your org over time.
</p>
<ProInlineCta
label="Attack surface monitoring"
desc="Continuous coverage for your whole org."
slug="asm"
surface="empty_state"
icon={Radar}
/>
</div>
)}
</div>
);
}
return (
<div className="space-y-2">
{sorted.map((v) => (
<button
key={v.id}
onClick={() => onSelect(v.id)}
className="cursor-pointer w-full text-left rounded-lg border border-[#222] hover:border-[#444] bg-[rgba(255,255,255,0.02)] px-4 py-3 transition-colors flex items-center gap-3"
>
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${getSeverityDot(v.severity)}`} aria-hidden="true" />
<span className="flex-1 min-w-0">
<span className="block text-sm font-medium text-white truncate">{v.title}</span>
{v.target && (
<span className="block text-xs text-[#666] font-mono truncate">{v.target}</span>
)}
</span>
<span
className={`text-xs font-semibold px-2 py-0.5 rounded-full border capitalize ${SEVERITY_COLORS[v.severity]}`}
>
{v.severity}
</span>
</button>
))}
</div>
);
}
/** Strip a single leading markdown heading (report sections embed their own). */
function stripLeadingHeading(md: string): string {
return md.replace(/^\s*#{1,6}[ \t]+.*(?:\r?\n)+/, "").trimStart();
}
function dedupeHeadings(md: string): string {
const out: string[] = [];
let lastHeading: string | null = null;
for (const line of md.split("\n")) {
const m = line.match(/^#{1,6}\s+(.*)$/);
if (m) {
const norm = m[1].trim().toLowerCase();
if (norm === lastHeading) continue;
lastHeading = norm;
} else if (line.trim() !== "") {
lastHeading = null;
}
out.push(line);
}
return out.join("\n");
}
/** Primary local CTA: email an encrypted PDF. Verify-email affordance, no lock. */
function EmailReportCta({ onOpenEmail }: { onOpenEmail: () => void }) {
return (
<button
onClick={onOpenEmail}
className="group w-full cursor-pointer rounded-xl border border-emerald-500/25 bg-emerald-500/[0.06] p-4 text-left transition-colors hover:border-emerald-500/40"
>
<div className="flex items-center gap-3">
<div
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg"
style={{ border: "1px solid rgba(16,185,129,0.3)", background: "rgba(16,185,129,0.08)" }}
>
<Mail className="h-4 w-4 text-emerald-400" aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-white">Email an encrypted PDF report of this run</p>
<p className="mt-0.5 text-xs text-[#888]">
Encrypted with a key only you can see, email verified with a one-time code before sending.
</p>
</div>
<span className="flex-shrink-0 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity group-hover:opacity-90">
Email report
</span>
</div>
</button>
);
}
function OverviewTab({
summary,
counts,
total,
reportMarkdown,
raw,
onOpenEmail,
}: {
summary: ParsedRunSummary;
counts: Record<VulnerabilitySeverity, number>;
total: number;
reportMarkdown: string | null;
raw: Record<string, unknown>;
onOpenEmail: () => void;
}) {
const sections = (
[
["Executive Summary", summary.executiveSummary],
["Technical Analysis", summary.technicalAnalysis],
["Methodology", summary.methodology],
["Recommendations", summary.recommendations],
] as const
)
.filter(([, content]) => !!content)
.map(([title, content]) => ({ title, content: stripLeadingHeading(content as string) }));
return (
<div className="space-y-6">
<RunDetails raw={raw} durationSeconds={summary.durationSeconds} />
{total > 0 && (
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
<IssueSeveritySummary findings={{ total, ...counts }} />
</div>
)}
{/* Primary CTA: the one primary on Overview. */}
<EmailReportCta onOpenEmail={onOpenEmail} />
{sections.length > 0 ? (
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5 space-y-8">
{sections.map((s) => (
<ContentSection key={s.title} title={s.title} content={s.content} />
))}
</div>
) : reportMarkdown ? (
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
<ContentSection content={dedupeHeadings(reportMarkdown)} />
</div>
) : (
total === 0 && (
<p className="text-sm text-[#888]">No summary available for this run yet.</p>
)
)}
{/* Near Recommendations: act on the fixes. */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{RECOMMENDATION_CTAS.map((item) => (
<ProTile key={item.slug} item={item} surface="overview" />
))}
</div>
{/* Continuous coverage for your org (restyled upsell tiles). */}
<div>
<p className="mb-2 text-sm font-semibold text-white">Continuous coverage for your org</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{COVERAGE_CTAS.map((item) => (
<ProTile key={item.slug} item={item} surface="overview" />
))}
</div>
</div>
</div>
);
}
function TabButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
className={`cursor-pointer relative pb-2.5 text-sm font-semibold transition-colors ${
active ? "text-white" : "text-[#666] hover:text-white"
}`}
>
{children}
{active && <span className="absolute bottom-0 inset-x-0 h-0.5 bg-white rounded-full" />}
</button>
);
}
function AgentsTab({ run, canSteer }: { run: LoadedRun; canSteer: boolean }) {
const { agents, events } = run.transcript;
const graphAgents = useMemo(() => buildGraphAgents(agents, events), [agents, events]);
// Clicking a graph node opens the agent's transcript in a modal (matching the
// cloud app); no node selected means no modal.
const [selectedId, setSelectedId] = useState<string | null>(null);
const selectedAgent = selectedId ? (agents.find((a) => a.id === selectedId) ?? null) : null;
// Live steering is only possible in-process (canSteer) while the scan runs.
const steerable = canSteer && !run.finished;
return (
<div className="space-y-5">
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
<div className="flex items-center gap-2">
<Waypoints className="w-4 h-4 text-[#888]" aria-hidden="true" />
<h2 className="text-sm font-semibold text-white">Agent graph</h2>
<span className="text-xs text-[#666]">
{agents.length} agent{agents.length === 1 ? "" : "s"}
</span>
</div>
<p className="mt-1 mb-4 text-xs text-[#666]">
Click an agent to open its full transcript.
</p>
<div className="h-[480px] rounded-lg border border-[#1a1a1a] overflow-hidden">
<AgentGraph
agents={graphAgents}
selectedAgentId={selectedId}
onSelectAgent={(id) => setSelectedId(id)}
eventsLoaded
eventsEmpty={graphAgents.size === 0}
scanCompleted={run.finished}
/>
</div>
</div>
{/* Live steering: only in-process while the scan runs. Otherwise omitted. */}
{steerable && <ScanPromptComposer agents={agents} />}
{/* Re-run always routes to Strix Cloud. */}
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
<p className="text-sm font-semibold text-white">Run this scan with more depth</p>
<p className="mt-0.5 text-xs text-[#666]">Re-run this scan on managed infra in the cloud.</p>
<div className="mt-3 flex flex-wrap gap-2.5">
<ProInlineCta
label="Re-run in Strix Cloud with more depth"
desc="Run this scan on managed infra with more depth."
slug="live_scan"
surface="agents"
icon={Rocket}
/>
</div>
</div>
{selectedAgent && (
<AgentDetailModal
agent={selectedAgent}
events={events}
steerable={steerable}
onClose={() => setSelectedId(null)}
/>
)}
</div>
);
}
@@ -0,0 +1,20 @@
// Extracted ProviderIcon from strix-app's AddRepositoryDialog. The dialog itself
// (and its next/link dependency) is dropped; the IssueSidebar only needs this SVG
// switch to badge a finding's source-control provider. Web-app targets resolve to
// provider === null and never reach here (they render a globe icon instead).
import { Github, Gitlab } from "lucide-react";
function BitbucketIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
<path d="M2.65 3a.72.72 0 0 0-.72.83l2.86 17.39a.98.98 0 0 0 .96.82h13.72a.72.72 0 0 0 .72-.6l2.86-17.4A.72.72 0 0 0 22.3 3H2.65Zm12.1 12.53H9.3L8.06 8.9h7.8l-1.11 6.63Z" />
</svg>
);
}
export function ProviderIcon({ provider, className }: { provider: string; className?: string }) {
const cls = className ?? "w-4 h-4";
if (provider === "gitlab") return <Gitlab className={`${cls} text-orange-400`} />;
if (provider === "bitbucket") return <BitbucketIcon className={`${cls} text-blue-400`} />;
return <Github className={`${cls} text-white`} />;
}
@@ -0,0 +1,375 @@
import { useEffect, useRef, useState } from "react";
import { Mail, ShieldCheck, Lock, Copy, Check, Loader2, AlertCircle, ArrowLeft } from "lucide-react";
import {
otpStart,
otpVerify,
sendReport,
type AuthStatus,
} from "@/data/serverSource";
import { track } from "@/lib/cta";
/**
* The email-report / email-verification flow rendered as its own page (not a
* modal, so it never floats over another surface). Report mode ends in the
* one-time password panel; verify mode just confirms the email and returns to
* the caller. The page unmounts when you navigate away, so state resets each
* time it is opened.
*/
type Step = "disclosure" | "email" | "code" | "sending" | "password";
interface EmailReportViewProps {
activeRun: string | null;
auth: AuthStatus | null;
purpose: "report" | "verify";
/**
* Skip the report disclosure and start the flow directly (used by the
* Overview CTA, which already states the tradeoff). Unverified users land on
* the email step; already-verified users send immediately.
*/
skipDisclosure?: boolean;
/** Refresh auth + runs after a successful verify (lifts state to App). */
onAuthChanged: () => void;
/** Leave this page (report "Done" -> overview; verify success -> history). */
onExit: (dest: "overview" | "history") => void;
}
const OTP_START_ERRORS: Record<string, string> = {
work_email_required: "Please use your work email, not a personal one.",
rate_limited: "Too many requests. Wait a minute and try again.",
invalid_email: "That email does not look right. Check it and try again.",
unavailable: "The email service is unavailable right now. Try again shortly.",
};
const SEND_ERRORS: Record<string, string> = {
forbidden: "This email was unsubscribed from Strix, so we cannot send to it.",
too_large: "This report is too large to email. Try a smaller run.",
unavailable: "The email service is unavailable right now. Try again shortly.",
};
// A small set of common personal providers for instant client-side feedback.
// The relay is authoritative (it checks the full free-email-domains list).
const COMMON_FREE_DOMAINS = new Set([
"gmail.com", "googlemail.com", "yahoo.com", "ymail.com", "outlook.com",
"hotmail.com", "live.com", "icloud.com", "me.com", "aol.com", "proton.me",
"protonmail.com", "gmx.com", "mail.com",
]);
export default function EmailReportView({
activeRun,
auth,
purpose,
skipDisclosure = false,
onAuthChanged,
onExit,
}: EmailReportViewProps) {
const verified = auth?.verified === true;
const verifyOnly = purpose === "verify";
// Verify mode (and the Overview CTA, which skips the disclosure) start on the
// email step; a verified user who skips the disclosure sends immediately.
const [step, setStep] = useState<Step>(() => {
if (verifyOnly) return "email";
if (skipDisclosure) return verified ? "sending" : "email";
return "disclosure";
});
const [email, setEmail] = useState(auth?.email ?? "");
const [code, setCode] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [password, setPassword] = useState("");
const [filename, setFilename] = useState("");
const [copied, setCopied] = useState(false);
const [sentTo, setSentTo] = useState("");
const autoSentRef = useRef(false);
const doSend = async () => {
setStep("sending");
setError(null);
const result = await sendReport(activeRun);
if (result.ok) {
track("report_sent");
setPassword(result.password);
setFilename(result.filename);
setStep("password");
return;
}
if (result.error === "reverify" || result.error === "unverified") {
setNotice("Your verification expired. Enter your email to verify again.");
setStep("email");
return;
}
setError(SEND_ERRORS[result.error] ?? "Could not send the report. Try again.");
setStep("disclosure");
};
const startFlow = () => {
setError(null);
setNotice(null);
if (verified) void doSend();
else setStep("email");
};
// A verified user who skipped the disclosure (Overview CTA) sends on arrival.
useEffect(() => {
if (!verifyOnly && skipDisclosure && verified && !autoSentRef.current) {
autoSentRef.current = true;
void doSend();
}
// Run once on mount; the page remounts fresh each time it is opened.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const submitEmail = async () => {
const value = email.trim();
if (!value) {
setError("Enter your email to continue.");
return;
}
const domain = value.slice(value.lastIndexOf("@") + 1).toLowerCase();
if (COMMON_FREE_DOMAINS.has(domain)) {
track("work_email_required");
setError(OTP_START_ERRORS.work_email_required);
return;
}
setBusy(true);
setError(null);
const result = await otpStart(value);
setBusy(false);
if (result.ok) {
track("email_submitted", { purpose });
setNotice(`We sent a 6-digit code to ${value}.`);
setStep("code");
} else {
if (result.error === "work_email_required") track("work_email_required");
setError(OTP_START_ERRORS[result.error] ?? "Could not send a code. Try again.");
}
};
const submitCode = async () => {
const value = code.trim();
if (value.length < 4) {
setError("Enter the 6-digit code from your email.");
return;
}
setBusy(true);
setError(null);
const result = await otpVerify(email.trim(), value);
setBusy(false);
if (!result.verified) {
setError("That code did not match. Check it and try again.");
return;
}
track("email_verified", { purpose });
setSentTo(result.email);
onAuthChanged();
if (verifyOnly) onExit("history");
else void doSend();
};
const copyPassword = async () => {
try {
await navigator.clipboard.writeText(password);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* clipboard may be unavailable; the password is visible to copy manually */
}
};
const confirmationEmail = sentTo || auth?.email || email.trim();
return (
<div className="mx-auto max-w-md space-y-4">
<button
onClick={() => onExit(verifyOnly ? "history" : "overview")}
className="cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] transition-colors hover:text-white"
>
<ArrowLeft className="h-4 w-4" />
{verifyOnly ? "Back to past runs" : "Back to results"}
</button>
<div className="flex items-center gap-2">
<Mail className="h-5 w-5 text-[#888]" aria-hidden="true" />
<h1 className="text-2xl font-semibold text-white">
{verifyOnly ? "Verify your email" : "Email report"}
</h1>
</div>
<div
className="w-full rounded-2xl bg-[rgba(255,255,255,0.02)] p-6"
style={{ border: "1px solid #2a2a2a" }}
>
<p className="mb-4 text-xs text-[#666]">
{verifyOnly
? "We send a one-time code to confirm it is you."
: "Verified by a one-time code sent to your email"}
</p>
{error && (
<div className="mb-4 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2">
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-red-400" aria-hidden="true" />
<p className="text-xs text-red-300">{error}</p>
</div>
)}
{notice && !error && step !== "password" && (
<p className="mb-4 text-xs text-[#888]">{notice}</p>
)}
{step === "disclosure" && (
<div className="space-y-4">
<div
className="space-y-2.5 rounded-lg p-3.5"
style={{ border: "1px solid #222", background: "rgba(255,255,255,0.02)" }}
>
<div className="flex items-start gap-2.5">
<ShieldCheck className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400" aria-hidden="true" />
<p className="text-xs leading-relaxed text-[#aaa]">
Viewing stays local and nothing is uploaded. Emailing is an explicit
opt-in: we send an <span className="text-white">encrypted PDF</span>.
</p>
</div>
<div className="flex items-start gap-2.5">
<Lock className="mt-0.5 h-4 w-4 flex-shrink-0 text-[#888]" aria-hidden="true" />
<p className="text-xs leading-relaxed text-[#aaa]">
The report is encrypted with a password that only you hold. Strix
cannot read it and never stores it. We collect only your email so we
can send it.
</p>
</div>
</div>
<button
onClick={startFlow}
className="w-full cursor-pointer rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90"
>
{verified ? "Email me the encrypted PDF" : "Continue with your email"}
</button>
{verified && auth?.email && (
<p className="text-center text-xs text-[#666]">Sending to {auth.email}</p>
)}
</div>
)}
{step === "email" && (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
void submitEmail();
}}
>
<label className="block">
<span className="mb-1.5 block text-xs text-[#888]">Your work email</span>
<input
type="email"
autoFocus
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@company.com"
className="w-full rounded-lg bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-[#444]"
style={{ border: "1px solid #2a2a2a" }}
/>
<span className="mt-1.5 block text-[11px] text-[#666]">Use your work email.</span>
</label>
<button
type="submit"
disabled={busy}
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60"
>
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
Send me a code
</button>
</form>
)}
{step === "code" && (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
void submitCode();
}}
>
<label className="block">
<span className="mb-1.5 block text-xs text-[#888]">6-digit code</span>
<input
inputMode="numeric"
autoFocus
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
placeholder="123456"
className="w-full rounded-lg bg-black px-3 py-2.5 text-center text-lg font-mono tracking-[0.4em] text-white outline-none transition-colors focus:border-[#444]"
style={{ border: "1px solid #2a2a2a" }}
/>
</label>
<button
type="submit"
disabled={busy}
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60"
>
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
{verifyOnly ? "Verify" : "Verify and send"}
</button>
<button
type="button"
onClick={() => {
setStep("email");
setError(null);
setNotice(null);
}}
className="w-full cursor-pointer text-center text-xs text-[#666] transition-colors hover:text-[#aaa]"
>
Use a different email
</button>
</form>
)}
{step === "sending" && (
<div className="flex flex-col items-center gap-3 py-8">
<Loader2 className="h-6 w-6 animate-spin text-white" aria-hidden="true" />
<p className="text-sm text-[#aaa]">Generating and encrypting locally...</p>
</div>
)}
{step === "password" && (
<div className="space-y-4">
<div className="flex items-start gap-2.5 rounded-lg border border-emerald-500/30 bg-emerald-500/5 px-3 py-2.5">
<Check className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400" aria-hidden="true" />
<p className="text-xs text-emerald-200">
Sent to {confirmationEmail}. Open the attached PDF with this password.
</p>
</div>
<div>
<span className="mb-1.5 block text-xs text-[#888]">Your one-time password</span>
<div
className="flex items-center gap-2 rounded-lg bg-black p-3"
style={{ border: "1px solid #2a2a2a" }}
>
<code className="flex-1 break-all font-mono text-base text-white">{password}</code>
<button
onClick={copyPassword}
className="flex cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-xs text-[#aaa] transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-white"
style={{ border: "1px solid #2a2a2a" }}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? "Copied" : "Copy"}
</button>
</div>
<p className="mt-2 text-xs text-[#666]">
Save this now. Strix never stores it, so we cannot show it again. File:{" "}
<span className="font-mono text-[#888]">{filename}</span>
</p>
</div>
<button
onClick={() => onExit("overview")}
className="w-full cursor-pointer rounded-lg px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-[rgba(255,255,255,0.06)]"
style={{ border: "1px solid #2a2a2a" }}
>
Done
</button>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,163 @@
import { useState } from "react";
import { Loader2, AlertCircle } from "lucide-react";
import { otpStart, otpVerify } from "@/data/serverSource";
import { track } from "@/lib/cta";
/**
* Compact inline email -> 6-digit-code verify flow. Unlike EmailReportView this
* has no page chrome, no report send, and no password panel: it just confirms
* the email so the past-runs list can unlock in place. On success it calls
* `onVerified` (the parent refreshes auth + runs).
*/
const OTP_START_ERRORS: Record<string, string> = {
work_email_required: "Please use your work email, not a personal one.",
rate_limited: "Too many requests. Wait a minute and try again.",
invalid_email: "That email does not look right. Check it and try again.",
unavailable: "The email service is unavailable right now. Try again shortly.",
};
// A small set of common personal providers for instant client-side feedback.
// The relay is authoritative (it checks the full free-email-domains list).
const COMMON_FREE_DOMAINS = new Set([
"gmail.com", "googlemail.com", "yahoo.com", "ymail.com", "outlook.com",
"hotmail.com", "live.com", "icloud.com", "me.com", "aol.com", "proton.me",
"protonmail.com", "gmx.com", "mail.com",
]);
export default function EmailVerifyInline({ onVerified }: { onVerified: () => void }) {
const [step, setStep] = useState<"email" | "code">("email");
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const submitEmail = async () => {
const value = email.trim();
if (!value) {
setError("Enter your email to continue.");
return;
}
const domain = value.slice(value.lastIndexOf("@") + 1).toLowerCase();
if (COMMON_FREE_DOMAINS.has(domain)) {
track("work_email_required");
setError(OTP_START_ERRORS.work_email_required);
return;
}
setBusy(true);
setError(null);
const result = await otpStart(value);
setBusy(false);
if (result.ok) {
track("email_submitted", { purpose: "verify" });
setNotice(`We sent a 6-digit code to ${value}.`);
setStep("code");
} else {
if (result.error === "work_email_required") track("work_email_required");
setError(OTP_START_ERRORS[result.error] ?? "Could not send a code. Try again.");
}
};
const submitCode = async () => {
const value = code.trim();
if (value.length < 4) {
setError("Enter the 6-digit code from your email.");
return;
}
setBusy(true);
setError(null);
const result = await otpVerify(email.trim(), value);
setBusy(false);
if (!result.verified) {
setError("That code did not match. Check it and try again.");
return;
}
track("email_verified", { purpose: "verify" });
onVerified();
};
return (
<div className="mx-auto mt-5 max-w-sm text-left">
{error && (
<div className="mb-3 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2">
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-red-400" aria-hidden="true" />
<p className="text-xs text-red-300">{error}</p>
</div>
)}
{notice && !error && <p className="mb-3 text-xs text-[#888]">{notice}</p>}
{step === "email" ? (
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
void submitEmail();
}}
>
<label className="block">
<span className="mb-1.5 block text-xs text-[#888]">Your work email</span>
<input
type="email"
autoFocus
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@company.com"
className="w-full rounded-lg bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-[#444]"
style={{ border: "1px solid #2a2a2a" }}
/>
<span className="mt-1.5 block text-[11px] text-[#666]">Use your work email.</span>
</label>
<button
type="submit"
disabled={busy}
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60"
>
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
Send me a code
</button>
</form>
) : (
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
void submitCode();
}}
>
<label className="block">
<span className="mb-1.5 block text-xs text-[#888]">6-digit code</span>
<input
inputMode="numeric"
autoFocus
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
placeholder="123456"
className="w-full rounded-lg bg-black px-3 py-2.5 text-center text-lg font-mono tracking-[0.4em] text-white outline-none transition-colors focus:border-[#444]"
style={{ border: "1px solid #2a2a2a" }}
/>
</label>
<button
type="submit"
disabled={busy}
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60"
>
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
Verify
</button>
<button
type="button"
onClick={() => {
setStep("email");
setError(null);
setNotice(null);
}}
className="w-full cursor-pointer text-center text-xs text-[#666] transition-colors hover:text-[#aaa]"
>
Use a different email
</button>
</form>
)}
</div>
);
}
@@ -0,0 +1,96 @@
import {
CalendarClock,
WandSparkles,
Puzzle,
Users,
ArrowUpRight,
} from "lucide-react";
import { SIGNUP_URL, PRICING_URL, ctaUrl, trackCta } from "@/lib/cta";
import type { ProFeature } from "@/lib/pro-features";
import { ProTag } from "@/components/ProCta";
/**
* In-app upsell page for a single platform feature. Modeled on the cloud app's
* Networks upsell: a centered bordered card with an icon medallion, tier pill,
* headline, one-line description, a shared "Included in Strix Pro" bullet list,
* then a primary sign-up CTA and a secondary link to all plans.
*/
const INCLUDED = [
{
icon: CalendarClock,
text: "Continuous coverage: scheduled pentests and attack surface monitoring",
},
{ icon: WandSparkles, text: "One-click autofix that opens a retested pull request" },
{ icon: Puzzle, text: "Two-way sync to Jira, Linear, and Slack" },
{ icon: Users, text: "Your whole team, with roles and shared history" },
];
export default function FeatureDetail({ feature }: { feature: ProFeature }) {
const Icon = feature.icon;
return (
<div className="mx-auto w-full max-w-lg">
<div className="rounded-2xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center">
<div
className="mx-auto flex h-12 w-12 items-center justify-center rounded-xl"
style={{ border: "1px solid #2a2a2a", background: "rgba(255,255,255,0.04)" }}
>
<Icon className="h-5 w-5 text-[#888]" aria-hidden="true" />
</div>
<div className="mt-4 flex justify-center">
<ProTag label={feature.tier} />
</div>
<h2 className="mt-3 text-2xl font-semibold text-white">{feature.headline}</h2>
<p className="mx-auto mt-2 max-w-md text-sm text-[#888]">{feature.description}</p>
<div
className="mt-6 rounded-xl p-4 text-left"
style={{ border: "1px solid #222", background: "rgba(255,255,255,0.02)" }}
>
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-[#666]">
Included in Strix Pro
</p>
<ul className="space-y-2.5">
{INCLUDED.map((item) => {
const BulletIcon = item.icon;
return (
<li key={item.text} className="flex items-start gap-2.5">
<BulletIcon
className="mt-0.5 h-4 w-4 flex-shrink-0 text-[#888]"
aria-hidden="true"
/>
<span className="text-sm text-[#aaa]">{item.text}</span>
</li>
);
})}
</ul>
</div>
<div className="mt-6 flex flex-col items-center gap-3">
<a
href={ctaUrl(SIGNUP_URL, feature.slug)}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackCta(feature.slug, "feature_page")}
className="inline-flex w-full items-center justify-center gap-1.5 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90"
>
Start free
<ArrowUpRight className="h-3.5 w-3.5" aria-hidden="true" />
</a>
<a
href={ctaUrl(PRICING_URL, feature.slug)}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackCta(feature.slug, "feature_page_plans")}
className="inline-flex items-center gap-1 text-xs text-[#888] transition-colors hover:text-white"
>
View all plans
<ArrowUpRight className="h-3 w-3" aria-hidden="true" />
</a>
</div>
</div>
</div>
);
}
@@ -0,0 +1,77 @@
import React from "react";
import { cn } from "@/lib/utils";
export interface IssueSeveritySummaryFindings {
total: number;
critical: number;
high: number;
medium: number;
low: number;
}
interface IssueSeveritySummaryProps {
findings: IssueSeveritySummaryFindings;
className?: string;
/** Noun for the total count (e.g. "issues", "CVEs"). Defaults to "issues". */
unit?: string;
/** Optional content rendered at the end of the count row (e.g. a KEV badge). */
trailing?: React.ReactNode;
}
const SEVERITIES = [
{ key: "critical", label: "critical", dotClass: "bg-red-500", textClass: "text-red-500" },
{ key: "high", label: "high", dotClass: "bg-orange-500", textClass: "text-orange-500" },
{ key: "medium", label: "medium", dotClass: "bg-yellow-500", textClass: "text-yellow-500" },
{ key: "low", label: "low", dotClass: "bg-blue-500", textClass: "text-blue-500" },
] as const;
export function IssueSeveritySummary({
findings,
className,
unit = "issues",
trailing,
}: IssueSeveritySummaryProps) {
if (findings.total <= 0) return null;
return (
<div className={cn("space-y-3", className)}>
<div className="flex flex-wrap items-center gap-x-8 gap-y-3">
<div className="flex items-center gap-2">
<span className="text-2xl font-semibold text-white tabular-nums">{findings.total}</span>
<span className="text-sm text-[#666]">{unit}</span>
</div>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
{SEVERITIES.map(({ key, label, dotClass, textClass }) => {
const count = findings[key];
if (count <= 0) return null;
return (
<div key={key} className="flex items-center gap-1.5">
<div className={cn("w-2 h-2 rounded-full", dotClass)} aria-hidden="true" />
<span className={cn("text-sm tabular-nums", textClass)}>{count}</span>
<span className="text-xs text-[#555]">{label}</span>
</div>
);
})}
</div>
{trailing ? <div className="flex items-center gap-2">{trailing}</div> : null}
</div>
<div className="h-1.5 rounded-full bg-[#222] overflow-hidden flex">
{SEVERITIES.map(({ key, dotClass }) => {
const count = findings[key];
if (count <= 0) return null;
return (
<div
key={key}
className={cn("h-full", dotClass)}
style={{ width: `${(count / findings.total) * 100}%` }}
/>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,176 @@
import { useState } from "react";
import { History, ChevronRight, Terminal } from "lucide-react";
import type { RunListEntry, RunsPayload, RunSeverityCounts } from "@/data/serverSource";
import { runTitle } from "@/lib/target-utils";
import { trackCta } from "@/lib/cta";
import EmailVerifyInline from "@/components/EmailVerifyInline";
/**
* "Past runs" panel. Unverified users see a tease with the run count and a
* verify affordance (the launched run stays fully visible; the CLI
* `strix view <name>` still works). Verified users get the full history and can
* switch the active run, which threads ?run=<name> through the data fetches.
*/
const SEV = [
{ key: "critical", dot: "bg-red-500", text: "text-red-500" },
{ key: "high", dot: "bg-orange-500", text: "text-orange-500" },
{ key: "medium", dot: "bg-yellow-500", text: "text-yellow-500" },
{ key: "low", dot: "bg-blue-500", text: "text-blue-500" },
] as const;
function SeverityChips({ counts }: { counts: RunSeverityCounts }) {
const shown = SEV.filter((s) => counts[s.key] > 0);
if (shown.length === 0) {
return <span className="text-xs text-[#555]">No findings</span>;
}
return (
<div className="flex items-center gap-3">
{shown.map((s) => (
<div key={s.key} className="flex items-center gap-1.5">
<span className={`h-2 w-2 rounded-full ${s.dot}`} aria-hidden="true" />
<span className={`text-xs tabular-nums ${s.text}`}>{counts[s.key]}</span>
</div>
))}
</div>
);
}
function formatDate(iso: string | null): string | null {
if (!iso) return null;
const normalized = iso.trim().replace(" UTC", "Z").replace(" ", "T");
const d = new Date(normalized);
if (Number.isNaN(d.getTime())) return null;
return d.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
/**
* Relative time ("just now" / "5m ago" / "3h ago" / "2d ago"), falling back to
* the absolute date for anything older than a week (mirrors the pro app).
*/
function formatTimeAgo(iso: string | null): string | null {
if (!iso) return null;
const normalized = iso.trim().replace(" UTC", "Z").replace(" ", "T");
const d = new Date(normalized);
if (Number.isNaN(d.getTime())) return null;
const diffMs = Date.now() - d.getTime();
const mins = Math.floor(diffMs / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}d ago`;
return formatDate(iso);
}
interface PastRunsViewProps {
runs: RunsPayload | null;
activeRun: string | null;
onSelectRun: (name: string) => void;
onVerified: () => void;
}
export default function PastRunsView({
runs,
activeRun,
onSelectRun,
onVerified,
}: PastRunsViewProps) {
const count = runs?.count ?? 0;
const [showVerify, setShowVerify] = useState(false);
if (!runs || runs.locked) {
return (
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center">
<div
className="mx-auto mb-4 flex h-11 w-11 items-center justify-center rounded-xl"
style={{ border: "1px solid #2a2a2a", background: "rgba(255,255,255,0.04)" }}
>
<History className="h-5 w-5 text-[#888]" aria-hidden="true" />
</div>
<h2 className="text-base font-semibold text-white">Browse every run on this machine</h2>
<p className="mx-auto mt-1.5 max-w-md text-sm text-[#888]">
You have {count} past {count === 1 ? "run" : "runs"} on this machine.
</p>
{showVerify ? (
<>
<p className="mx-auto mt-3 max-w-sm text-xs text-[#666]">
Verify your email with a one-time code to unlock the full history.
</p>
<EmailVerifyInline onVerified={onVerified} />
</>
) : (
<button
onClick={() => {
trackCta("history_unlock", "past_runs");
setShowVerify(true);
}}
className="mt-4 cursor-pointer rounded-lg bg-white px-4 py-2 text-sm font-semibold text-black transition-opacity hover:opacity-90"
>
View runs
</button>
)}
<p className="mt-4 flex items-center justify-center gap-1.5 text-xs text-[#555]">
<Terminal className="h-3.5 w-3.5" aria-hidden="true" />
Or open one from the CLI with{" "}
<code className="font-mono text-[#888]">strix view &lt;name&gt;</code>
</p>
</div>
);
}
if (runs.runs.length === 0) {
return (
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center text-sm text-[#888]">
No past runs found on this machine yet.
</div>
);
}
return (
<div className="space-y-2">
{runs.runs.map((run: RunListEntry) => {
const active = run.name === activeRun;
const date = formatTimeAgo(run.start_time) ?? formatTimeAgo(run.end_time);
const title = runTitle(run.target, run.name);
return (
<button
key={run.name}
onClick={() => onSelectRun(run.name)}
className={`group flex w-full cursor-pointer items-center gap-4 rounded-lg border px-4 py-3 text-left transition-colors ${
active
? "border-[#444] bg-[rgba(255,255,255,0.04)]"
: "border-[#222] bg-[rgba(255,255,255,0.02)] hover:border-[#444]"
}`}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-white">{title}</span>
{active && (
<span className="rounded-full px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-400" style={{ border: "1px solid rgba(16,185,129,0.3)" }}>
Active
</span>
)}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-[#666]">
{run.scan_mode && <span className="capitalize">{run.scan_mode}</span>}
{run.scan_mode && (date || run.status) && <span className="text-[#333]">·</span>}
{date && <span>{date}</span>}
{date && run.status && <span className="text-[#333]">·</span>}
{run.status && <span className="capitalize">{run.status}</span>}
</div>
</div>
<SeverityChips counts={run.severity_counts} />
<ChevronRight className="h-4 w-4 flex-shrink-0 text-[#555] transition-colors group-hover:text-[#aaa]" aria-hidden="true" />
</button>
);
})}
</div>
);
}
+184
View File
@@ -0,0 +1,184 @@
import React, { useState } from "react";
import { ArrowUpRight } from "lucide-react";
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
import type { ProFeature } from "@/lib/pro-features";
/**
* Shared Pro CTA primitives. Every Pro item is a direct link-out to the cloud
* sign-up in a new tab with a hover tooltip one-liner (no modal, no lock icon).
* Built once here and reused by the sidebar Platform section, the top upsell
* row, and the inline CTAs in the tabs.
*/
/** Small tier pill ("Pro" or "Enterprise"). Deliberately not a padlock. */
export function ProTag({ label = "Pro", className = "" }: { label?: string; className?: string }) {
return (
<span
className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-[#aaa] ${className}`}
style={{ border: "1px solid #2a2a2a", background: "rgba(255,255,255,0.04)" }}
>
{label}
</span>
);
}
/**
* Lightweight hover tooltip. Wraps a trigger and reveals `text` above it on
* hover/focus. Plain Tailwind + local state (no radix vendored).
*/
export function Tooltip({
text,
children,
className = "",
}: {
text: string;
children: React.ReactNode;
className?: string;
}) {
const [open, setOpen] = useState(false);
return (
<span
className={`relative inline-flex ${className}`}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
>
{children}
{open && (
<span
role="tooltip"
className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 w-max max-w-[240px] -translate-x-1/2 rounded-md px-2.5 py-1.5 text-xs text-[#ddd] shadow-lg"
style={{ border: "1px solid #2a2a2a", background: "#0a0a0a" }}
>
{text}
</span>
)}
</span>
);
}
export interface ProItem {
title: string;
desc: string;
slug: string;
icon: React.ElementType;
}
/**
* Card-style Pro feature tile: icon + name + one-liner + Pro tag + arrow.
* Used in the top upsell row and inline CTA grids.
*/
export function ProTile({ item, surface }: { item: ProItem; surface?: string }) {
const Icon = item.icon;
return (
<a
href={ctaUrl(SIGNUP_URL, item.slug)}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackCta(item.slug, surface)}
title={item.desc}
className="group block cursor-pointer rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-4 text-left transition-colors hover:border-[#444]"
>
<div className="mb-2 flex items-center justify-between">
<Icon className="h-4 w-4 text-[#888] transition-colors group-hover:text-white" aria-hidden="true" />
<div className="flex items-center gap-1.5">
<ProTag />
<ArrowUpRight className="h-3.5 w-3.5 text-[#555] transition-colors group-hover:text-[#aaa]" aria-hidden="true" />
</div>
</div>
<p className="text-sm font-medium text-white">{item.title}</p>
<p className="mt-0.5 text-xs text-[#666]">{item.desc}</p>
</a>
);
}
/**
* Sidebar-row Pro item: a two-line row (icon + label + short one-liner
* underneath) with a small right-aligned tier tag. Opens the in-app
* FeatureDetail view via onClick (no link-out) so it sits uniformly beside the
* run/local rows in the themed nav list.
*/
export function ProNavItem({
feature,
active,
onClick,
collapsed = false,
}: {
feature: ProFeature;
active?: boolean;
onClick: () => void;
collapsed?: boolean;
}) {
const Icon = feature.icon;
if (collapsed) {
return (
<button
onClick={onClick}
title={`${feature.title} (${feature.tier})`}
className={`group flex w-full cursor-pointer items-center justify-center rounded-md px-2.5 py-2 transition-colors ${
active
? "text-white"
: "text-[#888] hover:bg-[rgba(255,255,255,0.06)] hover:text-white"
}`}
style={active ? { background: "rgba(255,255,255,0.12)" } : undefined}
>
<Icon className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
</button>
);
}
return (
<button
onClick={onClick}
className={`group flex w-full cursor-pointer items-start gap-2.5 rounded-md px-2.5 py-1.5 text-left transition-colors ${
active
? "text-white"
: "text-[#888] hover:bg-[rgba(255,255,255,0.06)] hover:text-white"
}`}
style={active ? { background: "rgba(255,255,255,0.12)" } : undefined}
>
<Icon className="mt-0.5 h-4 w-4 flex-shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span className="flex-1 truncate text-sm">{feature.title}</span>
<ProTag label={feature.tier} />
</span>
<span className="mt-0.5 block text-[11px] leading-snug text-[#666]">{feature.navDesc}</span>
</span>
</button>
);
}
/**
* Inline Pro CTA button (compact). Used in the finding detail and per-surface
* rows where a full card is too heavy.
*/
export function ProInlineCta({
label,
desc,
slug,
icon: Icon,
surface,
}: {
label: string;
desc: string;
slug: string;
icon: React.ElementType;
surface?: string;
}) {
return (
<Tooltip text={desc}>
<a
href={ctaUrl(SIGNUP_URL, slug)}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackCta(slug, surface)}
className="group inline-flex items-center gap-2 rounded-lg border border-[#222] bg-[rgba(255,255,255,0.02)] px-3 py-2 text-sm text-[#aaa] transition-colors hover:border-[#444] hover:text-white"
>
<Icon className="h-4 w-4 text-[#888] transition-colors group-hover:text-white" aria-hidden="true" />
<span>{label}</span>
<ProTag className="ml-0.5" />
</a>
</Tooltip>
);
}
@@ -0,0 +1,185 @@
import { Info } from "lucide-react";
import { formatNumber } from "@/lib/display-number";
/**
* "Run details" card for the Overview tab: the launch configuration the run was
* started with (targets, instruction, scope, mode) and its LLM usage + cost.
* Everything is read defensively from the raw run.json record, which may be
* partial while a scan is still live.
*/
type Rec = Record<string, unknown>;
function rec(v: unknown): Rec {
return v && typeof v === "object" && !Array.isArray(v) ? (v as Rec) : {};
}
function arr(v: unknown): unknown[] {
return Array.isArray(v) ? v : [];
}
function str(v: unknown): string | null {
return typeof v === "string" && v.trim() ? v : null;
}
function num(v: unknown): number | null {
return typeof v === "number" && Number.isFinite(v) ? v : null;
}
function humanize(s: string): string {
return s.replace(/_/g, " ");
}
function cap(s: string | null): string | null {
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
}
function fmtDuration(seconds: number | null): string {
if (seconds == null || seconds < 0) return "n/a";
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h) return `${h}h ${m}m ${s}s`;
if (m) return `${m}m ${s}s`;
return `${s}s`;
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-[7rem_1fr] gap-3 items-baseline">
<dt className="text-[11px] uppercase tracking-wide text-[#666]">{label}</dt>
<dd className="min-w-0 break-words text-sm text-[#ddd]">{children}</dd>
</div>
);
}
export function RunDetails({
raw,
durationSeconds,
}: {
raw: Rec;
durationSeconds: number | null;
}) {
// Configuration (launch inputs)
const targets = arr(raw.targets_info).map((t) => {
const o = rec(t);
const display = str(o.original) ?? str(rec(o.details).target_url) ?? "unknown target";
const type = str(o.type);
return { display, type: type ? humanize(type) : null };
});
const instruction = str(raw.instruction);
const scanMode = cap(str(raw.scan_mode));
const scopeMode = str(raw.scope_mode);
const diff = rec(raw.diff_scope);
const diffActive = diff.active === true;
const diffMode = str(diff.mode);
const diffBase = str(raw.diff_base);
const nonInteractive = raw.non_interactive === true;
const localSources = arr(raw.local_sources).map((x) => String(x)).filter(Boolean);
const status = cap(str(raw.status));
let scope = scopeMode ?? "auto";
if (diffActive) {
scope += ` (diff${diffMode ? `: ${diffMode}` : ""}${diffBase ? ` vs ${diffBase}` : ""})`;
}
// Usage & cost
const usage = rec(raw.llm_usage);
const hasUsage = Object.keys(usage).length > 0;
const agents = arr(usage.agents).map(rec);
const models = Array.from(
new Set(agents.map((a) => str(a.model)).filter((m): m is string => !!m))
);
const requests = num(usage.requests);
const inputTokens = num(usage.input_tokens);
const cached = num(rec(arr(usage.input_tokens_details)[0]).cached_tokens);
const outputTokens = num(usage.output_tokens);
const reasoning = num(rec(arr(usage.output_tokens_details)[0]).reasoning_tokens);
const totalTokens = num(usage.total_tokens);
const cost = num(usage.cost);
const sub = (n: number, word: string) => (
<span className="text-[#666]"> ({formatNumber(n)} {word})</span>
);
return (
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
<div className="mb-4 flex items-center gap-2">
<Info className="h-4 w-4 text-[#888]" aria-hidden="true" />
<h2 className="text-sm font-semibold text-white">Run details</h2>
</div>
<div className="grid grid-cols-1 gap-x-8 gap-y-6 md:grid-cols-2">
<section>
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wide text-[#555]">
Configuration
</h3>
<dl className="space-y-2.5">
{targets.length > 0 && (
<Field label="Targets">
<div className="space-y-1">
{targets.map((t, i) => (
<div key={i} className="flex flex-wrap items-center gap-2">
<span className="font-mono text-[#ddd]">{t.display}</span>
{t.type && (
<span className="rounded-full border border-[#2a2a2a] px-1.5 py-0.5 text-[10px] text-[#888]">
{t.type}
</span>
)}
</div>
))}
</div>
</Field>
)}
<Field label="Instruction">
{instruction ? (
<span className="whitespace-pre-wrap">{instruction}</span>
) : (
<span className="text-[#666]">None</span>
)}
</Field>
{scanMode && <Field label="Scan mode">{scanMode}</Field>}
<Field label="Scope">{scope}</Field>
<Field label="Mode">{nonInteractive ? "Non-interactive" : "Interactive"}</Field>
{localSources.length > 0 && (
<Field label="Local sources">
<div className="space-y-0.5 font-mono text-[#ddd]">
{localSources.map((s, i) => (
<div key={i}>{s}</div>
))}
</div>
</Field>
)}
{status && <Field label="Status">{status}</Field>}
</dl>
</section>
<section>
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wide text-[#555]">
Usage &amp; cost
</h3>
{hasUsage ? (
<dl className="space-y-2.5 tabular-nums">
<Field label="Model">{models.length ? models.join(", ") : "n/a"}</Field>
<Field label="Run time">{fmtDuration(durationSeconds)}</Field>
{requests != null && <Field label="Requests">{formatNumber(requests)}</Field>}
{inputTokens != null && (
<Field label="Input tokens">
{formatNumber(inputTokens)}
{cached != null && sub(cached, "cached")}
</Field>
)}
{outputTokens != null && (
<Field label="Output tokens">
{formatNumber(outputTokens)}
{reasoning != null && sub(reasoning, "reasoning")}
</Field>
)}
{totalTokens != null && <Field label="Total tokens">{formatNumber(totalTokens)}</Field>}
{cost != null && <Field label="Cost">${cost.toFixed(2)}</Field>}
{agents.length > 0 && <Field label="Agents">{formatNumber(agents.length)}</Field>}
</dl>
) : (
<p className="text-sm text-[#666]">Not available yet.</p>
)}
</section>
</div>
</div>
);
}
export default RunDetails;
+283
View File
@@ -0,0 +1,283 @@
import React, { useEffect, useState } from "react";
import {
FileText,
Bug,
Waypoints,
History,
Mail,
ArrowUpRight,
LogOut,
ShieldCheck,
PanelLeftClose,
PanelLeft,
} from "lucide-react";
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
import { ProNavItem } from "@/components/ProCta";
import { FEATURES, PLATFORM_ORDER } from "@/lib/pro-features";
import type { View } from "@/App";
/**
* Persistent left rail. A single, ungrouped, ordered list of uniform two-line
* rows (icon + label + short one-liner): the current run's views, the local
* run-history + email-report actions, then the platform features. No section
* headers. Tier is shown only by the inline Pro/Enterprise tag on platform
* rows. Matches App.tsx's dark palette.
*
* Can collapse to a narrow icon-only rail; the collapsed state persists in
* localStorage and each icon row keeps a `title` tooltip so the labels stay
* discoverable.
*/
const COLLAPSE_KEY = "strix_viewer_sidebar_collapsed";
interface SidebarProps {
view: View;
onSelectView: (view: View) => void;
activeFeature: string | null;
onSelectFeature: (slug: string) => void;
issuesCount: number;
agentCount: number;
runCount: number;
verified: boolean;
email: string | null;
onOpenEmail: () => void;
onOpenHistory: () => void;
onForget: () => void;
}
export default function Sidebar({
view,
onSelectView,
activeFeature,
onSelectFeature,
issuesCount,
agentCount,
runCount,
verified,
email,
onOpenEmail,
onOpenHistory,
onForget,
}: SidebarProps) {
const [collapsed, setCollapsed] = useState(false);
useEffect(() => {
try {
setCollapsed(localStorage.getItem(COLLAPSE_KEY) === "1");
} catch {
/* localStorage may be unavailable; default to expanded */
}
}, []);
const toggleCollapsed = () => {
setCollapsed((prev) => {
const next = !prev;
try {
localStorage.setItem(COLLAPSE_KEY, next ? "1" : "0");
} catch {
/* best-effort persistence */
}
return next;
});
};
return (
<aside
className={`hidden flex-shrink-0 border-r border-[#222] lg:block ${collapsed ? "w-14" : "w-72"}`}
>
<div className="sticky top-0 flex h-screen flex-col overflow-y-auto px-3 py-4">
{/* Header: wordmark + Explore full platform + signed-in chip */}
<div className="px-1.5">
<div className={`flex items-center ${collapsed ? "flex-col gap-2" : "justify-between"}`}>
<a
href={ctaUrl("https://app.strix.ai", "logo")}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackCta("logo", "sidebar")}
className="flex items-center gap-1.5 opacity-90 transition-opacity hover:opacity-100"
title="Open Strix Cloud"
>
<img src="./logo.png" alt="Strix" className="h-8 w-10 object-cover" />
{!collapsed && (
<span className="text-base font-medium tracking-tight text-white">Strix</span>
)}
</a>
<button
onClick={toggleCollapsed}
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
className="flex-shrink-0 cursor-pointer rounded-md p-1.5 text-[#666] transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-white"
>
{collapsed ? (
<PanelLeft className="h-4 w-4" aria-hidden="true" />
) : (
<PanelLeftClose className="h-4 w-4" aria-hidden="true" />
)}
</button>
</div>
<a
href={ctaUrl(SIGNUP_URL, "sidebar_start_free")}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackCta("sidebar_start_free", "sidebar")}
title="Explore full platform"
className={`mt-3 flex w-full cursor-pointer items-center justify-center gap-1.5 rounded-lg bg-white font-semibold text-black transition-opacity hover:opacity-90 ${
collapsed ? "px-0 py-2" : "px-3 py-2 text-sm"
}`}
>
{!collapsed && "Explore full platform"}
<ArrowUpRight className="h-3.5 w-3.5" aria-hidden="true" />
</a>
{verified && email && (
collapsed ? (
<div
className="mt-2.5 flex items-center justify-center rounded-lg py-2"
style={{ border: "1px solid #222", background: "rgba(255,255,255,0.02)" }}
title={`Linked email: ${email}`}
>
<ShieldCheck className="h-3.5 w-3.5 flex-shrink-0 text-emerald-400" aria-hidden="true" />
</div>
) : (
<div
className="mt-2.5 flex items-center gap-2 rounded-lg px-2.5 py-2"
style={{ border: "1px solid #222", background: "rgba(255,255,255,0.02)" }}
>
<ShieldCheck className="h-3.5 w-3.5 flex-shrink-0 text-emerald-400" aria-hidden="true" />
<div className="min-w-0 flex-1">
<p className="truncate text-[11px] text-[#666]">Linked email</p>
<p className="truncate text-xs text-[#aaa]" title={email}>{email}</p>
</div>
<button
onClick={onForget}
title="Forget this email on this machine"
className="flex-shrink-0 cursor-pointer text-[#666] transition-colors hover:text-white"
aria-label="Forget"
>
<LogOut className="h-3.5 w-3.5" />
</button>
</div>
)
)}
</div>
{/* One single ordered list, no section headers. */}
<div className="mt-6 space-y-0.5">
<NavItem
icon={FileText}
label="Overview"
desc="This run's executive report"
active={view === "overview"}
onClick={() => onSelectView("overview")}
collapsed={collapsed}
/>
<NavItem
icon={Bug}
label="Issues"
desc="Findings from this run"
count={issuesCount > 0 ? issuesCount : undefined}
active={view === "issues"}
onClick={() => onSelectView("issues")}
collapsed={collapsed}
/>
{agentCount > 0 && (
<NavItem
icon={Waypoints}
label="Agents"
desc="What each agent did"
count={agentCount}
active={view === "agents"}
onClick={() => onSelectView("agents")}
collapsed={collapsed}
/>
)}
<NavItem
icon={History}
label="Past runs"
desc="Every run on this machine"
count={runCount > 0 ? runCount : undefined}
active={view === "history"}
onClick={onOpenHistory}
collapsed={collapsed}
/>
<NavItem
icon={Mail}
label="Email report"
desc="Get an encrypted PDF by email"
active={view === "email"}
onClick={onOpenEmail}
collapsed={collapsed}
/>
{PLATFORM_ORDER.map((slug) => {
const feature = FEATURES[slug];
if (!feature) return null;
return (
<ProNavItem
key={slug}
feature={feature}
active={view === "feature" && activeFeature === slug}
onClick={() => onSelectFeature(slug)}
collapsed={collapsed}
/>
);
})}
</div>
</div>
</aside>
);
}
function NavItem({
icon: Icon,
label,
desc,
count,
active,
onClick,
collapsed = false,
}: {
icon: React.ElementType;
label: string;
desc: string;
count?: number;
active?: boolean;
onClick: () => void;
collapsed?: boolean;
}) {
if (collapsed) {
return (
<button
onClick={onClick}
title={count != null ? `${label} (${count})` : label}
className={`flex w-full cursor-pointer items-center justify-center rounded-md px-2.5 py-2 transition-colors ${
active
? "text-white"
: "text-[#888] hover:bg-[rgba(255,255,255,0.06)] hover:text-white"
}`}
style={active ? { background: "rgba(255,255,255,0.12)" } : undefined}
>
<Icon className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
</button>
);
}
return (
<button
onClick={onClick}
className={`flex w-full cursor-pointer items-start gap-2.5 rounded-md px-2.5 py-1.5 text-left transition-colors ${
active
? "text-white"
: "text-[#888] hover:bg-[rgba(255,255,255,0.06)] hover:text-white"
}`}
style={active ? { background: "rgba(255,255,255,0.12)" } : undefined}
>
<Icon className="mt-0.5 h-4 w-4 flex-shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span className="flex-1 truncate text-sm">{label}</span>
{count != null && <span className="text-xs text-[#666] tabular-nums">{count}</span>}
</span>
<span className="mt-0.5 block text-[11px] leading-snug text-[#666]">{desc}</span>
</span>
</button>
);
}
@@ -0,0 +1,51 @@
import { useState } from "react";
import { ShieldCheck, X } from "lucide-react";
const DISMISS_KEY = "strix_viewer_trust_dismissed";
/**
* One-time privacy notice, shown as a toast pinned over the sidebar. Dismissing
* it persists to localStorage so it never returns on reload or view changes.
*/
export function TrustToast({ message }: { message: string }) {
const [dismissed, setDismissed] = useState<boolean>(() => {
try {
return localStorage.getItem(DISMISS_KEY) === "1";
} catch {
return false;
}
});
if (dismissed) return null;
const dismiss = () => {
try {
localStorage.setItem(DISMISS_KEY, "1");
} catch {
/* non-fatal: worst case the toast shows again next session */
}
setDismissed(true);
};
return (
<div
className="fixed bottom-3 left-3 z-[60] max-w-xs rounded-lg bg-[#0a0a0a] p-3 shadow-2xl"
style={{ border: "1px solid #2a2a2a" }}
role="status"
>
<div className="flex gap-2.5">
<ShieldCheck className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400" aria-hidden="true" />
<p className="text-xs leading-relaxed text-[#aaa]">{message}</p>
<button
onClick={dismiss}
aria-label="Dismiss"
className="-mr-0.5 -mt-0.5 flex-shrink-0 cursor-pointer rounded p-0.5 text-[#666] transition-colors hover:text-white"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}
export default TrustToast;
@@ -0,0 +1,117 @@
import { useCallback, useEffect, useRef } from "react";
import { X } from "lucide-react";
import { AgentTranscript } from "./AgentTranscript";
import { ScanPromptComposer } from "./ScanPromptComposer";
import type { TranscriptAgent, TranscriptEvent } from "@/data/serverSource";
/** Status -> the small leading dot color, matching the graph node styling. */
const STATUS_DOT: Record<string, string> = {
completed: "bg-emerald-400",
running: "bg-blue-400",
waiting: "bg-yellow-400",
stopped: "bg-[#888]",
crashed: "bg-red-400",
failed: "bg-red-400",
};
/** Consider the user "at the bottom" within this many px. */
const NEAR_BOTTOM_PX = 80;
/**
* Overlay modal showing a single agent's full transcript. Matches the cloud
* app: a fixed-size panel with a pinned header (status dot + agent name), the
* transcript scrolling beneath it, and a footer. Auto-scrolls to follow new
* activity while the user is near the bottom (so a live run trails). Closes on
* backdrop click, the X button, or Escape.
*/
export function AgentDetailModal({
agent,
events,
steerable,
onClose,
}: {
agent: TranscriptAgent;
events: TranscriptEvent[];
steerable: boolean;
onClose: () => void;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const nearBottom = useRef(false);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
nearBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX;
}, []);
// Follow new activity when the user is near the bottom (live trailing).
useEffect(() => {
const el = scrollRef.current;
if (!el || !nearBottom.current) return;
requestAnimationFrame(() => {
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
});
}, [events]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [onClose]);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 sm:p-8"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label={`Agent ${agent.name}`}
>
<div
className="relative flex h-[80vh] w-full max-w-5xl flex-col rounded-xl border border-[#222] bg-[#0a0a0a] shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-[#222] px-5 py-3.5">
<div className="flex min-w-0 items-center gap-2">
<span
className={`h-2 w-2 flex-shrink-0 rounded-full ${STATUS_DOT[agent.status] ?? "bg-[#888]"}`}
/>
<span className="truncate text-sm font-semibold text-white">{agent.name}</span>
<span className="flex-shrink-0 font-mono text-xs text-[#555]">{agent.id}</span>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="flex-shrink-0 rounded-md p-1 text-[#888] transition-colors hover:bg-[#1a1a1a] hover:text-white"
>
<X className="h-4 w-4" />
</button>
</div>
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto p-5">
<AgentTranscript agent={agent} events={events} showHeader={false} />
</div>
{steerable && (
<div className="border-t border-[#222] px-5 py-3">
<ScanPromptComposer
agents={[agent]}
fixedAgentId={agent.id}
className="mt-0"
/>
</div>
)}
</div>
</div>
);
}
export default AgentDetailModal;
@@ -0,0 +1,254 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
ReactFlow,
Background,
Controls,
MiniMap,
useNodesState,
useEdgesState,
useReactFlow,
type Node,
type Edge,
} from "@xyflow/react";
import dagre from "@dagrejs/dagre";
import AgentNodeComponent from "./AgentNode";
import GraphSkeleton from "./GraphSkeleton";
import type { AgentNode } from "@/types/events";
import "@xyflow/react/dist/style.css";
const NODE_WIDTH = 260;
const NODE_HEIGHT = 80;
const nodeTypes = { agentNode: AgentNodeComponent };
function getLayoutedElements(
agents: Map<string, AgentNode>,
selectedAgentId: string | null
) {
const g = new dagre.graphlib.Graph();
g.setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir: "TB", nodesep: 60, ranksep: 80 });
const nodes: Node[] = [];
const edges: Edge[] = [];
for (const [id, agent] of agents) {
g.setNode(id, { width: NODE_WIDTH, height: NODE_HEIGHT });
nodes.push({
id,
type: "agentNode",
position: { x: 0, y: 0 },
data: { ...agent, isSelected: id === selectedAgentId },
});
if (agent.parentId && agents.has(agent.parentId)) {
const edgeId = `${agent.parentId}->${id}`;
g.setEdge(agent.parentId, id);
edges.push({
id: edgeId,
source: agent.parentId,
target: id,
style: { stroke: "#2a2a2a", strokeWidth: 1.5 },
});
}
}
dagre.layout(g);
for (const node of nodes) {
const pos = g.node(node.id);
if (pos) {
node.position = {
x: pos.x - NODE_WIDTH / 2,
y: pos.y - NODE_HEIGHT / 2,
};
}
}
return { nodes, edges };
}
const ZOOM_DURATION = 300;
/** Centers viewport on the root node (no parentId) at a fixed zoom — only once on first load */
function CenterOnRoot({ nodes }: { nodes: Node[] }) {
const { setCenter } = useReactFlow();
const hasCentered = useRef(false);
useEffect(() => {
if (nodes.length > 0 && !hasCentered.current) {
const root = nodes.find((n) => !(n.data as Record<string, unknown>).parentId);
const target = root ?? nodes[0];
hasCentered.current = true;
const cx = target.position.x + NODE_WIDTH / 2;
const cy = target.position.y + NODE_HEIGHT / 2;
setTimeout(() => setCenter(cx, cy, { zoom: 0.85, duration: 400 }), 60);
}
}, [nodes, setCenter]);
return null;
}
function SmoothControls() {
const { zoomIn, zoomOut, fitView } = useReactFlow();
return (
<Controls
position="bottom-right"
showZoom={false}
showFitView={false}
showInteractive={false}
className="!bg-transparent !border-none !shadow-none"
>
<div className="flex flex-col overflow-hidden rounded-lg border border-[#222]">
<button onClick={() => zoomIn({ duration: ZOOM_DURATION })} className="flex items-center justify-center w-7 h-7 bg-[#111] text-white hover:bg-[#2a2a2a] transition-colors" title="Zoom in">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="w-3.5 h-3.5"><path d="M12 5v14M5 12h14" /></svg>
</button>
<button onClick={() => zoomOut({ duration: ZOOM_DURATION })} className="flex items-center justify-center w-7 h-7 bg-[#111] text-white hover:bg-[#2a2a2a] border-y border-[#222] transition-colors" title="Zoom out">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="w-3.5 h-3.5"><path d="M5 12h14" /></svg>
</button>
<button onClick={() => fitView({ padding: 0.3, duration: ZOOM_DURATION })} className="flex items-center justify-center w-7 h-7 bg-[#111] text-white hover:bg-[#2a2a2a] transition-colors" title="Fit view">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="w-3.5 h-3.5"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" /></svg>
</button>
</div>
</Controls>
);
}
interface AgentGraphProps {
agents: Map<string, AgentNode>;
selectedAgentId: string | null;
onSelectAgent: (id: string | null) => void;
eventsLoaded?: boolean;
eventsEmpty?: boolean;
scanCompleted?: boolean;
}
export default function AgentGraph({
agents,
selectedAgentId,
onSelectAgent,
eventsLoaded,
eventsEmpty,
scanCompleted,
}: AgentGraphProps) {
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
useEffect(() => {
if (agents.size === 0) return;
const { nodes: ln, edges: le } = getLayoutedElements(agents, selectedAgentId);
setNodes(ln);
setEdges(le);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agents.size, setNodes, setEdges]);
// Sync agent data (status, name, etc.) into existing nodes without re-layout
useEffect(() => {
if (agents.size === 0) return;
setNodes((nds) =>
nds.map((n) => {
const agent = agents.get(n.id);
if (!agent) return n;
return { ...n, data: { ...agent, isSelected: n.id === selectedAgentId } };
})
);
}, [agents, selectedAgentId, setNodes]);
const nodeClickedRef = useRef(false);
const onNodeClick = useCallback(
(_: React.MouseEvent, node: Node) => {
nodeClickedRef.current = true;
onSelectAgent(node.id);
},
[onSelectAgent]
);
const onPaneClick = useCallback(() => {
if (nodeClickedRef.current) {
nodeClickedRef.current = false;
return;
}
onSelectAgent(null);
}, [onSelectAgent]);
// Convex responded, zero events — show empty state (not skeleton)
if (agents.size === 0 && eventsLoaded && eventsEmpty) {
return (
<div className="flex flex-col items-center justify-center h-full text-center px-4">
<div className="w-10 h-10 mb-3 rounded-full bg-[#111] flex items-center justify-center">
{scanCompleted ? (
<svg className="w-5 h-5 text-[#444]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25a2.25 2.25 0 0 1-2.25-2.25v-2.25Z" />
</svg>
) : (
<div className="w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
)}
</div>
<p className="text-sm text-[#555]">
{scanCompleted
? "Agent trace data is not available for this pentest"
: "Waiting for agent data\u2026"}
</p>
</div>
);
}
const showGraph = agents.size > 0;
return (
<div className="relative h-full">
{/* Skeleton overlay — fades out when graph is ready */}
<div
className={`absolute inset-0 z-10 transition-opacity duration-500 ${
showGraph ? "opacity-0 pointer-events-none" : "opacity-100"
}`}
>
<GraphSkeleton />
</div>
{/* Graph — fades in */}
<div
className={`h-full transition-opacity duration-500 ${
showGraph ? "opacity-100" : "opacity-0"
}`}
>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
nodeTypes={nodeTypes}
nodesConnectable={false}
edgesFocusable={false}
edgesReconnectable={false}
minZoom={0.15}
maxZoom={1.5}
proOptions={{ hideAttribution: true }}
className="bg-black"
>
<Background color="#111" gap={20} />
<CenterOnRoot nodes={nodes} />
<SmoothControls />
<MiniMap
position="bottom-left"
nodeColor={(n) => {
const status = (n.data as Record<string, unknown>)?.status as string;
if (status === "running") return "#3b82f6";
if (status === "completed") return "#10b981";
if (status === "failed" || status === "error") return "#ef4444";
return "#555";
}}
maskColor="rgba(0,0,0,0.8)"
style={{ width: 80, height: 50 }}
className="!bg-[#0a0a0a] !border-[#222]"
/>
</ReactFlow>
</div>
</div>
);
}
@@ -0,0 +1,48 @@
"use client";
import { memo } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import type { AgentNode as AgentNodeData } from "@/types/events";
const STATUS_STYLES: Record<string, string> = {
running: "bg-blue-500",
completed: "bg-emerald-500",
failed: "bg-red-500",
error: "bg-red-500",
};
function AgentNodeComponent({ data, selected }: NodeProps) {
const agent = data as unknown as AgentNodeData & { isSelected: boolean };
return (
<div
className={`w-[260px] rounded-lg border px-4 py-3 transition-colors ${
agent.isSelected || selected
? "border-white/30 bg-[#0a0a0a]"
: "border-[#222] bg-black hover:border-[#333]"
}`}
>
<Handle type="target" position={Position.Top} isConnectable={false} className={`!w-1.5 !h-1.5 !border-0 ${agent.parentId ? "!bg-[#444]" : "!bg-transparent"}`} />
<div className="flex items-center gap-2">
<span className="relative flex h-2 w-2 shrink-0">
<span
className={`absolute inline-flex h-full w-full rounded-full opacity-75 ${STATUS_STYLES[agent.status] ?? "bg-gray-500"} ${
agent.status === "running" ? "animate-ping" : ""
}`}
/>
<span
className={`relative inline-flex h-2 w-2 rounded-full ${STATUS_STYLES[agent.status] ?? "bg-gray-500"}`}
/>
</span>
<span className="text-sm font-semibold text-white leading-snug line-clamp-3">
{agent.name}
</span>
</div>
<Handle type="source" position={Position.Bottom} isConnectable={false} className={`!w-1.5 !h-1.5 !border-0 ${agent.children && agent.children.length > 0 ? "!bg-[#444]" : "!bg-transparent"}`} />
</div>
);
}
export default memo(AgentNodeComponent);
@@ -0,0 +1,302 @@
import { Component, useMemo, type ReactNode } from "react";
import { Brain, Bot } from "lucide-react";
import { getToolRenderer, getToolIcon } from "./tool-renderers";
import ChatBubble from "./tool-renderers/ChatBubble";
import type { ToolRendererProps, AgentNode as GraphAgentNode } from "@/types/events";
import type { TranscriptAgent, TranscriptEvent } from "@/data/serverSource";
/* ---------- Error boundary so one bad event never blanks the transcript ---------- */
class RendererErrorBoundary extends Component<
{ toolName: string; children: ReactNode },
{ hasError: boolean }
> {
constructor(props: { toolName: string; children: ReactNode }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return (
<span className="text-[#555] font-semibold text-sm">
{this.props.toolName.replace(/_/g, " ")}
</span>
);
}
return this.props.children;
}
}
function SafeToolRenderer(props: ToolRendererProps) {
const Renderer = getToolRenderer(props.toolName);
return (
<RendererErrorBoundary toolName={props.toolName}>
<Renderer {...props} />
</RendererErrorBoundary>
);
}
/* ---------- Value coercion ----------
* args/result arrive as either a JSON object or a Python-repr string
* ("{'thought': '...'}"). Try JSON, then a naive python->json pass, then wrap
* the raw string so the fallback renderer can display it. Never throws. */
function coerce(value: unknown): unknown {
if (value == null || typeof value !== "string") return value;
const t = value.trim();
if (!t) return value;
try {
return JSON.parse(t);
} catch {
/* not JSON */
}
try {
const jsonish = t
.replace(/\bNone\b/g, "null")
.replace(/\bTrue\b/g, "true")
.replace(/\bFalse\b/g, "false")
.replace(/'/g, '"');
return JSON.parse(jsonish);
} catch {
return { __raw: value };
}
}
function asRecord(value: unknown): Record<string, unknown> {
const c = coerce(value);
if (c && typeof c === "object" && !Array.isArray(c)) return c as Record<string, unknown>;
if (c == null) return {};
return { __raw: typeof c === "string" ? c : JSON.stringify(c) };
}
/** Numeric suffix of an event id ("tool_37" -> 37) for stable ordering. */
function eventSeq(id: string): number {
const m = /(\d+)$/.exec(id);
return m ? parseInt(m[1], 10) : 0;
}
/** A chat event whose author is the human/user (vs an assistant "thinking"). */
function isUserChat(event: TranscriptEvent): boolean {
const role = event.data?.role;
return event.type === "chat" && (role === "user" || role === "human");
}
/**
* Inter-agent message deliveries land in the recipient's session as user-role
* items prefixed with a header (see the engine's message formatter). They are
* already represented via the sending agent's tool renderer, so we never render
* them as chat bubbles here.
*/
function isInterAgentDelivery(event: TranscriptEvent): boolean {
return isUserChat(event) && String(event.data?.content ?? "").startsWith("[Message from ");
}
/**
* The reconstructed SDK session records every incoming user-role item for an
* agent: its initial input (the root's assembled brief, or a subagent's spawn /
* inherited-context prompt), inter-agent deliveries, AND genuine human steering
* messages sent live from the viewer or TUI. We only want the last group. Given
* an agent's events in order, hide the first user message (its initial input)
* and every inter-agent delivery; keep the rest, which are the human's live
* instructions, rendered as "User" bubbles.
*/
function hiddenUserEventIds(agentEventsInOrder: TranscriptEvent[]): Set<string> {
const hidden = new Set<string>();
let sawInitialInput = false;
for (const e of agentEventsInOrder) {
if (!isUserChat(e)) continue;
if (isInterAgentDelivery(e)) {
hidden.add(e.id);
continue;
}
if (!sawInitialInput) {
sawInitialInput = true;
hidden.add(e.id);
}
}
return hidden;
}
const STATUS_STYLE: Record<string, string> = {
completed: "text-emerald-400 border-emerald-500/30 bg-emerald-500/10",
running: "text-blue-400 border-blue-500/30 bg-blue-500/10",
waiting: "text-yellow-400 border-yellow-500/30 bg-yellow-500/10",
stopped: "text-[#aaa] border-[#333] bg-[#1a1a1a]",
crashed: "text-red-400 border-red-500/30 bg-red-500/10",
failed: "text-red-400 border-red-500/30 bg-red-500/10",
};
/** Map our engine agent statuses onto the graph node's status union. */
function graphStatus(status: string): GraphAgentNode["status"] {
if (status === "completed") return "completed";
if (status === "running") return "running";
if (status === "failed" || status === "crashed") return "failed";
// waiting / stopped / unknown → keep the raw string; AgentNode/MiniMap fall
// back to a neutral gray for anything they don't explicitly style.
return status as GraphAgentNode["status"];
}
/**
* Adapt transcript agents + events into the Map<id, AgentNode> that the live
* AgentGraph renders: children from parent_id, tool/message counts by scanning
* events, and a task pulled from the spawning create_agent call where present.
*/
export function buildGraphAgents(
agents: TranscriptAgent[],
events: TranscriptEvent[]
): Map<string, GraphAgentNode> {
const childrenOf = new Map<string, string[]>();
for (const a of agents) {
if (a.parent_id) {
const arr = childrenOf.get(a.parent_id) ?? [];
arr.push(a.id);
childrenOf.set(a.parent_id, arr);
}
}
const toolCount = new Map<string, number>();
const messageCount = new Map<string, number>();
// A create_agent call names the child but not its id, so map spawned tasks by
// agent NAME (best-effort — used only for the graph node subtitle).
const taskByName = new Map<string, string>();
for (const e of events) {
if (e.type === "tool") {
toolCount.set(e.agent_id, (toolCount.get(e.agent_id) ?? 0) + 1);
if (e.data?.tool_name === "create_agent") {
const args = asRecord(e.data.args);
const name = (args.name as string) ?? (args.agent_name as string) ?? "";
const task = (args.task as string) ?? "";
if (name && task) taskByName.set(name, task);
}
} else if (!isUserChat(e)) {
// Count only assistant messages for the graph node subtitle.
messageCount.set(e.agent_id, (messageCount.get(e.agent_id) ?? 0) + 1);
}
}
const map = new Map<string, GraphAgentNode>();
for (const a of agents) {
map.set(a.id, {
id: a.id,
name: a.name,
task: taskByName.get(a.name) ?? "",
status: graphStatus(a.status),
parentId: a.parent_id,
children: childrenOf.get(a.id) ?? [],
createdAt: a.created_at,
toolCount: toolCount.get(a.id) ?? 0,
messageCount: messageCount.get(a.id) ?? 0,
});
}
return map;
}
/* ---------- Per-agent transcript ---------- */
export function AgentTranscript({
agent,
events,
showHeader = true,
}: {
agent: TranscriptAgent;
events: TranscriptEvent[];
showHeader?: boolean;
}) {
const mine = useMemo(() => {
const ordered = events
.filter((e) => e.agent_id === agent.id)
.sort((a, b) => eventSeq(a.id) - eventSeq(b.id));
const hidden = hiddenUserEventIds(ordered);
return ordered.filter((e) => !hidden.has(e.id));
}, [events, agent.id]);
const toolCount = mine.filter((e) => e.type === "tool").length;
const msgCount = mine.length - toolCount;
return (
<div>
{showHeader && (
<>
<div className="flex items-center gap-2 flex-wrap mb-1">
<span className="text-base font-semibold text-white truncate">{agent.name}</span>
<span
className={`flex-shrink-0 text-xs font-medium capitalize px-2 py-0.5 rounded-full border ${
STATUS_STYLE[agent.status] ?? "text-[#aaa] border-[#333] bg-[#1a1a1a]"
}`}
>
{agent.status}
</span>
<span className="font-mono text-xs text-[#555]">{agent.id}</span>
</div>
<p className="text-xs text-[#666] mb-4">
{msgCount} message{msgCount === 1 ? "" : "s"} · {toolCount} tool call
{toolCount === 1 ? "" : "s"}
</p>
</>
)}
{mine.length === 0 ? (
<p className="text-sm text-[#666]">No recorded activity for this agent.</p>
) : (
<div className="py-1">
{mine.map((event, i) => {
const isLast = i === mine.length - 1;
const isTool = event.type === "tool";
const toolName = isTool ? String(event.data?.tool_name ?? "tool") : "";
const role = !isTool ? String(event.data?.role ?? "assistant") : "";
let Icon;
let iconColor: string;
if (isTool) {
const meta = getToolIcon(toolName);
Icon = meta.icon;
iconColor = meta.color;
} else {
const isUser = role === "user" || role === "human";
Icon = isUser ? Bot : Brain;
iconColor = isUser ? "text-blue-400" : "text-purple-400";
}
const status = isTool ? String(event.data?.status ?? "completed") : "completed";
return (
<div key={event.id} className="flex gap-3">
<div className="flex flex-col items-center shrink-0">
<div
className={`w-[30px] h-[30px] rounded-full bg-black border flex items-center justify-center shrink-0 ${
isTool && status === "running"
? "border-blue-500/40 animate-pulse"
: isTool && status === "failed"
? "border-red-500/30"
: "border-[#222]"
}`}
>
<Icon className={`w-3.5 h-3.5 ${iconColor}`} />
</div>
{!isLast && <div className="w-px flex-1 bg-[#1a1a1a] mt-1" />}
</div>
<div className="flex-1 min-w-0 pt-[5px] pb-6">
{isTool ? (
<SafeToolRenderer
toolName={toolName}
args={asRecord(event.data?.args)}
result={coerce(event.data?.result) ?? null}
status={
status as ToolRendererProps["status"]
}
/>
) : (
<ChatBubble
role={role}
content={String(event.data?.content ?? "")}
/>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,70 @@
"use client";
function SkeletonNode({ w = 24 }: { w?: number }) {
return (
<div className="w-[180px] h-[72px] rounded-lg border border-[#222] bg-[#0a0a0a] px-3 py-2 shrink-0">
<div className="flex items-center gap-2 mb-1.5">
<div className="w-2 h-2 rounded-full bg-[#2a2a2a]" />
<div className="h-3 rounded bg-[#252525]" style={{ width: `${w * 4}px` }} />
</div>
<div className="h-2 w-28 rounded bg-[#1e1e1e] mb-1.5" />
<div className="flex gap-3">
<div className="h-2 w-8 rounded bg-[#1e1e1e]" />
<div className="h-2 w-8 rounded bg-[#1e1e1e]" />
</div>
</div>
);
}
function VLine() {
return <div className="w-px h-6 bg-[#2a2a2a]" />;
}
function HBranch({ count }: { count: number }) {
return (
<div className="relative flex justify-center">
<div className="absolute top-0 h-px bg-[#2a2a2a]" style={{ width: `${(count - 1) * 220}px` }} />
</div>
);
}
export default function GraphSkeleton() {
return (
<div className="h-full bg-black overflow-hidden">
<div className="flex flex-col items-center pt-10 animate-pulse">
<SkeletonNode w={20} />
<VLine />
<HBranch count={3} />
<div className="flex gap-10">
{[18, 22, 16].map((w, i) => (
<div key={i} className="flex flex-col items-center">
<VLine />
<SkeletonNode w={w} />
</div>
))}
</div>
<div className="flex gap-10 w-full justify-center">
<div className="flex flex-col items-center">
<VLine />
<HBranch count={2} />
<div className="flex gap-10">
{[14, 20].map((w, i) => (
<div key={i} className="flex flex-col items-center">
<VLine />
<SkeletonNode w={w} />
</div>
))}
</div>
</div>
<div className="flex flex-col items-center">
<VLine />
<SkeletonNode w={18} />
<VLine />
<SkeletonNode w={12} />
</div>
<div className="w-[180px]" />
</div>
</div>
</div>
);
}
@@ -0,0 +1,315 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { ArrowUp, ChevronDown, ChevronUp, Loader2, Sparkles } from "lucide-react";
import { steerAgent, type TranscriptAgent } from "@/data/serverSource";
import { track } from "@/lib/cta";
import { cn } from "@/lib/utils";
const ROOT_TARGET_VALUE = "__root__";
interface ScanPromptComposerProps {
/** All agents in the run; used to resolve the root and running children. */
agents: TranscriptAgent[];
/**
* Single-agent (modal) mode: pins the composer to one agent and shows a
* static "Target: <name>" pill instead of the dropdown. Omit for the
* multi-agent graph variant.
*/
fixedAgentId?: string;
className?: string;
}
/**
* Faithful port of the pro app's ScanPromptComposer for the local viewer.
* Collapsed by default into a "Guide the agent" pill; expands into a card with
* an auto-resizing textarea and a target control. The viewer's steering is
* immediate (no Enterprise lock, no bridge-connecting state), so this is only
* rendered by callers when steering is available. Sends via steerAgent, which
* requires a concrete agent id, so "Root agent" resolves to the root agent's id.
*/
export function ScanPromptComposer({
agents,
fixedAgentId,
className,
}: ScanPromptComposerProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [expanded, setExpanded] = useState(false);
const [focused, setFocused] = useState(false);
const [value, setValue] = useState("");
const [sending, setSending] = useState(false);
const [feedback, setFeedback] = useState<string | null>(null);
const isModal = fixedAgentId != null;
// Root = the agent with no parent; fall back to the first agent.
const rootAgent = useMemo(
() => agents.find((a) => !a.parent_id) ?? agents[0] ?? null,
[agents]
);
// Multi-agent dropdown options: running child agents plus Root (added in JSX).
const targetOptions = useMemo(
() => agents.filter((a) => a.parent_id && a.status === "running"),
[agents]
);
// Selected target for the multi-agent variant. ROOT sentinel by default.
const [selectedTarget, setSelectedTarget] = useState<string>(ROOT_TARGET_VALUE);
const [menuOpen, setMenuOpen] = useState(false);
// If the selected child target disappears (finished), fall back to Root.
useEffect(() => {
if (
selectedTarget !== ROOT_TARGET_VALUE &&
!targetOptions.some((a) => a.id === selectedTarget)
) {
setSelectedTarget(ROOT_TARGET_VALUE);
}
}, [selectedTarget, targetOptions]);
// Resolve the concrete agent id + display name for the current target.
const { targetId, targetName } = useMemo(() => {
if (isModal) {
const agent = agents.find((a) => a.id === fixedAgentId) ?? null;
return {
targetId: fixedAgentId ?? null,
targetName: agent?.name ?? "this agent",
};
}
if (selectedTarget === ROOT_TARGET_VALUE) {
return {
targetId: rootAgent?.id ?? null,
targetName: "Root agent",
};
}
const agent = agents.find((a) => a.id === selectedTarget) ?? null;
return {
targetId: agent?.id ?? rootAgent?.id ?? null,
targetName: agent?.name ?? "Root agent",
};
}, [agents, fixedAgentId, isModal, rootAgent, selectedTarget]);
const empty = value.trim().length === 0;
// Grow the textarea with its content, capped by max-h via CSS.
useLayoutEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, [value]);
const handleExpand = useCallback(() => {
setExpanded(true);
requestAnimationFrame(() => textareaRef.current?.focus());
}, []);
const handleCollapse = useCallback(() => {
setExpanded(false);
setFocused(false);
setMenuOpen(false);
}, []);
const handleSend = useCallback(async () => {
if (sending) return;
const message = value.trim();
if (!message || !targetId) return;
setSending(true);
setFeedback(null);
const name = targetName;
const res = await steerAgent(targetId, message);
setSending(false);
if (res.ok) {
setValue("");
setFeedback(`Sent to ${name}`);
track("agent_steered");
} else if (res.error === "not_delivered") {
setFeedback("Could not reach that agent (it may have finished).");
} else {
setFeedback("Could not send that message. Try again.");
}
}, [sending, value, targetId, targetName]);
if (!expanded) {
return (
<button
type="button"
onClick={handleExpand}
className={cn(
"mt-4 flex w-full items-center justify-between gap-3 rounded-2xl border border-white/[0.08] bg-[#050505] px-5 py-3 text-left transition-colors duration-300 hover:border-white/[0.12] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20",
className
)}
aria-expanded={false}
aria-label="Expand live prompt composer"
>
<div className="flex min-w-0 items-center gap-2">
<Sparkles className="h-4 w-4 shrink-0 text-[#666]" />
<span className="truncate text-sm font-medium text-white">Guide the agent</span>
</div>
<ChevronUp className="h-4 w-4 shrink-0 text-[#777]" />
</button>
);
}
return (
<div
className={cn(
"mt-4 rounded-2xl border border-white/[0.08] bg-[#050505] overflow-hidden transition-colors duration-300",
focused ? "border-white/[0.18]" : "hover:border-white/[0.12]",
className
)}
>
<div className="flex items-center justify-between gap-3 border-b border-white/[0.06] px-5 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-[#666]" />
<p className="text-sm font-medium text-white">Live prompt</p>
</div>
<p className="mt-0.5 text-xs text-[#777]">Connected</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{isModal ? (
<div className="rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1 text-xs text-[#aaa]">
Target: <span className="text-white">{targetName}</span>
</div>
) : (
<div className="flex items-center gap-1.5">
<span className="text-xs text-[#aaa]">Target:</span>
<div className="relative">
<button
type="button"
onClick={() => setMenuOpen((o) => !o)}
onBlur={() => requestAnimationFrame(() => setMenuOpen(false))}
className="inline-flex h-7 items-center gap-1 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 text-xs text-white transition-colors hover:border-white/[0.16] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20"
aria-haspopup="listbox"
aria-expanded={menuOpen}
>
<span className="max-w-[140px] truncate">{targetName}</span>
<ChevronDown className="h-3.5 w-3.5 text-[#999]" />
</button>
{menuOpen && (
<div
className="absolute right-0 z-10 mt-1 min-w-[160px] overflow-hidden rounded-lg border border-[#333] bg-[#0a0a0a] py-1 shadow-xl"
role="listbox"
>
<TargetMenuItem
label="Root agent"
active={selectedTarget === ROOT_TARGET_VALUE}
onSelect={() => {
setSelectedTarget(ROOT_TARGET_VALUE);
setMenuOpen(false);
}}
/>
{targetOptions.map((option) => (
<TargetMenuItem
key={option.id}
label={option.name}
active={selectedTarget === option.id}
onSelect={() => {
setSelectedTarget(option.id);
setMenuOpen(false);
}}
/>
))}
</div>
)}
</div>
</div>
)}
<button
type="button"
onClick={handleCollapse}
className="inline-flex h-7 w-7 items-center justify-center rounded-full text-[#777] transition-colors hover:bg-white/[0.06] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20"
aria-label="Collapse live prompt composer"
>
<ChevronDown className="h-4 w-4" />
</button>
</div>
</div>
<div className="px-5 pt-4 pb-3">
<textarea
ref={textareaRef}
rows={1}
value={value}
onChange={(e) => setValue(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
}}
placeholder="Send a live prompt to the running scan…"
maxLength={4000}
disabled={sending}
className="block w-full resize-none border-0 bg-transparent p-0 text-[15px] leading-6 text-white placeholder:text-[#444] focus:outline-none disabled:opacity-60 max-h-[160px] overflow-y-auto"
/>
</div>
<div className="flex items-center justify-between gap-3 px-4 pb-4">
<div className="text-xs text-[#666]">{feedback ?? "Press Enter to send."}</div>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
void handleSend();
}}
disabled={sending || empty}
className={cn(
"inline-flex h-10 min-w-[112px] items-center justify-center gap-2 rounded-full px-4 text-sm font-medium transition-colors",
sending || empty
? "bg-white/[0.08] text-[#666]"
: "bg-white text-black hover:bg-neutral-200"
)}
>
{sending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowUp className="h-4 w-4" strokeWidth={2.5} />
)}
<span>Send prompt</span>
</button>
</div>
</div>
);
}
function TargetMenuItem({
label,
active,
onSelect,
}: {
label: string;
active: boolean;
onSelect: () => void;
}) {
return (
<button
type="button"
role="option"
aria-selected={active}
// onMouseDown so the click lands before the trigger's onBlur closes the menu.
onMouseDown={(e) => {
e.preventDefault();
onSelect();
}}
className={cn(
"block w-full truncate px-3 py-1.5 text-left text-xs transition-colors hover:bg-white/[0.06]",
active ? "text-white" : "text-[#aaa]"
)}
>
{label}
</button>
);
}
export default ScanPromptComposer;
@@ -0,0 +1,92 @@
"use client";
import type { ToolRendererProps } from "@/types/events";
import { TruncatedText } from "./ToolCard";
export default function AgentCommsRenderer({ toolName, args }: ToolRendererProps) {
if (toolName === "create_agent") {
const name = (args.name as string) ?? (args.agent_name as string) ?? "";
const task = (args.task as string) ?? "";
return (
<div>
<div className="flex items-center gap-2">
<span className="text-cyan-400/80 font-semibold text-sm">spawning</span>
{name && <span className="text-cyan-400 font-semibold text-sm">{name}</span>}
</div>
{task && <div className="mt-1.5"><TruncatedText text={task} maxLines={15} /></div>}
</div>
);
}
if (toolName === "agent_finish") {
const summary = (args.result_summary as string) ?? "";
const success = args.success as boolean | undefined;
const rawFindings = args.findings;
const findings = Array.isArray(rawFindings) ? rawFindings as string[] : undefined;
return (
<div>
<span className={`font-semibold text-sm ${success === false ? "text-red-400/80" : "text-emerald-400/80"}`}>
{success === false ? "Agent failed" : "Agent completed"}
</span>
{summary && <div className="mt-1.5"><TruncatedText text={summary} maxLines={20} /></div>}
{findings && findings.length > 0 && (
<div className="mt-1.5 space-y-0.5">
{findings.map((f, i) => (
<div key={i} className="text-[13px] text-[#888]"><span className="text-red-400/50 mr-1"></span>{typeof f === "string" ? f : JSON.stringify(f)}</div>
))}
</div>
)}
</div>
);
}
if (toolName === "send_message_to_agent") {
const message = (args.message as string) ?? "";
const agentId = (args.target_agent_id as string) ?? (args.agent_id as string) ?? "";
return (
<div>
<div className="flex items-center gap-2">
<span className="text-cyan-400/80 font-semibold text-sm">message</span>
{agentId && <span className="text-[#888] text-[13px]">to {agentId.slice(0, 16)}</span>}
</div>
{message && <div className="mt-1.5"><TruncatedText text={message} maxLines={20} /></div>}
</div>
);
}
if (toolName === "wait_for_message") {
const reason = (args.reason as string) ?? "";
return (
<div className="flex items-center gap-2">
<span className="text-cyan-400/80 font-semibold text-sm">waiting</span>
{reason && <span className="text-[#888] text-[13px] truncate">{reason}</span>}
</div>
);
}
if (toolName === "stop_agent") {
const targetAgentId = (args.target_agent_id as string) ?? "";
const cascade = args.cascade !== false;
const reason = (args.reason as string) ?? "";
return (
<div>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-red-400/80 font-semibold text-sm">stopping</span>
{targetAgentId && <span className="text-[#888] text-[13px]">{targetAgentId.slice(0, 16)}</span>}
{cascade && <span className="text-[#555] text-[13px] italic">+ descendants</span>}
</div>
{reason && <div className="mt-1.5 text-[#888] text-[13px]">{reason}</div>}
</div>
);
}
if (toolName === "view_agent_graph") {
return (
<span className="text-cyan-400/80 font-semibold text-sm">viewing agents graph</span>
);
}
return (
<span className="text-cyan-400/80 font-semibold text-sm">{toolName.replace(/_/g, " ")}</span>
);
}
@@ -0,0 +1,124 @@
"use client";
import type { ToolRendererProps } from "@/types/events";
import { shortPath } from "./utils";
const DIFF_PREVIEW_LINES = 30;
const BEGIN_PATCH = "*** Begin Patch";
const END_PATCH = "*** End Patch";
const ADD_FILE = "*** Add File: ";
const UPDATE_FILE = "*** Update File: ";
const DELETE_FILE = "*** Delete File: ";
const OP_LABEL: Record<string, string> = { add: "create", update: "edit", delete: "delete" };
interface PatchOp {
kind: "add" | "update" | "delete";
path: string;
oldLines: string[];
newLines: string[];
}
/** apply_patch args arrive as {patch: text} (chat-completions FunctionTool) or
* {input: text} (CustomTool). Mirrors the OSS `_extract_patch_text`. */
function extractPatchText(args: Record<string, unknown>): string {
const raw = args.patch;
if (typeof raw === "string") return raw;
if (raw && typeof raw === "object" && typeof (raw as Record<string, unknown>).patch === "string") {
return (raw as Record<string, string>).patch;
}
return typeof args.input === "string" ? args.input : "";
}
/** Parse V4A patch text into per-file operations (mirrors `_parse_patch_operations`). */
function parsePatchOperations(patchText: string): PatchOp[] {
const ops: PatchOp[] = [];
let current: PatchOp | null = null;
const flush = () => {
if (current) ops.push(current);
current = null;
};
for (const line of patchText.split("\n")) {
if (line === BEGIN_PATCH || line === END_PATCH) continue;
if (line.startsWith(ADD_FILE)) {
flush();
current = { kind: "add", path: line.slice(ADD_FILE.length).trim(), oldLines: [], newLines: [] };
} else if (line.startsWith(UPDATE_FILE)) {
flush();
current = { kind: "update", path: line.slice(UPDATE_FILE.length).trim(), oldLines: [], newLines: [] };
} else if (line.startsWith(DELETE_FILE)) {
flush();
current = { kind: "delete", path: line.slice(DELETE_FILE.length).trim(), oldLines: [], newLines: [] };
} else if (current?.kind === "update") {
if (line.startsWith("@@")) continue;
if (line.startsWith("-") && !line.startsWith("---")) current.oldLines.push(line.slice(1));
else if (line.startsWith("+") && !line.startsWith("+++")) current.newLines.push(line.slice(1));
} else if (current?.kind === "add") {
if (line.startsWith("+")) current.newLines.push(line.slice(1));
else if (line.trim()) current.newLines.push(line);
}
}
flush();
return ops;
}
function Operation({ op }: { op: PatchOp }) {
const label = OP_LABEL[op.kind] ?? "file";
const total = op.oldLines.length + op.newLines.length;
const truncated = total > DIFF_PREVIEW_LINES;
const oldBudget = truncated && total > 0 ? Math.round(DIFF_PREVIEW_LINES * (op.oldLines.length / total)) : op.oldLines.length;
const newBudget = truncated ? DIFF_PREVIEW_LINES - oldBudget : op.newLines.length;
return (
<div>
<div className="flex items-baseline gap-2">
<span className="text-sky-400/80 font-semibold text-sm shrink-0">{label}</span>
{op.path && <span className="text-[#888] font-mono text-[13px] break-all">{shortPath(op.path)}</span>}
</div>
{(op.oldLines.length > 0 || op.newLines.length > 0) && (
<div className="font-mono text-[13px] leading-relaxed mt-1.5">
{op.oldLines.slice(0, oldBudget).map((line, i) => (
<div key={`o${i}`} className="text-red-400/60">
<span className="select-none text-red-400/30 mr-1">-</span>{line}
</div>
))}
{op.newLines.slice(0, newBudget).map((line, i) => (
<div key={`n${i}`} className="text-emerald-400/60">
<span className="select-none text-emerald-400/30 mr-1">+</span>{line}
</div>
))}
{truncated && <div className="text-[#444] mt-0.5">... {total - DIFF_PREVIEW_LINES} more lines</div>}
</div>
)}
</div>
);
}
export default function ApplyPatchRenderer({ args, result, status }: ToolRendererProps) {
const ops = parsePatchOperations(extractPatchText(args));
if (ops.length === 0) {
return (
<div>
<span className="text-sky-400/80 font-semibold text-sm">patch</span>
{status === "failed" && typeof result === "string" && result.trim() && (
<div className="text-red-400/70 text-[13px] mt-1">{result.trim()}</div>
)}
</div>
);
}
return (
<div className="space-y-2">
{ops.map((op, i) => (
<Operation key={i} op={op} />
))}
{status === "failed" && typeof result === "string" && result.trim() && (
<div className="text-red-400/70 text-[13px]">{result.trim()}</div>
)}
</div>
);
}
@@ -0,0 +1,110 @@
"use client";
import type { ToolRendererProps } from "@/types/events";
import { SyntaxBlock } from "./ToolCard";
const SIMPLE_ACTIONS: Record<string, string> = {
back: "going back in browser history",
forward: "going forward in browser history",
scroll_down: "scrolling down",
scroll_up: "scrolling up",
refresh: "refreshing",
close_tab: "closing tab",
switch_tab: "switching tab",
list_tabs: "listing tabs",
view_source: "viewing page source",
get_console_logs: "getting console logs",
screenshot: "taking screenshot",
wait: "waiting...",
close: "closing",
};
const CLICK_ACTIONS: Record<string, string> = {
click: "clicking",
double_click: "double clicking",
hover: "hovering",
};
function UrlLabel({ prefix, url, suffix }: { prefix: string; url?: string; suffix?: string }) {
return (
<span className="text-[#888] text-[13px]">
{prefix}
{url && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-cyan-400/80 hover:underline"
>
{url}
</a>
)}
{suffix}
</span>
);
}
function describeAction(args: Record<string, unknown>): React.ReactNode {
const action = (args.action as string) ?? "";
const url = (args.url as string) ?? undefined;
// Simple actions (no extra args)
if (action in SIMPLE_ACTIONS) return SIMPLE_ACTIONS[action];
// URL actions: launch, goto, new_tab
if (action === "launch") {
if (!url) return "launching";
return <UrlLabel prefix="launching " url={url} />;
}
if (action === "goto" || action === "navigate") {
return <UrlLabel prefix="navigating to " url={url} />;
}
if (action === "new_tab") {
return <UrlLabel prefix="opening tab " url={url} />;
}
// Click actions
if (action in CLICK_ACTIONS) return CLICK_ACTIONS[action];
// Type
if (action === "type") {
const text = ((args.text as string) ?? "").slice(0, 40);
return `typing "${text}"`;
}
// Key press
if (action === "press_key" || action === "key_press") {
return `pressing key ${(args.key as string) ?? ""}`;
}
// Save PDF
if (action === "save_pdf" || action === "save_as_pdf") {
const path = (args.file_path as string) ?? "";
return `saving PDF${path ? ` to ${path}` : ""}`;
}
// Execute JS — description only, code shown separately
if (action === "execute_js") return "executing javascript";
return action || "browser action";
}
export default function BrowserRenderer({ args }: ToolRendererProps) {
const action = (args.action as string) ?? "";
const jsCode = action === "execute_js"
? ((args.js_code as string) ?? (args.code as string) ?? "")
: "";
const description = describeAction(args);
return (
<div>
<div className="flex items-center gap-2 min-w-0">
<span className="text-blue-400/80 font-semibold text-sm shrink-0">Browser</span>
<span className="min-w-0 truncate text-[#888] text-[13px]">
{typeof description === "string" ? description : description}
</span>
</div>
{jsCode && <SyntaxBlock code={jsCode} language="javascript" collapsible />}
</div>
);
}
@@ -0,0 +1,25 @@
"use client";
import { TruncatedText } from "./ToolCard";
interface ChatBubbleProps {
role: string;
content: string;
}
const MAX_LINES = 30;
export default function ChatBubble({ role, content }: ChatBubbleProps) {
const isUser = role === "user" || role === "human";
return (
<div>
<span className={`font-semibold text-sm ${isUser ? "text-blue-400/80" : "text-purple-400/80"}`}>
{isUser ? "User" : "Thinking"}
</span>
<div className="mt-1.5 italic text-[#888]">
<TruncatedText text={content} maxLines={MAX_LINES} />
</div>
</div>
);
}
@@ -0,0 +1,36 @@
import type { ToolRendererProps } from "@/types/events";
import { CodeBlock } from "./ToolCard";
/**
* Generic renderer for tool names without a dedicated family renderer. Shows the
* humanized tool name plus a pretty-printed dump of args/result. Tolerates the
* server sending args/result as either a parsed object or an unparseable
* Python-repr string (which arrives here wrapped as { __raw }); never crashes.
*/
function pretty(value: unknown): string | null {
if (value == null) return null;
if (typeof value === "string") return value.trim() ? value : null;
if (typeof value === "object") {
const rec = value as Record<string, unknown>;
if (typeof rec.__raw === "string") return rec.__raw;
if (Object.keys(rec).length === 0) return null;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
return String(value);
}
export default function FallbackRenderer({ toolName, args, result }: ToolRendererProps) {
const argsText = pretty(args);
const resultText = pretty(result);
return (
<div>
<span className="text-[#888] font-semibold text-sm">{toolName.replace(/_/g, " ")}</span>
{argsText && <CodeBlock className="text-[#777]">{argsText}</CodeBlock>}
{resultText && <CodeBlock className="text-[#666]">{resultText}</CodeBlock>}
</div>
);
}
@@ -0,0 +1,65 @@
"use client";
import type { ToolRendererProps } from "@/types/events";
import { shortPath } from "./utils";
const DIFF_PREVIEW_LINES = 30;
export default function FileEditRenderer({ toolName, args }: ToolRendererProps) {
const filePath = (args.path as string) ?? (args.file_path as string) ?? "";
const command = (args.command as string) ?? "";
const oldStr = (args.old_str as string) ?? "";
const newStr = (args.new_str as string) ?? "";
const regex = (args.regex as string) ?? "";
let label: string;
if (toolName === "list_files") label = "list";
else if (toolName === "search_files") label = "search";
else if (command === "view") label = "view";
else if (command === "create") label = "create";
else if (command === "str_replace") label = "edit";
else if (command === "undo_edit") label = "undo";
else if (command === "insert") label = "insert";
else label = "file";
const pathDisplay = filePath ? shortPath(filePath) : "";
const regexDisplay = regex ? ` /${regex}/` : "";
const oldLines = oldStr ? oldStr.split("\n") : [];
const newLines = newStr ? newStr.split("\n") : [];
const totalLines = oldLines.length + newLines.length;
const truncated = totalLines > DIFF_PREVIEW_LINES;
// If truncated, split the budget proportionally
const oldBudget = truncated ? Math.round(DIFF_PREVIEW_LINES * (oldLines.length / totalLines)) : oldLines.length;
const newBudget = truncated ? DIFF_PREVIEW_LINES - oldBudget : newLines.length;
return (
<div>
<div className="flex items-baseline gap-2">
<span className="text-sky-400/80 font-semibold text-sm shrink-0">{label}</span>
{pathDisplay && <span className="text-[#888] font-mono text-[13px] break-all">{pathDisplay}</span>}
</div>
{regexDisplay && (
<div className="text-purple-400/60 font-mono text-[13px] break-all mt-0.5">{regexDisplay}</div>
)}
{(oldStr || newStr) && (
<div className="font-mono text-[13px] leading-relaxed mt-1.5">
{oldLines.slice(0, oldBudget).map((line, i) => (
<div key={`o${i}`} className="text-red-400/60">
<span className="select-none text-red-400/30 mr-1">-</span>{line}
</div>
))}
{newLines.slice(0, newBudget).map((line, i) => (
<div key={`n${i}`} className="text-emerald-400/60">
<span className="select-none text-emerald-400/30 mr-1">+</span>{line}
</div>
))}
{truncated && (
<div className="text-[#444] mt-0.5">... {totalLines - DIFF_PREVIEW_LINES} more lines</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,32 @@
"use client";
import type { ToolRendererProps } from "@/types/events";
import { TruncatedText } from "./ToolCard";
export default function FinishRenderer({ args }: ToolRendererProps) {
const executiveSummary = (args.executive_summary as string) ?? "";
const methodology = (args.methodology as string) ?? "";
const technicalAnalysis = (args.technical_analysis as string) ?? "";
const recommendations = (args.recommendations as string) ?? "";
return (
<div className="space-y-3">
<span className="text-emerald-400/80 font-semibold text-sm">Penetration test completed</span>
{executiveSummary && (
<div><span className="text-emerald-400/60 text-sm font-semibold">Executive Summary</span><div className="mt-1"><TruncatedText text={executiveSummary} maxLines={25} /></div></div>
)}
{methodology && (
<div><span className="text-emerald-400/60 text-sm font-semibold">Methodology</span><div className="mt-1"><TruncatedText text={methodology} maxLines={25} /></div></div>
)}
{technicalAnalysis && (
<div><span className="text-emerald-400/60 text-sm font-semibold">Technical Analysis</span><div className="mt-1"><TruncatedText text={technicalAnalysis} maxLines={25} /></div></div>
)}
{recommendations && (
<div><span className="text-emerald-400/60 text-sm font-semibold">Recommendations</span><div className="mt-1"><TruncatedText text={recommendations} maxLines={25} /></div></div>
)}
{!executiveSummary && !methodology && !technicalAnalysis && !recommendations && (
<div className="text-[#555] text-xs">Generating final report...</div>
)}
</div>
);
}
@@ -0,0 +1,20 @@
"use client";
import type { ToolRendererProps } from "@/types/events";
export default function LoadSkillRenderer({ args }: ToolRendererProps) {
const requestedRaw = (args.skills as string) ?? "";
const requestedSkills = requestedRaw
.split(",")
.map((skill) => skill.trim())
.filter(Boolean);
return (
<div className="flex items-center gap-2">
<span className="text-emerald-400/80 font-semibold text-sm">Loading skill</span>
{requestedSkills.length > 0 && (
<span className="text-[#888] text-[13px]">{requestedSkills.join(", ")}</span>
)}
</div>
);
}
@@ -0,0 +1,24 @@
"use client";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { rehypeCodeMeta, mdComponents } from "@/components/vulnerability/MdCodeBlock";
interface MarkdownProps {
text: string;
className?: string;
}
export default function Markdown({ text, className = "" }: MarkdownProps) {
return (
<div className={`prose-markdown ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeCodeMeta]}
components={mdComponents}
>
{text}
</ReactMarkdown>
</div>
);
}
@@ -0,0 +1,87 @@
"use client";
import type { ToolRendererProps } from "@/types/events";
import Markdown from "./Markdown";
export default function NotesRenderer({ toolName, args, result }: ToolRendererProps) {
if (toolName === "create_note") {
const title = (args.title as string) ?? "";
const content = (args.content as string) ?? "";
const category = (args.category as string) ?? "general";
return (
<div>
<div className="flex items-center gap-2">
<span className="text-amber-400/80 font-semibold text-sm">note</span>
<span className="text-[#555] text-[13px]">({category})</span>
</div>
{title && <div className="mt-1.5 text-[#999] text-[13px]">{title}</div>}
{content && <div className="mt-1"><Markdown text={content} /></div>}
</div>
);
}
if (toolName === "delete_note") {
return <span className="text-amber-400/80 font-semibold text-sm">note removed</span>;
}
if (toolName === "update_note") {
const title = (args.title as string) ?? "";
const content = (args.content as string) ?? "";
return (
<div>
<span className="text-amber-400/80 font-semibold text-sm">note updated</span>
{title && <div className="mt-1.5 text-[#999] text-[13px]">{title}</div>}
{content && <div className="mt-1"><Markdown text={content} /></div>}
</div>
);
}
if (toolName === "get_note") {
const res = result as Record<string, unknown> | null;
const note = res && typeof res === "object" && res.success
? (res.note as Record<string, string> | undefined)
: undefined;
return (
<div>
<span className="text-amber-400/80 font-semibold text-sm">note read</span>
{note && (
<>
<div className="mt-1.5 text-[#999] text-[13px]">
{note.title ?? "(untitled)"}
<span className="text-[#555] ml-1">({note.category ?? "general"})</span>
</div>
{note.content && <div className="mt-1"><Markdown text={note.content} /></div>}
</>
)}
</div>
);
}
if (toolName === "list_notes") {
const res = result as Record<string, unknown> | null;
let notes: Array<Record<string, string>> = [];
if (res && typeof res === "object" && res.success) {
const rawNotes = res.notes;
notes = Array.isArray(rawNotes) ? rawNotes as Array<Record<string, string>> : [];
}
return (
<div>
<span className="text-amber-400/80 font-semibold text-sm">notes</span>
{notes.length > 0 ? (
<div className="mt-1.5 space-y-0.5">
{notes.map((n, i) => (
<div key={i} className="text-[13px]">
<span className="text-[#555] mr-1">-</span>
<span className="text-[#999]">{n.title ?? "(untitled)"}</span>
<span className="text-[#555] ml-1">({n.category ?? "general"})</span>
{n.content && <div className="ml-3"><Markdown text={n.content} /></div>}
</div>
))}
</div>
) : <div className="mt-1 text-[#555] text-xs">No notes</div>}
</div>
);
}
return <span className="text-amber-400/80 font-semibold text-sm">note</span>;
}
@@ -0,0 +1,250 @@
"use client";
import type { ToolRendererProps } from "@/types/events";
import { CodeBlock } from "./ToolCard";
const MAX_LINE_LENGTH = 200;
const METHOD_COLORS: Record<string, string> = {
GET: "text-emerald-400/80", POST: "text-blue-400/80", PUT: "text-yellow-400/80",
PATCH: "text-orange-400/80", DELETE: "text-red-400/80",
};
function statusColor(code: number): string {
if (code < 300) return "text-emerald-400/80";
if (code < 400) return "text-yellow-400/80";
if (code < 500) return "text-orange-400/80";
return "text-red-400/80";
}
/** Hard truncate with trailing "..." */
function trunc(text: string, maxLen = 80): string {
return text.length > maxLen ? text.slice(0, maxLen - 3) + "..." : text;
}
/** Replace newlines/tabs, then truncate */
function sanitize(text: string, maxLen = 150): string {
return trunc(text.replace(/\n/g, " ").replace(/\r/g, "").replace(/\t/g, " "), maxLen);
}
/** Limit body to maxLines, each truncated to MAX_LINE_LENGTH-5; returns display string */
function limitBody(body: string, maxLines: number): string {
const lines = body.split("\n");
const display = lines.slice(0, maxLines).map(l => trunc(l, MAX_LINE_LENGTH - 5)).join("\n");
return lines.length > maxLines ? display + "\n..." : display;
}
function ListRequests({ args, result }: ToolRendererProps) {
const filter = (args.httpql_filter as string) ?? "";
const res = result as Record<string, unknown> | null;
const rawReqs = res ? res.requests : null;
const requests = Array.isArray(rawReqs) ? rawReqs as Array<Record<string, unknown>> : [];
return (
<div>
<div className="flex items-center gap-2">
<span className="text-purple-400/80 font-semibold text-sm">listing requests</span>
{filter && <span className="text-[#888] text-[13px]">{trunc(filter, 150)}</span>}
</div>
{requests.length > 0 && (
<div className="mt-1.5 font-mono text-[13px] space-y-0.5">
{requests.slice(0, 20).map((r, i) => {
const m = ((r.method as string) ?? "GET").toUpperCase();
const host = (r.host as string) ?? "";
const path = (r.path as string) ?? "";
const resp = r.response as Record<string, unknown> | undefined;
const sc = (resp?.statusCode as number) ?? null;
return (
<div key={i} className="flex gap-2">
<span className={`w-10 shrink-0 font-bold ${METHOD_COLORS[m] ?? "text-[#888]"}`}>{m}</span>
<span className="text-[#777] truncate">{trunc(host + path, 180)}</span>
{sc != null && <span className={`ml-auto shrink-0 ${statusColor(sc)}`}>{sc}</span>}
</div>
);
})}
{requests.length > 20 && <div className="text-[#555]">... +{requests.length - 20} more</div>}
</div>
)}
</div>
);
}
function ViewRequest({ args, result }: ToolRendererProps) {
const requestId = args.request_id as number | undefined;
const part = (args.part as string) ?? "request";
const searchPattern = (args.search_pattern as string) ?? "";
const res = result as Record<string, unknown> | null;
const rawMatches = res ? res.matches : null;
const matches = Array.isArray(rawMatches) ? rawMatches as Array<Record<string, string>> : [];
const content = res ? (res.content as string) ?? null : null;
const hasMore = res ? !!(res.has_more) : false;
return (
<div>
<div className="flex items-center gap-2">
<span className="text-purple-400/80 font-semibold text-sm">{searchPattern ? "searching" : "viewing"} {part}</span>
{requestId != null && <span className="text-[#888] text-[13px]">#{requestId}</span>}
{searchPattern && <span className="text-[#666] font-mono text-[13px]">/{trunc(searchPattern, 100)}/</span>}
</div>
{matches.length > 0 && (
<div className="mt-1.5 font-mono text-[13px] space-y-1">
{matches.slice(0, 5).map((m, i) => {
// Sanitize context: replace newlines with space, trim to 100 chars
const before = ((m.before ?? "").replace(/\n/g, " ").replace(/\r/g, "")).slice(-100);
const after = ((m.after ?? "").replace(/\n/g, " ").replace(/\r/g, "")).slice(0, 100);
return (
<div key={i}>
{before && <span className="text-[#555]">...{before}</span>}
<span className="text-amber-400/80 font-bold">{m.match}</span>
{after && <span className="text-[#555]">{after}...</span>}
</div>
);
})}
{matches.length > 5 && <div className="text-[#555]">... +{matches.length - 5} more matches</div>}
</div>
)}
{content && !matches.length && (() => {
const lines = content.split("\n");
const display = lines.slice(0, 15).map(l => trunc(l, MAX_LINE_LENGTH)).join("\n");
const showMore = hasMore || lines.length > 15;
return (
<CodeBlock className="text-[#666]">
{display + (showMore ? "\n... more content available" : "")}
</CodeBlock>
);
})()}
</div>
);
}
function SendRequest({ args, result }: ToolRendererProps) {
const method = ((args.method as string) ?? "GET").toUpperCase();
const url = (args.url as string) ?? "";
const headers = args.headers as Record<string, string> | undefined;
const rawBody = args.body;
const reqBody = typeof rawBody === "string" ? rawBody : "";
const res = result as Record<string, unknown> | null;
const error = res ? (res.error as string) ?? null : null;
const statusCode = res ? (res.status_code as number) ?? null : null;
const responseTime = res ? (res.response_time_ms as number) ?? null : null;
const rawResBody = res ? res.body : null;
const resBody = typeof rawResBody === "string" ? rawResBody : null;
return (
<div>
<span className="text-purple-400/80 font-semibold text-sm">request</span>
<div className="mt-1.5 font-mono text-[13px] space-y-0.5">
<div>
<span className="text-[#555] select-none mr-1">&gt;&gt;</span>
<span className={`font-bold ${METHOD_COLORS[method] ?? "text-[#888]"}`}>{method}</span>
<span className="text-[#888] ml-1 break-all">{trunc(url, 180)}</span>
</div>
{headers && typeof headers === "object" && Object.entries(headers).slice(0, 5).map(([k, v]) => (
<div key={k} className="text-[#555] pl-5">{k}: {sanitize(String(v), 150)}</div>
))}
</div>
{reqBody && (
<CodeBlock className="text-[#888]">{limitBody(reqBody, 4)}</CodeBlock>
)}
{error && <div className="text-red-400/70 text-[13px] mt-1.5">{sanitize(error, 150)}</div>}
{statusCode != null && (
<div className="font-mono text-[13px] mt-1.5">
<span className="text-[#555] select-none mr-1">&lt;&lt;</span>
<span className={`font-bold ${statusColor(statusCode)}`}>{statusCode}</span>
{responseTime != null && <span className="text-[#555] ml-2">{responseTime}ms</span>}
</div>
)}
{resBody && (
<CodeBlock className="text-[#666]">{limitBody(resBody, 6)}</CodeBlock>
)}
</div>
);
}
function RepeatRequest({ args, result }: ToolRendererProps) {
const requestId = args.request_id as number | undefined;
const modifications = args.modifications as Record<string, unknown> | undefined;
const res = result as Record<string, unknown> | null;
const statusCode = res ? (res.status_code as number) ?? null : null;
const responseTime = res ? (res.response_time_ms as number) ?? null : null;
const rawRepBody = res ? res.body : null;
const resBody = typeof rawRepBody === "string" ? rawRepBody : null;
return (
<div>
<div className="flex items-center gap-2">
<span className="text-purple-400/80 font-semibold text-sm">repeating request</span>
{requestId != null && <span className="text-[#888] text-[13px]">#{requestId}</span>}
</div>
{modifications && typeof modifications === "object" && Object.keys(modifications).length > 0 && (
<div className="mt-1.5 font-mono text-[13px] space-y-0.5">
{Object.entries(modifications).slice(0, 5).map(([k, v]) => (
<div key={k}><span className="text-orange-400/60">{k}:</span> <span className="text-[#777]">{sanitize(typeof v === "string" ? v : JSON.stringify(v), 150)}</span></div>
))}
</div>
)}
{statusCode != null && (
<div className="font-mono text-[13px] mt-1.5">
<span className="text-[#555] select-none mr-1">&lt;&lt;</span>
<span className={`font-bold ${statusColor(statusCode)}`}>{statusCode}</span>
{responseTime != null && <span className="text-[#555] ml-2">{responseTime}ms</span>}
</div>
)}
{resBody && (
<CodeBlock className="text-[#666]">{limitBody(resBody, 5)}</CodeBlock>
)}
</div>
);
}
const SCOPE_ACTION: Record<string, string> = {
get: "getting", list: "listing", create: "creating", update: "updating", delete: "deleting",
};
function ScopeRules({ args }: ToolRendererProps) {
const action = (args.action as string) ?? "";
const scopeName = (args.scope_name as string) ?? "";
const label = SCOPE_ACTION[action] ?? (action ? action : "managing");
return (
<div className="flex items-center gap-2">
<span className="text-purple-400/80 font-semibold text-sm">{label} proxy scope</span>
{scopeName && <span className="text-[#888] text-[13px]">{trunc(scopeName, 50)}</span>}
</div>
);
}
function ListSitemap({ args }: ToolRendererProps) {
const parentId = args.parent_id as string | undefined;
return (
<div className="flex items-center gap-2">
<span className="text-purple-400/80 font-semibold text-sm">listing sitemap</span>
{parentId && <span className="text-[#888] text-[13px]">under #{trunc(String(parentId), 20)}</span>}
</div>
);
}
function ViewSitemapEntry({ args }: ToolRendererProps) {
const entryId = args.entry_id as string | undefined;
return (
<div className="flex items-center gap-2">
<span className="text-purple-400/80 font-semibold text-sm">viewing sitemap entry</span>
{entryId && <span className="text-[#888] text-[13px]">#{trunc(String(entryId), 20)}</span>}
</div>
);
}
export default function ProxyRenderer(props: ToolRendererProps) {
switch (props.toolName) {
case "list_requests": return <ListRequests {...props} />;
case "view_request": return <ViewRequest {...props} />;
case "send_request": return <SendRequest {...props} />;
case "repeat_request": return <RepeatRequest {...props} />;
case "scope_rules": return <ScopeRules {...props} />;
case "list_sitemap": return <ListSitemap {...props} />;
case "view_sitemap_entry": return <ViewSitemapEntry {...props} />;
default:
return (
<span className="text-purple-400/80 font-semibold text-sm">{props.toolName.replace(/_/g, " ")}</span>
);
}
}
@@ -0,0 +1,68 @@
"use client";
import type { ToolRendererProps } from "@/types/events";
import { CodeBlock, SyntaxBlock } from "./ToolCard";
const MAX_OUTPUT_LINES = 50;
const MAX_LINE_LENGTH = 200;
const HEAD = 25;
const TAIL = 24;
// Full ANSI escape sequence pattern (matches Python's ANSI_PATTERN)
const ANSI_PATTERN = /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*\x07)/g;
// Strips truncation notices added by Python executor
const STRIP_PATTERN = /\.\.\. \[(stdout|stderr|result|output|error) truncated at \d+k? chars\]/g;
function stripAnsi(text: string): string {
return text.replace(ANSI_PATTERN, "");
}
function truncateLine(line: string): string {
const clean = stripAnsi(line);
if (clean.length > MAX_LINE_LENGTH) return clean.slice(0, MAX_LINE_LENGTH - 3) + "...";
return clean;
}
function cleanOutput(output: string): string {
return output.replace(STRIP_PATTERN, "").trim();
}
function formatOutput(output: string): string {
const lines = output.split("\n");
if (lines.length <= MAX_OUTPUT_LINES) return lines.map(truncateLine).join("\n");
const hiddenCount = lines.length - HEAD - TAIL;
return [
...lines.slice(0, HEAD).map(truncateLine),
`... ${hiddenCount} lines truncated ...`,
...lines.slice(-TAIL).map(truncateLine),
].join("\n");
}
export default function PythonRenderer({ args, result }: ToolRendererProps) {
const action = (args.action as string) ?? "";
const code = (args.code as string) ?? (args.script as string) ?? "";
const res = result as Record<string, unknown> | string | null;
let stdout: string | null = null;
if (res && typeof res === "object") stdout = typeof res.stdout === "string" ? res.stdout : null;
else if (typeof res === "string") stdout = res;
const subtitle =
action === "new_session" ? "new session" :
action === "close" ? "close session" :
action === "list_sessions" ? "list sessions" : null;
const output = stdout ? formatOutput(cleanOutput(stdout)) : null;
return (
<div>
<div className="flex items-center gap-2">
<span className="text-yellow-400/80 font-semibold text-sm">Python</span>
{subtitle && <span className="text-[#888] text-[13px]">{subtitle}</span>}
</div>
{code && <SyntaxBlock code={code} language="python" collapsible />}
{output && <CodeBlock className="text-[#666]">{output}</CodeBlock>}
</div>
);
}
@@ -0,0 +1,46 @@
"use client";
import type { ToolRendererProps } from "@/types/events";
import { TruncatedText } from "./ToolCard";
function ScanStartInfo({ args }: ToolRendererProps) {
const rawTargets = args.targets;
const targets = Array.isArray(rawTargets) ? rawTargets : [];
const targetNames = targets.map((t) => (typeof t === "object" && t ? (t.original as string) ?? null : null)).filter(Boolean) as string[];
return (
<div>
<div className="flex items-center gap-2">
<span className="text-emerald-400/80 font-semibold text-sm">Starting penetration test</span>
{targetNames.length === 1 && <span className="text-[#888] text-[13px]">on {targetNames[0]}</span>}
</div>
{targetNames.length > 1 && (
<div className="mt-1.5 space-y-0.5">
{targetNames.map((t, i) => (
<div key={i} className="text-[13px] text-[#888]"><span className="text-[#555] mr-1"></span>{t}</div>
))}
</div>
)}
</div>
);
}
function SubagentStartInfo({ args }: ToolRendererProps) {
const name = (args.name as string) ?? "Unknown Agent";
const task = (args.task as string) ?? "";
return (
<div>
<div className="flex items-center gap-2">
<span className="text-[#888] text-[13px]">subagent</span>
<span className="text-purple-400 font-semibold text-sm">{name}</span>
</div>
{task && <div className="mt-1.5"><TruncatedText text={task} maxLines={15} /></div>}
</div>
);
}
export default function ScanInfoRenderer(props: ToolRendererProps) {
if (props.toolName === "subagent_start_info") return <SubagentStartInfo {...props} />;
return <ScanStartInfo {...props} />;
}
@@ -0,0 +1,131 @@
"use client";
import type { ToolRendererProps } from "@/types/events";
import { CodeBlock, SyntaxBlock } from "./ToolCard";
const MAX_OUTPUT_LINES = 50;
const MAX_LINE_LENGTH = 200;
const HEAD = 25;
const TAIL = 24;
const STRIP_PATTERNS: RegExp[] = [
/\n?\[Command still running after [\d.]+s - showing output so far\.?\s*(?:Use C-c to interrupt if needed\.)?\]/g,
/^\[Below is the output of the previous command\.\]\n?/gm,
/^No command is currently running\. Cannot send input\.$/gm,
/^A command is already running\. Use is_input=true to send input to it, or interrupt it first \(e\.g\., with C-c\)\.$/gm,
];
// Terminal-tool chunk metadata (the OSS engine's shell tool prepends these; the
// TUI strips them in strix/interface/tui/renderers/shell_renderer.py). Only a
// contiguous block anchored on a "Chunk ID:" line is stripped, so identical
// text inside real command output is left untouched.
const CHUNK_PREAMBLE_START = /^Chunk ID: [0-9a-f]+\s*$/;
const CHUNK_PREAMBLE_METADATA: RegExp[] = [
/^Wall time: [\d.]+ seconds\s*$/,
/^Process exited with code -?\d+\s*$/,
/^Process running with session ID \d+\s*$/,
/^Original token count: \d+\s*$/,
];
function stripChunkPreambles(lines: string[]): string[] {
const out: string[] = [];
for (let i = 0; i < lines.length; i++) {
if (CHUNK_PREAMBLE_START.test(lines[i])) {
let j = i + 1;
while (j < lines.length && CHUNK_PREAMBLE_METADATA.some((p) => p.test(lines[j]))) j++;
if (j < lines.length && lines[j].trim() === "Output:") j++;
i = j - 1;
continue;
}
out.push(lines[i]);
}
return out;
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function truncateLine(line: string): string {
if (line.length > MAX_LINE_LENGTH) return line.slice(0, MAX_LINE_LENGTH - 3) + "...";
return line;
}
function cleanOutput(raw: string, command: string = ""): string {
// Strip ANSI escape sequences and carriage returns
let cleaned = raw.replace(/\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*\x07)/g, "").replace(/\r/g, "");
for (const pattern of STRIP_PATTERNS) {
cleaned = cleaned.replace(pattern, "");
}
if (cleaned.trim()) {
const lines = stripChunkPreambles(cleaned.split("\n"));
const filtered: string[] = [];
for (const line of lines) {
// Skip leading blank lines
if (filtered.length === 0 && !line.trim()) continue;
// Skip [STRIX_N]$ prompt lines
if (/^\[STRIX_\d+\]\$\s*/.test(line)) continue;
// Skip echoed command (plain)
if (command && line.trim() === command.trim()) continue;
// Skip echoed command with $/#/> prefix
if (command && new RegExp(`^[\\$#>]\\s*${escapeRegex(command.trim())}\\s*$`).test(line)) continue;
filtered.push(line);
}
// Strip trailing [STRIX_N]$ lines
while (filtered.length > 0 && /^\[STRIX_\d+\]\$\s*/.test(filtered[filtered.length - 1])) {
filtered.pop();
}
cleaned = filtered.join("\n");
}
return cleaned.trim();
}
function formatOutput(output: string): string {
const lines = output.split("\n");
if (lines.length <= MAX_OUTPUT_LINES) return lines.map(truncateLine).join("\n");
const hiddenCount = lines.length - HEAD - TAIL;
return [
...lines.slice(0, HEAD).map(truncateLine),
`... ${hiddenCount} lines truncated ...`,
...lines.slice(-TAIL).map(truncateLine),
].join("\n");
}
export default function TerminalRenderer({ toolName, args, result }: ToolRendererProps) {
const isStdin = toolName === "write_stdin";
const command = isStdin
? ((args.chars as string) ?? (args.input as string) ?? "")
: ((args.command as string) ?? (args.cmd as string) ?? "");
const res = result as Record<string, unknown> | string | null;
let content: string | null = null;
let error: string | null = null;
let exitCode: number | null = null;
if (res && typeof res === "object") {
content = typeof res.content === "string" ? res.content : null;
error = typeof res.error === "string" ? res.error : null;
exitCode = typeof res.exit_code === "number" ? res.exit_code : null;
const s = typeof res.status === "string" ? res.status : "";
if (s === "running" || s === "command still running") content = null;
} else if (typeof res === "string") {
content = res;
}
const output = content ? formatOutput(cleanOutput(content, command)) : null;
return (
<div>
<span className="text-emerald-400/80 font-semibold text-sm">{isStdin ? "Terminal input" : "Terminal"}</span>
{command && <SyntaxBlock code={command} language="bash" collapsible />}
{error && <CodeBlock className="text-red-400/70">{error}</CodeBlock>}
{output && <CodeBlock className="text-[#666]">{output}</CodeBlock>}
{exitCode != null && exitCode !== 0 && (
<div className="font-mono text-[13px] text-red-400/70 mt-0.5">exit code {exitCode}</div>
)}
</div>
);
}
@@ -0,0 +1,18 @@
"use client";
import type { ToolRendererProps } from "@/types/events";
import { TruncatedText } from "./ToolCard";
export default function ThinkRenderer({ args }: ToolRendererProps) {
const thought = (args.thought as string) ?? (args.content as string) ?? "";
if (!thought) return null;
return (
<div>
<span className="text-purple-400/80 font-semibold text-sm">Agent is thinking</span>
<div className="mt-1.5 italic text-[#888]">
<TruncatedText text={thought} maxLines={20} />
</div>
</div>
);
}
@@ -0,0 +1,120 @@
"use client";
import type { ToolRendererProps } from "@/types/events";
import { Circle, CircleDot, CircleCheckBig, Trash2, Plus, RefreshCw, CheckCheck, RotateCcw, Pencil } from "lucide-react";
interface TodoItem {
id?: string;
title?: string;
status?: string;
}
const ACTION_LABELS: Record<string, { label: string; Icon: typeof Circle }> = {
create_todo: { label: "Task added", Icon: Plus },
list_todos: { label: "Plan", Icon: CheckCheck },
update_todo: { label: "Task updated", Icon: Pencil },
mark_todo_done: { label: "Task completed", Icon: CircleCheckBig },
mark_todo_pending: { label: "Task reopened", Icon: RotateCcw },
delete_todo: { label: "Task removed", Icon: Trash2 },
};
function StatusIcon({ status }: { status: string }) {
if (status === "done") return <CircleCheckBig className="w-3.5 h-3.5 text-emerald-400/70 shrink-0" />;
if (status === "in_progress") return <CircleDot className="w-3.5 h-3.5 text-purple-400/70 shrink-0 animate-pulse" />;
return <Circle className="w-3.5 h-3.5 text-[#444] shrink-0" />;
}
function TodoList({ todos, highlightId }: { todos: TodoItem[]; highlightId?: string }) {
return (
<div className="space-y-0">
{todos.map((todo, i) => {
const s = todo.status ?? "pending";
const isHighlighted = highlightId && todo.id === highlightId;
return (
<div
key={todo.id ?? i}
className={`flex items-start gap-2.5 py-1.5 px-2 -mx-2 rounded-md transition-colors ${
isHighlighted ? "bg-purple-500/[0.08]" : ""
}`}
>
<div className="mt-[1px]">
<StatusIcon status={s} />
</div>
<span
className={`text-[13px] leading-snug ${
s === "done"
? "text-[#555] line-through"
: s === "in_progress"
? "text-[#bbb]"
: "text-[#999]"
}`}
>
{todo.title ?? "(untitled)"}
</span>
</div>
);
})}
</div>
);
}
export default function TodoRenderer({ toolName, args, result }: ToolRendererProps) {
const action = ACTION_LABELS[toolName] ?? { label: "Plan", Icon: RefreshCw };
const ActionIcon = action.Icon;
const res = result as Record<string, unknown> | string | null;
// Simple string result
if (typeof res === "string" && res.trim()) {
return (
<div>
<div className="flex items-center gap-2">
<ActionIcon className="w-3.5 h-3.5 text-purple-400/60" />
<span className="text-purple-400/80 font-semibold text-sm">{action.label}</span>
</div>
<div className="mt-1.5 text-[#888] text-[13px]">{res.trim()}</div>
</div>
);
}
// Parse structured result
let todos: TodoItem[] = [];
let error: string | null = null;
let todoId: string | undefined;
if (res && typeof res === "object") {
error = (res.error as string) ?? null;
if (res.success) {
const rawTodos = res.todos;
todos = Array.isArray(rawTodos) ? (rawTodos as TodoItem[]) : [];
}
todoId = (res.id as string) ?? (args.todo_id as string) ?? undefined;
}
// For mutations, highlight the affected item
const highlightId = toolName !== "list_todos" ? todoId : undefined;
// No todos and no error — brief label only
if (todos.length === 0 && !error) {
return (
<div className="flex items-center gap-2">
<ActionIcon className="w-3.5 h-3.5 text-purple-400/60" />
<span className="text-purple-400/80 font-semibold text-sm">{action.label}</span>
</div>
);
}
return (
<div>
<div className="flex items-center gap-2 mb-2">
<ActionIcon className="w-3.5 h-3.5 text-purple-400/60" />
<span className="text-purple-400/80 font-semibold text-sm">{action.label}</span>
</div>
{error && <div className="text-red-400/70 text-[13px] mb-2">{error}</div>}
{todos.length > 0 && (
<div className="rounded-lg border border-white/[0.06] bg-white/[0.015] px-3 py-2">
<TodoList todos={todos} highlightId={highlightId} />
</div>
)}
</div>
);
}
@@ -0,0 +1,100 @@
"use client";
import { useState } from "react";
import Markdown from "./Markdown";
import hljs from "@/lib/hljs";
import "highlight.js/styles/github-dark.css";
const OUTPUT_PREVIEW_LINES = 6;
const CODE_PREVIEW_LINES = 20;
/** Truncatable markdown text with "Show more" */
export function TruncatedText({ text, maxLines = 20 }: { text: string; maxLines?: number }) {
const [expanded, setExpanded] = useState(false);
const lines = text.trimEnd().split("\n");
const needsTruncation = lines.length > maxLines;
return (
<div>
<div
className={expanded && needsTruncation ? "max-h-[1200px] overflow-auto" : ""}
style={!expanded && needsTruncation ? { display: "-webkit-box", WebkitLineClamp: maxLines, WebkitBoxOrient: "vertical", overflow: "hidden" } : undefined}
>
<Markdown text={text} />
</div>
{needsTruncation && (
<button onClick={() => setExpanded(!expanded)} className="text-xs text-[#555] hover:text-[#888] mt-1">
{expanded ? "Show less" : "Show more"}
</button>
)}
</div>
);
}
/** Code/output block — truncates to 12 lines with "Show more", expanded view scrolls */
export function CodeBlock({ children, className = "" }: { children: React.ReactNode; className?: string }) {
const [expanded, setExpanded] = useState(false);
const isString = typeof children === "string";
const lines = isString ? (children as string).trimEnd().split("\n") : null;
const needsTruncation = lines !== null && lines.length > OUTPUT_PREVIEW_LINES;
const displayContent = needsTruncation && !expanded
? lines!.slice(0, OUTPUT_PREVIEW_LINES).join("\n")
: children;
return (
<div>
<pre className={`font-mono text-[13px] leading-relaxed whitespace-pre-wrap break-words mt-1 ${
expanded ? "overflow-auto max-h-[1200px]" : "overflow-hidden"
} ${className}`}>
{displayContent}
</pre>
{needsTruncation && (
<button
onClick={() => setExpanded(!expanded)}
className="text-xs text-[#555] hover:text-[#888] mt-0.5"
>
{expanded ? "Show less" : "Show more"}
</button>
)}
</div>
);
}
/** Syntax-highlighted code block — no border, no line numbers, just highlighting.
* Pass `collapsible` to get a "Show more" toggle instead of a scroll cap. */
export function SyntaxBlock({ code, language, className = "", collapsible = false }: { code: string; language?: string; className?: string; collapsible?: boolean }) {
const [expanded, setExpanded] = useState(false);
const lines = code.trimEnd().split("\n");
const needsTruncation = collapsible && lines.length > CODE_PREVIEW_LINES;
const displayCode = needsTruncation && !expanded
? lines.slice(0, CODE_PREVIEW_LINES).join("\n")
: code;
let highlighted: string;
try {
highlighted = language
? hljs.highlight(displayCode, { language, ignoreIllegals: true }).value
: hljs.highlightAuto(displayCode).value;
} catch {
highlighted = hljs.highlightAuto(displayCode).value;
}
return (
<div>
<pre className={`font-mono text-[12px] leading-relaxed px-0 py-1 mt-1 whitespace-pre-wrap break-all ${
collapsible
? expanded ? "overflow-auto max-h-[1200px]" : "overflow-hidden"
: "overflow-auto max-h-[400px]"
} ${className}`}>
<code dangerouslySetInnerHTML={{ __html: highlighted }} />
</pre>
{needsTruncation && (
<button onClick={() => setExpanded(!expanded)} className="text-xs text-[#555] hover:text-[#888] mt-0.5">
{expanded ? "Show less" : "Show more"}
</button>
)}
</div>
);
}
@@ -0,0 +1,30 @@
"use client";
import type { ToolRendererProps } from "@/types/events";
import { shortPath } from "./utils";
/** Mirrors the OSS TUI `ViewImageRenderer`: surfaces load errors, otherwise a
* compact "view image <path>" line. */
export default function ViewImageRenderer({ args, result }: ToolRendererProps) {
const path = ((args.path as string) ?? "").trim();
const res = result as Record<string, unknown> | string | null;
let error: string | null = null;
if (typeof res === "string") {
const trimmed = res.trim();
// A string result that isn't an image payload or structured data is an error message
if (trimmed && !trimmed.toLowerCase().startsWith("data:image/") && !trimmed.startsWith("{")) {
error = trimmed;
}
}
return (
<div>
<div className="flex items-baseline gap-2">
<span className="text-sky-400/80 font-semibold text-sm shrink-0">view image</span>
{path && <span className="text-[#888] font-mono text-[13px] break-all">{shortPath(path)}</span>}
</div>
{error && <div className="text-red-400/70 text-[13px] mt-1">{error}</div>}
</div>
);
}
@@ -0,0 +1,73 @@
"use client";
import type { ToolRendererProps } from "@/types/events";
import { TruncatedText } from "./ToolCard";
import { MdCodeBlock } from "@/components/vulnerability/MdCodeBlock";
import Markdown from "./Markdown";
const SEVERITY_COLORS: Record<string, string> = {
critical: "text-red-400", high: "text-orange-400", medium: "text-yellow-400",
low: "text-blue-400", info: "text-cyan-400",
};
export default function VulnReportRenderer({ args, result }: ToolRendererProps) {
const title = (args.title as string) ?? "";
const description = (args.description as string) ?? "";
const impact = (args.impact as string) ?? "";
const target = (args.target as string) ?? "";
const endpoint = (args.endpoint as string) ?? "";
const method = (args.method as string) ?? "";
const technicalAnalysis = (args.technical_analysis as string) ?? "";
const pocDescription = (args.poc_description as string) ?? "";
const pocCode = (args.poc_script_code as string) ?? "";
const remediation = (args.remediation_steps as string) ?? "";
const cve = (args.cve as string) ?? "";
const cwe = (args.cwe as string) ?? "";
const res = result as Record<string, unknown> | null;
const rawSev = (res && typeof res === "object" ? res.severity : null) ?? args.severity ?? "medium";
const severity = String(rawSev).toLowerCase();
const cvss = (res && typeof res === "object" ? (res.cvss_score as number) : null) ?? (args.cvss as number) ?? null;
const sevColor = SEVERITY_COLORS[severity] ?? "text-yellow-400";
return (
<div className="space-y-3">
<div className="flex items-center gap-2 flex-wrap">
<span className={`font-semibold text-sm ${sevColor}`}>{severity.toUpperCase()}</span>
{cvss != null && <span className="text-[#888] text-[13px]">CVSS {cvss}</span>}
{cve && <span className="text-[#888] font-mono text-[13px]">{cve}</span>}
{cwe && <span className="text-[#888] font-mono text-[13px]">{cwe}</span>}
</div>
{title && <div className="text-[15px] text-white/80 font-semibold">{title}</div>}
{(target || endpoint) && (
<div className="text-[13px] text-[#888] font-mono">{target}{endpoint ? ` ${method} ${endpoint}` : ""}</div>
)}
{description && <TruncatedText text={description} maxLines={20} />}
{impact && (
<div>
<span className="text-emerald-400/60 text-sm font-semibold">Impact</span>
<div className="mt-1"><TruncatedText text={impact} maxLines={15} /></div>
</div>
)}
{technicalAnalysis && (
<div>
<span className="text-emerald-400/60 text-sm font-semibold">Technical Analysis</span>
<div className="mt-1"><TruncatedText text={technicalAnalysis} maxLines={20} /></div>
</div>
)}
{(pocDescription || pocCode) && (
<div>
<span className="text-emerald-400/60 text-sm font-semibold">Proof of Concept</span>
{pocDescription && <div className="mt-1"><Markdown text={pocDescription} /></div>}
{pocCode && <MdCodeBlock>{pocCode}</MdCodeBlock>}
</div>
)}
{remediation && (
<div>
<span className="text-emerald-400/60 text-sm font-semibold">Remediation</span>
<div className="mt-1"><TruncatedText text={remediation} maxLines={15} /></div>
</div>
)}
</div>
);
}
@@ -0,0 +1,24 @@
"use client";
import type { ToolRendererProps } from "@/types/events";
import { TruncatedText } from "./ToolCard";
export default function WebSearchRenderer({ args, result }: ToolRendererProps) {
const query = (args.query as string) ?? (args.search_query as string) ?? "";
const res = result as Record<string, unknown> | null;
const content = res ? (res.content as string) ?? null : null;
const error = res && !res.success ? (res.message as string) ?? null : null;
return (
<div>
<span className="text-amber-400/80 font-semibold text-sm">Searching the web</span>
{query && <div className="text-[#888] text-[13px] mt-0.5">{query}</div>}
{error && <div className="text-red-400/70 text-[13px] mt-1.5">{error}</div>}
{content && (
<div className="mt-2">
<TruncatedText text={content} maxLines={15} />
</div>
)}
</div>
);
}
@@ -0,0 +1,173 @@
import type { ComponentType } from "react";
import type { ToolRendererProps } from "@/types/events";
import {
Terminal, Globe, FileText, ShieldAlert, ArrowUpRight, Brain,
Bot, MessageCircle, Flag, Eye, Search, Code, StickyNote,
ListTodo, Crosshair, Wrench, Ban, Image,
} from "lucide-react";
import TerminalRenderer from "./TerminalRenderer";
import BrowserRenderer from "./BrowserRenderer";
import FileEditRenderer from "./FileEditRenderer";
import ApplyPatchRenderer from "./ApplyPatchRenderer";
import ViewImageRenderer from "./ViewImageRenderer";
import VulnReportRenderer from "./VulnReportRenderer";
import ProxyRenderer from "./ProxyRenderer";
import ThinkRenderer from "./ThinkRenderer";
import AgentCommsRenderer from "./AgentCommsRenderer";
import WebSearchRenderer from "./WebSearchRenderer";
import PythonRenderer from "./PythonRenderer";
import ScanInfoRenderer from "./ScanInfoRenderer";
import FinishRenderer from "./FinishRenderer";
import NotesRenderer from "./NotesRenderer";
import TodoRenderer from "./TodoRenderer";
import FallbackRenderer from "./FallbackRenderer";
import LoadSkillRenderer from "./LoadSkillRenderer";
/**
* Tool-renderer mapping — data-driven, keyed by the engine's tool *family*.
*
* The OSS strix engine (usestrix/strix) is the source of truth for tool names:
* see `strix/tools/**` for definitions and `strix/interface/tui/renderers/` for
* the TUI equivalents of these components. Tools come in families that share a
* React renderer + icon (terminal, proxy, notes, todos, …), so we describe each
* family ONCE instead of repeating a row per tool name. A new tool that joins an
* existing family (e.g. another `*_request` proxy tool) is picked up by the
* family prefix matcher with no code change; only genuinely-new families need an
* entry here.
*/
export type ToolCategory =
| "terminal"
| "python"
| "browser"
| "filesystem"
| "proxy"
| "reporting"
| "thinking"
| "agents"
| "search"
| "lifecycle"
| "notes"
| "skills"
| "todos"
| "telemetry";
export interface ToolIconMeta {
icon: ComponentType<{ className?: string }>;
color: string;
}
interface CategoryMeta {
renderer: ComponentType<ToolRendererProps>;
icon: ComponentType<{ className?: string }>;
color: string;
/** Family matcher for graceful fallback of unknown tools in this family. */
match?: RegExp;
}
/** Per-family defaults: renderer + base icon/color + a family-name matcher. */
const CATEGORY_META: Record<ToolCategory, CategoryMeta> = {
terminal: { renderer: TerminalRenderer, icon: Terminal, color: "text-emerald-400" },
python: { renderer: PythonRenderer, icon: Code, color: "text-yellow-400" },
browser: { renderer: BrowserRenderer, icon: Globe, color: "text-blue-400" },
filesystem: { renderer: FileEditRenderer, icon: FileText, color: "text-sky-400" },
proxy: { renderer: ProxyRenderer, icon: ArrowUpRight, color: "text-purple-400", match: /request|sitemap|scope/ },
reporting: { renderer: VulnReportRenderer, icon: ShieldAlert, color: "text-red-400" },
thinking: { renderer: ThinkRenderer, icon: Brain, color: "text-purple-400" },
agents: { renderer: AgentCommsRenderer, icon: Bot, color: "text-cyan-400", match: /agent/ },
search: { renderer: WebSearchRenderer, icon: Search, color: "text-amber-400" },
lifecycle: { renderer: ScanInfoRenderer, icon: Flag, color: "text-emerald-400" },
notes: { renderer: NotesRenderer, icon: StickyNote, color: "text-amber-400", match: /note/ },
skills: { renderer: LoadSkillRenderer, icon: Wrench, color: "text-emerald-400" },
todos: { renderer: TodoRenderer, icon: ListTodo, color: "text-purple-400", match: /todo/ },
telemetry: { renderer: FallbackRenderer, icon: Wrench, color: "text-[#555]" },
};
/**
* Tool name → family. Grouped by family; legacy names the engine used before the
* OSS SDK migration (terminal_execute, python_action, browser_action,
* str_replace_editor, send_request, …) are kept as aliases so historical scan
* data keeps rendering.
*/
const CATEGORY_TOOLS: Record<ToolCategory, readonly string[]> = {
// Shell — SDK `exec_command` / `write_stdin` (legacy: terminal_execute)
terminal: ["exec_command", "write_stdin", "terminal_execute"],
// Legacy Python session tool (now runs through the shell)
python: ["python_action"],
// Legacy browser tool (now driven via agent-browser CLI over the shell)
browser: ["browser_action"],
// SDK filesystem — `apply_patch` / `view_image` (legacy: str_replace_editor, list/search)
filesystem: ["apply_patch", "view_image", "str_replace_editor", "list_files", "search_files"],
// Caido proxy tools (legacy: send_request)
proxy: ["list_requests", "view_request", "repeat_request", "list_sitemap", "view_sitemap_entry", "scope_rules", "send_request"],
reporting: ["create_vulnerability_report"],
thinking: ["think"],
agents: ["create_agent", "agent_finish", "send_message_to_agent", "wait_for_message", "view_agent_graph", "stop_agent"],
search: ["web_search"],
// scan_start_info / subagent_start_info are strix-app synthetic events; finish_scan is the engine's
lifecycle: ["scan_start_info", "subagent_start_info", "finish_scan"],
notes: ["create_note", "delete_note", "update_note", "list_notes", "get_note"],
skills: ["load_skill"],
todos: ["create_todo", "list_todos", "update_todo", "mark_todo_done", "mark_todo_pending", "delete_todo"],
telemetry: ["sandbox_error_details", "llm_error_details"],
};
/** Reverse index (tool name → family), built once from CATEGORY_TOOLS. */
const TOOL_CATEGORY: Record<string, ToolCategory> = Object.fromEntries(
(Object.entries(CATEGORY_TOOLS) as [ToolCategory, readonly string[]][]).flatMap(
([category, names]) => names.map((name) => [name, category] as const),
),
);
/**
* Per-tool renderer overrides — for the rare tool whose renderer differs from its
* family default (finish_scan renders the final report, not the scan-start card).
*/
const RENDERER_OVERRIDES: Partial<Record<string, ComponentType<ToolRendererProps>>> = {
finish_scan: FinishRenderer,
apply_patch: ApplyPatchRenderer,
view_image: ViewImageRenderer,
};
/**
* Per-tool icon overrides — for tools whose icon/color differs from their family
* default (the agents family and lifecycle family each vary per tool).
*/
const ICON_OVERRIDES: Partial<Record<string, ToolIconMeta>> = {
agent_finish: { icon: Flag, color: "text-cyan-400" },
send_message_to_agent: { icon: MessageCircle, color: "text-cyan-400" },
wait_for_message: { icon: MessageCircle, color: "text-cyan-400" },
view_agent_graph: { icon: Eye, color: "text-cyan-400" },
stop_agent: { icon: Ban, color: "text-red-400" },
scan_start_info: { icon: Crosshair, color: "text-emerald-400" },
subagent_start_info: { icon: Bot, color: "text-purple-400" },
view_image: { icon: Image, color: "text-sky-400" },
};
const FALLBACK_META: CategoryMeta = CATEGORY_META.telemetry;
/** Resolve a tool name to its family, falling back to family-name matchers. */
function resolveCategory(toolName: string): ToolCategory | null {
const direct = TOOL_CATEGORY[toolName];
if (direct) return direct;
for (const [category, meta] of Object.entries(CATEGORY_META) as [ToolCategory, CategoryMeta][]) {
if (meta.match?.test(toolName)) return category;
}
return null;
}
export function getToolRenderer(toolName: string): ComponentType<ToolRendererProps> {
const override = RENDERER_OVERRIDES[toolName];
if (override) return override;
const category = resolveCategory(toolName);
return category ? CATEGORY_META[category].renderer : FallbackRenderer;
}
export function getToolIcon(toolName: string): ToolIconMeta {
const override = ICON_OVERRIDES[toolName];
if (override) return override;
const category = resolveCategory(toolName);
const meta = category ? CATEGORY_META[category] : FALLBACK_META;
return { icon: meta.icon, color: meta.color };
}
@@ -0,0 +1,3 @@
export function shortPath(p: string): string {
return p.length > 60 ? "..." + p.slice(-57) : p;
}
@@ -0,0 +1,114 @@
"use client";
import { useState } from "react";
import hljs from "@/lib/hljs";
import "highlight.js/styles/github-dark.css";
import { diffLines } from "diff";
import { Copy, Check } from "lucide-react";
import { getLanguageFromFile, copyToClipboard } from "@/lib/vulnerability-utils";
function safeHighlight(code: string, lang: string): string {
try {
return hljs.highlight(code, { language: lang, ignoreIllegals: true }).value;
} catch {
return hljs.highlightAuto(code).value;
}
}
interface CodeDiffBlockProps {
file: string;
startLine: number;
endLine?: number;
before: string;
after: string;
onCopy?: () => void;
}
export function CodeDiffBlock({ file, startLine, endLine, before, after, onCopy }: CodeDiffBlockProps) {
const [copied, setCopied] = useState(false);
const lineRef =
endLine && endLine !== startLine ? `${startLine}-${endLine}` : `${startLine}`;
const lang = getLanguageFromFile(file) || "text";
const changes = diffLines(before, after);
let oldLineNo = startLine;
let newLineNo = startLine;
const rows = changes.flatMap((change) =>
change.value
.replace(/\n$/, "")
.split("\n")
.map((line) => {
const highlighted =
line === ""
? "\n"
: lang !== "text"
? safeHighlight(line, lang)
: line.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
let leftNo = "";
let rightNo = "";
if (change.removed) {
leftNo = String(oldLineNo++);
} else if (change.added) {
rightNo = String(newLineNo++);
} else {
leftNo = String(oldLineNo++);
rightNo = String(newLineNo++);
}
return { highlighted, added: !!change.added, removed: !!change.removed, leftNo, rightNo };
})
);
const copy = () => {
copyToClipboard(after);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
onCopy?.();
};
return (
<div className="rounded-md border border-[#2a2a2a] overflow-hidden">
<div className="flex items-stretch">
<span className="relative flex items-center text-[13px] text-[#999] font-mono px-4 py-2 border-r border-[#2a2a2a] break-all">
{file}:{lineRef}
<span className="absolute top-0 inset-x-0 h-0.5 bg-white/60 rounded-full" />
</span>
<div className="flex-1 border-b border-[#2a2a2a]" />
<button
onClick={copy}
className="px-3 py-2 text-[#555] hover:text-white transition-colors flex-shrink-0 border-b border-[#2a2a2a]"
aria-label="Copy fixed code"
>
{copied ? (
<Check className="w-3.5 h-3.5 text-emerald-400" />
) : (
<Copy className="w-3.5 h-3.5" />
)}
</button>
</div>
<div className="overflow-auto max-h-[400px]">
<table className="w-full border-collapse font-mono text-[12px] leading-[22px] [font-variant-ligatures:none]">
<tbody>
{rows.map((row, i) => (
<tr
key={i}
className={row.added ? "bg-blue-500/[0.12]" : row.removed ? "bg-red-500/[0.12]" : ""}
>
<td className="select-none w-[1px] whitespace-nowrap pl-4 pr-1.5 text-right text-[#555] align-top text-[12px] leading-[22px]">
{row.leftNo}
</td>
<td className="select-none w-[1px] whitespace-nowrap pl-1.5 pr-4 text-right text-[#555] align-top text-[12px] leading-[22px] border-r border-[#2a2a2a]">
{row.rightNo}
</td>
<td
className="pl-4 pr-4 whitespace-pre"
dangerouslySetInnerHTML={{ __html: row.highlighted }}
/>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,27 @@
"use client";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { rehypeCodeMeta, mdComponents } from "./MdCodeBlock";
export function ContentSection({ title, content, action }: { title?: string; content: string; action?: React.ReactNode }) {
return (
<section>
{(title || action) && (
<div className="flex items-center justify-between gap-3 mb-3">
{title ? <h2 className="text-xl font-semibold text-white">{title}</h2> : <span />}
{action}
</div>
)}
<div className="prose-markdown">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeCodeMeta]}
components={mdComponents}
>
{content}
</ReactMarkdown>
</div>
</section>
);
}
@@ -0,0 +1,277 @@
"use client";
import { useState } from "react";
import { Clock, Globe, ChevronDown } from "lucide-react";
import { parseTarget } from "@/lib/target-utils";
import { ProviderIcon } from "@/components/AddRepositoryDialog";
import { getSeverityDot } from "@/lib/vulnerability-utils";
import { formatTimeAgo } from "@/lib/utils";
import { FIX_EFFORT_META, type FixEffort, type Vulnerability } from "@/types/issues";
/* ─── Human-friendly CVSS labels ─── */
const HUMAN_LABELS: Record<string, Record<string, string>> = {
attack_vector: { N: "Remotely exploitable", A: "Adjacent network", L: "Local access required", P: "Physical access required" },
attack_complexity: { L: "Easy to exploit", H: "Requires specific conditions" },
privileges_required: { N: "No authentication needed", L: "Low privileges needed", H: "High privileges needed" },
user_interaction: { N: "No user action required", R: "Requires user action", P: "Passive user role", A: "Active user role" },
scope: { U: "Impact stays contained", C: "Can spread to other systems" },
confidentiality: { N: "No data exposure", L: "Partial data exposure", H: "Full data exposure" },
integrity: { N: "No data modification", L: "Limited modification", H: "Full data modification" },
availability: { N: "No service disruption", L: "Limited disruption", H: "Full service disruption" },
};
const RISK_LEVEL: Record<string, Record<string, "low" | "medium" | "high">> = {
attack_vector: { N: "high", A: "medium", L: "low", P: "low" },
attack_complexity: { L: "high", H: "low" },
privileges_required: { N: "high", L: "medium", H: "low" },
user_interaction: { N: "high", R: "low", P: "medium", A: "low" },
scope: { C: "high", U: "low" },
confidentiality: { H: "high", L: "medium", N: "low" },
integrity: { H: "high", L: "medium", N: "low" },
availability: { H: "high", L: "medium", N: "low" },
};
const RISK_BADGE: Record<string, string> = {
high: "bg-red-500/15 text-red-400 border-red-500/25",
medium: "bg-yellow-500/15 text-yellow-400 border-yellow-500/25",
low: "bg-[#222] text-[#666] border-[#333]",
};
const FACTOR_GROUPS: { label: string; keys: string[] }[] = [
{ label: "Exploitability", keys: ["attack_vector", "attack_complexity", "privileges_required", "user_interaction"] },
{ label: "Impact", keys: ["scope", "confidentiality", "integrity", "availability"] },
];
/* ─── Location link builder ─── */
export function buildLocationHref(
repoUrl: string,
provider: string,
branch: string,
file: string,
startLine: number,
): string | null {
const base = repoUrl.replace(/\.git$/, "").replace(/\/+$/, "");
const encodedFile = file.split("/").map(encodeURIComponent).join("/");
const encodedBranch = branch.split("/").map(encodeURIComponent).join("/");
if (provider === "github") {
return `${base}/blob/${encodedBranch}/${encodedFile}#L${startLine}`;
}
if (provider === "gitlab") {
return `${base}/-/blob/${encodedBranch}/${encodedFile}#L${startLine}`;
}
return null;
}
/* ─── Props ─── */
interface IssueSidebarProps {
vulnerability: Vulnerability;
statusSlot: React.ReactNode;
slackThreadUrl?: string | null;
}
/* ─── Component ─── */
export function IssueSidebar({ vulnerability, statusSlot, slackThreadUrl }: IssueSidebarProps) {
const { severity, cvss, cve, cwe, fix_effort, created_at, target, endpoint, method, code_locations, cvss_breakdown, location_meta } = vulnerability;
const [riskOpen, setRiskOpen] = useState(true);
const fixLocations = code_locations?.filter((loc) => loc.fix_before && loc.fix_after);
const hasLocations = fixLocations && fixLocations.length > 0;
const parsed = target ? parseTarget(target) : null;
const hasAsset = !!(target || endpoint || method || hasLocations);
const hasBreakdown = cvss_breakdown && Object.values(cvss_breakdown).some((v) => v != null);
return (
<aside className="lg:sticky lg:top-6 lg:max-h-[calc(100vh-3rem)] lg:overflow-y-auto">
{/* ─── Metadata ─── */}
<div className="pb-4">
<div className="space-y-3">
{/* Severity */}
<div className="flex items-center justify-between">
<span className="text-xs text-[#aaa]">Severity</span>
<div className="flex items-center gap-1.5">
<div className={`w-2 h-2 rounded-full ${getSeverityDot(severity)}`} aria-hidden="true" />
<span className="text-sm font-medium capitalize text-white">{severity}</span>
</div>
</div>
{/* CVSS */}
<div className="flex items-center justify-between">
<span className="text-xs text-[#aaa]">CVSS Score</span>
<span className="text-sm font-semibold tabular-nums text-white">{cvss !== null ? cvss : "N/A"}</span>
</div>
{/* CVE */}
{cve && (
<div className="flex items-center justify-between">
<span className="text-xs text-[#aaa]">CVE</span>
<span className="text-sm text-white font-mono">{cve}</span>
</div>
)}
{/* CWE */}
{cwe && cwe.length > 0 && (
<div className="flex items-center justify-between">
<span className="text-xs text-[#aaa]">CWE</span>
<span className="text-xs text-white font-mono truncate max-w-[80%] text-right" title={cwe.join(" · ")}>
{cwe.join(" · ")}
</span>
</div>
)}
{/* Fix Effort */}
{fix_effort && (
<div className="flex items-center justify-between">
<span className="text-xs text-[#aaa]">Fix Effort</span>
<span className={`inline-flex items-center px-2 py-0.5 text-[11px] font-medium rounded-full border ${FIX_EFFORT_META[fix_effort as FixEffort]?.color ?? "text-[#666]"}`}>
{fix_effort.charAt(0).toUpperCase() + fix_effort.slice(1)}
</span>
</div>
)}
{/* Discovered */}
<div className="flex items-center justify-between">
<span className="text-xs text-[#aaa]">Discovered</span>
<div className="flex items-center gap-1.5">
<Clock className="w-3 h-3 text-[#444]" aria-hidden="true" />
<span className="text-sm text-white">{formatTimeAgo(created_at)}</span>
</div>
</div>
{/* Status */}
<div className="flex items-center justify-between">
<span className="text-xs text-[#aaa]">Status</span>
{statusSlot}
</div>
</div>
</div>
{/* ─── Asset ─── */}
{hasAsset && (
<div className="border-t border-[#191919] pt-4 pb-4">
<p className="text-xs font-medium text-[#aaa] mb-2.5">Asset</p>
<div className="space-y-2.5">
{target && parsed && (
<div className="flex items-center gap-1.5">
{parsed.provider ? (
<span className="flex-shrink-0 [&_svg]:w-3.5 [&_svg]:h-3.5" aria-hidden="true">
<ProviderIcon provider={parsed.provider} />
</span>
) : (
<Globe className="w-3.5 h-3.5 text-[#555] flex-shrink-0" aria-hidden="true" />
)}
{parsed.href ? (
<a
href={parsed.href}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-white hover:text-[#ccc] break-words min-w-0 transition-colors"
>
{parsed.display}
</a>
) : (
<span className="text-sm text-white break-words min-w-0">{parsed.display}</span>
)}
</div>
)}
{endpoint && (
<div className="flex items-center justify-between">
<span className="text-xs text-[#aaa]">Endpoint</span>
<span className="text-xs text-white font-mono truncate max-w-[75%] text-right">{endpoint}</span>
</div>
)}
{method && (
<div className="flex items-center justify-between">
<span className="text-xs text-[#aaa]">Method</span>
<span className="text-xs text-white font-mono">{method}</span>
</div>
)}
{hasLocations && (
<div>
<span className="text-xs text-[#aaa] mb-1.5 block">Locations</span>
<div className="space-y-0.5">
{fixLocations!.map((loc, i) => {
const label = `${loc.file}:${loc.start_line}`;
const href = location_meta
? buildLocationHref(location_meta.repo_url, location_meta.provider, location_meta.branch, loc.file, loc.start_line)
: null;
return href ? (
<a
key={`loc-${i}`}
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-[13px] text-[#888] hover:text-white font-mono break-all transition-colors block"
>
{label}
</a>
) : (
<span key={`loc-${i}`} className="text-[13px] text-[#888] font-mono break-all block">
{label}
</span>
);
})}
</div>
</div>
)}
</div>
</div>
)}
{/* ─── Risk Assessment (human-friendly CVSS) ─── */}
{hasBreakdown && (
<div className="border-t border-[#191919] pt-4">
<button
onClick={() => setRiskOpen(!riskOpen)}
className="flex items-center justify-between w-full mb-2.5 group"
aria-expanded={riskOpen}
>
<span className="text-xs font-medium text-[#aaa]">Risk Assessment</span>
<ChevronDown className={`w-3.5 h-3.5 text-[#555] group-hover:text-white transition-transform ${riskOpen ? "" : "-rotate-90"}`} aria-hidden="true" />
</button>
<div className={`space-y-3 ${riskOpen ? "" : "hidden"}`}>
{FACTOR_GROUPS.map((group) => {
const factors = group.keys.filter(
(k) => (cvss_breakdown as unknown as Record<string, string | null>)[k] != null
);
if (factors.length === 0) return null;
return (
<div key={group.label}>
<div className="flex items-center justify-between mb-1.5">
<p className="text-[10px] uppercase tracking-wider text-[#444] font-medium">
{group.label}
</p>
<p className="text-[10px] uppercase tracking-wider text-[#444] font-medium mr-2">
Risk
</p>
</div>
<div className="space-y-1">
{factors.map((key) => {
const raw = (cvss_breakdown as unknown as Record<string, string | null>)[key];
const level = raw ? (RISK_LEVEL[key]?.[raw] ?? "low") : "low";
const label = raw ? (HUMAN_LABELS[key]?.[raw] ?? raw) : "N/A";
return (
<div key={key} className="flex items-center justify-between py-0.5">
<span className="text-[12px] text-[#aaa]">{label}</span>
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded border ${RISK_BADGE[level]}`}>
{level}
</span>
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
)}
</aside>
);
}
@@ -0,0 +1,141 @@
"use client";
import { useState } from "react";
import hljs from "@/lib/hljs";
import "highlight.js/styles/github-dark.css";
import { Copy, Check } from "lucide-react";
import { copyToClipboard } from "@/lib/vulnerability-utils";
export function MdCodeBlock({
className,
children,
node,
}: {
className?: string;
children?: React.ReactNode;
node?: { data?: { meta?: string }; properties?: { metastring?: string } };
}) {
const [copied, setCopied] = useState(false);
const raw = String(children).replace(/\n$/, "");
const match = /language-(\w+)/.exec(className || "");
const isBlock = raw.includes("\n") || match;
if (!isBlock) {
return <code className={`${className || ""} bg-white/8 px-1.5 py-0.5 rounded text-[13px]`}>{children}</code>;
}
const meta = node?.data?.meta || node?.properties?.metastring || "";
const titleMatch = /title=["']?([^"'\s}]+)["']?/.exec(meta);
const startMatch = /startLineNumber=(\d+)/.exec(meta);
const fileName = titleMatch?.[1] || null;
const startLine = startMatch ? parseInt(startMatch[1], 10) : 1;
const headerLabel = fileName
? startMatch
? `${fileName}:${startLine}`
: fileName
: null;
let highlighted: string;
if (match) {
try {
highlighted = hljs.highlight(raw, { language: match[1], ignoreIllegals: true }).value;
} catch {
highlighted = hljs.highlightAuto(raw).value;
}
} else {
highlighted = hljs.highlightAuto(raw).value;
}
const lines = highlighted.split("\n");
const copy = () => {
copyToClipboard(raw);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="group/code relative rounded-md border border-[#2a2a2a] my-4 text-[#ddd] overflow-hidden">
{headerLabel ? (
<div className="flex items-stretch">
<span className="relative flex items-center text-[13px] text-[#999] font-mono px-4 py-2 border-r border-[#2a2a2a]">{headerLabel}<span className="absolute top-0 inset-x-0 h-0.5 bg-white/60 rounded-full" /></span>
<div className="flex-1 border-b border-[#2a2a2a]" />
<button
onClick={copy}
className="px-3 py-2 text-[#555] hover:text-white transition-colors border-b border-[#2a2a2a]"
aria-label="Copy code"
>
{copied ? (
<Check className="w-3.5 h-3.5 text-emerald-400" />
) : (
<Copy className="w-3.5 h-3.5" />
)}
</button>
</div>
) : (
<button
onClick={copy}
className="absolute top-2 right-2 z-10 p-1 rounded text-[#444] hover:text-white opacity-0 group-hover/code:opacity-100 transition-opacity"
aria-label="Copy code"
>
{copied ? (
<Check className="w-3.5 h-3.5 text-emerald-400" />
) : (
<Copy className="w-3.5 h-3.5" />
)}
</button>
)}
<div className="overflow-auto max-h-[400px]">
<table className="w-full border-collapse font-mono text-[12px] leading-[22px] [font-variant-ligatures:none]">
<tbody>
{lines.map((line, i) => (
<tr key={i}>
<td className="select-none w-[1px] whitespace-nowrap px-4 text-right text-[#555] align-top text-[12px] leading-[22px] border-r border-[#2a2a2a]">
{startLine + i}
</td>
<td
className="pl-4 pr-4 whitespace-pre"
dangerouslySetInnerHTML={{ __html: line || "\n" }}
/>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// rehype plugin: pass code fence meta string through to code element properties
type HastNode = {
type: string;
tagName?: string;
children?: HastNode[];
properties?: Record<string, unknown>;
data?: { meta?: string };
};
export function rehypeCodeMeta() {
return (tree: HastNode) => {
const visit = (node: HastNode) => {
if (node.type === "element" && node.tagName === "pre" && node.children) {
const codeEl = node.children.find(
(c) => c.type === "element" && c.tagName === "code"
);
if (codeEl?.data?.meta) {
codeEl.properties = codeEl.properties || {};
codeEl.properties.metastring = codeEl.data.meta;
}
}
if (node.children) {
node.children.forEach((child) => visit(child));
}
};
visit(tree);
};
}
export const mdComponents = {
code: MdCodeBlock as React.ComponentType<React.HTMLAttributes<HTMLElement>>,
pre: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
};
@@ -0,0 +1,77 @@
"use client";
import { useState } from "react";
import hljs from "@/lib/hljs";
import "highlight.js/styles/github-dark.css";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Copy, Check } from "lucide-react";
import { copyToClipboard } from "@/lib/vulnerability-utils";
import { rehypeCodeMeta, mdComponents } from "./MdCodeBlock";
interface PocBlockProps {
description?: string | null;
scriptCode?: string | null;
onCopy?: () => void;
}
export function PocBlock({ description, scriptCode, onCopy }: PocBlockProps) {
const [copied, setCopied] = useState(false);
if (!description && !scriptCode) return null;
const copy = () => {
if (!scriptCode) return;
copyToClipboard(scriptCode);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
onCopy?.();
};
return (
<section>
<h2 className="text-xl font-semibold text-white mb-3">Proof of Concept</h2>
<div className="space-y-4">
{description && (
<div className="prose-markdown">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeCodeMeta]}
components={mdComponents}
>
{description}
</ReactMarkdown>
</div>
)}
{scriptCode && (
<div className="group/poc relative rounded-md border border-[#2a2a2a] overflow-hidden">
<div className="flex items-stretch">
<span className="relative flex items-center text-[13px] text-[#999] font-mono px-4 py-2 border-r border-[#2a2a2a]">PoC Script<span className="absolute top-0 inset-x-0 h-0.5 bg-white/60 rounded-full" /></span>
<div className="flex-1 border-b border-[#2a2a2a]" />
<button
onClick={copy}
className="px-3 py-2 text-[#555] hover:text-white transition-colors flex-shrink-0 border-b border-[#2a2a2a]"
aria-label="Copy PoC code"
>
{copied ? (
<Check className="w-3.5 h-3.5 text-emerald-400" />
) : (
<Copy className="w-3.5 h-3.5" />
)}
</button>
</div>
<div className="overflow-auto max-h-[400px] px-4 py-3">
<pre className="font-mono text-[12px] leading-[22px] whitespace-pre [font-variant-ligatures:none]">
<code
dangerouslySetInnerHTML={{
__html: hljs.highlight(scriptCode, { language: "python" }).value,
}}
/>
</pre>
</div>
</div>
)}
</div>
</section>
);
}
@@ -0,0 +1,265 @@
"use client";
import React, { useState } from "react";
import { Clock, CheckCircle2, Ban, History, BellOff, Wrench, GitMerge, GitPullRequest } from "lucide-react";
import { ProInlineCta } from "@/components/ProCta";
import { Vulnerability, VulnerabilityStatus, SEVERITY_COLORS, STATUS_META, isSeverityOverridden } from "@/types/issues";
import { formatTimeAgo } from "@/lib/utils";
import { getSeverityDot } from "@/lib/vulnerability-utils";
import { formatStrixId } from "@/lib/display-number";
import { ContentSection } from "@/components/vulnerability/ContentSection";
import { CodeDiffBlock } from "@/components/vulnerability/CodeDiffBlock";
import { PocBlock } from "@/components/vulnerability/PocBlock";
import { IssueSidebar } from "@/components/vulnerability/IssueSidebar";
function bannerTime(dateString: string | null): string {
if (!dateString) return "";
const diffInSeconds = Math.floor((Date.now() - new Date(dateString).getTime()) / 1000);
if (diffInSeconds < 604800) return ` ${formatTimeAgo(dateString)}`;
return ` on ${formatTimeAgo(dateString)}`;
}
const STATUS_BANNER: Record<VulnerabilityStatus, { icon: React.ElementType; label: string; iconColor: string } | null> = {
open: null,
in_progress: { icon: Clock, label: "Marked as In Progress", iconColor: "text-blue-400" },
snoozed: { icon: BellOff, label: "Snoozed", iconColor: "text-purple-400" },
fixed: { icon: CheckCircle2, label: "Marked as Fixed", iconColor: "text-emerald-400" },
ignored: { icon: Ban, label: "Marked as Ignored", iconColor: "text-[#888]" },
};
type BottomTab = "fix" | "reproduction";
interface VulnerabilityDetailProps {
vulnerability: Vulnerability;
}
/**
* Self-contained finding detail (header + status banners + content grid),
* without page chrome. Shared by the public /share/issues page and the local
* /results view so both render findings identically.
*/
export default function VulnerabilityDetail({ vulnerability }: VulnerabilityDetailProps) {
const currentMeta = STATUS_META[vulnerability.status];
const hasCodeLocations = vulnerability.code_locations && vulnerability.code_locations.length > 0;
const hasFix = hasCodeLocations || vulnerability.remediation_steps;
const hasReproduction = !!(vulnerability.evidence || vulnerability.assumptions || vulnerability.poc_description || vulnerability.poc_script_code);
const [activeTab, setActiveTab] = useState<BottomTab>("fix");
const bottomTabs: { id: BottomTab; label: string; show: boolean }[] = [
{ id: "fix", label: "Fix", show: !!hasFix },
{ id: "reproduction", label: "Reproduction", show: hasReproduction },
];
const visibleTabs = bottomTabs.filter((t) => t.show);
return (
<div className="space-y-6">
{/* Header */}
<div>
<div className="mb-2">
{vulnerability.display_number && (
<span className="text-xs font-mono text-[#555] block mb-1">
{formatStrixId(vulnerability.display_number)}
</span>
)}
<h1 className="text-2xl font-semibold text-white">{vulnerability.title}</h1>
</div>
<div className="flex flex-wrap items-center gap-3">
<span className={`inline-flex items-center gap-1.5 px-3 py-1 text-sm font-medium rounded-full border ${currentMeta.color}`}>
{currentMeta.label}
</span>
<div
className={`inline-flex items-center gap-1.5 px-3 py-1 text-sm font-semibold rounded-full border ${SEVERITY_COLORS[vulnerability.severity]}`}
title={isSeverityOverridden(vulnerability) ? `Adjusted from ${vulnerability.original_severity}` : undefined}
>
<div className={`w-2 h-2 rounded-full ${getSeverityDot(vulnerability.severity)}`} />
<span className="capitalize">
{vulnerability.severity}
{!isSeverityOverridden(vulnerability) && vulnerability.cvss ? ` ${vulnerability.cvss}` : ""}
</span>
{isSeverityOverridden(vulnerability) && (
<History className="w-3 h-3 opacity-70" aria-hidden="true" />
)}
</div>
{vulnerability.cve && (
<>
<span className="text-[#333]">&middot;</span>
<span className="text-sm text-[#666] font-mono">{vulnerability.cve}</span>
</>
)}
</div>
</div>
{/* Status banner */}
{vulnerability.status !== "open" && (() => {
const banner = STATUS_BANNER[vulnerability.status];
if (!banner) return null;
const BannerIcon = banner.icon;
return (
<div className="rounded-lg px-4 py-3.5 flex gap-3" style={{ border: "1px solid rgba(255,255,255,0.08)" }}>
<BannerIcon className={`w-5 h-5 flex-shrink-0 mt-0.5 ${banner.iconColor}`} aria-hidden="true" />
<div className="min-w-0">
<p className="text-sm font-semibold text-white">
{banner.label}{bannerTime(vulnerability.status_changed_at)}
</p>
{vulnerability.status_note && (
<p className="text-sm text-[#666] italic mt-1">
&ldquo;{vulnerability.status_note}&rdquo;
</p>
)}
</div>
</div>
);
})()}
{/* Severity override banner */}
{isSeverityOverridden(vulnerability) && (
<div className="rounded-lg px-4 py-3.5 flex gap-3" style={{ border: "1px solid rgba(255,255,255,0.08)" }}>
<History className="w-5 h-5 flex-shrink-0 mt-0.5 text-orange-400" aria-hidden="true" />
<div className="min-w-0">
<p className="text-sm font-semibold text-white">
Severity changed manually from{" "}
<span className="capitalize">{vulnerability.original_severity}</span>
{vulnerability.cvss != null ? ` (${vulnerability.cvss})` : ""} to{" "}
<span className="capitalize">{vulnerability.severity}</span>
{bannerTime(vulnerability.severity_changed_at)}
</p>
{vulnerability.severity_override_reason && (
<p className="text-sm text-[#666] italic mt-1">
&ldquo;{vulnerability.severity_override_reason}&rdquo;
</p>
)}
</div>
</div>
)}
{/* Content grid */}
<div className="grid grid-cols-1 lg:grid-cols-[1fr_340px] gap-8">
{/* Main content */}
<div className="min-w-0">
<div className="space-y-8">
<ContentSection title="TL;DR" content={vulnerability.description} />
{vulnerability.impact && <ContentSection title="Impact" content={vulnerability.impact} />}
{vulnerability.technical_analysis && (
<ContentSection title="Technical Details" content={vulnerability.technical_analysis} />
)}
</div>
{/* Bottom tabs */}
{visibleTabs.length > 0 && (
<div className="mt-10">
<div className="border-b border-[#2a2a2a]">
<nav className="flex gap-6" aria-label="Tabs">
{visibleTabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`relative min-w-[80px] text-center pb-3 text-[16px] font-semibold transition-colors ${
activeTab === tab.id
? "text-white"
: "text-[#666] hover:text-white"
}`}
aria-current={activeTab === tab.id ? "page" : undefined}
>
{tab.label}
{activeTab === tab.id && (
<span className="absolute bottom-0 inset-x-0 h-0.5 bg-white rounded-full" />
)}
</button>
))}
</nav>
</div>
{/* Fix tab */}
{hasFix && (
<div className={`pt-6 space-y-6 ${activeTab === "fix" ? "animate-tab-in" : "hidden"}`}>
{vulnerability.remediation_steps && (
<ContentSection title="How do I fix it?" content={vulnerability.remediation_steps} />
)}
{hasCodeLocations &&
vulnerability.code_locations!
.filter((loc) => loc.fix_before && loc.fix_after)
.map((loc, i) => (
<CodeDiffBlock
key={`fix-${i}`}
file={loc.file}
startLine={loc.start_line}
endLine={loc.end_line}
before={loc.fix_before!}
after={loc.fix_after!}
/>
))}
</div>
)}
{/* Reproduction tab */}
{hasReproduction && (
<div className={`pt-6 space-y-8 ${activeTab === "reproduction" ? "animate-tab-in" : "hidden"}`}>
{vulnerability.assumptions && (
<ContentSection title="Assumptions" content={vulnerability.assumptions} />
)}
{vulnerability.evidence && (
<ContentSection title="Evidence" content={vulnerability.evidence} />
)}
<PocBlock
description={vulnerability.poc_description}
scriptCode={vulnerability.poc_script_code}
/>
</div>
)}
</div>
)}
</div>
{/* Sidebar */}
<div className="lg:border-l lg:border-[#2a2a2a] lg:pl-6">
<IssueSidebar
vulnerability={vulnerability}
statusSlot={
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full border ${currentMeta.color}`}>
<div className={`w-1.5 h-1.5 rounded-full ${currentMeta.dotColor}`} />
{currentMeta.label}
</span>
}
/>
</div>
</div>
{/* Team-workflow CTAs (Pro). Highest-intent surface: act on this finding. */}
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
<p className="text-sm font-semibold text-white">Ship the fix with your team</p>
<p className="mt-0.5 text-xs text-[#666]">
Take this finding into your team's workflow.
</p>
<div className="mt-3 flex flex-wrap gap-2.5">
<ProInlineCta
label="Auto-fix & open a PR"
desc="Fix it for you and open a PR, retested."
slug="autofix"
icon={Wrench}
surface="finding_detail"
/>
<ProInlineCta
label="Sync to Jira / Linear"
desc="Two-way sync findings to Jira, Linear, and Slack."
slug="integrations"
icon={GitMerge}
surface="finding_detail"
/>
<ProInlineCta
label="Catch this in PR reviews"
desc="Pentest every pull request your team opens."
slug="pr_reviews"
icon={GitPullRequest}
surface="finding_detail"
/>
</div>
</div>
</div>
);
}
+237
View File
@@ -0,0 +1,237 @@
import type { Vulnerability } from "@/types/issues";
import {
parseRunJson,
parseVulnerabilitiesJson,
type ParsedRunSummary,
} from "@/lib/local-run-parser";
/**
* Data seam for the local viewer. Replaces strix-app's browser file-picker
* (`loadFromTexts`) with fetches against the local Python server's JSON
* endpoints (same origin, relative URLs). Produces the same in-memory
* `LoadedRun` shape the UI renders, plus a `finished` flag driving live polling.
*
* The server serves a live in-progress run and a finished one identically; the
* only signal is `run.finished`.
*/
/** A transcript agent as emitted by GET /api/transcript (already parsed). */
export interface TranscriptAgent {
id: string;
name: string;
parent_id: string | null;
status: string;
created_at: string;
updated_at: string;
}
/** Chat/tool event data as emitted by GET /api/transcript. */
export interface TranscriptEvent {
id: string;
type: "chat" | "tool";
agent_id: string;
timestamp: string;
version: number;
data: Record<string, unknown>;
}
export interface Transcript {
agents: TranscriptAgent[];
events: TranscriptEvent[];
}
export interface LoadedRun {
summary: ParsedRunSummary;
/** Whole raw run record (for llm_usage, targets_info details, etc.). */
raw: Record<string, unknown>;
finished: boolean;
vulnerabilities: Vulnerability[];
reportMarkdown: string | null;
transcript: Transcript;
}
async function getJson(path: string): Promise<unknown> {
const res = await fetch(path, { cache: "no-store" });
if (!res.ok) throw new Error(`${path} responded ${res.status}`);
return res.json();
}
/** Build a ``?run=<name>`` suffix for run-scoped data endpoints. */
function runQuery(runName?: string | null): string {
return runName ? `?run=${encodeURIComponent(runName)}` : "";
}
export async function fetchRunSummary(runName?: string | null): Promise<{
summary: ParsedRunSummary;
raw: Record<string, unknown>;
finished: boolean;
}> {
const raw = (await getJson("/api/run" + runQuery(runName))) as Record<string, unknown>;
// parseRunJson tolerates extra keys and takes raw TEXT.
const summary = parseRunJson(JSON.stringify(raw));
const finished = raw.finished === true;
return { summary, raw, finished };
}
export async function fetchVulnerabilities(
runId: string | null,
runName?: string | null
): Promise<Vulnerability[]> {
const arr = await getJson("/api/vulnerabilities" + runQuery(runName));
return parseVulnerabilitiesJson(JSON.stringify(arr), runId);
}
export async function fetchReportMarkdown(runName?: string | null): Promise<string | null> {
const obj = (await getJson("/api/report" + runQuery(runName))) as { markdown?: string };
return obj?.markdown ?? null;
}
export async function fetchTranscript(runName?: string | null): Promise<Transcript> {
const obj = (await getJson("/api/transcript" + runQuery(runName))) as Partial<Transcript>;
return {
agents: Array.isArray(obj?.agents) ? obj.agents : [],
events: Array.isArray(obj?.events) ? obj.events : [],
};
}
/** One-shot fetch of every endpoint (used on mount and on final settle). */
export async function fetchAll(runName?: string | null): Promise<LoadedRun> {
const { summary, raw, finished } = await fetchRunSummary(runName);
const [vulnerabilities, reportMarkdown, transcript] = await Promise.all([
fetchVulnerabilities(summary.runId, runName).catch(() => [] as Vulnerability[]),
fetchReportMarkdown(runName).catch(() => null),
fetchTranscript(runName).catch(() => ({ agents: [], events: [] }) as Transcript),
]);
return { summary, raw, finished, vulnerabilities, reportMarkdown, transcript };
}
// ---------------------------------------------------------------------------
// Run history + email auth + report send
//
// These endpoints back the "Your runs" sidebar section. Auth and report-send
// responses carry a meaningful JSON body on non-2xx statuses (an ``error``
// code), so they read the body regardless of status rather than throwing.
// ---------------------------------------------------------------------------
export interface RunSeverityCounts {
critical: number;
high: number;
medium: number;
low: number;
}
export interface RunListEntry {
name: string;
target: string | null;
scan_mode: string | null;
status: string | null;
start_time: string | null;
end_time: string | null;
finished: boolean;
severity_counts: RunSeverityCounts;
}
export interface RunsPayload {
locked: boolean;
count: number;
runs: RunListEntry[];
}
export interface AuthStatus {
verified: boolean;
email: string | null;
}
export type OtpStartResult = { ok: true } | { ok: false; error: string };
export type OtpVerifyResult =
| { verified: true; email: string }
| { verified: false; error: string };
export type SendReportResult =
| { ok: true; password: string; filename: string }
| { ok: false; error: string };
async function postJson(
path: string,
body: Record<string, unknown>
): Promise<{ ok: boolean; status: number; data: Record<string, unknown> }> {
const res = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
cache: "no-store",
});
let data: Record<string, unknown> = {};
try {
const parsed = await res.json();
if (parsed && typeof parsed === "object") data = parsed as Record<string, unknown>;
} catch {
/* empty or non-JSON body */
}
return { ok: res.ok, status: res.status, data };
}
export async function fetchRuns(): Promise<RunsPayload> {
const obj = (await getJson("/api/runs")) as Partial<RunsPayload>;
return {
locked: obj?.locked ?? true,
count: typeof obj?.count === "number" ? obj.count : 0,
runs: Array.isArray(obj?.runs) ? (obj.runs as RunListEntry[]) : [],
};
}
export interface Capabilities {
can_steer: boolean;
}
export type SteerResult = { ok: true } | { ok: false; error: string };
/** GET /api/capabilities. can_steer is true only inside a live in-TUI scan. */
export async function fetchCapabilities(): Promise<Capabilities> {
const obj = (await getJson("/api/capabilities")) as Partial<Capabilities>;
return { can_steer: obj?.can_steer === true };
}
/** POST /api/agents/steer. Sends a steering instruction to a running agent. */
export async function steerAgent(agentId: string, message: string): Promise<SteerResult> {
const { ok, data } = await postJson("/api/agents/steer", {
agent_id: agentId,
message,
});
if (ok && data.ok === true) return { ok: true };
return { ok: false, error: String(data.error ?? "unavailable") };
}
export async function fetchAuthStatus(): Promise<AuthStatus> {
const obj = (await getJson("/api/auth/status")) as Partial<AuthStatus>;
return { verified: obj?.verified === true, email: obj?.email ?? null };
}
export async function otpStart(email: string): Promise<OtpStartResult> {
const { ok, data } = await postJson("/api/auth/otp/start", { email });
if (ok && data.ok === true) return { ok: true };
return { ok: false, error: String(data.error ?? "unavailable") };
}
export async function otpVerify(email: string, code: string): Promise<OtpVerifyResult> {
const { ok, data } = await postJson("/api/auth/otp/verify", { email, code });
if (ok && data.verified === true) {
return { verified: true, email: String(data.email ?? email) };
}
return { verified: false, error: String(data.error ?? "invalid_code") };
}
export async function forgetAuth(): Promise<void> {
await postJson("/api/auth/forget", {});
}
export async function sendReport(runName?: string | null): Promise<SendReportResult> {
const { ok, data } = await postJson("/api/report/send", runName ? { run: runName } : {});
if (ok && data.ok === true) {
return {
ok: true,
password: String(data.password ?? ""),
filename: String(data.filename ?? "strix-report.pdf"),
};
}
return { ok: false, error: String(data.error ?? "unavailable") };
}
+173
View File
@@ -0,0 +1,173 @@
@import "tailwindcss";
:root {
--font-geist-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif;
--font-geist-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
"Liberation Mono", "Courier New", monospace;
}
@theme inline {
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
html,
body {
background: #000000;
color: #ffffff;
font-family: var(--font-geist-sans);
}
/* Tab content transition (lifted from strix-app globals.css) */
@keyframes tab-in {
from {
opacity: 0;
filter: blur(4px);
transform: translateY(6px);
}
to {
opacity: 1;
filter: blur(0px);
transform: translateY(0px);
}
}
.animate-tab-in {
animation: tab-in 200ms ease-out;
}
/* Markdown prose styling (lifted from strix-app globals.css) */
.prose-markdown {
font-size: 14px;
line-height: 1.7;
color: #999;
word-wrap: break-word;
overflow-wrap: break-word;
}
.prose-markdown p {
margin-bottom: 0.75em;
}
.prose-markdown p:last-child {
margin-bottom: 0;
}
.prose-markdown strong {
color: #ccc;
font-weight: 600;
}
.prose-markdown em {
font-style: italic;
}
.prose-markdown code {
background: #0a0a0a;
border: 1px solid #111;
border-radius: 4px;
padding: 0.15em 0.4em;
font-size: 0.9em;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
color: #ccc;
font-variant-ligatures: none;
}
.prose-markdown pre {
background: transparent;
border: none;
border-radius: 0;
padding: 0;
margin: 0;
font-variant-ligatures: none;
}
.prose-markdown pre code {
background: none;
border: none;
padding: 0;
font-size: 13px;
color: inherit;
}
.prose-markdown ul,
.prose-markdown ol {
padding-left: 1.5em;
margin-bottom: 0.75em;
}
.prose-markdown ul {
list-style-type: disc;
}
.prose-markdown ol {
list-style-type: decimal;
}
.prose-markdown li {
margin-bottom: 0.25em;
}
.prose-markdown li > ul,
.prose-markdown li > ol {
padding-left: 1.5em;
margin-top: 0.25em;
margin-bottom: 0.25em;
}
.prose-markdown ol + ul {
padding-left: 3em;
margin-top: -0.5em;
}
.prose-markdown h1,
.prose-markdown h2,
.prose-markdown h3,
.prose-markdown h4,
.prose-markdown h5,
.prose-markdown h6 {
color: #ddd;
font-weight: 600;
margin-top: 1em;
margin-bottom: 0.5em;
}
.prose-markdown a {
color: inherit;
text-decoration: none;
pointer-events: none;
}
.prose-markdown blockquote {
border-left: 3px solid #333;
padding-left: 1em;
color: #777;
margin: 0.75em 0;
}
.prose-markdown hr {
border: none;
border-top: 1px solid #222;
margin: 1em 0;
}
.prose-markdown > table {
width: 100%;
border-collapse: collapse;
margin: 0.75em 0;
}
.prose-markdown > table th,
.prose-markdown > table td {
border: 1px solid #333;
padding: 0.4em 0.75em;
text-align: left;
font-size: 13px;
}
.prose-markdown > table th {
background: #1a1a1a;
color: #ccc;
font-weight: 600;
}
+43
View File
@@ -0,0 +1,43 @@
// All upsell / sign-up CTAs route anonymous local-viewer users to the public
// cloud sign-up. Open in a new tab so the local results stay put.
export const SIGNUP_URL = "https://app.strix.ai/api/auth/signup";
export const DEMO_URL = "https://strix.ai/demo";
export const PRICING_URL = "https://strix.ai/pricing";
// Attribution params appended to every outbound CTA link so the destination
// analytics can see the local viewer drove the click, with utm_content carrying
// the CTA slug so we know which one.
const CTA_PARAMS =
"ref=oss_viewer&utm_source=oss_viewer&utm_medium=local_viewer&utm_campaign=oss_viewer";
export function ctaUrl(base: string, slug: string): string {
const sep = base.includes("?") ? "&" : "?";
return `${base}${sep}${CTA_PARAMS}&utm_content=${encodeURIComponent(slug)}`;
}
// Best-effort, anonymous beacon. The local server forwards this to PostHog only
// if the user has telemetry enabled; it never blocks navigation. Undefined
// props are dropped so we only send what is set. NEVER pass PII here (no email,
// code, or report content) - the props are limited to anonymous metadata.
export function track(event: string, props: Record<string, string | undefined> = {}): void {
try {
const body: Record<string, string> = { event };
for (const [key, value] of Object.entries(props)) {
if (value !== undefined) body[key] = value;
}
const payload = JSON.stringify(body);
if (typeof navigator !== "undefined" && navigator.sendBeacon) {
navigator.sendBeacon("/api/event", payload);
} else {
void fetch("/api/event", { method: "POST", body: payload, keepalive: true });
}
} catch {
/* analytics is best-effort */
}
}
// Anonymous conversion tracking for a sign-up/upsell click. `surface` records
// where the click happened so one CTA slug can be reused across placements.
export function trackCta(cta: string, surface?: string): void {
track("cta_clicked", { cta, surface });
}
@@ -0,0 +1,12 @@
// Slim, dependency-free extract of strix-app's display-number helper. The full
// version queries Supabase to compute org-wide finding numbers; the local viewer
// only ever needs the pure formatter, so the supabase-backed functions are
// intentionally omitted (a local run has no org context).
export function formatStrixId(num: number): string {
return `STRIX-${num}`;
}
/** Format an integer with locale thousands separators (e.g. 68339486 -> "68,339,486"). */
export function formatNumber(num: number): string {
return new Intl.NumberFormat("en-US").format(num);
}
+14
View File
@@ -0,0 +1,14 @@
import hljs from "highlight.js/lib/common";
import http from "highlight.js/lib/languages/http";
import nginx from "highlight.js/lib/languages/nginx";
import apache from "highlight.js/lib/languages/apache";
import dockerfile from "highlight.js/lib/languages/dockerfile";
import properties from "highlight.js/lib/languages/properties";
hljs.registerLanguage("http", http);
hljs.registerLanguage("nginx", nginx);
hljs.registerLanguage("apache", apache);
hljs.registerLanguage("dockerfile", dockerfile);
hljs.registerLanguage("properties", properties);
export default hljs;
@@ -0,0 +1,325 @@
import type {
Vulnerability,
VulnerabilitySeverity,
VulnerabilityStatus,
} from "@/types/issues";
/**
* Pure, dependency-free parsers that turn a Strix CLI local run
* (`strix_runs/<run>/{run.json,vulnerabilities.json}`) into the app's own
* types, so the /results view can reuse the dashboard's finding components.
*
* These run entirely client-side against files the user picked from disk —
* nothing here uploads or persists anything.
*/
export class RunParseError extends Error {
constructor(message: string) {
super(message);
this.name = "RunParseError";
}
}
export interface ParsedRunSummary {
runId: string | null;
runName: string | null;
targets: string[];
scanMode: string | null;
status: string | null;
startTime: string | null;
endTime: string | null;
durationSeconds: number | null;
executiveSummary: string | null;
technicalAnalysis: string | null;
methodology: string | null;
recommendations: string | null;
}
const KNOWN_SEVERITIES: VulnerabilitySeverity[] = ["critical", "high", "medium", "low"];
function coerceSeverity(raw: unknown): VulnerabilitySeverity {
const s = String(raw ?? "").toLowerCase().trim();
if ((KNOWN_SEVERITIES as string[]).includes(s)) return s as VulnerabilitySeverity;
// The app's severity type has no "info"/"informational" bucket; fold those
// (and anything unrecognized) into "low" so the shared UI renders cleanly.
return "low";
}
function toIsoTimestamp(raw: unknown): string {
if (typeof raw === "string" && raw.trim()) {
// CLI writes e.g. "2025-01-02 03:04:05 UTC".
const normalized = raw.trim().replace(" UTC", "Z").replace(" ", "T");
const d = new Date(normalized);
if (!Number.isNaN(d.getTime())) return d.toISOString();
const direct = new Date(raw);
if (!Number.isNaN(direct.getTime())) return direct.toISOString();
}
return new Date().toISOString();
}
function asStringOrNull(v: unknown): string | null {
return typeof v === "string" && v.length > 0 ? v : null;
}
function asNumberOrNull(v: unknown): number | null {
return typeof v === "number" && Number.isFinite(v) ? v : null;
}
function parseJson(text: string, label: string): unknown {
try {
return JSON.parse(text);
} catch {
throw new RunParseError(
`${label} isn't valid JSON. Make sure you selected a Strix run directory.`
);
}
}
export function parseRunJson(text: string): ParsedRunSummary {
const data = parseJson(text, "run.json");
if (!data || typeof data !== "object" || Array.isArray(data)) {
throw new RunParseError("run.json is not an object.");
}
const record = data as Record<string, unknown>;
const targets: string[] = [];
const targetsInfo = record.targets_info;
if (Array.isArray(targetsInfo)) {
for (const t of targetsInfo) {
if (t && typeof t === "object") {
const original = (t as Record<string, unknown>).original;
if (typeof original === "string" && original) targets.push(original);
}
}
}
const startTime = asStringOrNull(record.start_time);
const endTime = asStringOrNull(record.end_time);
let durationSeconds: number | null = null;
if (startTime && endTime) {
const s = new Date(startTime).getTime();
const e = new Date(endTime).getTime();
if (!Number.isNaN(s) && !Number.isNaN(e) && e >= s) {
durationSeconds = Math.round((e - s) / 1000);
}
}
let executiveSummary: string | null = null;
let technicalAnalysis: string | null = null;
let methodology: string | null = null;
let recommendations: string | null = null;
const scanResults = record.scan_results;
if (scanResults && typeof scanResults === "object") {
const sr = scanResults as Record<string, unknown>;
executiveSummary = asStringOrNull(sr.executive_summary);
technicalAnalysis = asStringOrNull(sr.technical_analysis);
methodology = asStringOrNull(sr.methodology);
recommendations = asStringOrNull(sr.recommendations);
}
return {
runId: asStringOrNull(record.run_id),
runName: asStringOrNull(record.run_name),
targets,
scanMode: asStringOrNull(record.scan_mode),
status: asStringOrNull(record.status),
startTime,
endTime,
durationSeconds,
executiveSummary,
technicalAnalysis,
methodology,
recommendations,
};
}
/** Fields on the app's Vulnerability type that a local run never provides. */
function emptyVulnerabilityDefaults(): Omit<
Vulnerability,
| "id"
| "title"
| "description"
| "severity"
| "created_at"
| "scan_id"
| "status"
> {
return {
pr_review_id: null,
cve: null,
cvss: null,
potential_risk_saving: null,
risk_saving_description: null,
impact: null,
endpoint: null,
method: null,
target: null,
technical_analysis: null,
poc_description: null,
poc_script_code: null,
code_diff: null,
code_file: null,
code_before: null,
code_after: null,
cwe: null,
code_locations: null,
remediation_steps: null,
fix_pr_body: null,
evidence: null,
assumptions: null,
fix_effort: null,
cvss_breakdown: null,
status_changed_at: null,
status_changed_by: null,
status_note: null,
snoozed_until: null,
reopened_at: null,
reopened_by: null,
original_severity: null,
severity_changed_at: null,
severity_changed_by: null,
severity_override_reason: null,
retest_of_vulnerability_id: null,
};
}
function parseOneVulnerability(
raw: Record<string, unknown>,
index: number,
runId: string | null
): Vulnerability {
const cweRaw = raw.cwe;
const cwe =
typeof cweRaw === "string" && cweRaw.trim()
? [cweRaw.trim()]
: Array.isArray(cweRaw)
? (cweRaw.filter((c) => typeof c === "string" && c) as string[])
: null;
const status: VulnerabilityStatus = "open";
return {
...emptyVulnerabilityDefaults(),
id: asStringOrNull(raw.id) ?? `vuln-${index + 1}`,
scan_id: runId,
title: asStringOrNull(raw.title) ?? "Untitled finding",
description: asStringOrNull(raw.description) ?? "",
severity: coerceSeverity(raw.severity),
status,
created_at: toIsoTimestamp(raw.timestamp),
cve: asStringOrNull(raw.cve),
cvss: asNumberOrNull(raw.cvss),
impact: asStringOrNull(raw.impact),
endpoint: asStringOrNull(raw.endpoint),
method: asStringOrNull(raw.method),
target: asStringOrNull(raw.target),
technical_analysis: asStringOrNull(raw.technical_analysis),
poc_description: asStringOrNull(raw.poc_description),
poc_script_code: asStringOrNull(raw.poc_script_code),
cwe,
code_locations: Array.isArray(raw.code_locations)
? (raw.code_locations as Vulnerability["code_locations"])
: null,
remediation_steps: asStringOrNull(raw.remediation_steps),
fix_pr_body: asStringOrNull(raw.fix_pr_body),
evidence: asStringOrNull(raw.evidence),
assumptions: asStringOrNull(raw.assumptions),
fix_effort: (asStringOrNull(raw.fix_effort) as Vulnerability["fix_effort"]) ?? null,
cvss_breakdown: (raw.cvss_breakdown as Vulnerability["cvss_breakdown"]) ?? null,
};
}
export function parseVulnerabilitiesJson(
text: string,
runId: string | null = null
): Vulnerability[] {
const data = parseJson(text, "vulnerabilities.json");
if (!Array.isArray(data)) {
throw new RunParseError("vulnerabilities.json is not a JSON array.");
}
return data.map((item, i) => {
if (!item || typeof item !== "object") {
throw new RunParseError(`vulnerabilities.json entry #${i + 1} is not an object.`);
}
return parseOneVulnerability(item as Record<string, unknown>, i, runId);
});
}
export interface ParsedAgent {
id: string;
name: string;
status: string;
parentId: string | null;
task: string | null;
skills: string[];
depth: number;
}
/**
* Parse the agent execution trace from `.state/agents.json` into a pre-ordered
* tree (children follow their parent; `depth` drives indentation). Rendered
* 100% client-side and never uploaded — traces contain target details, so they
* must stay on the user's machine. The heavier `.state/agents.db` (SQLite) is
* intentionally ignored; `agents.json` has everything the panel needs.
*/
export function parseAgentsJson(text: string): ParsedAgent[] {
const data = parseJson(text, "agents.json");
if (!data || typeof data !== "object" || Array.isArray(data)) return [];
const record = data as Record<string, unknown>;
const statuses = (record.statuses ?? {}) as Record<string, unknown>;
const parentOf = (record.parent_of ?? {}) as Record<string, unknown>;
const names = (record.names ?? {}) as Record<string, unknown>;
const metadata = (record.metadata ?? {}) as Record<string, unknown>;
const agents = new Map<string, ParsedAgent>();
for (const id of Object.keys(statuses)) {
const meta = (metadata[id] ?? {}) as Record<string, unknown>;
const skillsRaw = meta.skills;
agents.set(id, {
id,
name: asStringOrNull(names[id]) ?? id,
status: asStringOrNull(statuses[id]) ?? "unknown",
parentId: asStringOrNull(parentOf[id]),
task: asStringOrNull(meta.task),
skills: Array.isArray(skillsRaw)
? skillsRaw.filter((s): s is string => typeof s === "string")
: [],
depth: 0,
});
}
if (agents.size === 0) return [];
const childrenOf = new Map<string | null, string[]>();
for (const a of agents.values()) {
const key = a.parentId && agents.has(a.parentId) ? a.parentId : null;
(childrenOf.get(key) ?? childrenOf.set(key, []).get(key)!).push(a.id);
}
const ordered: ParsedAgent[] = [];
const seen = new Set<string>();
const visit = (id: string, depth: number): void => {
const a = agents.get(id);
if (!a || seen.has(id)) return;
seen.add(id);
a.depth = depth;
ordered.push(a);
for (const childId of childrenOf.get(id) ?? []) visit(childId, depth + 1);
};
for (const rootId of childrenOf.get(null) ?? []) visit(rootId, 0);
// Defensive: include any agents not reachable from a root.
for (const a of agents.values()) if (!seen.has(a.id)) ordered.push(a);
return ordered;
}
export function severityCounts(
vulns: Vulnerability[]
): Record<VulnerabilitySeverity, number> {
const counts: Record<VulnerabilitySeverity, number> = {
critical: 0,
high: 0,
medium: 0,
low: 0,
};
for (const v of vulns) counts[v.severity] += 1;
return counts;
}
+174
View File
@@ -0,0 +1,174 @@
import type React from "react";
import {
GitPullRequest,
Layers,
Globe,
Puzzle,
Users,
Search,
LayoutDashboard,
AlertTriangle,
MessageSquare,
Network,
Database,
} from "lucide-react";
/**
* Platform (Pro / Enterprise) feature catalog. Powers both the unified sidebar
* nav rows and the in-app FeatureDetail upsell view. Everything is "Pro" except
* Networks, which is "Enterprise". No lock icons anywhere.
*/
export type FeatureTier = "Pro" | "Enterprise";
export interface ProFeature {
slug: string;
title: string;
icon: React.ElementType;
tier: FeatureTier;
/** Short one-liner for the sidebar nav row (two-line layout). */
navDesc: string;
/** Headline shown on the FeatureDetail upsell page. */
headline: string;
/** Longer sentence shown on the FeatureDetail upsell page. */
description: string;
}
// Flat catalog of every platform feature, keyed by slug for routing. The
// sidebar groups these into capability themes (see PLATFORM_THEMES); nothing
// here implies a tier ordering.
export const PLATFORM_FEATURES: ProFeature[] = [
{
slug: "pr_reviews",
title: "PR Reviews",
icon: GitPullRequest,
tier: "Pro",
navDesc: "Pentest every pull request",
headline: "Pentest every pull request",
description:
"Strix reviews every pull request your team opens and catches exploitable changes before they merge.",
},
{
slug: "repositories",
title: "Repositories",
icon: Layers,
tier: "Pro",
navDesc: "Connect your team's repos",
headline: "Connect your team's repositories",
description:
"Link your org's repositories so Strix can scan them continuously and track findings over time.",
},
{
slug: "domains",
title: "Domains",
icon: Globe,
tier: "Pro",
navDesc: "Cover the domains you own",
headline: "Cover every domain you own",
description:
"Add the domains your team owns and let Strix watch them for newly exposed paths and drift.",
},
{
slug: "integrations",
title: "Integrations",
icon: Puzzle,
tier: "Pro",
navDesc: "Sync to Jira, Linear, Slack",
headline: "Sync findings to your tools",
description:
"Two-way sync findings to Jira, Linear, and Slack so fixes happen where your team already works.",
},
{
slug: "members",
title: "Members",
icon: Users,
tier: "Pro",
navDesc: "Invite your team, set roles",
headline: "Bring your whole team",
description:
"Invite your team, set roles, and share findings and run history across your org.",
},
{
slug: "pentests",
title: "Pentests",
icon: Search,
tier: "Pro",
navDesc: "Deeper scans on managed infra",
headline: "Launch deeper pentests",
description:
"Run deeper, longer pentests on managed infrastructure whenever you need them.",
},
{
slug: "dashboard",
title: "Dashboard",
icon: LayoutDashboard,
tier: "Pro",
navDesc: "Everything in one place",
headline: "See everything in one place",
description:
"Track every project, run, and finding across your org from a single dashboard.",
},
{
slug: "platform_issues",
title: "Issues",
icon: AlertTriangle,
tier: "Pro",
navDesc: "Triage across your org",
headline: "Triage findings across your org",
description:
"Manage and triage findings across every project and repository in one queue.",
},
{
slug: "chat",
title: "Chat",
icon: MessageSquare,
tier: "Pro",
navDesc: "Ask about any finding",
headline: "Ask Strix anything",
description:
"Ask the agents about any finding, run, or part of your app in natural language.",
},
{
slug: "networks",
title: "Networks",
icon: Network,
tier: "Enterprise",
navDesc: "Reach internal, VPN-only targets",
headline: "Scan internal networks",
description:
"Connect private networks to scan internal applications, VPN-only services, and RFC1918 targets.",
},
{
slug: "knowledge",
title: "Knowledge",
icon: Database,
tier: "Pro",
navDesc: "Give agents context",
headline: "Give agents context",
description:
"Teach Strix about your systems and business logic so every run gets smarter.",
},
];
export const FEATURES: Record<string, ProFeature> = Object.fromEntries(
PLATFORM_FEATURES.map((f) => [f.slug, f])
);
/**
* Order the platform rows appear in the sidebar's single, ungrouped nav list
* (after the run/local rows). No section headers; tier is shown only by each
* row's inline tag.
*/
export const PLATFORM_ORDER: string[] = [
"pr_reviews",
"repositories",
"domains",
"integrations",
"members",
"pentests",
"networks",
"chat",
"dashboard",
"platform_issues",
"knowledge",
];
+61
View File
@@ -0,0 +1,61 @@
export interface ParsedTarget {
display: string;
href: string | null;
provider: "github" | "gitlab" | "bitbucket" | null;
}
export function parseTarget(target: string): ParsedTarget {
// GitHub URL
const ghMatch = target.match(
/(?:https?:\/\/)?(?:www\.)?github\.com\/([^\s/]+\/[^\s/]+)/
);
if (ghMatch) {
const slug = ghMatch[1].replace(/\.git$/, "");
return { display: slug, href: `https://github.com/${slug}`, provider: "github" };
}
// GitLab URL
const glMatch = target.match(
/(?:https?:\/\/)?(?:www\.)?gitlab\.com\/([^\s/]+\/[^\s/]+)/
);
if (glMatch) {
const slug = glMatch[1].replace(/\.git$/, "");
return { display: slug, href: `https://gitlab.com/${slug}`, provider: "gitlab" };
}
// Bitbucket URL
const bbMatch = target.match(
/(?:https?:\/\/)?(?:www\.)?bitbucket\.org\/([^\s/]+\/[^\s/]+)/
);
if (bbMatch) {
const slug = bbMatch[1].replace(/\.git$/, "");
return { display: slug, href: `https://bitbucket.org/${slug}`, provider: "bitbucket" };
}
// URL with protocol
if (/^https?:\/\//i.test(target)) {
return {
display: target.replace(/^https?:\/\/(www\.)?/, ""),
href: target,
provider: null,
};
}
// Bare domain (e.g. "example.com" or "example.com/path")
if (/^[a-zA-Z0-9][\w.-]*\.[a-zA-Z]{2,}/.test(target)) {
return { display: target, href: `https://${target}`, provider: null };
}
// Not a URL
return { display: target, href: null, provider: null };
}
/**
* A clean, human-readable run title derived from the scan target (e.g.
* "arch.co") rather than the raw run dir name ("arch-co_ca3f"). Falls back to
* the raw name, then a generic label.
*/
export function runTitle(target: string | null, fallback: string): string {
if (target) return parseTarget(target).display.replace(/\/$/, "");
return fallback || "Untitled scan";
}
+124
View File
@@ -0,0 +1,124 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
export function isValidUrl(url: string): boolean {
if (!url || !url.trim()) return false;
try {
// Add protocol if missing
let urlWithProtocol = url.trim();
if (!urlWithProtocol.startsWith("http://") && !urlWithProtocol.startsWith("https://")) {
urlWithProtocol = `https://${urlWithProtocol}`;
}
const parsed = new URL(urlWithProtocol);
// Check if it has a valid hostname with at least one dot (domain)
return Boolean(parsed.hostname) && parsed.hostname.includes(".");
} catch {
return false;
}
}
export function isValidDomain(domain: string | null): boolean {
if (!domain) return false;
// Basic domain validation regex
const domainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
// Check basic format
if (!domainRegex.test(domain)) {
return false;
}
// Check length constraints
if (domain.length > 253) {
return false;
}
// Must have at least one dot (TLD required)
if (!domain.includes(".")) {
return false;
}
// Check each label length (max 63 chars per label)
const labels = domain.split(".");
for (const label of labels) {
if (label.length === 0 || label.length > 63) {
return false;
}
}
// TLD should be at least 2 characters
const tld = labels[labels.length - 1];
if (tld.length < 2) {
return false;
}
return true;
}
export function formatCurrency(amount: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
}
export function formatTimeAgo(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diffInSeconds < 60) {
return "just now";
}
if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60);
return `${minutes}m ago`;
}
if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600);
return `${hours}h ago`;
}
if (diffInSeconds < 604800) {
const days = Math.floor(diffInSeconds / 86400);
return `${days}d ago`;
}
return formatDate(dateString);
}
export function formatTimeUntil(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffInSeconds = Math.floor((date.getTime() - now.getTime()) / 1000);
if (diffInSeconds < 0) return "now";
if (diffInSeconds < 60) return "in <1m";
if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60);
return `in ${minutes}m`;
}
if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600);
return `in ${hours}h`;
}
if (diffInSeconds < 604800) {
const days = Math.round(diffInSeconds / 86400);
return `in ${days || 1}d`;
}
return formatDate(dateString);
}
@@ -0,0 +1,196 @@
const LANGUAGE_MAP: Record<string, string> = {
js: "javascript", ts: "typescript", tsx: "typescript", jsx: "javascript",
py: "python", rb: "ruby", go: "go", rs: "rust", java: "java", php: "php",
cs: "csharp", cpp: "cpp", c: "c", sh: "bash", bash: "bash", sql: "sql",
html: "html", css: "css", json: "json", yaml: "yaml", yml: "yaml", xml: "xml",
};
export function getLanguageFromFile(filename: string | null | undefined): string | null {
if (!filename) return null;
const ext = filename.split(".").pop()?.toLowerCase();
return ext ? LANGUAGE_MAP[ext] || null : null;
}
export function getSeverityDot(severity: string): string {
switch (severity) {
case "critical": return "bg-red-500";
case "high": return "bg-orange-500";
case "medium": return "bg-yellow-500";
default: return "bg-blue-500";
}
}
import type { Vulnerability } from "@/types/issues";
export function buildMarkdown(v: Vulnerability): string {
const parts: string[] = [];
parts.push(`# ${v.title}`);
parts.push("");
const cwePart = v.cwe && v.cwe.length > 0 ? ` · **CWE:** ${v.cwe.join(", ")}` : "";
parts.push(`**Severity:** ${v.severity.toUpperCase()}${v.cvss ? ` · **CVSS:** ${v.cvss}` : ""}${v.cve ? ` · **CVE:** ${v.cve}` : ""}${cwePart}${v.fix_effort ? ` · **Fix Effort:** ${v.fix_effort}` : ""}`);
parts.push(`**Status:** ${v.status}`);
if (v.target) parts.push(`**Target:** ${v.target}`);
if (v.endpoint) parts.push(`**Endpoint:** ${v.method ? `${v.method} ` : ""}${v.endpoint}`);
parts.push("");
if (v.description) {
parts.push("## Description");
parts.push("");
parts.push(v.description);
parts.push("");
}
if (v.impact) {
parts.push("## Impact");
parts.push("");
parts.push(v.impact);
parts.push("");
}
if (v.evidence) {
parts.push("## Evidence");
parts.push("");
parts.push(v.evidence);
parts.push("");
}
if (v.assumptions) {
parts.push("## Assumptions");
parts.push("");
parts.push(v.assumptions);
parts.push("");
}
if (v.technical_analysis) {
parts.push("## Technical Details");
parts.push("");
parts.push(v.technical_analysis);
parts.push("");
}
if (v.remediation_steps) {
parts.push("## How to Fix");
parts.push("");
parts.push(v.remediation_steps);
parts.push("");
}
if (v.poc_description) {
parts.push("## Proof of Concept");
parts.push("");
parts.push(v.poc_description);
if (v.poc_script_code) {
parts.push("");
parts.push("```");
parts.push(v.poc_script_code);
parts.push("```");
}
parts.push("");
}
if (v.code_locations?.length) {
parts.push("## Code Locations");
parts.push("");
if (v.location_meta) {
parts.push(`**Repository:** ${v.location_meta.repo_url} (${v.location_meta.branch})`);
parts.push("");
}
for (const loc of v.code_locations) {
if (!loc.file) continue;
const lineRef = loc.end_line && loc.end_line !== loc.start_line
? `${loc.file}:${loc.start_line}-${loc.end_line}`
: `${loc.file}:${loc.start_line}`;
parts.push(`### \`${lineRef}\``);
if (loc.label) parts.push(loc.label);
if (loc.snippet) {
parts.push("");
parts.push("```");
parts.push(loc.snippet);
parts.push("```");
}
if (loc.fix_before && loc.fix_after) {
parts.push("");
parts.push("```diff");
for (const l of loc.fix_before.split("\n")) parts.push(`- ${l}`);
for (const l of loc.fix_after.split("\n")) parts.push(`+ ${l}`);
parts.push("```");
}
parts.push("");
}
} else if (v.code_file && (v.code_before || v.code_after)) {
parts.push("## Code");
parts.push("");
parts.push(`**File:** \`${v.code_file}\``);
if (v.code_before && v.code_after) {
parts.push("");
parts.push("```diff");
for (const l of v.code_before.split("\n")) parts.push(`- ${l}`);
for (const l of v.code_after.split("\n")) parts.push(`+ ${l}`);
parts.push("```");
} else if (v.code_diff) {
parts.push("");
parts.push("```diff");
parts.push(v.code_diff);
parts.push("```");
}
parts.push("");
}
return parts.join("\n");
}
export function buildAIFixPrompt(v: Vulnerability): string {
const parts: string[] = [];
parts.push("This is a security vulnerability found during a code review.");
parts.push("");
parts.push(`Vulnerability: ${v.title}`);
parts.push(`Severity: ${v.severity.toUpperCase()}`);
if (v.cwe && v.cwe.length > 0) {
parts.push(`CWE: ${v.cwe.join(", ")}`);
}
parts.push("");
parts.push(v.description);
if (v.evidence) {
parts.push("");
parts.push("Evidence:");
parts.push(v.evidence);
}
const allLocations = v.code_locations || [];
for (const fixLoc of allLocations) {
if (!fixLoc.file) continue;
const startLine = fixLoc.start_line || 0;
const endLine = fixLoc.end_line || startLine;
parts.push("");
parts.push(`Location: ${fixLoc.file}:${startLine}-${endLine}`);
if (fixLoc.label) {
parts.push(`Context: ${fixLoc.label}`);
}
if (fixLoc.fix_before && fixLoc.fix_after) {
parts.push("```");
parts.push(`// Before:\n${fixLoc.fix_before}`);
parts.push(`// After:\n${fixLoc.fix_after}`);
parts.push("```");
} else if (fixLoc.snippet) {
parts.push("```");
parts.push(fixLoc.snippet);
parts.push("```");
}
}
if (v.remediation_steps) {
parts.push("");
parts.push("How to fix:");
parts.push(v.remediation_steps);
}
parts.push("");
parts.push("Please fix this vulnerability. If you propose a fix, make it concise and minimal.");
return parts.join("\n");
}
export async function copyToClipboard(text: string): Promise<void> {
try {
await navigator.clipboard.writeText(text);
} catch {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "absolute";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);
+16
View File
@@ -0,0 +1,16 @@
// Local stub of the strix-app auth store. The local viewer has no accounts:
// there is never a signed-in user and no feature is entitled, so every upsell
// CTA routes to the external cloud sign-up link.
interface AuthState {
user: null;
hasFeature: (feature: string) => boolean;
}
const STATE: AuthState = {
user: null,
hasFeature: () => false,
};
export function useAuthStore<T>(selector: (s: AuthState) => T): T {
return selector(STATE);
}
+102
View File
@@ -0,0 +1,102 @@
// Types matching the Python tracer's event shapes stored in Convex
export type EventType =
| "run.started"
| "run.configured"
| "run.completed"
| "agent.created"
| "agent.status.updated"
| "tool.execution.started"
| "tool.execution.updated"
| "chat.message"
| "finding.created"
| "finding.reviewed"
| "traffic.batch";
export interface EventActor {
agent_id?: string;
agent_name?: string;
tool_name?: string;
execution_id?: number;
role?: string;
}
export interface ConvexEvent {
_id: string;
_creationTime: number;
timestamp: string;
event_type: EventType;
run_id: string;
trace_id?: string | null;
span_id?: string | null;
parent_span_id?: string | null;
actor: EventActor | null;
payload: Record<string, unknown> | null;
status: string | null;
error: unknown | null;
source?: string;
run_metadata?: RunMetadata;
}
export interface RunMetadata {
run_id: string;
run_name: string | null;
start_time: string;
end_time: string | null;
targets: string[];
status: string;
user_instructions?: string;
max_iterations?: number;
}
export interface AgentNode {
id: string;
name: string;
task: string;
status: "running" | "completed" | "failed" | "error";
parentId: string | null;
children: string[];
createdAt: string;
toolCount: number;
messageCount: number;
}
export interface ToolExecution {
executionId: number;
agentId: string;
toolName: string;
args: Record<string, unknown>;
result: unknown;
status: "running" | "completed" | "failed" | "error";
startedAt: string;
completedAt: string | null;
}
export interface ChatMessage {
messageId: number;
agentId: string | null;
role: string;
content: string;
timestamp: string;
metadata: Record<string, unknown>;
}
export interface Finding {
id: string;
title: string;
severity: "critical" | "high" | "medium" | "low";
description?: string;
target?: string;
endpoint?: string;
method?: string;
cvss?: number;
cve?: string;
timestamp: string;
}
export interface ToolRendererProps {
toolName: string;
args: Record<string, unknown>;
result: unknown;
status: "running" | "completed" | "failed" | "error";
}
+197
View File
@@ -0,0 +1,197 @@
export type VulnerabilitySeverity = "critical" | "high" | "medium" | "low";
export type VulnerabilityStatus = "open" | "in_progress" | "snoozed" | "fixed" | "ignored";
export type FixEffort = "trivial" | "low" | "medium" | "high";
export const ACTIVE_STATUSES: VulnerabilityStatus[] = ["open", "in_progress", "snoozed"];
export const RESOLVED_STATUSES: VulnerabilityStatus[] = ["fixed", "ignored"];
// Statuses worth retesting in a "retest all" — everything except ignored
// (fixed issues are still re-verified; ignored issues are intentionally skipped).
export const RETESTABLE_STATUSES: VulnerabilityStatus[] = ["open", "in_progress", "snoozed", "fixed"];
export const ALL_STATUSES: VulnerabilityStatus[] = ["open", "in_progress", "snoozed", "fixed", "ignored"];
export interface StatusCounts {
all: number;
open: number;
in_progress: number;
snoozed: number;
fixed: number;
ignored: number;
}
export interface StatusMeta {
label: string;
color: string;
dotColor: string;
description: string;
}
export const STATUS_META: Record<VulnerabilityStatus, StatusMeta> = {
open: {
label: "Open",
color: "bg-red-500/10 text-red-400 border-red-500/20",
dotColor: "bg-red-500",
description: "Newly discovered, awaiting triage",
},
in_progress: {
label: "In Progress",
color: "bg-blue-500/10 text-blue-400 border-blue-500/20",
dotColor: "bg-blue-500",
description: "Someone is working on this",
},
snoozed: {
label: "Snoozed",
color: "bg-purple-500/10 text-purple-400 border-purple-500/20",
dotColor: "bg-purple-500",
description: "Temporarily hidden until a follow-up date",
},
fixed: {
label: "Fixed",
color: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
dotColor: "bg-emerald-500",
description: "This vulnerability has been fixed",
},
ignored: {
label: "Ignored",
color: "bg-gray-500/10 text-gray-400 border-gray-500/20",
dotColor: "bg-gray-500",
description: "Acknowledged but accepted",
},
};
export const FIX_EFFORT_META: Record<FixEffort, { label: string; color: string }> = {
trivial: { label: "Trivial", color: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20" },
low: { label: "Low", color: "bg-blue-500/10 text-blue-400 border-blue-500/20" },
medium: { label: "Medium", color: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20" },
high: { label: "High", color: "bg-orange-500/10 text-orange-400 border-orange-500/20" },
};
export interface CodeLocation {
file: string;
start_line: number;
end_line?: number;
snippet?: string;
label?: string;
fix_before?: string;
fix_after?: string;
}
export interface CVSSBreakdown {
attack_vector: string | null;
attack_complexity: string | null;
privileges_required: string | null;
user_interaction: string | null;
scope: string | null;
confidentiality: string | null;
integrity: string | null;
availability: string | null;
}
export interface Vulnerability {
id: string;
scan_id: string | null;
pr_review_id: string | null;
title: string;
description: string;
cve: string | null;
cvss: number | null;
created_at: string;
potential_risk_saving: number | null;
risk_saving_description: string | null;
status: VulnerabilityStatus;
severity: VulnerabilitySeverity;
impact: string | null;
endpoint: string | null;
method: string | null;
target: string | null;
technical_analysis: string | null;
poc_description: string | null;
poc_script_code: string | null;
code_diff: string | null;
code_file: string | null;
code_before: string | null;
code_after: string | null;
cwe: string[] | null;
code_locations: CodeLocation[] | null;
remediation_steps: string | null;
fix_pr_body: string | null;
evidence: string | null;
assumptions: string | null;
fix_effort: FixEffort | null;
cvss_breakdown: CVSSBreakdown | null;
status_changed_at: string | null;
status_changed_by: string | null;
status_note: string | null;
snoozed_until: string | null;
reopened_at: string | null;
reopened_by: string | null;
original_severity: VulnerabilitySeverity | null;
severity_changed_at: string | null;
severity_changed_by: string | null;
severity_override_reason: string | null;
retest_of_vulnerability_id: string | null;
slack_thread_url?: string;
display_number?: number | null;
location_meta?: {
branch: string;
provider: string;
repo_url: string;
} | null;
fix_pr_eligible?: boolean;
fix_pr_reason?: string | null;
fix_pr_url?: string | null;
}
export interface VulnerabilityFilters {
scan_id?: string;
severity?: VulnerabilitySeverity;
status?: VulnerabilityStatus;
search?: string;
sortBy?: "cvss" | "created_at";
sortOrder?: "asc" | "desc";
domain_id?: string;
repository_id?: string;
}
export interface VulnerabilityAction {
type: "status_change" | "generate_report" | "create_ticket";
notes?: string;
verification?: string;
reason?: string;
explanation?: string;
report_type?: string;
system?: string;
priority?: string;
assignee?: string;
timestamp: string;
}
export const SEVERITY_COLORS: Record<VulnerabilitySeverity, string> = {
critical: "bg-red-500/20 text-red-500 border-red-500/30",
high: "bg-orange-500/20 text-orange-500 border-orange-500/30",
medium: "bg-yellow-500/20 text-yellow-500 border-yellow-500/30",
low: "bg-blue-500/20 text-blue-500 border-blue-500/30",
};
export const STATUS_COLORS: Record<VulnerabilityStatus, string> = {
open: STATUS_META.open.color,
in_progress: STATUS_META.in_progress.color,
snoozed: STATUS_META.snoozed.color,
fixed: STATUS_META.fixed.color,
ignored: STATUS_META.ignored.color,
};
export function isSeverityOverridden(
v: Pick<Vulnerability, "original_severity" | "severity">
): boolean {
return v.original_severity != null && v.original_severity !== v.severity;
}
export function formatCvssLabel(cvss: number | null): string {
if (cvss === null) return "N/A";
if (cvss >= 9.0) return "Critical";
if (cvss >= 7.0) return "High";
if (cvss >= 4.0) return "Medium";
return "Low";
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { fileURLToPath, URL } from "node:url";
// The viewer is served as static files by a stdlib Python server on an
// arbitrary ephemeral port, so all asset URLs must be relative (base: "./").
// The build output is committed at strix/viewer/viewer_dist and shipped.
export default defineConfig({
base: "./",
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
build: {
outDir: "../viewer/viewer_dist",
emptyOutDir: true,
},
});
+105
View File
@@ -0,0 +1,105 @@
"""Tests for building and encrypting the viewer PDF report."""
from __future__ import annotations
import json
from io import BytesIO
from typing import TYPE_CHECKING
import pytest
from pypdf import PdfReader
from pypdf.errors import WrongPasswordError
from strix.viewer.report_pdf import (
build_encrypted_report,
encrypt_pdf,
generate_password,
generate_report_pdf,
)
if TYPE_CHECKING:
from pathlib import Path
def _make_run(base: Path, name: str = "sample") -> Path:
run_dir = base / "strix_runs" / name
run_dir.mkdir(parents=True)
record = {
"run_name": name,
"targets_info": [{"original": "https://example.com"}],
"scan_mode": "deep",
"status": "completed",
"start_time": "2026-01-01T00:00:00Z",
"end_time": "2026-01-01T01:02:03Z",
"scan_results": {
"executive_summary": "Summary with an ampersand & an <angle> bracket.",
"recommendations": "Patch things.",
},
}
(run_dir / "run.json").write_text(json.dumps(record), encoding="utf-8")
vulns = [
{
"title": "SQL Injection",
"severity": "CRITICAL",
"cvss": 9.8,
"description": "User input reaches the query.",
"impact": "Full database read.",
"technical_analysis": "Details here.",
"poc_description": "Send a crafted parameter.",
"poc_script_code": "print('exploit')",
"evidence": "HTTP 500 with SQL error.",
"remediation_steps": ["Use parameterized queries", "Validate input"],
"target": "https://example.com",
"endpoint": "/login",
"method": "POST",
},
{"title": "Informational note", "severity": "info"},
]
(run_dir / "vulnerabilities.json").write_text(json.dumps(vulns), encoding="utf-8")
return run_dir
def test_generate_report_pdf_has_pdf_header(tmp_path: Path) -> None:
run_dir = _make_run(tmp_path)
pdf = generate_report_pdf(run_dir)
assert pdf.startswith(b"%PDF-")
assert len(pdf) > 1000
def test_generate_password_is_long_and_random() -> None:
first = generate_password()
second = generate_password()
assert len(first) >= 20
assert first != second
def test_encrypt_pdf_roundtrip(tmp_path: Path) -> None:
run_dir = _make_run(tmp_path)
pdf = generate_report_pdf(run_dir)
password = generate_password()
encrypted = encrypt_pdf(pdf, password)
reader = PdfReader(BytesIO(encrypted))
assert reader.is_encrypted
assert reader.decrypt(password)
# A correct password unlocks the pages.
assert len(reader.pages) >= 1
def test_wrong_password_is_rejected(tmp_path: Path) -> None:
run_dir = _make_run(tmp_path)
encrypted = encrypt_pdf(generate_report_pdf(run_dir), "correct-horse-battery")
with pytest.raises(WrongPasswordError):
PdfReader(BytesIO(encrypted), password="not-the-password")
def test_build_encrypted_report(tmp_path: Path) -> None:
run_dir = _make_run(tmp_path, name="run-42")
pdf_bytes, password, filename = build_encrypted_report(run_dir)
assert filename == "strix-report-run-42.pdf"
assert len(password) >= 20
reader = PdfReader(BytesIO(pdf_bytes))
assert reader.is_encrypted
assert reader.decrypt(password)
+425
View File
@@ -0,0 +1,425 @@
"""Tests for the local run viewer (strix.viewer) and its path helpers."""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
from typing import TYPE_CHECKING
from strix.core.paths import latest_run_dir, runs_base_dir
from strix.viewer.server import serve
from strix.viewer.transcript import (
build_run_state,
read_report_markdown,
read_run_summary,
read_vulnerabilities,
)
if TYPE_CHECKING:
from collections.abc import Mapping
from pathlib import Path
import pytest
def _make_run(base: Path, name: str, *, status: str, end_time: str | None) -> Path:
run_dir = base / "strix_runs" / name
state_dir = run_dir / ".state"
state_dir.mkdir(parents=True)
record = {"run_name": name, "status": status, "end_time": end_time}
(run_dir / "run.json").write_text(json.dumps(record), encoding="utf-8")
agents = {
"statuses": {"root": "completed", "child": "running"},
"names": {"root": "strix", "child": "recon"},
"parent_of": {"root": None, "child": "root"},
}
(state_dir / "agents.json").write_text(json.dumps(agents), encoding="utf-8")
return run_dir
def test_latest_run_dir_none_when_no_runs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
assert latest_run_dir() is None
assert runs_base_dir() == tmp_path / "strix_runs"
def test_latest_run_dir_picks_newest_by_record_mtime(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
older = _make_run(tmp_path, "old", status="completed", end_time="2026-01-01T00:00:00Z")
newer = _make_run(tmp_path, "new", status="running", end_time=None)
# Force a newer mtime on the second run's record.
os.utime(newer / "run.json", (2_000_000_000, 2_000_000_000))
os.utime(older / "run.json", (1_000_000_000, 1_000_000_000))
assert latest_run_dir() == newer
def test_read_run_summary_finished_flag(tmp_path: Path) -> None:
finished = _make_run(tmp_path, "done", status="completed", end_time="2026-01-01T00:00:00Z")
live = _make_run(tmp_path, "live", status="running", end_time=None)
assert read_run_summary(finished)["finished"] is True
assert read_run_summary(live)["finished"] is False
# A terminal status without an end_time is not "finished".
partial = _make_run(tmp_path, "partial", status="failed", end_time=None)
assert read_run_summary(partial)["finished"] is False
def test_read_missing_artifacts_return_defaults(tmp_path: Path) -> None:
run_dir = _make_run(tmp_path, "empty", status="running", end_time=None)
assert read_vulnerabilities(run_dir) == []
assert read_report_markdown(run_dir) == ""
def test_build_run_state_from_agents_json(tmp_path: Path) -> None:
run_dir = _make_run(tmp_path, "graph", status="running", end_time=None)
state = build_run_state(run_dir)
ids = {a["id"] for a in state["agents"]}
assert ids == {"root", "child"}
child = next(a for a in state["agents"] if a["id"] == "child")
assert child["parent_id"] == "root"
assert child["name"] == "recon"
# No agents.db, so no message/tool events.
assert state["events"] == []
def _get(url: str) -> tuple[int, str, bytes]:
with urllib.request.urlopen(url) as resp: # noqa: S310 - localhost test server
return resp.status, resp.headers.get("Content-Type", ""), resp.read()
def test_server_serves_api_and_static(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
run_dir = _make_run(tmp_path, "served", status="completed", end_time="2026-01-01T00:00:00Z")
assets = tmp_path / "bundle"
(assets / "assets").mkdir(parents=True)
(assets / "index.html").write_text("<!doctype html><div id=root></div>", encoding="utf-8")
(assets / "assets" / "app.js").write_text("console.log(1)", encoding="utf-8")
monkeypatch.setattr("strix.viewer.server.bundle_dir", lambda: assets)
httpd, url, _ = serve(run_dir, open_browser=False)
try:
status, ctype, body = _get(f"{url}/api/run")
assert status == 200
assert "application/json" in ctype
assert json.loads(body)["finished"] is True
status, _, body = _get(f"{url}/api/transcript")
assert {a["id"] for a in json.loads(body)["agents"]} == {"root", "child"}
# Real asset is served.
status, ctype, _ = _get(f"{url}/assets/app.js")
assert status == 200
# Unknown non-API route falls back to index.html (SPA routing).
status, ctype, body = _get(f"{url}/agents/root")
assert status == 200
assert b"<div id=root>" in body
finally:
httpd.shutdown()
httpd.server_close()
def test_server_event_endpoint_forwards_cta(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
run_dir = _make_run(tmp_path, "evt", status="running", end_time=None)
assets = tmp_path / "bundle"
assets.mkdir()
(assets / "index.html").write_text("x", encoding="utf-8")
monkeypatch.setattr("strix.viewer.server.bundle_dir", lambda: assets)
seen: list[tuple[str, str | None]] = []
monkeypatch.setattr(
"strix.telemetry.posthog.viewer_cta_clicked",
lambda cta, surface=None: seen.append((cta, surface)),
)
httpd, url, _ = serve(run_dir, open_browser=False)
try:
body = json.dumps(
{"event": "cta_clicked", "cta": "PR reviews", "surface": "sidebar_nav"}
).encode()
req = urllib.request.Request( # noqa: S310 - localhost test server
f"{url}/api/event", data=body, headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req) as resp: # noqa: S310
assert resp.status == 204
assert seen == [("PR reviews", "sidebar_nav")]
finally:
httpd.shutdown()
httpd.server_close()
def test_server_event_endpoint_forwards_email_funnel(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
run_dir = _make_run(tmp_path, "evt2", status="running", end_time=None)
assets = tmp_path / "bundle"
assets.mkdir()
(assets / "index.html").write_text("x", encoding="utf-8")
monkeypatch.setattr("strix.viewer.server.bundle_dir", lambda: assets)
seen: list[tuple[str, str | None]] = []
monkeypatch.setattr(
"strix.telemetry.posthog.viewer_email_event",
lambda step, purpose=None: seen.append((step, purpose)),
)
httpd, url, _ = serve(run_dir, open_browser=False)
try:
# A whitelisted funnel event is forwarded; an unknown event is ignored.
for payload, expected in (
({"event": "email_verified", "purpose": "report"}, [("email_verified", "report")]),
({"event": "not_a_real_event"}, [("email_verified", "report")]),
):
req = urllib.request.Request( # noqa: S310 - localhost test server
f"{url}/api/event",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req) as resp: # noqa: S310
assert resp.status == 204
assert seen == expected
finally:
httpd.shutdown()
httpd.server_close()
def _post(
url: str, path: str, payload: Mapping[str, object], *, cookie: str | None = None
) -> tuple[int, bytes]:
headers = {"Content-Type": "application/json"}
if cookie:
headers["Cookie"] = cookie
req = urllib.request.Request( # noqa: S310 - localhost test server
url + path, data=json.dumps(payload).encode(), headers=headers, method="POST"
)
try:
with urllib.request.urlopen(req) as resp: # noqa: S310
return resp.status, resp.read()
except urllib.error.HTTPError as exc:
return exc.code, exc.read()
def _session_cookie(url: str, token: str) -> str:
"""Bootstrap a session via the tokened URL and return its ``name=value`` cookie."""
bootstrap = f"{url}/?token={token}"
with urllib.request.urlopen(bootstrap) as resp: # noqa: S310 - localhost test server
raw = str(resp.headers.get("Set-Cookie", ""))
return raw.split(";", 1)[0]
def _get_status(url: str, *, cookie: str | None = None) -> int:
headers = {"Cookie": cookie} if cookie else {}
req = urllib.request.Request(url, headers=headers) # noqa: S310 - localhost test server
try:
with urllib.request.urlopen(req) as resp: # noqa: S310
return int(resp.status)
except urllib.error.HTTPError as exc:
return int(exc.code)
def _bundle(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
assets = tmp_path / "bundle"
assets.mkdir()
(assets / "index.html").write_text("<!doctype html><div id=root></div>", encoding="utf-8")
monkeypatch.setattr("strix.viewer.server.bundle_dir", lambda: assets)
def test_capability_issued_only_for_tokened_bootstrap(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
run_dir = _make_run(tmp_path, "cookie", status="running", end_time=None)
assets = tmp_path / "bundle"
(assets / "assets").mkdir(parents=True)
(assets / "index.html").write_text("<!doctype html>index", encoding="utf-8")
(assets / "assets" / "app.js").write_text("1", encoding="utf-8")
monkeypatch.setattr("strix.viewer.server.bundle_dir", lambda: assets)
httpd, url, token = serve(run_dir, open_browser=False)
try:
# A bare index load -- all a reachable client can do -- hands out nothing.
with urllib.request.urlopen(url + "/") as resp: # noqa: S310
assert resp.headers.get("Set-Cookie") is None
# A wrong token is likewise refused the capability.
with urllib.request.urlopen(f"{url}/?token=wrong") as resp: # noqa: S310
assert resp.headers.get("Set-Cookie") is None
# Only the correct bootstrap token mints the session cookie.
with urllib.request.urlopen(f"{url}/?token={token}") as resp: # noqa: S310
cookie = str(resp.headers.get("Set-Cookie", ""))
assert "strix_viewer_session=" in cookie
assert "HttpOnly" in cookie and "SameSite=Strict" in cookie
# Static assets never carry it.
with urllib.request.urlopen(url + "/assets/app.js") as resp: # noqa: S310
assert resp.headers.get("Set-Cookie") is None
finally:
httpd.shutdown()
httpd.server_close()
def test_unauthorized_client_cannot_acquire_capability(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
run_dir = _make_run(tmp_path, "exposed", status="running", end_time=None)
_bundle(tmp_path, monkeypatch)
delivered: list[tuple[str, str]] = []
def handler(agent_id: str, message: str) -> bool:
delivered.append((agent_id, message))
return True
httpd, url, _ = serve(run_dir, open_browser=False, steer_handler=handler)
try:
# A direct network client can reach the page but is handed no capability,
# so replaying an empty/guessed cookie cannot steer a live scan.
with urllib.request.urlopen(url + "/") as resp: # noqa: S310
assert resp.headers.get("Set-Cookie") is None
status, _ = _post(
url,
"/api/agents/steer",
{"agent_id": "root", "message": "pwn"},
cookie="strix_viewer_session=",
)
assert status == 403
assert delivered == []
finally:
httpd.shutdown()
httpd.server_close()
def test_auth_status_reflects_expiry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
run_dir = _make_run(tmp_path, "status", status="running", end_time=None)
_bundle(tmp_path, monkeypatch)
monkeypatch.setattr("strix.viewer.auth.read_auth", lambda: {"email": "a@b.com", "token": "t"})
verified = {"value": True}
monkeypatch.setattr("strix.viewer.auth.is_verified", lambda: verified["value"])
httpd, url, _ = serve(run_dir, open_browser=False)
try:
_, _, body = _get(f"{url}/api/auth/status")
assert json.loads(body) == {"verified": True, "email": "a@b.com"}
# Once expired, status must advertise unverified so the SPA re-prompts.
verified["value"] = False
_, _, body = _get(f"{url}/api/auth/status")
assert json.loads(body)["verified"] is False
finally:
httpd.shutdown()
httpd.server_close()
def test_steer_requires_session_cookie(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
run_dir = _make_run(tmp_path, "steer", status="running", end_time=None)
_bundle(tmp_path, monkeypatch)
delivered: list[tuple[str, str]] = []
def handler(agent_id: str, message: str) -> bool:
delivered.append((agent_id, message))
return True
httpd, url, token = serve(run_dir, open_browser=False, steer_handler=handler)
try:
body = {"agent_id": "root", "message": "focus on auth"}
# No cookie: rejected before reaching the live coordinator.
status, _ = _post(url, "/api/agents/steer", body)
assert status == 403
assert delivered == []
# With the session cookie the message is delivered.
status, raw = _post(url, "/api/agents/steer", body, cookie=_session_cookie(url, token))
assert status == 200
assert json.loads(raw)["ok"] is True
assert delivered == [("root", "focus on auth")]
finally:
httpd.shutdown()
httpd.server_close()
def test_report_send_requires_session_cookie(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
run_dir = _make_run(tmp_path, "report", status="completed", end_time="2026-01-01T00:00:00Z")
_bundle(tmp_path, monkeypatch)
# A verified machine token exists, but that alone must not authorize a caller.
monkeypatch.setattr("strix.viewer.auth.read_auth", lambda: {"email": "a@b.com", "token": "t"})
httpd, url, token = serve(run_dir, open_browser=False)
try:
# No cookie: forbidden before the machine token is ever consulted.
status, _ = _post(url, "/api/report/send", {})
assert status == 403
# With the cookie the request clears the session gate; it then reaches
# the run resolver, so an unknown run is a 404 rather than a 403.
status, _ = _post(
url, "/api/report/send", {"run": "does-not-exist"}, cookie=_session_cookie(url, token)
)
assert status == 404
finally:
httpd.shutdown()
httpd.server_close()
def test_historical_run_data_requires_verification(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
launched = _make_run(tmp_path, "launched", status="completed", end_time="2026-01-01T00:00:00Z")
_make_run(tmp_path, "other", status="completed", end_time="2026-01-01T00:00:00Z")
_bundle(tmp_path, monkeypatch)
verified = {"value": False}
monkeypatch.setattr("strix.viewer.auth.is_verified", lambda: verified["value"])
httpd, url, token = serve(launched, open_browser=False)
try:
# The launched run is always viewable, no verification and no cookie.
status, _, _ = _get(f"{url}/api/run")
assert status == 200
cookie = _session_cookie(url, token)
# A different run needs the session capability first: a cookie-less
# caller is forbidden even once the machine is verified.
verified["value"] = True
assert _get_status(f"{url}/api/run?run=other") == 403
# With the cookie but not verified, the history gate returns 401.
verified["value"] = False
assert _get_status(f"{url}/api/run?run=other", cookie=cookie) == 401
# With both the cookie and verification, the historical run resolves.
verified["value"] = True
assert _get_status(f"{url}/api/run?run=other", cookie=cookie) == 200
finally:
httpd.shutdown()
httpd.server_close()
def test_server_rejects_path_traversal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
run_dir = _make_run(tmp_path, "guard", status="completed", end_time="2026-01-01T00:00:00Z")
secret = tmp_path / "secret.txt"
secret.write_text("top secret", encoding="utf-8")
assets = tmp_path / "bundle"
assets.mkdir()
(assets / "index.html").write_text("<!doctype html>index", encoding="utf-8")
monkeypatch.setattr("strix.viewer.server.bundle_dir", lambda: assets)
httpd, url, _ = serve(run_dir, open_browser=False)
try:
# A traversal target must never leak the file; it falls back to index.html.
_, _, body = _get(f"{url}/..%2f..%2fsecret.txt")
assert b"top secret" not in body
finally:
httpd.shutdown()
httpd.server_close()
+167
View File
@@ -0,0 +1,167 @@
"""Tests for viewer auth state and the relay client mapping."""
from __future__ import annotations
import stat
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any
import pytest
from strix.viewer import auth
def _iso(delta: timedelta) -> str:
return (datetime.now(UTC) + delta).isoformat()
if TYPE_CHECKING:
from pathlib import Path
@pytest.fixture(autouse=True)
def _tmp_auth(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
home = tmp_path / "home"
monkeypatch.setattr(auth, "AUTH_PATH", home / ".strix" / "viewer-auth.json")
return auth.AUTH_PATH
def test_write_read_forget_roundtrip() -> None:
assert auth.read_auth() is None
assert auth.is_verified() is False
auth.write_auth(email="user@example.com", token="tok-123", verified_at=_iso(timedelta(days=30)))
record = auth.read_auth()
assert record is not None
assert record["email"] == "user@example.com"
assert record["token"] == "tok-123"
assert auth.is_verified() is True
auth.forget()
assert auth.read_auth() is None
assert auth.is_verified() is False
# Forget is a no-op when the file is already gone.
auth.forget()
def test_is_verified_enforces_expiry() -> None:
# An expired record still reads back, but no longer unlocks history.
auth.write_auth(email="a@b.com", token="t", verified_at=_iso(timedelta(hours=-1)))
assert auth.read_auth() is not None
assert auth.is_verified() is False
# A future expiry unlocks it.
auth.write_auth(email="a@b.com", token="t", verified_at=_iso(timedelta(hours=1)))
assert auth.is_verified() is True
def test_is_verified_fails_closed_when_expiry_absent_or_unparseable() -> None:
# No/blank expiry: fail closed rather than unlocking history forever.
auth.write_auth(email="a@b.com", token="t", verified_at="")
assert auth.read_auth() is not None
assert auth.is_verified() is False
# Garbage expiry likewise requires re-verification.
auth.write_auth(email="a@b.com", token="t", verified_at="not-a-date")
assert auth.is_verified() is False
def test_is_verified_accepts_epoch_expiry() -> None:
# A relay expiry expressed as epoch seconds must not be misread as missing.
future = (datetime.now(UTC) + timedelta(hours=1)).timestamp()
past = (datetime.now(UTC) - timedelta(hours=1)).timestamp()
# As a numeric string (how write_auth persists it).
auth.write_auth(email="a@b.com", token="t", verified_at=str(future))
assert auth.is_verified() is True
auth.write_auth(email="a@b.com", token="t", verified_at=str(past))
assert auth.is_verified() is False
# As a raw JSON number, if a record is written that way.
auth.AUTH_PATH.parent.mkdir(parents=True, exist_ok=True)
auth.AUTH_PATH.write_text(
f'{{"email": "a@b.com", "token": "t", "verified_at": {future}}}', encoding="utf-8"
)
assert auth.is_verified() is True
def test_write_auth_is_0600() -> None:
auth.write_auth(email="a@b.com", token="t", verified_at="")
mode = stat.S_IMODE(auth.AUTH_PATH.stat().st_mode)
assert mode == 0o600
def test_read_auth_rejects_incomplete_record() -> None:
auth.AUTH_PATH.parent.mkdir(parents=True, exist_ok=True)
auth.AUTH_PATH.write_text('{"email": "a@b.com"}', encoding="utf-8")
assert auth.read_auth() is None
assert auth.is_verified() is False
def _stub_post(monkeypatch: pytest.MonkeyPatch, status: int, body: dict[str, Any]) -> None:
def fake(path: str, payload: dict[str, Any], *, timeout: int) -> tuple[int, dict[str, Any]]:
return status, body
monkeypatch.setattr(auth, "_post_json", fake)
def test_otp_start_maps_errors(monkeypatch: pytest.MonkeyPatch) -> None:
_stub_post(monkeypatch, 200, {"ok": True})
auth.otp_start("a@b.com") # no raise
_stub_post(monkeypatch, 429, {"error": "rate_limited"})
with pytest.raises(auth.RelayError) as exc:
auth.otp_start("a@b.com")
assert exc.value.code == "rate_limited"
_stub_post(monkeypatch, 400, {})
with pytest.raises(auth.RelayError) as exc:
auth.otp_start("bad")
assert exc.value.code == "invalid_email"
def test_otp_verify_success_and_invalid(monkeypatch: pytest.MonkeyPatch) -> None:
expires = _iso(timedelta(hours=1))
_stub_post(monkeypatch, 200, {"token": "t", "email": "a@b.com", "expires_at": expires})
result = auth.otp_verify("a@b.com", "123456")
assert result["token"] == "t"
_stub_post(monkeypatch, 403, {"error": "invalid_code"})
with pytest.raises(auth.RelayError) as exc:
auth.otp_verify("a@b.com", "000000")
assert exc.value.code == "invalid_code"
def test_otp_verify_rejects_token_without_usable_expiry(monkeypatch: pytest.MonkeyPatch) -> None:
# A 200 with a token but no valid expiry must not be reported as success,
# otherwise the caller would store a record that immediately reads unverified.
for expires in (None, "", "later"):
_stub_post(monkeypatch, 200, {"token": "t", "email": "a@b.com", "expires_at": expires})
with pytest.raises(auth.RelayError) as exc:
auth.otp_verify("a@b.com", "123456")
assert exc.value.code == "unavailable"
def test_report_send_never_includes_password(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict[str, Any] = {}
def fake(path: str, payload: dict[str, Any], *, timeout: int) -> tuple[int, dict[str, Any]]:
captured["payload"] = payload
return 200, {"ok": True}
monkeypatch.setattr(auth, "_post_json", fake)
auth.report_send("tok", b"%PDF-fake", "strix-report-x.pdf", "x", "https://example.com")
payload = captured["payload"]
assert set(payload) == {"token", "pdf_base64", "filename", "run_name", "target"}
# The password is generated locally and must never appear in the relay body.
assert "password" not in payload
assert all("password" not in str(k).lower() for k in payload)
def test_report_send_reverify_on_401(monkeypatch: pytest.MonkeyPatch) -> None:
_stub_post(monkeypatch, 401, {"error": "invalid_token"})
with pytest.raises(auth.RelayError) as exc:
auth.report_send("tok", b"x", "f.pdf", "r", "t")
assert exc.value.code == "reverify"
+89
View File
@@ -0,0 +1,89 @@
"""Tests for the /api/runs gating and the ?run= resolver (pure functions)."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from strix.viewer.server import build_runs_payload, resolve_run_dir
if TYPE_CHECKING:
from pathlib import Path
def _make_run(base: Path, name: str, *, severity: str = "high") -> Path:
run_dir = base / "strix_runs" / name
run_dir.mkdir(parents=True)
record = {
"run_name": name,
"targets_info": [{"original": f"https://{name}.example.com"}],
"scan_mode": "deep",
"status": "completed",
"start_time": "2026-01-01T00:00:00Z",
"end_time": "2026-01-01T00:10:00Z",
}
(run_dir / "run.json").write_text(json.dumps(record), encoding="utf-8")
(run_dir / "vulnerabilities.json").write_text(
json.dumps([{"title": "v", "severity": severity}]), encoding="utf-8"
)
return run_dir
def test_runs_payload_locked_when_unverified(tmp_path: Path) -> None:
base = tmp_path / "strix_runs"
_make_run(tmp_path, "alpha")
_make_run(tmp_path, "beta")
payload = build_runs_payload(base, verified=False)
assert payload["locked"] is True
assert payload["count"] == 2
assert payload["runs"] == []
def test_runs_payload_lists_when_verified(tmp_path: Path) -> None:
base = tmp_path / "strix_runs"
_make_run(tmp_path, "alpha", severity="critical")
_make_run(tmp_path, "beta", severity="info")
payload = build_runs_payload(base, verified=True)
assert payload["locked"] is False
assert payload["count"] == 2
assert len(payload["runs"]) == 2
entry = next(r for r in payload["runs"] if r["name"] == "alpha")
assert entry["target"] == "https://alpha.example.com"
assert entry["severity_counts"]["critical"] == 1
# "info" folds into low, matching the SPA's bucketing.
beta = next(r for r in payload["runs"] if r["name"] == "beta")
assert beta["severity_counts"]["low"] == 1
def test_runs_payload_empty_base(tmp_path: Path) -> None:
payload = build_runs_payload(tmp_path / "strix_runs", verified=True)
assert payload == {"locked": False, "count": 0, "runs": []}
def test_resolve_run_dir_defaults_when_absent(tmp_path: Path) -> None:
base = tmp_path / "strix_runs"
default = _make_run(tmp_path, "alpha")
assert resolve_run_dir(base, None, default) == default
assert resolve_run_dir(base, "", default) == default
def test_resolve_run_dir_valid_named_run(tmp_path: Path) -> None:
base = tmp_path / "strix_runs"
default = _make_run(tmp_path, "alpha")
other = _make_run(tmp_path, "beta")
assert resolve_run_dir(base, "beta", default) == other
def test_resolve_run_dir_rejects_unknown_and_traversal(tmp_path: Path) -> None:
base = tmp_path / "strix_runs"
default = _make_run(tmp_path, "alpha")
secret = tmp_path / "secret"
secret.mkdir()
(secret / "run.json").write_text("{}", encoding="utf-8")
assert resolve_run_dir(base, "nope", default) is None
assert resolve_run_dir(base, "../secret", default) is None
assert resolve_run_dir(base, "../../etc", default) is None
Generated
+1568 -1469
View File
File diff suppressed because it is too large Load Diff