Compare commits

..
Author SHA1 Message Date
Alex Schapiro 389326cad3 fix(deps): cap cryptography <49 to keep Intel macOS universal2 wheel
cryptography 49.x ships arm64-only macOS wheels (no universal2), forcing the
Intel macOS (macos-x86_64) release runner to build from sdist under
`uv sync --frozen`. Pin to 48.0.1, which still clears GHSA-537c-gmf6-5ccf
(fixed in 48.0.1) and provides a macosx_10_9_universal2 wheel.
2026-07-22 20:47:31 +00:00
alex sandGitHub 473b3c4af1 chore(deps): bump cryptography to 49.0.0 and pyasn1 to 0.6.4 (#856)
Resolves GHSA-537c-gmf6-5ccf (vulnerable OpenSSL in cryptography wheels,
fixed in 48.0.1) and CVE-2026-59885 / CVE-2026-59886 (pyasn1 DoS via
OBJECT IDENTIFIER / REAL decoding, fixed in 0.6.4).
2026-07-22 16:33:06 -04:00
alex sandGitHub d1a73a24f8 feat(cli): update notifications + self-update (strix --update) (#807)
* feat(cli): update notifications + self-update (strix --update)

* fix(update): verify release checksum, clean up staged binary, roll back Windows rename on failure

* feat(update): 3-way pre-scan prompt (update now / not now / skip this version) + package-manager upgrade

* fix(update): never show update prompt/notice in non-interactive runs
2026-07-22 16:29:03 -04:00
Ahmed AllamandAhmed Allam a2f5e3acb6 chore: release v1.3.1 2026-07-22 11:47:02 -07:00
Ahmed AllamandAhmed Allam e8c2564595 sandbox: bump install.sh pre-pull tag to 1.1.0 (match runtime default) 2026-07-22 11:40:23 -07:00
Ahmed AllamandAhmed Allam 78594e1645 sandbox: bump default inner-sandbox image to 1.1.0 2026-07-22 11:40:23 -07:00
Ahmed AllamandGitHub 8ec54d9e2b chore: release v1.3.0 (#849) 2026-07-22 09:21:24 -07:00
Ahmed AllamandGitHub 1f7373714b Local viewer: prominent scan switcher + rename to "pentest" terminology (#848) 2026-07-22 09:17:52 -07:00
Ahmed AllamandGitHub ef07bad945 Local viewer: UI polish and a Feedback & support tab (#847) 2026-07-22 08:34:52 -07:00
seanturner83andGitHub 89a707ff51 sandbox: shrink image 7.2GB → 3.8GB (cache cleanup, multi-stage Go build, drop ZAP) (#474) 2026-07-22 07:25:38 -07:00
Ahmed AllamandAhmed Allam 59f49a1fa2 fix(prompt): make root agent orchestrate-only and fold fixing into reporting 2026-07-22 02:56:49 -07:00
alex sandGitHub 2bb730c366 fix(container): keep /app/.venv/bin on the login-shell PATH so python3 finds preinstalled libs (#839) 2026-07-21 23:49:44 -04:00
Ahmed AllamandAhmed Allam 48b4821f69 chore: release v1.2.0 2026-07-21 09:05:04 -07:00
yoni-at-strixandGitHub f600f99103 Local run viewer: email reports, run history, and the platform suite (#813) 2026-07-21 08:13:03 -07:00
alex sandGitHub 6a3e0597ce docs(skills): add Active Directory / Kerberos domain testing skill (#825)
* docs(skills): add Active Directory / Kerberos domain testing skill

* docs(skills): fix AD skill collector package + split invalid pipx install
2026-07-21 10:59:28 -04:00
100 changed files with 18138 additions and 1526 deletions
+12 -4
View File
@@ -1,17 +1,25 @@
# Node / local-viewer SPA source (the built bundle in
# strix/viewer/static/ is committed and shipped; do not ignore it)
node_modules/
strix/viewer/frontend/node_modules/
strix/viewer/frontend/.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/frontend/` (a Vite + React project) and whose built output is
committed to `strix/viewer/static/` and shipped in the package. End users never
run a JS build. If you change anything under `strix/viewer/frontend/`, rebuild
and commit the output:
```bash
make viewer # or: cd strix/viewer/frontend && npm ci && npm run build
```
Commit both the source change and the regenerated `strix/viewer/static/`.
## 🤝 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/frontend && npm ci && npm run build
@echo "✅ Viewer built to strix/viewer/static/ (commit the changes)."
dev: format lint type-check
@echo "✅ Development cycle complete!"
+25
View File
@@ -145,6 +145,31 @@ Advanced multi-agent orchestration for comprehensive automated penetration testi
---
## 🖥️ Local Web Viewer
Every scan writes its results to disk as it runs. Bring them up in a local dashboard with a single command:
```bash
# Open the most recent run
strix view
# ...or open a specific run by name
strix view my-run-name
```
`strix view` starts a lightweight local server (bound to `127.0.0.1` on a random port) and opens your browser to a private, tokened link. Nothing leaves your machine: the dashboard reads the run's files straight off disk, with no cloud account or upload required. The UI ships prebuilt with Strix, so there is no extra install and no JS build step.
### What's in the dashboard
- **Overview**: run status, target, and a severity breakdown of everything found so far.
- **Vulnerabilities**: each validated finding with its severity, details, and reproduction steps.
- **Agent graph**: a live map of the multi-agent team, showing which agent is doing what.
- **Steering**: send instructions to a live scan from the browser to redirect the agents mid-run.
- **History**: browse past runs on this machine and jump between them.
- **Reports**: generate a shareable report and email it to yourself or your team.
---
## Usage Examples
### Basic Usage
+54 -17
View File
@@ -1,3 +1,26 @@
# ---------------------------------------------------------------------------
# Builder stage: compile the Go tools here so the Go toolchain (~225MB) and the
# module/build caches never reach the runtime image. The resulting binaries are
# statically linked and copied into the final stage.
# ---------------------------------------------------------------------------
FROM kalilinux/kali-rolling:latest AS gobuilder
RUN apt-get update && \
apt-get install -y kali-archive-keyring && \
apt-get update && \
apt-get install -y --no-install-recommends golang-go git ca-certificates
ENV GOBIN=/out/bin
RUN mkdir -p /out/bin && \
go install -v github.com/projectdiscovery/httpx/cmd/httpx@latest && \
go install -v github.com/projectdiscovery/katana/cmd/katana@latest && \
go install -v github.com/projectdiscovery/cvemap/cmd/vulnx@latest && \
go install -v github.com/jaeles-project/gospider@latest && \
go install -v github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest
# ---------------------------------------------------------------------------
# Runtime stage
# ---------------------------------------------------------------------------
FROM kalilinux/kali-rolling:latest
LABEL description="AI Agent Penetration Testing Environment with Comprehensive Automated Tools"
@@ -19,14 +42,13 @@ RUN apt-get update && \
apt-get install -y --no-install-recommends \
wget curl git vim nano unzip tar \
apt-transport-https ca-certificates gnupg lsb-release \
build-essential software-properties-common \
gcc libc6-dev pkg-config libpcap-dev libssl-dev \
python3 python3-pip python3-dev python3-venv python3-setuptools \
golang-go \
software-properties-common \
gcc libc6-dev \
python3 python3-pip python3-venv python3-setuptools \
net-tools dnsutils whois \
file xxd \
jq parallel ripgrep grep \
less man-db procps htop \
less procps htop \
iproute2 iputils-ping netcat-traditional \
nmap ncat ndiff \
sqlmap nuclei subfinder naabu ffuf \
@@ -66,11 +88,8 @@ RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/b
USER pentester
WORKDIR /tmp
RUN go install -v github.com/projectdiscovery/httpx/cmd/httpx@latest && \
go install -v github.com/projectdiscovery/katana/cmd/katana@latest && \
go install -v github.com/projectdiscovery/cvemap/cmd/vulnx@latest && \
go install -v github.com/jaeles-project/gospider@latest && \
go install -v github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest
# Go tools are built in the gobuilder stage; copy the static binaries only.
COPY --from=gobuilder --chown=pentester:pentester /out/bin/ /home/pentester/go/bin/
RUN nuclei -update-templates
@@ -87,7 +106,10 @@ RUN npm install -g retire@latest && \
npm install -g js-beautify@latest && \
npm install -g @ast-grep/cli@latest && \
npm install -g tree-sitter-cli@latest && \
npm install -g agent-browser@0.26.0
npm install -g agent-browser@0.26.0 && \
npm cache clean --force && \
# ast-grep ships two identical binaries (`ast-grep` and `sg`); dedupe (~52MB)
ln -sf ast-grep /home/pentester/.npm-global/lib/node_modules/@ast-grep/cli/sg
ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
ENV AGENT_BROWSER_USER_AGENT="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
@@ -132,7 +154,14 @@ RUN git clone https://github.com/aravind0x7/JS-Snooper.git && \
USER root
RUN curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh -s -- -b /usr/local/bin
# Install trufflehog into a pentester-owned dir on PATH so its runtime self-update
# (which replaces the binary in place) succeeds: as non-root `pentester` it cannot
# overwrite a root-owned binary under /usr/local/bin, which otherwise fails with
# "cannot move binary" and aborts the scan. Pin the initial version for
# reproducible builds; self-update then pulls fresh detectors at runtime.
ARG TRUFFLEHOG_VERSION=3.95.9
RUN curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh -s -- -b /home/pentester/.local/bin "v${TRUFFLEHOG_VERSION}" && \
chown -R pentester:pentester /home/pentester/.local
RUN set -eux; \
ARCH="$(uname -m)"; \
case "$ARCH" in \
@@ -146,8 +175,6 @@ RUN set -eux; \
install -m 0755 /tmp/gitleaks /usr/local/bin/gitleaks; \
rm -f /tmp/gitleaks /tmp/gitleaks.tgz
RUN apt-get update && apt-get install -y zaproxy
RUN curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
RUN apt-get install -y wapiti
@@ -163,7 +190,12 @@ USER root
RUN apt-get autoremove -y && \
apt-get autoclean && \
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \
# Purge non-English locales (~160MB)
find /usr/share/locale -mindepth 1 -maxdepth 1 -type d \
! -name 'en' ! -name 'en_US' ! -name 'C' -exec rm -rf {} + && \
# Remove package documentation and man pages not needed at runtime (~95MB)
rm -rf /usr/share/doc/* /usr/share/doc-base/* /usr/share/man/*
ENV PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:/app/.venv/bin:$PATH"
ENV VIRTUAL_ENV="/app/.venv"
@@ -205,8 +237,13 @@ RUN python3 -m venv /app/.venv && \
COPY --chown=pentester:pentester strix/tools/proxy/caido_api.py /opt/strix-python/caido_api.py
ENV PYTHONPATH=/opt/strix-python
RUN echo 'export PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:$PATH"' >> /home/pentester/.bashrc && \
echo 'export PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:$PATH"' >> /home/pentester/.profile
# Login shells (e.g. `bash -lc`) source /etc/profile, which on Debian/Kali
# hard-resets PATH and drops the image's ENV PATH entries. Re-add the same
# directories here — including /app/.venv/bin — so `python3`/`pip` resolve to
# the venv (which ships requests, httpx, bs4, lxml, pyjwt, cryptography, and the
# Caido SDK) instead of the externally-managed system interpreter.
RUN echo 'export PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:/app/.venv/bin:$PATH"' >> /home/pentester/.bashrc && \
echo 'export PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:/app/.venv/bin:$PATH"' >> /home/pentester/.profile
USER root
COPY containers/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
+21 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "strix-agent"
version = "1.1.0"
version = "1.3.1"
description = "Open-source AI Hackers for your apps"
readme = "README.md"
license = "Apache-2.0"
@@ -44,6 +44,11 @@ dependencies = [
"requests>=2.32.0",
"cvss>=3.2",
"caido-sdk-client>=0.2.0",
"reportlab>=4.0",
"pypdf>=5.0",
# Cap <49: 49.x drops the universal2 macOS wheel (arm64-only), which breaks
# the Intel macOS (macos-x86_64) release build's `uv sync --frozen`.
"cryptography>=48.0.1,<49",
]
[project.optional-dependencies]
@@ -74,6 +79,10 @@ build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["strix"]
# The prebuilt viewer bundle under strix/viewer/static/ ships automatically
# (hatchling includes non-.py files under the package). The Vite SOURCE lives
# under the package dir too (strix/viewer/frontend/) but must never ship in the wheel.
exclude = ["strix/viewer/frontend", "strix/viewer/frontend/**"]
# ============================================================================
# Type Checking Configuration
@@ -111,6 +120,8 @@ module = [
"docker.*",
"caido_sdk_client.*",
"pydantic_settings.*",
"reportlab.*",
"pypdf.*",
]
ignore_missing_imports = true
disable_error_code = ["import-untyped"]
@@ -201,6 +212,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"]
+1 -1
View File
@@ -4,7 +4,7 @@ set -euo pipefail
APP=strix
REPO="usestrix/strix"
STRIX_IMAGE="ghcr.io/usestrix/strix-sandbox:1.0.0"
STRIX_IMAGE="ghcr.io/usestrix/strix-sandbox:1.1.0"
MUTED='\033[0;2m'
RED='\033[0;31m'
+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_static = strix_root / 'viewer' / 'static'
for asset in viewer_static.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',
]
+1
View File
@@ -91,6 +91,7 @@ def render_system_prompt(
loaded_skill_names=list(skill_content.keys()),
available_skills=get_available_skills(),
interactive=interactive,
is_root=is_root,
system_prompt_context=system_prompt_context or {},
**skill_content,
)
+27 -17
View File
@@ -1,5 +1,14 @@
You are Strix, an advanced AI application security validation agent developed by OmniSecure Labs. Your purpose is to perform authorized security verification, reproduce and validate weaknesses on in-scope assets, and help remediate real security issues.
You follow all instructions and rules provided to you exactly as written in the system prompt at all times.
{% if is_root %}
<root_agent_directive>
YOU ARE THE ROOT AGENT. Your job is ORCHESTRATION, not hands-on testing.
- You accomplish security work by DELEGATING to specialized subagents via create_agent — you do NOT run scanners, crawlers, fuzzers, or send exploit/injection payloads yourself.
- IMPORTANT — how to read this prompt as root: the rest of this system prompt is written in the second person ("you") and describes the hands-on testing methodology (recon, mapping, scanning, payload spraying, PoC building, fixing). When you are the root agent, treat every such hands-on instruction as something you ensure gets done BY A SUBAGENT, not as a task you perform in your own turns. The "map the target", "recon first", "mandatory initial phases", and "spray payloads" directives are DELEGATION REQUIREMENTS for you — spawn recon/mapping/testing subagents to satisfy them.
- Do NOT probe endpoints, run "basic" or "quick" injection/XSS/etc. tests, or do exploratory scanning before delegating. Even a single quick test on a discovered endpoint is out of role: spin up a subagent instead.
- Your own turns should be spent on: reading scope/config, decomposing the target, spawning and monitoring subagents, tracking todos/notes/coverage, deciding next steps, and aggregating results into the final report.
</root_agent_directive>
{% endif %}
<core_capabilities>
- Security assessment and vulnerability scanning
@@ -125,10 +134,8 @@ WHITE-BOX TESTING (code provided):
- Local execution, unit/integration testing, patch verification, and HTTP requests against locally started in-scope services are normal authorized white-box validation
- If dynamically running the code proves impossible after exhaustive attempts, pivot to comprehensive static analysis.
- Try to infer how to run the code based on its structure and content.
- FIX discovered vulnerabilities in code in same file.
- Test patches to confirm vulnerability removal.
- Do not stop until all reported vulnerabilities are fixed.
- Include code diff in final report.
- Derive the code fix as PART OF reporting, not as a separate later pass: create_vulnerability_report already requires the concrete patch inline (`code_locations` with verbatim `fix_before`/`fix_after` and `fix_pr_body`), so the reporting agent that analyzes the root cause is the one that produces the fix. Do NOT spawn a downstream agent afterwards to re-derive/re-apply the same patch.
- If you also apply and verify the patch in the repo (edit the file, re-test that the vulnerability is gone), do it in the same agent/turn while the analysis is fresh — right before or as part of filing the report — never as a second re-analysis pass.
COMBINED MODE (code + deployed target present):
- Treat this as static analysis plus dynamic testing simultaneously
@@ -189,7 +196,7 @@ EFFICIENCY TACTICS:
- For Caido proxy automation inside Python, explicitly import from
`caido_api`:
`from caido_api import list_requests, view_request, repeat_request, list_sitemap, view_sitemap_entry, scope_rules`
- Prefer established fuzzers/scanners where applicable: ffuf, sqlmap, zaproxy, nuclei, wapiti, arjun, httpx, katana, semgrep, bandit, trufflehog, nmap. Use scripts mainly to coordinate or validate around them, not to replace them without reason
- Prefer established fuzzers/scanners where applicable: ffuf, sqlmap, nuclei, wapiti, arjun, httpx, katana, semgrep, bandit, trufflehog, nmap. Use scripts mainly to coordinate or validate around them, not to replace them without reason
- For trial-heavy vectors (SQLi, XSS, XXE, SSRF, RCE, auth/JWT, deserialization), DO NOT iterate payloads manually in the browser. Always spray payloads via Python scripts through `exec_command` or terminal tools.
- When using established fuzzers/scanners, use the proxy for inspection where helpful
- Generate/adapt large payload corpora: combine encodings (URL, unicode, base64), comment styles, wrappers, time-based/differential probes. Expand with wordlists/templates
@@ -206,7 +213,7 @@ VALIDATION REQUIREMENTS:
- Document complete attack chain
- Keep going until you find something that matters
- A vulnerability is ONLY considered reported when a reporting agent uses create_vulnerability_report (or create_dependency_report for known-CVE dependency/supply-chain findings) with full details. Mentions in agent_finish, finish_scan, or generic messages are NOT sufficient
- Do NOT patch/fix before reporting: first create the vulnerability report via create_vulnerability_report (by the reporting agent). Only after reporting is completed should fixing/patching proceed
- Reporting and fixing are ONE step, not two: when source is available, the reporting agent derives the concrete fix and files it INLINE via create_vulnerability_report (`code_locations` with `fix_before`/`fix_after` + `fix_pr_body`) — the report is not complete without it. Do NOT report first and then spawn a separate downstream agent to re-derive and re-apply the same patch; that just re-does the analysis and wastes tokens. (Do not silently patch a finding WITHOUT filing a report — the report, with its embedded fix, is the deliverable.)
- DEDUPLICATION: The create_vulnerability_report tool uses LLM-based deduplication. If it rejects your report as a duplicate, DO NOT attempt to re-submit the same vulnerability. Accept the rejection and move on to testing other areas. The vulnerability has already been reported by another agent
</execution_guidelines>
@@ -262,7 +269,9 @@ DISK & SCRATCH HYGIENE:
- If disk gets tight or a write fails for space, check what's large under /workspace and clean up files from your own task; leave another agent's files unless you've confirmed they're no longer in use
MANDATORY INITIAL PHASES:
{% if is_root %}
- ROOT AGENT: these phases are mandatory for the assessment, but you MUST accomplish them by delegating to reconnaissance/mapping subagents — do NOT run recon, crawling, enumeration, or mapping tools in your own turns. Spawn the appropriate subagent(s) and track their coverage.
{% endif %}
BLACK-BOX TESTING - PHASE 1 (RECON & MAPPING):
- COMPLETE full reconnaissance: subdomain enumeration, port scanning, service detection
- MAP entire attack surface: all endpoints, parameters, APIs, forms, inputs
@@ -291,13 +300,14 @@ ROOT AGENT ROLE:
- The root agent should coordinate strategy, delegate meaningful work, track progress, maintain todo lists, maintain notes, monitor subagent results, and decide next steps
- The root agent should keep a clear view of overall coverage, uncovered attack surfaces, validation status, and reporting/fixing progress
- The root agent should avoid spending its own iterations on detailed testing, payload execution, or deep target-specific investigation when that work can be delegated to specialized subagents
- The root agent may do lightweight triage, quick verification, or setup work when necessary to unblock delegation, but its default mode should be coordinator/controller
- The root agent may do orchestration-support work needed to delegate well — reading scope/config, inspecting workspace layout, reading subagent output/reports, and light bookkeeping. It must NOT do the actual security testing itself: no running scanners/fuzzers/crawlers, no sending injection/XSS/SSRF/etc. payloads, and no "basic" or "quick" probing of discovered endpoints. If a check requires touching the target, delegate it to a subagent rather than doing it yourself
- Its default and near-exclusive mode is coordinator/controller
- Subagents should do the substantive testing, validation, reporting, and fixing work
- The root agent is responsible for ensuring that work is broken down clearly, tracked, and completed across the agent tree
1. **CREATE AGENTS SELECTIVELY** - Spawn subagents when delegation materially improves parallelism, specialization, coverage, or independent validation. Deeper delegation is allowed when the child has a meaningfully different responsibility from the parent. Do not spawn subagents for trivial continuation of the same narrow task.
2. **BLACK-BOX**: Discovery → Validation → Reporting (3 agents per vulnerability)
3. **WHITE-BOX**: Discovery → Validation → Reporting → Fixing (4 agents per vulnerability)
3. **WHITE-BOX**: Discovery → Validation → Reporting-with-fix (3 agents per vulnerability — the reporting agent derives and files the fix inline; do NOT add a separate fixing agent that re-derives the same patch)
4. **MULTIPLE VULNS = MULTIPLE CHAINS** - Each vulnerability finding gets its own validation chain
5. **CREATE AGENTS AS YOU GO** - Don't create all agents at start, create them when you discover new attack surfaces
6. **ONE JOB PER AGENT** - Each agent has ONE specific task only
@@ -316,8 +326,7 @@ BLACK-BOX (domain/URL only):
WHITE-BOX (source code provided):
- Found authentication code issues? → Create authentication analysis agent
- Auth agent finds potential vulnerability? → Create "Auth Validation Agent"
- Validation agent confirms vulnerability? → Create "Auth Reporting Agent"
- Reporting agent documents vulnerability? → Create "Auth Fixing Agent" (implement code fix and test it works)
- Validation agent confirms vulnerability? → Create "Auth Reporting Agent" that files the report AND its inline fix (`code_locations` + `fix_pr_body`) in one shot — no separate fixing agent
VULNERABILITY WORKFLOW (MANDATORY FOR EVERY FINDING):
@@ -338,9 +347,11 @@ Authentication Code Agent finds weak password validation
Spawns "Auth Validation Agent" (proves it's exploitable)
If valid → Spawns "Auth Reporting Agent" (creates vulnerability report)
If valid → Spawns "Auth Reporting Agent" (creates the vulnerability report
WITH the fix inline: code_locations fix_before/fix_after + fix_pr_body,
applying/verifying the patch in the same turn if desired)
Spawns "Auth Fixing Agent" (implements secure code fix)
STOP - no separate fixing agent; the fix was derived once, at report time
```
CRITICAL RULES:
@@ -376,7 +387,7 @@ FOCUS PRINCIPLES:
REALISTIC TESTING OUTCOMES:
- **No Findings**: Agent completes testing but finds no vulnerabilities
- **Validation Failed**: Initial finding was false positive, validation agent confirms it's not exploitable
- **Valid Vulnerability**: Validation succeeds, spawns reporting agent and then fixing agent (white-box)
- **Valid Vulnerability**: Validation succeeds, spawns a reporting agent that files the report with the fix inline (white-box) — no separate fixing agent
PERSISTENCE IS MANDATORY:
- Real vulnerabilities take TIME - expect to need 2000+ steps minimum
@@ -401,7 +412,6 @@ VULNERABILITY ASSESSMENT:
- nuclei - Vulnerability scanner with templates
- sqlmap - SQL injection detection/exploitation
- trivy - Container/dependency vulnerability scanner
- zaproxy - OWASP ZAP web app scanner
- wapiti - Web vulnerability scanner
WEB FUZZING & DISCOVERY:
@@ -439,10 +449,10 @@ PROXY & INTERCEPTION:
- Ignore Caido proxy-generated 50x HTML error pages; these are proxy issues (might happen when requesting a wrong host or SSL/TLS issues, etc).
PROGRAMMING:
- Python 3, uv, Go, Node.js/npm
- Python 3, uv, Node.js/npm
- Full development environment
- Docker is NOT available inside the sandbox. Do not run docker; rely on provided tools to run locally.
- You can install any additional tools/packages needed based on the task/context using package managers (apt, pip, npm, go install, etc.)
- You can install any additional tools/packages needed based on the task/context using package managers (apt, pip, npm, etc.)
Directories:
- /workspace - where you should work.
+11 -1
View File
@@ -47,7 +47,7 @@ class RuntimeSettings(BaseSettings):
model_config = _BASE_CONFIG
image: str = Field(
default="ghcr.io/usestrix/strix-sandbox:1.0.0",
default="ghcr.io/usestrix/strix-sandbox:1.1.0",
alias="STRIX_IMAGE",
)
backend: str = Field(default="docker", alias="STRIX_RUNTIME_BACKEND")
@@ -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;
+96 -2
View File
@@ -5,8 +5,10 @@ Strix Agent Interface
import argparse
import asyncio
import os
import shutil
import sys
import time
from datetime import UTC, datetime
from pathlib import Path
@@ -32,6 +34,13 @@ from strix.config.models import (
from strix.core.paths import run_dir_for, runtime_state_dir
from strix.interface.cli import run_cli
from strix.interface.tui import run_tui
from strix.interface.update_check import (
is_binary_install,
notify_update,
prompt_update_if_available,
self_update,
start_background_check,
)
from strix.interface.utils import (
assign_workspace_subdirs,
build_final_stats_text,
@@ -447,6 +456,14 @@ Examples:
version=f"strix {get_version()}",
)
parser.add_argument(
"--update",
action="store_true",
help="Update strix to the latest version and exit. Self-updates the "
"standalone binary install; for pip/pipx/uv installs, prints the "
"matching upgrade command instead.",
)
parser.add_argument(
"-t",
"--target",
@@ -565,6 +582,9 @@ Examples:
args = parser.parse_args()
if args.update:
sys.exit(0 if self_update() else 1)
if args.instruction and args.instruction_file:
parser.error(
"Cannot specify both --instruction and --instruction-file. Use one or the other."
@@ -720,7 +740,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 +781,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")
@@ -788,6 +833,8 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) ->
"[#60a5fa]discord.gg/strix-ai[/]"
)
console.print()
if not args.non_interactive:
notify_update(console)
def pull_docker_image() -> None:
@@ -846,11 +893,25 @@ 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:
apply_config_override(validate_config_file(args.config))
start_background_check()
if not args.non_interactive and prompt_update_if_available(Console()):
if is_binary_install() and sys.platform != "win32":
os.execv(sys.executable, sys.argv) # noqa: S606 # nosec B606
sys.exit(0)
check_docker_installed()
pull_docker_image()
@@ -940,7 +1001,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:
+389
View File
@@ -0,0 +1,389 @@
"""Update notifications and self-update for the strix CLI.
Follows the pattern used by tools like gh, uv, and pip: a background,
rate-limited (once per 24h) check against the release source, a cached
result in ``~/.strix``, a non-intrusive notice with the upgrade command
for the detected install method, and a ``strix --update`` self-update
path for the standalone binary install.
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import platform
import shutil
import stat
import subprocess
import sys
import tarfile
import tempfile
import threading
import time
import zipfile
from pathlib import Path
from typing import cast
import requests
from rich.console import Console
from rich.prompt import Prompt
from strix.telemetry._common import get_version
logger = logging.getLogger(__name__)
GITHUB_REPO = "usestrix/strix"
PYPI_PACKAGE = "strix-agent"
CHECK_INTERVAL_SECONDS = 24 * 60 * 60
REQUEST_TIMEOUT_SECONDS = 5
_CACHE_PATH = Path.home() / ".strix" / "update-check.json"
_background_thread: threading.Thread | None = None
def _is_disabled() -> bool:
return bool(os.environ.get("STRIX_NO_UPDATE_CHECK")) or any(
os.environ.get(key)
for key in ("CI", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS_URL", "BUILDKITE", "CIRCLECI")
)
def is_binary_install() -> bool:
return bool(getattr(sys, "frozen", False))
def get_install_method() -> str:
if is_binary_install():
return "binary"
prefix = str(Path(sys.prefix)).replace("\\", "/")
if "/pipx/" in prefix or prefix.endswith("/pipx"):
return "pipx"
if "/uv/tools/" in prefix:
return "uv"
return "pip"
def get_upgrade_command(method: str | None = None) -> str:
method = method or get_install_method()
commands = {
"binary": "strix --update",
"pipx": "pipx upgrade strix-agent",
"uv": "uv tool upgrade strix-agent",
"pip": "pip install --upgrade strix-agent",
}
return commands[method]
def _parse_version(value: str) -> tuple[int, ...] | None:
parts = value.strip().lstrip("v").split(".")
try:
return tuple(int(part) for part in parts)
except ValueError:
return None
def _is_newer(latest: str, current: str) -> bool:
latest_parts = _parse_version(latest)
current_parts = _parse_version(current)
if latest_parts is None or current_parts is None:
return False
return latest_parts > current_parts
def _fetch_latest_version() -> str | None:
try:
if is_binary_install():
response = requests.get(
f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest",
timeout=REQUEST_TIMEOUT_SECONDS,
)
response.raise_for_status()
tag = response.json().get("tag_name", "")
return tag.lstrip("v") or None
response = requests.get(
f"https://pypi.org/pypi/{PYPI_PACKAGE}/json",
timeout=REQUEST_TIMEOUT_SECONDS,
)
response.raise_for_status()
version = response.json().get("info", {}).get("version")
return str(version) if version else None
except Exception: # noqa: BLE001
logger.debug("update check failed", exc_info=True)
return None
def _fetch_asset_digest(version: str, filename: str) -> str | None:
"""Return the expected sha256 (hex) for a release asset, if the API provides one."""
try:
response = requests.get(
f"https://api.github.com/repos/{GITHUB_REPO}/releases/tags/v{version}",
timeout=REQUEST_TIMEOUT_SECONDS,
)
response.raise_for_status()
for asset in response.json().get("assets", []):
if asset.get("name") == filename:
digest = asset.get("digest") or ""
if digest.startswith("sha256:"):
return digest.removeprefix("sha256:")
except Exception: # noqa: BLE001
logger.debug("release asset digest lookup failed", exc_info=True)
return None
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
def _read_cache() -> dict[str, object]:
try:
with _CACHE_PATH.open(encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
return cast("dict[str, object]", data)
except Exception: # noqa: BLE001, S110
pass # nosec B110
return {}
def _write_cache(**fields: object) -> None:
try:
cache = _read_cache()
cache.update(fields)
_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
_CACHE_PATH.write_text(json.dumps(cache), encoding="utf-8")
except Exception: # noqa: BLE001, S110
pass # nosec B110
def skip_version(version: str) -> None:
"""Remember not to prompt again for this version (newer releases still notify)."""
_write_cache(skipped_version=version)
def _refresh_cache() -> None:
latest = _fetch_latest_version()
if latest:
_write_cache(latest_version=latest, checked_at=time.time())
def start_background_check() -> None:
"""Refresh the cached latest-version info in a daemon thread (at most once per 24h)."""
global _background_thread # noqa: PLW0603
if _is_disabled():
return
cache = _read_cache()
checked_at = cache.get("checked_at")
if isinstance(checked_at, int | float) and time.time() - checked_at < CHECK_INTERVAL_SECONDS:
return
_background_thread = threading.Thread(target=_refresh_cache, daemon=True)
_background_thread.start()
def get_available_update(*, respect_skip: bool = True) -> str | None:
"""Return the newer version from the cache, or None if up to date / unknown."""
if _is_disabled():
return None
if _background_thread is not None:
_background_thread.join(timeout=0.2)
cache = _read_cache()
latest = cache.get("latest_version")
current = get_version()
if not isinstance(latest, str) or current == "unknown" or not _is_newer(latest, current):
return None
if respect_skip and cache.get("skipped_version") == latest:
return None
return latest
def notify_update(console: Console) -> None:
latest = get_available_update()
if not latest:
return
console.print(
f"[#eab308]A new version of strix is available:[/] "
f"[dim]{get_version()}[/] [dim]→[/] [bold #22c55e]{latest}[/]"
f" [dim]·[/] [#60a5fa]{get_upgrade_command()}[/]"
)
console.print()
def run_package_upgrade(console: Console, method: str) -> bool:
"""Upgrade a package-manager install by running its upgrade command."""
command = get_upgrade_command(method).split()
console.print(f"[dim]Running[/] [#60a5fa]{' '.join(command)}[/]")
try:
result = subprocess.run(command, check=False) # noqa: S603
except OSError as e:
console.print(f"[bold red]Update failed:[/] {e}")
return False
if result.returncode != 0:
console.print(
f"[bold red]Update failed[/] [dim](exit code {result.returncode}).[/] "
f"Run it manually: [#60a5fa]{get_upgrade_command(method)}[/]"
)
return False
console.print("[#22c55e]✓ strix updated — restart the scan to use the new version[/]")
return True
def prompt_update_if_available(console: Console) -> bool:
"""Offer an interactive update before a scan starts.
Returns True if strix was updated (caller should re-exec / exit).
"""
latest = get_available_update()
if not latest or not sys.stdin.isatty() or not sys.stdout.isatty():
return False
console.print()
console.print(
f"[#eab308]A new version of strix is available:[/] "
f"[dim]{get_version()}[/] [dim]→[/] [bold #22c55e]{latest}[/]"
)
console.print(
"[dim] y — update now n — not now (ask again next run) s — skip this version[/]"
)
choice = Prompt.ask("Update strix?", choices=["y", "n", "s"], default="n")
console.print()
if choice == "s":
skip_version(latest)
return False
if choice != "y":
return False
method = get_install_method()
if method == "binary":
return self_update(console, version=latest)
return run_package_upgrade(console, method)
def _release_target() -> str | None:
raw_os = platform.system().lower()
os_name = {"darwin": "macos", "linux": "linux", "windows": "windows"}.get(raw_os)
arch = platform.machine().lower()
arch = {"aarch64": "arm64", "amd64": "x86_64"}.get(arch, arch)
if os_name is None:
return None
target = f"{os_name}-{arch}"
supported = {"linux-x86_64", "macos-x86_64", "macos-arm64", "windows-x86_64"}
return target if target in supported else None
def _download_and_replace(version: str, target: str, console: Console) -> bool:
is_windows = target.startswith("windows")
archive_ext = ".zip" if is_windows else ".tar.gz"
filename = f"strix-{version}-{target}{archive_ext}"
url = f"https://github.com/{GITHUB_REPO}/releases/download/v{version}/{filename}"
binary_name = f"strix-{version}-{target}" + (".exe" if is_windows else "")
current_exe = Path(sys.executable).resolve()
with tempfile.TemporaryDirectory() as tmp:
tmp_dir = Path(tmp)
archive_path = tmp_dir / filename
console.print(f"[dim]Downloading[/] {url}")
with requests.get( # nosec B113
url,
stream=True,
timeout=REQUEST_TIMEOUT_SECONDS * 12,
) as response:
response.raise_for_status()
with archive_path.open("wb") as f:
for chunk in response.iter_content(chunk_size=1 << 20):
f.write(chunk)
expected_digest = _fetch_asset_digest(version, filename)
if expected_digest:
actual_digest = _sha256_file(archive_path)
if actual_digest != expected_digest:
raise RuntimeError(
f"checksum mismatch for {filename}: "
f"expected sha256 {expected_digest}, got {actual_digest}"
)
else:
console.print("[dim yellow]No published checksum available; skipping verification[/]")
if is_windows:
with zipfile.ZipFile(archive_path) as zf:
zf.extract(binary_name, tmp_dir)
else:
with tarfile.open(archive_path, "r:gz") as tf:
tf.extract(binary_name, tmp_dir, filter="data")
new_binary = tmp_dir / binary_name
new_binary.chmod(new_binary.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
staged = current_exe.with_name(current_exe.name + ".new")
try:
shutil.copy2(new_binary, staged)
if is_windows:
# Windows can't replace a running executable in place; move it aside first.
old = current_exe.with_name(current_exe.name + ".old")
old.unlink(missing_ok=True)
current_exe.rename(old)
try:
staged.replace(current_exe)
except Exception:
old.rename(current_exe)
raise
else:
staged.replace(current_exe)
except Exception:
staged.unlink(missing_ok=True)
raise
return True
def self_update(console: Console | None = None, version: str | None = None) -> bool:
"""Replace the running standalone binary with the latest release.
Returns True on success. For package-manager installs this only
prints the right upgrade command and returns False.
"""
console = console or Console()
if not is_binary_install():
method = get_install_method()
console.print(
f"[#eab308]This strix was installed via {method};[/] "
f"upgrade it with: [#60a5fa]{get_upgrade_command(method)}[/]"
)
return False
latest = version or _fetch_latest_version()
if not latest:
console.print("[bold red]Could not determine the latest strix version.[/]")
return False
current = get_version()
if current != "unknown" and not _is_newer(latest, current):
console.print(f"[#22c55e]strix {current} is already the latest version.[/]")
return True
target = _release_target()
if not target:
console.print(
f"[bold red]No prebuilt binary for this platform "
f"({platform.system()}/{platform.machine()}).[/]"
)
return False
try:
_download_and_replace(latest, target, console)
except Exception as e: # noqa: BLE001
logger.debug("self-update failed", exc_info=True)
console.print(f"[bold red]Update failed:[/] {e}")
console.print(
"[dim]You can reinstall manually with:[/] "
"[#60a5fa]curl -sSL https://strix.ai/install | bash[/]"
)
return False
_write_cache(latest_version=latest, checked_at=time.time())
console.print(f"[#22c55e]✓ Updated strix to {latest}[/]")
return True
+3 -4
View File
@@ -5,7 +5,7 @@ description: Orchestration layer that coordinates specialized subagents for secu
# Root Agent
Orchestration layer for security assessments. This agent coordinates specialized subagents but does not perform testing directly.
Orchestration layer for security assessments. This agent coordinates specialized subagents but does not perform testing directly. You never run scanners, crawlers, or fuzzers and never send exploit/injection payloads yourself — not even a quick "basic" test on a discovered endpoint. Any work that touches the target is delegated to a subagent.
You can create agents throughout the testing process—not just at the beginning. Spawn agents dynamically based on findings and evolving scope.
@@ -18,7 +18,7 @@ You can create agents throughout the testing process—not just at the beginning
## Scope Decomposition
Before spawning agents, analyze the target:
Before spawning agents, analyze the target from the scan config/scope and any provided context (and, once recon subagents report, from their results) — not by running recon tools yourself:
1. **Identify attack surfaces** - web apps, APIs, infrastructure, etc.
2. **Define boundaries** - in-scope domains, IP ranges, excluded assets
@@ -72,8 +72,7 @@ Before creating agents:
Complex findings warrant specialized subagents:
- Discovery agent finds potential vulnerability
- Validation agent confirms exploitability
- Reporting agent documents with reproduction steps
- Fix agent provides remediation (if needed)
- Reporting agent documents with reproduction steps AND supplies the fix inline (the report tool carries the patch via `code_locations`/`fix_pr_body`) — do not add a separate fix agent that re-derives the same patch
**Resource Efficiency**
+46
View File
@@ -142,6 +142,52 @@ 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 viewer_feedback_submitted() -> None:
_send("viewer_feedback_submitted", {**base_props()})
def viewer_agent_steered() -> None:
_send("viewer_agent_steered", {**base_props()})
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"]
+272
View File
@@ -0,0 +1,272 @@
"""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 feedback_submit(email: str, message: str) -> None:
"""Relay a feedback message + email to Strix. No verification is required;
the email is taken as given. Raises RelayError on failure."""
status, data = _post_json(
"/api/oss/feedback",
{"email": email, "message": message},
timeout=_OTP_TIMEOUT,
)
if status == 200:
return
if status == 429:
raise RelayError("rate_limited")
if status == 400:
code = data.get("error")
if code in ("invalid_email", "invalid_message"):
raise RelayError(str(code))
raise RelayError("invalid_message")
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",
"feedback_submit",
"forget",
"is_verified",
"otp_start",
"otp_verify",
"read_auth",
"report_send",
"write_auth",
]
+142
View File
@@ -0,0 +1,142 @@
"""`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/frontend && 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:")
# Print the URL alone on its own line with soft_wrap so Rich never inserts a
# wrap into the (long, tokened) link -- that keeps it selectable/copyable.
console.print(f" [#60a5fa]{open_url}[/]", soft_wrap=True)
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"]
+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>
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"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-icons": "^5.7.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

+789
View File
@@ -0,0 +1,789 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ArrowLeft,
AlertCircle,
Bot,
Mail,
ChevronDown,
Radar,
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 FeedbackView from "@/components/FeedbackView";
import { ProInlineCta } from "@/components/ProCta";
export type View = "overview" | "issues" | "agents" | "history" | "email" | "feedback";
const TRUST_BANNER =
"Your findings stay on your machine. They're rendered here locally in your browser and never uploaded or stored by Strix.";
const SEVERITY_ORDER: VulnerabilitySeverity[] = ["critical", "high", "medium", "low"];
const POLL_MS = 500;
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 [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 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) => {
// Clicking a sidebar view always lands on that section's top level,
// so leaving a specific issue's detail view and clicking "Issues"
// returns to the full findings list.
setSelectedId(null);
if (v === "history") openHistory();
else userSetView(v);
}}
issuesCount={run?.vulnerabilities.length ?? 0}
agentCount={agentCount}
runCount={runs?.count ?? 0}
finished={run?.finished ?? false}
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-[88rem] mx-auto px-3 sm: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>
{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-[88rem] mx-auto px-3 sm:px-6 py-8 sm:py-12 space-y-6">
{error && !run && view !== "history" && view !== "email" && (
<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>
)}
{/* Keyed wrapper: re-mounts on every view / finding / run change so the
page-in transition replays. */}
<div
key={`${activeRun ?? "launched"}:${view}:${selectedId ?? ""}`}
className="animate-page-in space-y-6"
>
{view === "email" ? (
<EmailReportView
activeRun={activeRun}
auth={auth}
purpose={emailPurpose}
skipDisclosure={emailSkipDisclosure}
onAuthChanged={() => {
void refreshAuth();
void refreshRuns();
}}
onExit={(dest) => setView(dest === "history" ? "history" : "overview")}
/>
) : view === "feedback" ? (
<FeedbackView
defaultEmail={auth?.email ?? null}
onExit={(dest) => setView(dest)}
/>
) : 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")}>
Pentest 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}
finished={run.finished}
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>
</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)}
aria-label="Switch pentest"
className="flex items-center gap-2 rounded-lg border border-[#3a3a3a] bg-[rgba(255,255,255,0.05)] px-3 py-2 text-sm text-white transition-colors hover:border-[#555] hover:bg-[rgba(255,255,255,0.09)]"
>
<History className="h-4 w-4 flex-shrink-0 text-[#888]" aria-hidden="true" />
<span className="flex-shrink-0 text-[#888]">Pentest</span>
<span className="max-w-[260px] truncate font-medium">{current}</span>
<ChevronDown className="h-4 w-4 flex-shrink-0 text-[#aaa]" aria-hidden="true" />
</button>
{open && (
<div
className="absolute right-0 z-50 mt-2 max-h-96 w-96 overflow-y-auto rounded-xl py-1.5 shadow-2xl"
style={{ border: "1px solid #3a3a3a", background: "#0a0a0a" }}
>
<div className="border-b border-[#222] px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-[#666]">
Switch pentest
</div>
{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.5 text-left text-sm transition-colors hover:bg-[rgba(255,255,255,0.06)] ${
active ? "bg-[rgba(255,255,255,0.04)] text-white" : "text-[#aaa]"
}`}
>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{runTitle(r.target, r.name)}</span>
{r.target && <span className="block truncate font-mono text-xs text-[#666]">{r.target}</span>}
</span>
{active && <span className="h-2 w-2 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 ?? "Pentest 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 pentest 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="animate-card-in 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">
Export report to PDF
</span>
</div>
</button>
);
}
function OverviewTab({
summary,
counts,
total,
reportMarkdown,
raw,
finished,
onOpenEmail,
}: {
summary: ParsedRunSummary;
counts: Record<VulnerabilitySeverity, number>;
total: number;
reportMarkdown: string | null;
raw: Record<string, unknown>;
finished: boolean;
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">
<div className="animate-card-in">
<RunDetails raw={raw} durationSeconds={summary.durationSeconds} />
</div>
{total > 0 && (
<div className="animate-card-in 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. Hidden until the run is
finished, since a live scan would only email a partial report. */}
{finished && (
<div className="animate-card-in">
<EmailReportCta onOpenEmail={onOpenEmail} />
</div>
)}
{sections.length > 0 ? (
<div className="animate-card-in 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="animate-card-in 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>
)
)}
</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; 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">
<Bot 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 pentest with more depth</p>
<p className="mt-0.5 text-xs text-[#666]">Re-run this pentest on managed infra in the cloud.</p>
<div className="mt-3 flex flex-wrap gap-2.5">
<ProInlineCta
label="Re-run in Strix Pro with more depth"
desc="Run this pentest on managed infra with more depth."
slug="live_scan"
surface="agents"
icon={Rocket}
/>
</div>
</div>
<AgentDetailModal
open={selectedAgent !== null}
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,371 @@
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-xl 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" : "Export report to PDF"}
</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]">
We email an <span className="text-white">encrypted PDF</span>. Nothing else leaves your machine.
</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]">
Only you hold the password; Strix can&apos;t read 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"
>
Export report
</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" }}
/>
</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,134 @@
import { useState } from "react";
import { ArrowLeft, AlertCircle, CheckCircle2 } from "lucide-react";
import { IoChatbubblesOutline } from "react-icons/io5";
import { submitFeedback } from "@/data/serverSource";
import type { View } from "@/App";
const MAX_MESSAGE = 5000;
const ERROR_COPY: Record<string, string> = {
invalid_email: "That email doesn't look right.",
invalid_message: "Please write a little more.",
unavailable: "Couldn't send that just now. Try again.",
};
/**
* Feedback & support form. Collects a message plus a work email (no
* verification — the email is taken as-is) and relays it to Strix via the local
* server. Mirrors EmailReportView's centered-card styling and palette.
*/
export default function FeedbackView({
defaultEmail,
onExit,
}: {
defaultEmail: string | null;
onExit: (dest: View) => void;
}) {
const [message, setMessage] = useState("");
const [email, setEmail] = useState(defaultEmail ?? "");
const [step, setStep] = useState<"form" | "sending" | "sent">("form");
const [error, setError] = useState<string | null>(null);
const canSend = message.trim().length > 0 && email.trim().length > 0 && step !== "sending";
const send = async () => {
if (!canSend) return;
setStep("sending");
setError(null);
const result = await submitFeedback(message.trim(), email.trim());
if (result.ok) {
setStep("sent");
return;
}
setStep("form");
setError(ERROR_COPY[result.error] ?? ERROR_COPY.unavailable);
};
return (
<div className="mx-auto max-w-xl space-y-4">
<button
onClick={() => onExit("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" />
Back to results
</button>
<div className="flex items-center gap-2">
<IoChatbubblesOutline className="h-5 w-5 text-[#888]" aria-hidden="true" />
<h1 className="text-2xl font-semibold text-white">Feedback &amp; support</h1>
</div>
<div
className="w-full rounded-2xl bg-[rgba(255,255,255,0.02)] p-6"
style={{ border: "1px solid #2a2a2a" }}
>
{step === "sent" ? (
<div className="flex items-start gap-3">
<CheckCircle2 className="mt-0.5 h-5 w-5 flex-shrink-0 text-emerald-400" aria-hidden="true" />
<div className="min-w-0">
<p className="text-sm font-semibold text-white">Thanks, we got it.</p>
<p className="mt-1 text-xs text-[#888]">
We read every message. If it needs a reply, we&apos;ll reach out to the email you gave.
</p>
<button
onClick={() => {
setMessage("");
setStep("form");
}}
className="mt-4 cursor-pointer text-xs text-[#888] transition-colors hover:text-white"
>
Send more feedback
</button>
</div>
</div>
) : (
<>
<p className="mb-4 text-xs text-[#666]">
Bugs, feature requests, or anything else. Tell us what&apos;s on your mind.
</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>
)}
<label className="block">
<span className="mb-1.5 block text-xs text-[#888]">Your feedback</span>
<textarea
autoFocus
value={message}
maxLength={MAX_MESSAGE}
onChange={(e) => setMessage(e.target.value)}
rows={5}
placeholder="What's working, what's not, what you'd love to see…"
className="w-full resize-y rounded-lg border border-[#2a2a2a] bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-white/50 focus:ring-2 focus:ring-white/10"
/>
</label>
<label className="mt-4 block">
<span className="mb-1.5 block text-xs text-[#888]">Your work email</span>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@company.com"
className="w-full rounded-lg border border-[#2a2a2a] bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-white/50 focus:ring-2 focus:ring-white/10"
/>
</label>
<button
onClick={() => void send()}
disabled={!canSend}
className="mt-4 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"
>
{step === "sending" ? "Sending…" : "Send feedback"}
</button>
</>
)}
</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={`animate-card-in 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>
);
}
@@ -0,0 +1,72 @@
import React, { useState } from "react";
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
/**
* 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>
);
}
/**
* Compact inline CTA button that links out to sign-up in a new tab, with a
* hover tooltip one-liner. Used in 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>
</a>
</Tooltip>
);
}
@@ -0,0 +1,206 @@
import { useState } from "react";
import { ChevronDown, ChevronUp, 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;
}) {
const [open, setOpen] = useState(true);
// 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) => {
if (typeof x === "string") return x;
const o = rec(x);
return str(o.source_path) ?? str(o.target_path) ?? "";
})
.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">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex w-full cursor-pointer items-center gap-2 text-left"
>
<Info className="h-4 w-4 text-[#888]" aria-hidden="true" />
<h2 className="text-sm font-semibold text-white">Run details</h2>
{open ? (
<ChevronUp className="ml-auto h-4 w-4 text-[#666]" aria-hidden="true" />
) : (
<ChevronDown className="ml-auto h-4 w-4 text-[#666]" aria-hidden="true" />
)}
</button>
{open && (
<div className="mt-4 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="Pentest 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;
@@ -0,0 +1,435 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
AlertTriangle,
Bot,
Users,
History,
Mail,
LogOut,
ChevronsUpDown,
} from "lucide-react";
import { LuGitPullRequestArrow } from "react-icons/lu";
import { VscExtensions } from "react-icons/vsc";
import { IoChatbubblesOutline } from "react-icons/io5";
import { cn } from "@/lib/utils";
import { ctaUrl, trackCta } from "@/lib/cta";
import { UpgradeModal } from "@/components/UpgradeModal";
import type { View } from "@/App";
/**
* Persistent left rail: a black rail with a right hairline border, an
* account-switcher-style header, a single ungrouped list of h-9 nav rows (36px
* icon slot, 14px label, rgba(255,255,255,0.12) active fill), a hairline
* separator, and a user footer. Drag the right edge to resize; drag past the
* collapse threshold to hide it, then click the left pull-zone to bring it back.
*/
const MIN_WIDTH = 160;
const DEFAULT_WIDTH = 260;
const MAX_WIDTH = 400;
const COLLAPSE_THRESHOLD = 140;
const WIDTH_KEY = "strix_viewer_sidebar_width";
const COLLAPSE_KEY = "strix_viewer_sidebar_collapsed";
interface SidebarProps {
view: View;
onSelectView: (view: View) => void;
issuesCount: number;
agentCount: number;
runCount: number;
finished: boolean;
verified: boolean;
email: string | null;
onOpenEmail: () => void;
onOpenHistory: () => void;
onForget: () => void;
}
function readInt(key: string, fallback: number): number {
try {
const raw = localStorage.getItem(key);
const n = raw ? parseInt(raw, 10) : NaN;
return Number.isFinite(n) ? n : fallback;
} catch {
return fallback;
}
}
export default function Sidebar({
view,
onSelectView,
issuesCount,
agentCount,
runCount,
finished,
verified,
email,
onOpenEmail,
onOpenHistory,
onForget,
}: SidebarProps) {
const [width, setWidth] = useState(() => {
const w = readInt(WIDTH_KEY, DEFAULT_WIDTH);
return Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, w));
});
const [collapsed, setCollapsed] = useState(() => {
try {
return localStorage.getItem(COLLAPSE_KEY) === "1";
} catch {
return false;
}
});
const [isResizing, setIsResizing] = useState(false);
const [showUserMenu, setShowUserMenu] = useState(false);
const [upgradeFeature, setUpgradeFeature] = useState<string | null>(null);
const userMenuRef = useRef<HTMLDivElement>(null);
// Open the upgrade dialog for a platform feature, recording which feature
// drove the open (the dialog's own CTAs track the deeper conversion).
const openUpgrade = (slug: string, description: string) => {
trackCta(slug, "sidebar");
setUpgradeFeature(description);
};
const persistWidth = useCallback((w: number) => {
setWidth(w);
try {
localStorage.setItem(WIDTH_KEY, String(w));
} catch {
/* best-effort persistence */
}
}, []);
const persistCollapsed = useCallback((c: boolean) => {
setCollapsed(c);
try {
localStorage.setItem(COLLAPSE_KEY, c ? "1" : "0");
} catch {
/* best-effort persistence */
}
}, []);
const expandSidebar = useCallback(() => {
persistCollapsed(false);
persistWidth(DEFAULT_WIDTH);
}, [persistCollapsed, persistWidth]);
const handleResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
}, []);
// Global drag handlers for the resize handle. Dragging below the collapse
// threshold hides the rail entirely.
useEffect(() => {
if (!isResizing || collapsed) return;
const handleMouseMove = (e: MouseEvent) => {
const newWidth = e.clientX;
if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
setWidth(newWidth);
} else if (newWidth > MAX_WIDTH) {
setWidth(MAX_WIDTH);
}
};
const handleMouseUp = (e: MouseEvent) => {
const finalWidth = e.clientX;
if (finalWidth < COLLAPSE_THRESHOLD) {
persistCollapsed(true);
persistWidth(DEFAULT_WIDTH);
} else {
persistWidth(Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, finalWidth)));
}
setIsResizing(false);
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [isResizing, collapsed, persistCollapsed, persistWidth]);
// Close the user menu when clicking outside it.
useEffect(() => {
if (!showUserMenu) return;
const onDown = (e: MouseEvent) => {
if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) {
setShowUserMenu(false);
}
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [showUserMenu]);
return (
<>
{/* Left-edge pull zone: click to bring the rail back when collapsed. */}
{collapsed && (
<div
className="fixed left-0 top-0 z-40 hidden h-full w-4 cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.08)] lg:block"
onClick={expandSidebar}
title="Expand sidebar"
/>
)}
<aside
className={cn(
"sticky top-0 z-20 hidden h-screen flex-shrink-0 flex-col overflow-hidden border-r border-[rgba(255,255,255,0.08)] bg-black lg:flex",
!isResizing && "transition-[width] duration-200 ease-out"
)}
style={{ width: collapsed ? 0 : width }}
>
{/* Header — account-switcher stand-in (links out to Strix Cloud). */}
<header className="relative flex flex-col gap-1 pt-1 min-w-[160px]">
<div className="flex flex-row py-1 px-2">
<div className="flex h-10 w-full flex-row items-center">
<a
href={ctaUrl("https://app.strix.ai", "logo")}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackCta("logo", "sidebar")}
className="flex flex-1 flex-row items-center gap-2 rounded-md py-2 pl-2.5 pr-1 min-w-0 transition-colors hover:bg-[rgba(255,255,255,0.06)]"
title="Open Strix Cloud"
>
<span
className="flex flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500"
style={{ width: 20, height: 20 }}
>
<span className="text-[10px] font-semibold text-white">S</span>
</span>
<span className="flex flex-1 flex-row items-center gap-2 min-w-0">
<span className="truncate min-w-0 text-[14px] font-medium text-[#ededed]">Strix</span>
<span className="flex h-5 flex-shrink-0 items-center rounded px-2 text-[11px] font-medium text-[#888] bg-[rgba(255,255,255,0.08)]">
Local
</span>
</span>
</a>
<a
href={ctaUrl("https://app.strix.ai", "logo")}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackCta("logo", "sidebar")}
className="flex flex-none items-center rounded-md px-1.5 py-2 transition-colors hover:bg-[rgba(255,255,255,0.06)]"
aria-label="Open Strix Cloud"
>
<ChevronsUpDown className="h-4 w-4 text-[#666]" />
</a>
</div>
</div>
</header>
{/* Navigation */}
<nav className="relative min-w-[160px] flex-1 overflow-y-auto overflow-x-clip scrollbar-thin pb-10 pt-2">
<div className="relative flex flex-col gap-px px-2">
<NavItem
icon={<ProjectsIcon />}
label="Pentest Overview"
active={view === "overview"}
onClick={() => onSelectView("overview")}
/>
<NavItem
icon={<AlertTriangle className="h-4 w-4" />}
label="Issues"
count={issuesCount > 0 ? issuesCount : undefined}
active={view === "issues"}
onClick={() => onSelectView("issues")}
/>
{agentCount > 0 && (
<NavItem
icon={<Bot className="h-4 w-4" />}
label="Agents"
count={agentCount}
active={view === "agents"}
onClick={() => onSelectView("agents")}
/>
)}
<NavItem
icon={<History className="h-4 w-4" />}
label="Past runs"
count={runCount > 0 ? runCount : undefined}
active={view === "history"}
onClick={onOpenHistory}
/>
{finished && (
<NavItem
icon={<Mail className="h-4 w-4" />}
label="Export report"
active={view === "email"}
onClick={onOpenEmail}
/>
)}
<NavItem
icon={<IoChatbubblesOutline className="h-4 w-4" />}
label="Feedback & support"
active={view === "feedback"}
onClick={() => onSelectView("feedback")}
/>
<hr className="mx-0 my-1 h-px w-full border-0 bg-[rgba(255,255,255,0.08)]" />
<NavItem
icon={<LuGitPullRequestArrow className="h-4 w-4" />}
label="PR Security Reviews"
active={false}
onClick={() =>
openUpgrade(
"pr_reviews",
"Strix reviews every pull request and flags exploitable changes before they merge."
)
}
/>
<NavItem
icon={<VscExtensions className="h-4 w-4" />}
label="Integrations"
active={false}
onClick={() =>
openUpgrade(
"integrations",
"Sync findings to Jira, Linear, and Slack so fixes happen where your team already works."
)
}
/>
<NavItem
icon={<Users className="h-4 w-4" />}
label="Members"
active={false}
onClick={() =>
openUpgrade(
"members",
"Invite your team, set roles, and share findings and run history across your org."
)
}
/>
</div>
</nav>
{/* User footer — verified-email footer. */}
<section className="flex min-w-[160px] flex-col gap-0.5" ref={userMenuRef}>
<div className="relative p-2">
{verified && email ? (
<button
onClick={() => setShowUserMenu((v) => !v)}
className="relative flex w-full cursor-pointer items-center gap-2 rounded-md bg-transparent px-2.5 py-2 transition-colors hover:bg-[rgba(255,255,255,0.06)]"
>
<span
className="flex flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500"
style={{ width: 20, height: 20 }}
>
<span className="text-[9px] font-semibold text-white">
{email[0]?.toUpperCase() || "U"}
</span>
</span>
<span className="flex min-w-0 flex-1 flex-col text-left">
<span className="truncate text-[13px] font-medium text-[#ededed]">{email}</span>
<span className="truncate text-[11px] text-[#555]">Linked to this machine</span>
</span>
</button>
) : (
<div className="flex items-center gap-2 rounded-md px-2.5 py-2">
<span
className="flex flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500"
style={{ width: 20, height: 20 }}
>
<span className="text-[9px] font-semibold text-white">S</span>
</span>
<span className="flex min-w-0 flex-1 flex-col text-left">
<span className="truncate text-[13px] font-medium text-[#ededed]">Local viewer</span>
</span>
</div>
)}
{showUserMenu && verified && email && (
<div className="absolute bottom-full left-2 right-2 z-50 mb-1 overflow-hidden rounded-lg border border-[#333] bg-black shadow-xl">
<div className="border-b border-[#333] px-3 py-2">
<p className="truncate text-[13px] font-medium text-white">Linked email</p>
<p className="truncate text-[11px] text-[#666]">{email}</p>
</div>
<button
onClick={() => {
setShowUserMenu(false);
onForget();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-[13px] text-[#888] transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-red-400"
>
<LogOut className="h-4 w-4" />
Forget this email
</button>
</div>
)}
</div>
</section>
{/* Resize handle */}
<div
className="group absolute right-0 top-0 z-30 h-full w-1 cursor-col-resize"
onMouseDown={handleResizeStart}
>
<div
className={cn(
"absolute right-0 top-0 h-full w-px bg-[rgba(255,255,255,0.08)] transition-all duration-100",
isResizing ? "w-0.5 bg-[rgba(255,255,255,0.3)]" : "group-hover:bg-[rgba(255,255,255,0.2)]"
)}
/>
</div>
</aside>
{/* Overlay during resize to prevent text selection. */}
{isResizing && <div className="fixed inset-0 z-10 cursor-col-resize" />}
<UpgradeModal
open={upgradeFeature !== null}
description={upgradeFeature ?? ""}
source="sidebar"
onClose={() => setUpgradeFeature(null)}
/>
</>
);
}
interface NavItemProps {
icon: React.ReactNode;
label: string;
active: boolean;
onClick: () => void;
count?: number;
}
function NavItem({ icon, label, active, onClick, count }: NavItemProps) {
return (
<button
onClick={onClick}
className={cn(
"group flex h-9 w-full origin-left flex-row items-center rounded-md transition-colors",
active
? "bg-[rgba(255,255,255,0.12)] text-white"
: "text-[#888] hover:bg-[rgba(255,255,255,0.06)] hover:text-[#ededed]"
)}
>
<div className="grid flex-none place-content-center" style={{ width: 36, height: 36 }}>
{icon}
</div>
<span className="min-w-0 flex-1 truncate text-left text-[14px] font-medium">{label}</span>
{count != null && (
<span className="mr-2 flex-none rounded-full border border-white/10 px-2 py-0.5 text-[10px] tabular-nums leading-none text-[#777]">
{count}
</span>
)}
</button>
);
}
// Overview icon: a dashboard grid glyph (16x16 viewBox).
function ProjectsIcon() {
return (
<svg style={{ width: 16, height: 16, color: "currentcolor" }} viewBox="0 0 16 16" fill="currentColor">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2.5 5.5V2.5H5.5V5.5H2.5ZM1 2C1 1.44772 1.44772 1 2 1H6C6.55228 1 7 1.44772 7 2V6C7 6.55228 6.55228 7 6 7H2C1.44772 7 1 6.55228 1 6V2ZM2.5 13.5V10.5H5.5V13.5H2.5ZM1 10C1 9.44772 1.44772 9 2 9H6C6.55228 9 7 9.44772 7 10V14C7 14.5523 6.55228 15 6 15H2C1.44772 15 1 14.5523 1 14V10ZM10.5 2.5V5.5H13.5V2.5H10.5ZM10 1C9.44772 1 9 1.44772 9 2V6C9 6.55228 9.44772 7 10 7H14C14.5523 7 15 6.55228 15 6V2C15 1.44772 14.5523 1 14 1H10ZM10.5 13.5V10.5H13.5V13.5H10.5ZM9 10C9 9.44772 9.44772 9 10 9H14C14.5523 9 15 9.44772 15 10V14C15 14.5523 14.5523 15 14 15H10C9.44772 15 9 14.5523 9 14V10Z"
/>
</svg>
);
}
@@ -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,150 @@
import { useEffect, useState } from "react";
import {
X,
Sparkles,
ExternalLink,
GitPullRequest,
Shield,
Zap,
CalendarClock,
WandSparkles,
Plug,
} from "lucide-react";
import { SIGNUP_URL, PRICING_URL, ctaUrl, trackCta } from "@/lib/cta";
/**
* Dialog shown when a platform feature is clicked in the sidebar: a short
* description of the feature plus what Strix Cloud includes. The local viewer
* has no billing, so both CTAs link out to the public sign-up / pricing pages.
*/
const CLOUD_HIGHLIGHTS: { icon: React.ElementType; label: string }[] = [
{ icon: GitPullRequest, label: "PR security reviews" },
{ icon: Shield, label: "Attack surface monitoring" },
{ icon: Zap, label: "Real-time threat intelligence" },
{ icon: CalendarClock, label: "Scheduled pentesting" },
{ icon: WandSparkles, label: "One-click autofix" },
{ icon: Plug, label: "Jira, Linear & Slack integrations" },
];
export function UpgradeModal({
open,
onClose,
description,
source = "sidebar",
}: {
open: boolean;
onClose: () => void;
/** A short sentence describing what the clicked feature does. */
description: string;
source?: string;
}) {
// Keep the dialog mounted through its exit animation: `render` controls
// presence in the DOM and `state` ("open"/"closed") drives the keyframe. On
// close we flip to "closed", let the 200ms animation play, then unmount --
// the same lifecycle Radix gives shadcn's Dialog.
const [render, setRender] = useState(open);
const [state, setState] = useState<"open" | "closed">(open ? "open" : "closed");
useEffect(() => {
if (open) {
setRender(true);
setState("open");
return;
}
setState("closed");
const t = setTimeout(() => setRender(false), 200);
return () => clearTimeout(t);
}, [open]);
useEffect(() => {
if (!render) return;
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;
};
}, [render, onClose]);
if (!render) return null;
return (
<div
data-state={state}
className="dialog-overlay fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label="Upgrade your plan"
>
<div
data-state={state}
className="dialog-panel relative w-full max-w-md rounded-2xl border border-[#222] bg-black p-6 shadow-lg sm:rounded-lg"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="absolute right-4 top-4 rounded-md p-1 text-[#888] transition-colors hover:bg-[#1a1a1a] hover:text-white"
>
<X className="h-4 w-4" />
</button>
<div>
<h2 className="text-lg text-white">Available in Strix Cloud</h2>
{description && (
<p className="mt-2 text-base leading-relaxed text-[#e5e5e5]">{description}</p>
)}
</div>
<div className="space-y-4 pt-4">
<div className="rounded-xl border border-[#333] bg-[#0a0a0a] p-4 sm:rounded-lg">
<div className="mb-3 flex items-center gap-2">
<Sparkles className="h-4 w-4 text-blue-400" />
<span className="text-sm font-medium text-white">Strix Cloud also includes</span>
</div>
<ul className="space-y-2 text-sm text-[#888]">
{CLOUD_HIGHLIGHTS.map((f) => (
<li key={f.label} className="flex items-center gap-2">
<f.icon className="h-3.5 w-3.5 text-[#555]" />
{f.label}
</li>
))}
</ul>
</div>
<div className="flex flex-col gap-2">
<a
href={ctaUrl(SIGNUP_URL, "upgrade_try_free")}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackCta("upgrade_try_free", source)}
className="flex h-10 w-full items-center justify-center gap-2 rounded-lg bg-white px-4 text-sm font-semibold text-black transition-colors hover:bg-neutral-200"
>
Open Strix Cloud
<ExternalLink className="h-3.5 w-3.5" />
</a>
<a
href={ctaUrl(PRICING_URL, "upgrade_view_plans")}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackCta("upgrade_view_plans", source)}
className="flex h-9 w-full items-center justify-center gap-1.5 rounded-lg border border-[#333] px-4 text-sm font-medium text-[#888] transition-colors hover:border-[#555] hover:text-white"
>
Learn more
<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
</div>
</div>
);
}
export default UpgradeModal;
@@ -0,0 +1,167 @@
import { useCallback, useEffect, useRef, useState } 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. A centered
* ``max-w-6xl`` / ``60vh`` panel that animates in and out via the shared
* ``agent-modal`` data-state keyframes (fade), 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. Closes on backdrop click, the X
* button, or Escape.
*
* Driven by an ``open`` prop (rather than conditional mounting) so the exit
* animation can play before unmount; the last agent is retained through the
* close so content doesn't blank out mid-animation.
*/
export function AgentDetailModal({
open,
agent,
events,
steerable,
onClose,
}: {
open: boolean;
agent: TranscriptAgent | null;
events: TranscriptEvent[];
steerable: boolean;
onClose: () => void;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const nearBottom = useRef(false);
// Keep the modal mounted through its exit animation (see UpgradeModal).
const [render, setRender] = useState(open);
const [state, setState] = useState<"open" | "closed">(open ? "open" : "closed");
// Defer the (heavy) transcript one frame so the shell + fade paint instantly
// instead of waiting on the full event list to render.
const [contentReady, setContentReady] = useState(false);
// Retain the last non-null agent so the panel keeps rendering its content
// during the close animation, after the parent has cleared the selection.
const lastAgentRef = useRef<TranscriptAgent | null>(agent);
useEffect(() => {
if (agent) lastAgentRef.current = agent;
}, [agent]);
const shownAgent = agent ?? lastAgentRef.current;
useEffect(() => {
if (open) {
setRender(true);
setState("open");
return;
}
setState("closed");
const t = setTimeout(() => setRender(false), 140);
return () => clearTimeout(t);
}, [open]);
// Mount the transcript a frame after the shell is on screen.
useEffect(() => {
if (!render) {
setContentReady(false);
return;
}
const id = requestAnimationFrame(() => setContentReady(true));
return () => cancelAnimationFrame(id);
}, [render]);
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(() => {
if (!render) return;
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;
};
}, [render, onClose]);
if (!render || !shownAgent) return null;
return (
<div
data-state={state}
className="agent-modal fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4 sm:p-8"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label={`Agent ${shownAgent.name}`}
>
<div
className="relative flex h-[60vh] w-[calc(100vw-4rem)] max-w-6xl flex-col overflow-hidden 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[shownAgent.status] ?? "bg-[#888]"}`}
/>
<span className="truncate text-sm font-semibold text-white">{shownAgent.name}</span>
<span className="flex-shrink-0 font-mono text-xs text-[#555]">{shownAgent.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">
{contentReady && (
<AgentTranscript agent={shownAgent} events={events} showHeader={false} />
)}
</div>
{steerable && (
<div className="border-t border-[#222] px-5 py-3">
<ScanPromptComposer
agents={[shownAgent]}
fixedAgentId={shownAgent.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 pentest…"
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,21 @@
"use client";
import type { ToolRendererProps } from "@/types/events";
export default function LoadSkillRenderer({ args }: ToolRendererProps) {
// `skills` may arrive as an array of names or a comma-separated string
// depending on the tool call, so normalize both to a clean list.
const raw = args.skills;
const requestedSkills = (Array.isArray(raw) ? raw : String(raw ?? "").split(","))
.map((skill) => String(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 } from "lucide-react";
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
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";
// Team-workflow actions shown top-right of the finding header; each links out
// to sign-up. `requiresCode` actions only appear when the finding has concrete
// code locations to act on -- an autofix PR makes no sense for a black-box
// finding with no code to change.
const WORKFLOW_CTAS: { label: string; slug: string; icon: React.ElementType; requiresCode?: boolean }[] = [
{ label: "Auto-fix & open a PR", slug: "autofix", icon: Wrench, requiresCode: true },
{ label: "Sync to Jira / Linear", slug: "integrations", icon: GitMerge },
];
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: title/badges on the left, workflow actions top-right. */}
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<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>
<div className="flex flex-shrink-0 flex-wrap items-center gap-2">
{WORKFLOW_CTAS.filter((cta) => !cta.requiresCode || hasCodeLocations).map((cta) => {
const Icon = cta.icon;
return (
<a
key={cta.slug}
href={ctaUrl(SIGNUP_URL, cta.slug)}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackCta(cta.slug, "finding_detail")}
className="inline-flex items-center gap-1.5 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity hover:opacity-90"
>
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
{cta.label}
</a>
);
})}
</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>
</div>
);
}
@@ -0,0 +1,251 @@
import type { Vulnerability } from "@/types/issues";
import {
parseRunJson,
parseVulnerabilitiesJson,
type ParsedRunSummary,
} from "@/lib/local-run-parser";
/**
* Data seam for the local viewer: fetches against the local Python server's
* JSON endpoints (same origin, relative URLs), producing the 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 type SubmitFeedbackResult = { ok: true } | { ok: false; error: string };
/**
* POST /api/feedback. Sends a feedback message plus a work email (no
* verification) to the local server, which relays it to Strix.
*/
export async function submitFeedback(
message: string,
email: string
): Promise<SubmitFeedbackResult> {
const { ok, data } = await postJson("/api/feedback", { message, email });
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") };
}
+326
View File
@@ -0,0 +1,326 @@
@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);
}
/* Thin sidebar scrollbar */
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
/* Motion vocabulary --------------------- */
/* Page transition: replayed on every view change via a keyed wrapper. */
@keyframes page-in {
from {
opacity: 0;
filter: blur(8px);
transform: translateY(8px);
}
to {
opacity: 1;
filter: blur(0px);
transform: translateY(0px);
}
}
.animate-page-in {
animation: page-in 150ms ease-out;
}
/* Plain fade. */
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.animate-fade-in {
animation: fade-in 350ms ease-out;
}
/* Staggered card entrance for lists/grids (first four cascade). */
@keyframes cardIn {
from {
opacity: 0;
filter: blur(4px);
transform: translateY(8px) scale(0.97);
}
to {
opacity: 1;
filter: blur(0px);
transform: translateY(0) scale(1);
}
}
.animate-card-in {
opacity: 0;
animation: cardIn 300ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.animate-card-in:nth-child(1) {
animation-delay: 0ms;
}
.animate-card-in:nth-child(2) {
animation-delay: 50ms;
}
.animate-card-in:nth-child(3) {
animation-delay: 100ms;
}
.animate-card-in:nth-child(4) {
animation-delay: 150ms;
}
/* Shimmer sweep for progress indicators. */
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(400%);
}
}
.animate-shimmer {
animation: shimmer 2s infinite;
}
/* Dialog enter/exit — mirrors shadcn's data-[state]:animate-in/animate-out
(fade-in-0/zoom-in-95 in, fade-out-0/zoom-out-95 out) driven off a
data-state attribute rather than a transition, so the enter always plays. */
@keyframes dialog-overlay-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dialog-overlay-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes dialog-panel-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes dialog-panel-out {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.95);
}
}
.dialog-overlay[data-state="open"] {
animation: dialog-overlay-in 200ms ease;
}
.dialog-overlay[data-state="closed"] {
animation: dialog-overlay-out 200ms ease forwards;
}
.dialog-panel[data-state="open"] {
animation: dialog-panel-in 200ms ease;
}
.dialog-panel[data-state="closed"] {
animation: dialog-panel-out 200ms ease forwards;
}
/* Agent detail modal: fade only (no scale) and faster. Its panel holds the full
transcript, and animating a transform on that much DOM janks; fading the
overlay (the panel inherits its opacity) stays cheap and snappy. */
.agent-modal[data-state="open"] {
animation: dialog-overlay-in 140ms ease;
}
.agent-modal[data-state="closed"] {
animation: dialog-overlay-out 140ms ease forwards;
}
/* Tab content transition. */
@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 */
.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;
}
@@ -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 pentest";
}
+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/static and shipped.
export default defineConfig({
base: "./",
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
build: {
outDir: "../static",
emptyOutDir: true,
},
});
+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",
]
+607
View File
@@ -0,0 +1,607 @@
"""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 / "static"
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/feedback":
self._handle_feedback()
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)
elif event == "agent_steered":
from strix.telemetry import posthog
posthog.viewer_agent_steered()
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. The
# cross-run history list (/api/runs) unlocks its entries only for a
# caller that holds this process's session capability *and* is email
# verified, so merely reaching an exposed --host port never leaks the
# run list (the payload still advertises the count as a teaser).
if path == "/api/runs":
unlocked = self._has_session() and auth.is_verified()
payload = build_runs_payload(state.base_dir, verified=unlocked)
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":
self._handle_auth_status()
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_auth_status(self) -> None:
# The cached verified email is only disclosed to a caller holding this
# process's session capability, so a cookie-less client on an exposed
# --host port cannot read it; everyone else looks unverified.
# Verification is reported 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.
if not self._has_session():
self._send_json(HTTPStatus.OK, {"verified": False, "email": None})
return
record = auth.read_auth()
self._send_json(
HTTPStatus.OK,
{
"verified": auth.is_verified(),
"email": record.get("email") if record else None,
},
)
def _handle_otp_start(self) -> None:
if not self._has_session():
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
return
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:
if not self._has_session():
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
return
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:
# Clearing the cached verification is a state change, so it requires
# this process's session capability: a cookie-less caller on an
# exposed --host port must not be able to log the operator out.
if not self._has_session():
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
return
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
summary = read_run_summary(run_dir)
# Emailing only makes sense for a completed run; a live scan would
# send a partial report. The UI hides the entry point, but fail
# closed here too so the endpoint can't be driven mid-scan.
if not summary.get("finished", False):
self._send_json(HTTPStatus.CONFLICT, {"error": "run_not_finished"})
return
from strix.viewer.report_pdf import build_encrypted_report
pdf_bytes, password, filename = build_encrypted_report(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 feedback message so a runaway client cannot flood the relay.
_FEEDBACK_MESSAGE_MAX = 5000
def _handle_feedback(self) -> None:
# Requires this process's session capability, like the other POSTs,
# so an exposed --host port can't be used to spam the relay.
if not self._has_session():
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
return
body = self._read_body()
email = str(body.get("email") or "").strip()
message = str(body.get("message") or "").strip()
if not email:
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_email"})
return
if not message:
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_message"})
return
message = message[: self._FEEDBACK_MESSAGE_MAX]
try:
auth.feedback_submit(email, message)
except auth.RelayError as exc:
self._send_relay_error(exc)
return
# Server-authoritative: fire only after a successful relay (respects
# the telemetry opt-out; no message/email content is sent).
from strix.telemetry import posthog
posthog.viewer_feedback_submitted()
self._send_json(HTTPStatus.OK, {"ok": True})
# 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,
"invalid_message": 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"]
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-BNKUksp9.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BdiSGmzb.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

+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",
]
+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)
+172
View File
@@ -0,0 +1,172 @@
import hashlib
import io
import json
import platform
import time
from pathlib import Path
import pytest
from rich.console import Console
from strix.interface import update_check
@pytest.fixture(autouse=True)
def _isolated_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(update_check, "_CACHE_PATH", tmp_path / "update-check.json")
monkeypatch.setattr(update_check, "_background_thread", None)
monkeypatch.delenv("STRIX_NO_UPDATE_CHECK", raising=False)
for key in ("CI", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS_URL", "BUILDKITE", "CIRCLECI"):
monkeypatch.delenv(key, raising=False)
@pytest.mark.parametrize(
("latest", "current", "expected"),
[
("1.2.0", "1.1.0", True),
("1.1.0", "1.1.0", False),
("1.0.9", "1.1.0", False),
("2.0.0", "1.99.99", True),
("1.10.0", "1.9.0", True),
("v1.2.0", "1.1.0", True),
("not-a-version", "1.1.0", False),
("1.2.0", "unknown", False),
],
)
def test_is_newer(latest: str, current: str, expected: bool) -> None:
assert update_check._is_newer(latest, current) is expected
def test_get_available_update_from_cache(monkeypatch: pytest.MonkeyPatch) -> None:
update_check._CACHE_PATH.write_text(
json.dumps({"latest_version": "9.9.9", "checked_at": time.time()})
)
monkeypatch.setattr(update_check, "get_version", lambda: "1.0.0")
assert update_check.get_available_update() == "9.9.9"
def test_get_available_update_up_to_date(monkeypatch: pytest.MonkeyPatch) -> None:
update_check._CACHE_PATH.write_text(
json.dumps({"latest_version": "1.0.0", "checked_at": time.time()})
)
monkeypatch.setattr(update_check, "get_version", lambda: "1.0.0")
assert update_check.get_available_update() is None
def test_get_available_update_disabled_by_env(monkeypatch: pytest.MonkeyPatch) -> None:
update_check._CACHE_PATH.write_text(
json.dumps({"latest_version": "9.9.9", "checked_at": time.time()})
)
monkeypatch.setattr(update_check, "get_version", lambda: "1.0.0")
monkeypatch.setenv("STRIX_NO_UPDATE_CHECK", "1")
assert update_check.get_available_update() is None
def test_get_available_update_disabled_in_ci(monkeypatch: pytest.MonkeyPatch) -> None:
update_check._CACHE_PATH.write_text(
json.dumps({"latest_version": "9.9.9", "checked_at": time.time()})
)
monkeypatch.setattr(update_check, "get_version", lambda: "1.0.0")
monkeypatch.setenv("CI", "true")
assert update_check.get_available_update() is None
def test_get_available_update_corrupt_cache(monkeypatch: pytest.MonkeyPatch) -> None:
update_check._CACHE_PATH.write_text("{not json")
monkeypatch.setattr(update_check, "get_version", lambda: "1.0.0")
assert update_check.get_available_update() is None
def test_background_check_skipped_when_fresh(monkeypatch: pytest.MonkeyPatch) -> None:
update_check._CACHE_PATH.write_text(
json.dumps({"latest_version": "1.0.0", "checked_at": time.time()})
)
called = False
def fake_refresh() -> None:
nonlocal called
called = True
monkeypatch.setattr(update_check, "_refresh_cache", fake_refresh)
update_check.start_background_check()
assert update_check._background_thread is None
assert called is False
def test_background_check_runs_when_stale(monkeypatch: pytest.MonkeyPatch) -> None:
update_check._CACHE_PATH.write_text(
json.dumps({"latest_version": "1.0.0", "checked_at": time.time() - 2 * 24 * 60 * 60})
)
monkeypatch.setattr(update_check, "_fetch_latest_version", lambda: "1.2.3")
update_check.start_background_check()
assert update_check._background_thread is not None
update_check._background_thread.join(timeout=5)
cache = json.loads(update_check._CACHE_PATH.read_text())
assert cache["latest_version"] == "1.2.3"
def test_skipped_version_suppresses_update(monkeypatch: pytest.MonkeyPatch) -> None:
update_check._CACHE_PATH.write_text(
json.dumps({"latest_version": "9.9.9", "checked_at": time.time()})
)
monkeypatch.setattr(update_check, "get_version", lambda: "1.0.0")
update_check.skip_version("9.9.9")
assert update_check.get_available_update() is None
assert update_check.get_available_update(respect_skip=False) == "9.9.9"
def test_newer_release_overrides_skipped_version(monkeypatch: pytest.MonkeyPatch) -> None:
update_check._CACHE_PATH.write_text(
json.dumps(
{"latest_version": "9.9.10", "checked_at": time.time(), "skipped_version": "9.9.9"}
)
)
monkeypatch.setattr(update_check, "get_version", lambda: "1.0.0")
assert update_check.get_available_update() == "9.9.10"
def test_write_cache_preserves_existing_fields() -> None:
update_check.skip_version("9.9.9")
update_check._write_cache(latest_version="1.2.3", checked_at=123.0)
cache = json.loads(update_check._CACHE_PATH.read_text())
assert cache == {"latest_version": "1.2.3", "checked_at": 123.0, "skipped_version": "9.9.9"}
def test_get_upgrade_command_all_methods() -> None:
assert update_check.get_upgrade_command("binary") == "strix --update"
assert update_check.get_upgrade_command("pipx") == "pipx upgrade strix-agent"
assert update_check.get_upgrade_command("uv") == "uv tool upgrade strix-agent"
assert update_check.get_upgrade_command("pip") == "pip install --upgrade strix-agent"
def test_self_update_non_binary_prints_command(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(update_check, "is_binary_install", lambda: False)
buffer = io.StringIO()
assert update_check.self_update(Console(file=buffer)) is False
assert "upgrade" in buffer.getvalue()
def test_self_update_already_latest(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(update_check, "is_binary_install", lambda: True)
monkeypatch.setattr(update_check, "_fetch_latest_version", lambda: "1.0.0")
monkeypatch.setattr(update_check, "get_version", lambda: "1.0.0")
assert update_check.self_update() is True
def test_sha256_file(tmp_path: Path) -> None:
path = tmp_path / "blob"
path.write_bytes(b"strix")
assert update_check._sha256_file(path) == hashlib.sha256(b"strix").hexdigest()
def test_release_target(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(platform, "system", lambda: "Linux")
monkeypatch.setattr(platform, "machine", lambda: "x86_64")
assert update_check._release_target() == "linux-x86_64"
monkeypatch.setattr(platform, "system", lambda: "Darwin")
monkeypatch.setattr(platform, "machine", lambda: "arm64")
assert update_check._release_target() == "macos-arm64"
monkeypatch.setattr(platform, "machine", lambda: "riscv64")
assert update_check._release_target() is None
+555
View File
@@ -0,0 +1,555 @@
"""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, *, cookie: str | None = None) -> tuple[int, str, bytes]:
headers = {"Cookie": cookie} if cookie else {}
req = urllib.request.Request(url, headers=headers) # noqa: S310 - localhost test server
with urllib.request.urlopen(req) 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 test_server_event_endpoint_forwards_agent_steered(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
run_dir = _make_run(tmp_path, "steerevt", status="running", end_time=None)
_bundle(tmp_path, monkeypatch)
seen: list[bool] = []
monkeypatch.setattr("strix.telemetry.posthog.viewer_agent_steered", lambda: seen.append(True))
httpd, url, _ = serve(run_dir, open_browser=False)
try:
req = urllib.request.Request( # noqa: S310 - localhost test server
f"{url}/api/event",
data=json.dumps({"event": "agent_steered"}).encode(),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req) as resp: # noqa: S310
assert resp.status == 204
assert seen == [True]
finally:
httpd.shutdown()
httpd.server_close()
def test_feedback_records_telemetry_on_success(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
run_dir = _make_run(tmp_path, "fbtel", status="running", end_time=None)
_bundle(tmp_path, monkeypatch)
sent: list[bool] = []
monkeypatch.setattr("strix.viewer.auth.feedback_submit", lambda *_a: None)
monkeypatch.setattr(
"strix.telemetry.posthog.viewer_feedback_submitted", lambda: sent.append(True)
)
httpd, url, token = serve(run_dir, open_browser=False)
try:
cookie = _session_cookie(url, token)
# A successful, session-holding submission relays and records telemetry.
status, _ = _post(
url, "/api/feedback", {"email": "a@b.com", "message": "hi"}, cookie=cookie
)
assert status == 200
assert sent == [True]
# A cookie-less caller is rejected and records nothing.
sent.clear()
status, _ = _post(url, "/api/feedback", {"email": "a@b.com", "message": "hi"})
assert status == 403
assert sent == []
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, token = serve(run_dir, open_browser=False)
try:
cookie = _session_cookie(url, token)
_, _, body = _get(f"{url}/api/auth/status", cookie=cookie)
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", cookie=cookie)
assert json.loads(body)["verified"] is False
# A cookie-less caller never sees the cached email or verified state.
verified["value"] = True
_, _, body = _get(f"{url}/api/auth/status")
assert json.loads(body) == {"verified": False, "email": None}
finally:
httpd.shutdown()
httpd.server_close()
def test_auth_mutations_require_session(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
run_dir = _make_run(tmp_path, "authmut", status="running", end_time=None)
_bundle(tmp_path, monkeypatch)
forgotten = {"value": False}
monkeypatch.setattr("strix.viewer.auth.forget", lambda: forgotten.update(value=True))
httpd, url, _ = serve(run_dir, open_browser=False)
try:
for path in ("/api/auth/forget", "/api/auth/otp/start", "/api/auth/otp/verify"):
status, _ = _post(url, path, {"email": "a@b.com", "code": "123456"})
assert status == 403, path
assert forgotten["value"] 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_report_send_rejects_live_run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
# A running scan would only produce a partial report, so the endpoint must
# fail closed even for a verified, session-holding caller.
run_dir = _make_run(tmp_path, "live", status="running", end_time=None)
_bundle(tmp_path, monkeypatch)
monkeypatch.setattr("strix.viewer.auth.read_auth", lambda: {"email": "a@b.com", "token": "t"})
httpd, url, token = serve(run_dir, open_browser=False)
try:
status, _ = _post(url, "/api/report/send", {}, cookie=_session_cookie(url, token))
assert status == 409
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_runs_list_requires_session_and_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)
monkeypatch.setattr("strix.viewer.auth.is_verified", lambda: True)
def _runs(cookie: str | None) -> dict[str, object]:
headers = {"Cookie": cookie} if cookie else {}
req = urllib.request.Request(f"{url}/api/runs", headers=headers) # noqa: S310
with urllib.request.urlopen(req) as resp: # noqa: S310 - localhost test server
return dict(json.loads(resp.read()))
httpd, url, token = serve(launched, open_browser=False)
try:
# A cookie-less caller (even with the machine verified) only sees the
# teaser count, never the run entries.
payload = _runs(None)
assert payload["locked"] is True
assert payload["count"] == 2
assert payload["runs"] == []
# With the session cookie and verification, the entries unlock.
payload = _runs(_session_cookie(url, token))
assert payload["locked"] is False
assert {r["name"] for r in payload["runs"]} == {"launched", "other"} # type: ignore[attr-defined]
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
+1571 -1472
View File
File diff suppressed because it is too large Load Diff