diff --git a/containers/Dockerfile b/containers/Dockerfile index a2f4f8fa..4b3c84d2 100644 --- a/containers/Dockerfile +++ b/containers/Dockerfile @@ -1,3 +1,26 @@ +# --------------------------------------------------------------------------- +# Builder stage: compile the Go tools here so the Go toolchain (~225MB) and the +# module/build caches never reach the runtime image. The resulting binaries are +# statically linked and copied into the final stage. +# --------------------------------------------------------------------------- +FROM kalilinux/kali-rolling:latest AS gobuilder + +RUN apt-get update && \ + apt-get install -y kali-archive-keyring && \ + apt-get update && \ + apt-get install -y --no-install-recommends golang-go git ca-certificates + +ENV GOBIN=/out/bin +RUN mkdir -p /out/bin && \ + go install -v github.com/projectdiscovery/httpx/cmd/httpx@latest && \ + go install -v github.com/projectdiscovery/katana/cmd/katana@latest && \ + go install -v github.com/projectdiscovery/cvemap/cmd/vulnx@latest && \ + go install -v github.com/jaeles-project/gospider@latest && \ + go install -v github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest + +# --------------------------------------------------------------------------- +# Runtime stage +# --------------------------------------------------------------------------- FROM kalilinux/kali-rolling:latest LABEL description="AI Agent Penetration Testing Environment with Comprehensive Automated Tools" @@ -19,14 +42,13 @@ RUN apt-get update && \ apt-get install -y --no-install-recommends \ wget curl git vim nano unzip tar \ apt-transport-https ca-certificates gnupg lsb-release \ - build-essential software-properties-common \ - gcc libc6-dev pkg-config libpcap-dev libssl-dev \ - python3 python3-pip python3-dev python3-venv python3-setuptools \ - golang-go \ + software-properties-common \ + gcc libc6-dev \ + python3 python3-pip python3-venv python3-setuptools \ net-tools dnsutils whois \ file xxd \ jq parallel ripgrep grep \ - less man-db procps htop \ + less procps htop \ iproute2 iputils-ping netcat-traditional \ nmap ncat ndiff \ sqlmap nuclei subfinder naabu ffuf \ @@ -66,11 +88,8 @@ RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/b USER pentester WORKDIR /tmp -RUN go install -v github.com/projectdiscovery/httpx/cmd/httpx@latest && \ - go install -v github.com/projectdiscovery/katana/cmd/katana@latest && \ - go install -v github.com/projectdiscovery/cvemap/cmd/vulnx@latest && \ - go install -v github.com/jaeles-project/gospider@latest && \ - go install -v github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest +# Go tools are built in the gobuilder stage; copy the static binaries only. +COPY --from=gobuilder --chown=pentester:pentester /out/bin/ /home/pentester/go/bin/ RUN nuclei -update-templates @@ -87,7 +106,10 @@ RUN npm install -g retire@latest && \ npm install -g js-beautify@latest && \ npm install -g @ast-grep/cli@latest && \ npm install -g tree-sitter-cli@latest && \ - npm install -g agent-browser@0.26.0 + npm install -g agent-browser@0.26.0 && \ + npm cache clean --force && \ + # ast-grep ships two identical binaries (`ast-grep` and `sg`); dedupe (~52MB) + ln -sf ast-grep /home/pentester/.npm-global/lib/node_modules/@ast-grep/cli/sg ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium ENV AGENT_BROWSER_USER_AGENT="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" @@ -132,7 +154,14 @@ RUN git clone https://github.com/aravind0x7/JS-Snooper.git && \ USER root -RUN curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh -s -- -b /usr/local/bin +# Install trufflehog into a pentester-owned dir on PATH so its runtime self-update +# (which replaces the binary in place) succeeds: as non-root `pentester` it cannot +# overwrite a root-owned binary under /usr/local/bin, which otherwise fails with +# "cannot move binary" and aborts the scan. Pin the initial version for +# reproducible builds; self-update then pulls fresh detectors at runtime. +ARG TRUFFLEHOG_VERSION=3.95.9 +RUN curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh -s -- -b /home/pentester/.local/bin "v${TRUFFLEHOG_VERSION}" && \ + chown -R pentester:pentester /home/pentester/.local RUN set -eux; \ ARCH="$(uname -m)"; \ case "$ARCH" in \ @@ -146,8 +175,6 @@ RUN set -eux; \ install -m 0755 /tmp/gitleaks /usr/local/bin/gitleaks; \ rm -f /tmp/gitleaks /tmp/gitleaks.tgz -RUN apt-get update && apt-get install -y zaproxy - RUN curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin RUN apt-get install -y wapiti @@ -163,7 +190,12 @@ USER root RUN apt-get autoremove -y && \ apt-get autoclean && \ - rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \ + # Purge non-English locales (~160MB) + find /usr/share/locale -mindepth 1 -maxdepth 1 -type d \ + ! -name 'en' ! -name 'en_US' ! -name 'C' -exec rm -rf {} + && \ + # Remove package documentation and man pages not needed at runtime (~95MB) + rm -rf /usr/share/doc/* /usr/share/doc-base/* /usr/share/man/* ENV PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:/app/.venv/bin:$PATH" ENV VIRTUAL_ENV="/app/.venv" diff --git a/pyproject.toml b/pyproject.toml index 04b23833..24ad7122 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "strix-agent" -version = "1.2.0" +version = "1.3.1" description = "Open-source AI Hackers for your apps" readme = "README.md" license = "Apache-2.0" @@ -46,7 +46,9 @@ dependencies = [ "caido-sdk-client>=0.2.0", "reportlab>=4.0", "pypdf>=5.0", - "cryptography>=42", + # Cap <49: 49.x drops the universal2 macOS wheel (arm64-only), which breaks + # the Intel macOS (macos-x86_64) release build's `uv sync --frozen`. + "cryptography>=48.0.1,<49", ] [project.optional-dependencies] diff --git a/scripts/install.sh b/scripts/install.sh index d45c325f..2024dd87 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -4,7 +4,7 @@ set -euo pipefail APP=strix REPO="usestrix/strix" -STRIX_IMAGE="ghcr.io/usestrix/strix-sandbox:1.0.0" +STRIX_IMAGE="ghcr.io/usestrix/strix-sandbox:1.1.0" MUTED='\033[0;2m' RED='\033[0;31m' diff --git a/strix/agents/prompt.py b/strix/agents/prompt.py index 8000eb09..20f10d0f 100644 --- a/strix/agents/prompt.py +++ b/strix/agents/prompt.py @@ -91,6 +91,7 @@ def render_system_prompt( loaded_skill_names=list(skill_content.keys()), available_skills=get_available_skills(), interactive=interactive, + is_root=is_root, system_prompt_context=system_prompt_context or {}, **skill_content, ) diff --git a/strix/agents/prompts/system_prompt.jinja b/strix/agents/prompts/system_prompt.jinja index 0ebd7c79..39079c52 100644 --- a/strix/agents/prompts/system_prompt.jinja +++ b/strix/agents/prompts/system_prompt.jinja @@ -1,5 +1,14 @@ You are Strix, an advanced AI application security validation agent developed by OmniSecure Labs. Your purpose is to perform authorized security verification, reproduce and validate weaknesses on in-scope assets, and help remediate real security issues. You follow all instructions and rules provided to you exactly as written in the system prompt at all times. +{% if is_root %} + +YOU ARE THE ROOT AGENT. Your job is ORCHESTRATION, not hands-on testing. +- You accomplish security work by DELEGATING to specialized subagents via create_agent — you do NOT run scanners, crawlers, fuzzers, or send exploit/injection payloads yourself. +- IMPORTANT — how to read this prompt as root: the rest of this system prompt is written in the second person ("you") and describes the hands-on testing methodology (recon, mapping, scanning, payload spraying, PoC building, fixing). When you are the root agent, treat every such hands-on instruction as something you ensure gets done BY A SUBAGENT, not as a task you perform in your own turns. The "map the target", "recon first", "mandatory initial phases", and "spray payloads" directives are DELEGATION REQUIREMENTS for you — spawn recon/mapping/testing subagents to satisfy them. +- Do NOT probe endpoints, run "basic" or "quick" injection/XSS/etc. tests, or do exploratory scanning before delegating. Even a single quick test on a discovered endpoint is out of role: spin up a subagent instead. +- Your own turns should be spent on: reading scope/config, decomposing the target, spawning and monitoring subagents, tracking todos/notes/coverage, deciding next steps, and aggregating results into the final report. + +{% endif %} - Security assessment and vulnerability scanning @@ -125,10 +134,8 @@ WHITE-BOX TESTING (code provided): - Local execution, unit/integration testing, patch verification, and HTTP requests against locally started in-scope services are normal authorized white-box validation - If dynamically running the code proves impossible after exhaustive attempts, pivot to comprehensive static analysis. - Try to infer how to run the code based on its structure and content. -- FIX discovered vulnerabilities in code in same file. -- Test patches to confirm vulnerability removal. -- Do not stop until all reported vulnerabilities are fixed. -- Include code diff in final report. +- Derive the code fix as PART OF reporting, not as a separate later pass: create_vulnerability_report already requires the concrete patch inline (`code_locations` with verbatim `fix_before`/`fix_after` and `fix_pr_body`), so the reporting agent that analyzes the root cause is the one that produces the fix. Do NOT spawn a downstream agent afterwards to re-derive/re-apply the same patch. +- If you also apply and verify the patch in the repo (edit the file, re-test that the vulnerability is gone), do it in the same agent/turn while the analysis is fresh — right before or as part of filing the report — never as a second re-analysis pass. COMBINED MODE (code + deployed target present): - Treat this as static analysis plus dynamic testing simultaneously @@ -189,7 +196,7 @@ EFFICIENCY TACTICS: - For Caido proxy automation inside Python, explicitly import from `caido_api`: `from caido_api import list_requests, view_request, repeat_request, list_sitemap, view_sitemap_entry, scope_rules` -- Prefer established fuzzers/scanners where applicable: ffuf, sqlmap, zaproxy, nuclei, wapiti, arjun, httpx, katana, semgrep, bandit, trufflehog, nmap. Use scripts mainly to coordinate or validate around them, not to replace them without reason +- Prefer established fuzzers/scanners where applicable: ffuf, sqlmap, nuclei, wapiti, arjun, httpx, katana, semgrep, bandit, trufflehog, nmap. Use scripts mainly to coordinate or validate around them, not to replace them without reason - For trial-heavy vectors (SQLi, XSS, XXE, SSRF, RCE, auth/JWT, deserialization), DO NOT iterate payloads manually in the browser. Always spray payloads via Python scripts through `exec_command` or terminal tools. - When using established fuzzers/scanners, use the proxy for inspection where helpful - Generate/adapt large payload corpora: combine encodings (URL, unicode, base64), comment styles, wrappers, time-based/differential probes. Expand with wordlists/templates @@ -206,7 +213,7 @@ VALIDATION REQUIREMENTS: - Document complete attack chain - Keep going until you find something that matters - A vulnerability is ONLY considered reported when a reporting agent uses create_vulnerability_report (or create_dependency_report for known-CVE dependency/supply-chain findings) with full details. Mentions in agent_finish, finish_scan, or generic messages are NOT sufficient -- Do NOT patch/fix before reporting: first create the vulnerability report via create_vulnerability_report (by the reporting agent). Only after reporting is completed should fixing/patching proceed +- Reporting and fixing are ONE step, not two: when source is available, the reporting agent derives the concrete fix and files it INLINE via create_vulnerability_report (`code_locations` with `fix_before`/`fix_after` + `fix_pr_body`) — the report is not complete without it. Do NOT report first and then spawn a separate downstream agent to re-derive and re-apply the same patch; that just re-does the analysis and wastes tokens. (Do not silently patch a finding WITHOUT filing a report — the report, with its embedded fix, is the deliverable.) - DEDUPLICATION: The create_vulnerability_report tool uses LLM-based deduplication. If it rejects your report as a duplicate, DO NOT attempt to re-submit the same vulnerability. Accept the rejection and move on to testing other areas. The vulnerability has already been reported by another agent @@ -262,7 +269,9 @@ DISK & SCRATCH HYGIENE: - If disk gets tight or a write fails for space, check what's large under /workspace and clean up files from your own task; leave another agent's files unless you've confirmed they're no longer in use MANDATORY INITIAL PHASES: - +{% if is_root %} +- ROOT AGENT: these phases are mandatory for the assessment, but you MUST accomplish them by delegating to reconnaissance/mapping subagents — do NOT run recon, crawling, enumeration, or mapping tools in your own turns. Spawn the appropriate subagent(s) and track their coverage. +{% endif %} BLACK-BOX TESTING - PHASE 1 (RECON & MAPPING): - COMPLETE full reconnaissance: subdomain enumeration, port scanning, service detection - MAP entire attack surface: all endpoints, parameters, APIs, forms, inputs @@ -291,13 +300,14 @@ ROOT AGENT ROLE: - The root agent should coordinate strategy, delegate meaningful work, track progress, maintain todo lists, maintain notes, monitor subagent results, and decide next steps - The root agent should keep a clear view of overall coverage, uncovered attack surfaces, validation status, and reporting/fixing progress - The root agent should avoid spending its own iterations on detailed testing, payload execution, or deep target-specific investigation when that work can be delegated to specialized subagents -- The root agent may do lightweight triage, quick verification, or setup work when necessary to unblock delegation, but its default mode should be coordinator/controller +- The root agent may do orchestration-support work needed to delegate well — reading scope/config, inspecting workspace layout, reading subagent output/reports, and light bookkeeping. It must NOT do the actual security testing itself: no running scanners/fuzzers/crawlers, no sending injection/XSS/SSRF/etc. payloads, and no "basic" or "quick" probing of discovered endpoints. If a check requires touching the target, delegate it to a subagent rather than doing it yourself +- Its default and near-exclusive mode is coordinator/controller - Subagents should do the substantive testing, validation, reporting, and fixing work - The root agent is responsible for ensuring that work is broken down clearly, tracked, and completed across the agent tree 1. **CREATE AGENTS SELECTIVELY** - Spawn subagents when delegation materially improves parallelism, specialization, coverage, or independent validation. Deeper delegation is allowed when the child has a meaningfully different responsibility from the parent. Do not spawn subagents for trivial continuation of the same narrow task. 2. **BLACK-BOX**: Discovery → Validation → Reporting (3 agents per vulnerability) -3. **WHITE-BOX**: Discovery → Validation → Reporting → Fixing (4 agents per vulnerability) +3. **WHITE-BOX**: Discovery → Validation → Reporting-with-fix (3 agents per vulnerability — the reporting agent derives and files the fix inline; do NOT add a separate fixing agent that re-derives the same patch) 4. **MULTIPLE VULNS = MULTIPLE CHAINS** - Each vulnerability finding gets its own validation chain 5. **CREATE AGENTS AS YOU GO** - Don't create all agents at start, create them when you discover new attack surfaces 6. **ONE JOB PER AGENT** - Each agent has ONE specific task only @@ -316,8 +326,7 @@ BLACK-BOX (domain/URL only): WHITE-BOX (source code provided): - Found authentication code issues? → Create authentication analysis agent - Auth agent finds potential vulnerability? → Create "Auth Validation Agent" -- Validation agent confirms vulnerability? → Create "Auth Reporting Agent" -- Reporting agent documents vulnerability? → Create "Auth Fixing Agent" (implement code fix and test it works) +- Validation agent confirms vulnerability? → Create "Auth Reporting Agent" that files the report AND its inline fix (`code_locations` + `fix_pr_body`) in one shot — no separate fixing agent VULNERABILITY WORKFLOW (MANDATORY FOR EVERY FINDING): @@ -338,9 +347,11 @@ Authentication Code Agent finds weak password validation ↓ Spawns "Auth Validation Agent" (proves it's exploitable) ↓ -If valid → Spawns "Auth Reporting Agent" (creates vulnerability report) +If valid → Spawns "Auth Reporting Agent" (creates the vulnerability report + WITH the fix inline: code_locations fix_before/fix_after + fix_pr_body, + applying/verifying the patch in the same turn if desired) ↓ -Spawns "Auth Fixing Agent" (implements secure code fix) +STOP - no separate fixing agent; the fix was derived once, at report time ``` CRITICAL RULES: @@ -376,7 +387,7 @@ FOCUS PRINCIPLES: REALISTIC TESTING OUTCOMES: - **No Findings**: Agent completes testing but finds no vulnerabilities - **Validation Failed**: Initial finding was false positive, validation agent confirms it's not exploitable -- **Valid Vulnerability**: Validation succeeds, spawns reporting agent and then fixing agent (white-box) +- **Valid Vulnerability**: Validation succeeds, spawns a reporting agent that files the report with the fix inline (white-box) — no separate fixing agent PERSISTENCE IS MANDATORY: - Real vulnerabilities take TIME - expect to need 2000+ steps minimum @@ -401,7 +412,6 @@ VULNERABILITY ASSESSMENT: - nuclei - Vulnerability scanner with templates - sqlmap - SQL injection detection/exploitation - trivy - Container/dependency vulnerability scanner -- zaproxy - OWASP ZAP web app scanner - wapiti - Web vulnerability scanner WEB FUZZING & DISCOVERY: @@ -439,10 +449,10 @@ PROXY & INTERCEPTION: - Ignore Caido proxy-generated 50x HTML error pages; these are proxy issues (might happen when requesting a wrong host or SSL/TLS issues, etc). PROGRAMMING: -- Python 3, uv, Go, Node.js/npm +- Python 3, uv, Node.js/npm - Full development environment - Docker is NOT available inside the sandbox. Do not run docker; rely on provided tools to run locally. -- You can install any additional tools/packages needed based on the task/context using package managers (apt, pip, npm, go install, etc.) +- You can install any additional tools/packages needed based on the task/context using package managers (apt, pip, npm, etc.) Directories: - /workspace - where you should work. diff --git a/strix/config/settings.py b/strix/config/settings.py index 54be01e9..8e1cf871 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -47,7 +47,7 @@ class RuntimeSettings(BaseSettings): model_config = _BASE_CONFIG image: str = Field( - default="ghcr.io/usestrix/strix-sandbox:1.0.0", + default="ghcr.io/usestrix/strix-sandbox:1.1.0", alias="STRIX_IMAGE", ) backend: str = Field(default="docker", alias="STRIX_RUNTIME_BACKEND") diff --git a/strix/interface/main.py b/strix/interface/main.py index 27a2659a..8a019517 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -5,9 +5,9 @@ Strix Agent Interface import argparse import asyncio +import os import shutil import sys -import time from datetime import UTC, datetime from pathlib import Path @@ -33,6 +33,13 @@ from strix.config.models import ( from strix.core.paths import run_dir_for, runtime_state_dir from strix.interface.cli import run_cli from strix.interface.tui import run_tui +from strix.interface.update_check import ( + is_binary_install, + notify_update, + prompt_update_if_available, + self_update, + start_background_check, +) from strix.interface.utils import ( assign_workspace_subdirs, build_final_stats_text, @@ -448,6 +455,14 @@ Examples: version=f"strix {get_version()}", ) + parser.add_argument( + "--update", + action="store_true", + help="Update strix to the latest version and exit. Self-updates the " + "standalone binary install; for pip/pipx/uv installs, prints the " + "matching upgrade command instead.", + ) + parser.add_argument( "-t", "--target", @@ -566,6 +581,9 @@ Examples: args = parser.parse_args() + if args.update: + sys.exit(0 if self_update() else 1) + if args.instruction and args.instruction_file: parser.error( "Cannot specify both --instruction and --instruction-file. Use one or the other." @@ -725,9 +743,7 @@ 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, web_url: str | None = None -) -> None: +def display_completion_message(args: argparse.Namespace, results_path: Path) -> None: console = Console() report_state = get_global_report_state() @@ -766,28 +782,12 @@ def display_completion_message( 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]) + 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() @@ -818,6 +818,8 @@ def display_completion_message( "[#60a5fa]discord.gg/strix-ai[/]" ) console.print() + if not args.non_interactive: + notify_update(console) def pull_docker_image() -> None: @@ -889,6 +891,12 @@ def main() -> None: if args.config: apply_config_override(validate_config_file(args.config)) + start_background_check() + if not args.non_interactive and prompt_update_if_available(Console()): + if is_binary_install() and sys.platform != "win32": + os.execv(sys.executable, sys.argv) # noqa: S606 # nosec B606 + sys.exit(0) + check_docker_installed() pull_docker_image() @@ -979,39 +987,7 @@ def main() -> None: results_path = run_dir_for(args.run_name) - # 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() + display_completion_message(args, results_path) if args.non_interactive: report_state = get_global_report_state() diff --git a/strix/interface/update_check.py b/strix/interface/update_check.py new file mode 100644 index 00000000..56ed4cd5 --- /dev/null +++ b/strix/interface/update_check.py @@ -0,0 +1,389 @@ +"""Update notifications and self-update for the strix CLI. + +Follows the pattern used by tools like gh, uv, and pip: a background, +rate-limited (once per 24h) check against the release source, a cached +result in ``~/.strix``, a non-intrusive notice with the upgrade command +for the detected install method, and a ``strix --update`` self-update +path for the standalone binary install. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +import platform +import shutil +import stat +import subprocess +import sys +import tarfile +import tempfile +import threading +import time +import zipfile +from pathlib import Path +from typing import cast + +import requests +from rich.console import Console +from rich.prompt import Prompt + +from strix.telemetry._common import get_version + + +logger = logging.getLogger(__name__) + +GITHUB_REPO = "usestrix/strix" +PYPI_PACKAGE = "strix-agent" +CHECK_INTERVAL_SECONDS = 24 * 60 * 60 +REQUEST_TIMEOUT_SECONDS = 5 + +_CACHE_PATH = Path.home() / ".strix" / "update-check.json" + +_background_thread: threading.Thread | None = None + + +def _is_disabled() -> bool: + return bool(os.environ.get("STRIX_NO_UPDATE_CHECK")) or any( + os.environ.get(key) + for key in ("CI", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS_URL", "BUILDKITE", "CIRCLECI") + ) + + +def is_binary_install() -> bool: + return bool(getattr(sys, "frozen", False)) + + +def get_install_method() -> str: + if is_binary_install(): + return "binary" + prefix = str(Path(sys.prefix)).replace("\\", "/") + if "/pipx/" in prefix or prefix.endswith("/pipx"): + return "pipx" + if "/uv/tools/" in prefix: + return "uv" + return "pip" + + +def get_upgrade_command(method: str | None = None) -> str: + method = method or get_install_method() + commands = { + "binary": "strix --update", + "pipx": "pipx upgrade strix-agent", + "uv": "uv tool upgrade strix-agent", + "pip": "pip install --upgrade strix-agent", + } + return commands[method] + + +def _parse_version(value: str) -> tuple[int, ...] | None: + parts = value.strip().lstrip("v").split(".") + try: + return tuple(int(part) for part in parts) + except ValueError: + return None + + +def _is_newer(latest: str, current: str) -> bool: + latest_parts = _parse_version(latest) + current_parts = _parse_version(current) + if latest_parts is None or current_parts is None: + return False + return latest_parts > current_parts + + +def _fetch_latest_version() -> str | None: + try: + if is_binary_install(): + response = requests.get( + f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest", + timeout=REQUEST_TIMEOUT_SECONDS, + ) + response.raise_for_status() + tag = response.json().get("tag_name", "") + return tag.lstrip("v") or None + response = requests.get( + f"https://pypi.org/pypi/{PYPI_PACKAGE}/json", + timeout=REQUEST_TIMEOUT_SECONDS, + ) + response.raise_for_status() + version = response.json().get("info", {}).get("version") + return str(version) if version else None + except Exception: # noqa: BLE001 + logger.debug("update check failed", exc_info=True) + return None + + +def _fetch_asset_digest(version: str, filename: str) -> str | None: + """Return the expected sha256 (hex) for a release asset, if the API provides one.""" + try: + response = requests.get( + f"https://api.github.com/repos/{GITHUB_REPO}/releases/tags/v{version}", + timeout=REQUEST_TIMEOUT_SECONDS, + ) + response.raise_for_status() + for asset in response.json().get("assets", []): + if asset.get("name") == filename: + digest = asset.get("digest") or "" + if digest.startswith("sha256:"): + return digest.removeprefix("sha256:") + except Exception: # noqa: BLE001 + logger.debug("release asset digest lookup failed", exc_info=True) + return None + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _read_cache() -> dict[str, object]: + try: + with _CACHE_PATH.open(encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict): + return cast("dict[str, object]", data) + except Exception: # noqa: BLE001, S110 + pass # nosec B110 + return {} + + +def _write_cache(**fields: object) -> None: + try: + cache = _read_cache() + cache.update(fields) + _CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) + _CACHE_PATH.write_text(json.dumps(cache), encoding="utf-8") + except Exception: # noqa: BLE001, S110 + pass # nosec B110 + + +def skip_version(version: str) -> None: + """Remember not to prompt again for this version (newer releases still notify).""" + _write_cache(skipped_version=version) + + +def _refresh_cache() -> None: + latest = _fetch_latest_version() + if latest: + _write_cache(latest_version=latest, checked_at=time.time()) + + +def start_background_check() -> None: + """Refresh the cached latest-version info in a daemon thread (at most once per 24h).""" + global _background_thread # noqa: PLW0603 + if _is_disabled(): + return + cache = _read_cache() + checked_at = cache.get("checked_at") + if isinstance(checked_at, int | float) and time.time() - checked_at < CHECK_INTERVAL_SECONDS: + return + _background_thread = threading.Thread(target=_refresh_cache, daemon=True) + _background_thread.start() + + +def get_available_update(*, respect_skip: bool = True) -> str | None: + """Return the newer version from the cache, or None if up to date / unknown.""" + if _is_disabled(): + return None + if _background_thread is not None: + _background_thread.join(timeout=0.2) + cache = _read_cache() + latest = cache.get("latest_version") + current = get_version() + if not isinstance(latest, str) or current == "unknown" or not _is_newer(latest, current): + return None + if respect_skip and cache.get("skipped_version") == latest: + return None + return latest + + +def notify_update(console: Console) -> None: + latest = get_available_update() + if not latest: + return + console.print( + f"[#eab308]A new version of strix is available:[/] " + f"[dim]{get_version()}[/] [dim]→[/] [bold #22c55e]{latest}[/]" + f" [dim]·[/] [#60a5fa]{get_upgrade_command()}[/]" + ) + console.print() + + +def run_package_upgrade(console: Console, method: str) -> bool: + """Upgrade a package-manager install by running its upgrade command.""" + command = get_upgrade_command(method).split() + console.print(f"[dim]Running[/] [#60a5fa]{' '.join(command)}[/]") + try: + result = subprocess.run(command, check=False) # noqa: S603 + except OSError as e: + console.print(f"[bold red]Update failed:[/] {e}") + return False + if result.returncode != 0: + console.print( + f"[bold red]Update failed[/] [dim](exit code {result.returncode}).[/] " + f"Run it manually: [#60a5fa]{get_upgrade_command(method)}[/]" + ) + return False + console.print("[#22c55e]✓ strix updated — restart the scan to use the new version[/]") + return True + + +def prompt_update_if_available(console: Console) -> bool: + """Offer an interactive update before a scan starts. + + Returns True if strix was updated (caller should re-exec / exit). + """ + latest = get_available_update() + if not latest or not sys.stdin.isatty() or not sys.stdout.isatty(): + return False + console.print() + console.print( + f"[#eab308]A new version of strix is available:[/] " + f"[dim]{get_version()}[/] [dim]→[/] [bold #22c55e]{latest}[/]" + ) + console.print( + "[dim] y — update now n — not now (ask again next run) s — skip this version[/]" + ) + choice = Prompt.ask("Update strix?", choices=["y", "n", "s"], default="n") + console.print() + if choice == "s": + skip_version(latest) + return False + if choice != "y": + return False + method = get_install_method() + if method == "binary": + return self_update(console, version=latest) + return run_package_upgrade(console, method) + + +def _release_target() -> str | None: + raw_os = platform.system().lower() + os_name = {"darwin": "macos", "linux": "linux", "windows": "windows"}.get(raw_os) + arch = platform.machine().lower() + arch = {"aarch64": "arm64", "amd64": "x86_64"}.get(arch, arch) + if os_name is None: + return None + target = f"{os_name}-{arch}" + supported = {"linux-x86_64", "macos-x86_64", "macos-arm64", "windows-x86_64"} + return target if target in supported else None + + +def _download_and_replace(version: str, target: str, console: Console) -> bool: + is_windows = target.startswith("windows") + archive_ext = ".zip" if is_windows else ".tar.gz" + filename = f"strix-{version}-{target}{archive_ext}" + url = f"https://github.com/{GITHUB_REPO}/releases/download/v{version}/{filename}" + binary_name = f"strix-{version}-{target}" + (".exe" if is_windows else "") + current_exe = Path(sys.executable).resolve() + + with tempfile.TemporaryDirectory() as tmp: + tmp_dir = Path(tmp) + archive_path = tmp_dir / filename + console.print(f"[dim]Downloading[/] {url}") + with requests.get( # nosec B113 + url, + stream=True, + timeout=REQUEST_TIMEOUT_SECONDS * 12, + ) as response: + response.raise_for_status() + with archive_path.open("wb") as f: + for chunk in response.iter_content(chunk_size=1 << 20): + f.write(chunk) + + expected_digest = _fetch_asset_digest(version, filename) + if expected_digest: + actual_digest = _sha256_file(archive_path) + if actual_digest != expected_digest: + raise RuntimeError( + f"checksum mismatch for {filename}: " + f"expected sha256 {expected_digest}, got {actual_digest}" + ) + else: + console.print("[dim yellow]No published checksum available; skipping verification[/]") + + if is_windows: + with zipfile.ZipFile(archive_path) as zf: + zf.extract(binary_name, tmp_dir) + else: + with tarfile.open(archive_path, "r:gz") as tf: + tf.extract(binary_name, tmp_dir, filter="data") + + new_binary = tmp_dir / binary_name + new_binary.chmod(new_binary.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + staged = current_exe.with_name(current_exe.name + ".new") + try: + shutil.copy2(new_binary, staged) + if is_windows: + # Windows can't replace a running executable in place; move it aside first. + old = current_exe.with_name(current_exe.name + ".old") + old.unlink(missing_ok=True) + current_exe.rename(old) + try: + staged.replace(current_exe) + except Exception: + old.rename(current_exe) + raise + else: + staged.replace(current_exe) + except Exception: + staged.unlink(missing_ok=True) + raise + return True + + +def self_update(console: Console | None = None, version: str | None = None) -> bool: + """Replace the running standalone binary with the latest release. + + Returns True on success. For package-manager installs this only + prints the right upgrade command and returns False. + """ + console = console or Console() + + if not is_binary_install(): + method = get_install_method() + console.print( + f"[#eab308]This strix was installed via {method};[/] " + f"upgrade it with: [#60a5fa]{get_upgrade_command(method)}[/]" + ) + return False + + latest = version or _fetch_latest_version() + if not latest: + console.print("[bold red]Could not determine the latest strix version.[/]") + return False + + current = get_version() + if current != "unknown" and not _is_newer(latest, current): + console.print(f"[#22c55e]strix {current} is already the latest version.[/]") + return True + + target = _release_target() + if not target: + console.print( + f"[bold red]No prebuilt binary for this platform " + f"({platform.system()}/{platform.machine()}).[/]" + ) + return False + + try: + _download_and_replace(latest, target, console) + except Exception as e: # noqa: BLE001 + logger.debug("self-update failed", exc_info=True) + console.print(f"[bold red]Update failed:[/] {e}") + console.print( + "[dim]You can reinstall manually with:[/] " + "[#60a5fa]curl -sSL https://strix.ai/install | bash[/]" + ) + return False + + _write_cache(latest_version=latest, checked_at=time.time()) + console.print(f"[#22c55e]✓ Updated strix to {latest}[/]") + return True diff --git a/strix/skills/coordination/root_agent.md b/strix/skills/coordination/root_agent.md index 981c3b97..778e6d87 100644 --- a/strix/skills/coordination/root_agent.md +++ b/strix/skills/coordination/root_agent.md @@ -5,7 +5,7 @@ description: Orchestration layer that coordinates specialized subagents for secu # Root Agent -Orchestration layer for security assessments. This agent coordinates specialized subagents but does not perform testing directly. +Orchestration layer for security assessments. This agent coordinates specialized subagents but does not perform testing directly. You never run scanners, crawlers, or fuzzers and never send exploit/injection payloads yourself — not even a quick "basic" test on a discovered endpoint. Any work that touches the target is delegated to a subagent. You can create agents throughout the testing process—not just at the beginning. Spawn agents dynamically based on findings and evolving scope. @@ -18,7 +18,7 @@ You can create agents throughout the testing process—not just at the beginning ## Scope Decomposition -Before spawning agents, analyze the target: +Before spawning agents, analyze the target from the scan config/scope and any provided context (and, once recon subagents report, from their results) — not by running recon tools yourself: 1. **Identify attack surfaces** - web apps, APIs, infrastructure, etc. 2. **Define boundaries** - in-scope domains, IP ranges, excluded assets @@ -72,8 +72,7 @@ Before creating agents: Complex findings warrant specialized subagents: - Discovery agent finds potential vulnerability - Validation agent confirms exploitability -- Reporting agent documents with reproduction steps -- Fix agent provides remediation (if needed) +- Reporting agent documents with reproduction steps AND supplies the fix inline (the report tool carries the patch via `code_locations`/`fix_pr_body`) — do not add a separate fix agent that re-derives the same patch **Resource Efficiency** diff --git a/strix/telemetry/posthog.py b/strix/telemetry/posthog.py index ef3c8d4f..43db11f2 100644 --- a/strix/telemetry/posthog.py +++ b/strix/telemetry/posthog.py @@ -180,6 +180,14 @@ def viewer_email_event(step: str, purpose: str | None = None) -> None: ) +def viewer_feedback_submitted() -> None: + _send("viewer_feedback_submitted", {**base_props()}) + + +def viewer_agent_steered() -> None: + _send("viewer_agent_steered", {**base_props()}) + + def error(error_type: str) -> None: props = {**base_props(), "error_type": error_type} _send("error", props) diff --git a/strix/viewer/auth.py b/strix/viewer/auth.py index 1c7c5fef..c25d12b6 100644 --- a/strix/viewer/auth.py +++ b/strix/viewer/auth.py @@ -207,6 +207,26 @@ def otp_verify(email: str, code: str) -> dict[str, Any]: raise RelayError("unavailable") +def feedback_submit(email: str, message: str) -> None: + """Relay a feedback message + email to Strix. No verification is required; + the email is taken as given. Raises RelayError on failure.""" + status, data = _post_json( + "/api/oss/feedback", + {"email": email, "message": message}, + timeout=_OTP_TIMEOUT, + ) + if status == 200: + return + if status == 429: + raise RelayError("rate_limited") + if status == 400: + code = data.get("error") + if code in ("invalid_email", "invalid_message"): + raise RelayError(str(code)) + raise RelayError("invalid_message") + raise RelayError("unavailable") + + def report_send( token: str, pdf_bytes: bytes, @@ -241,6 +261,7 @@ def report_send( __all__ = [ "AUTH_PATH", "RelayError", + "feedback_submit", "forget", "is_verified", "otp_start", diff --git a/strix/viewer/frontend/package-lock.json b/strix/viewer/frontend/package-lock.json index 1c74cd0c..9f86e348 100644 --- a/strix/viewer/frontend/package-lock.json +++ b/strix/viewer/frontend/package-lock.json @@ -16,6 +16,7 @@ "lucide-react": "^0.563.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-icons": "^5.7.0", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0" @@ -62,6 +63,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -919,9 +921,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -936,9 +935,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -953,9 +949,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -970,9 +963,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -987,9 +977,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1004,9 +991,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1021,9 +1005,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1038,9 +1019,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1055,9 +1033,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1072,9 +1047,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1089,9 +1061,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1106,9 +1075,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1123,9 +1089,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1349,9 +1312,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1369,9 +1329,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1389,9 +1346,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1409,9 +1363,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1654,6 +1605,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1664,6 +1616,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -1786,6 +1739,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", @@ -1966,6 +1920,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -2543,9 +2498,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2567,9 +2519,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2591,9 +2540,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2615,9 +2561,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3628,6 +3571,7 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3679,6 +3623,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -3688,6 +3633,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3695,6 +3641,15 @@ "react": "^19.2.7" } }, + "node_modules/react-icons": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz", + "integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -4154,6 +4109,7 @@ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/strix/viewer/frontend/package.json b/strix/viewer/frontend/package.json index 37eaff55..e79db76f 100644 --- a/strix/viewer/frontend/package.json +++ b/strix/viewer/frontend/package.json @@ -17,6 +17,7 @@ "lucide-react": "^0.563.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-icons": "^5.7.0", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0" diff --git a/strix/viewer/frontend/src/App.tsx b/strix/viewer/frontend/src/App.tsx index 8afc025c..042d54ac 100644 --- a/strix/viewer/frontend/src/App.tsx +++ b/strix/viewer/frontend/src/App.tsx @@ -2,14 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ArrowLeft, AlertCircle, - Waypoints, + Bot, Mail, ChevronDown, - Wrench, - FileCheck2, - CalendarClock, Radar, - GitPullRequest, Rocket, ArrowUpRight, History, @@ -45,11 +41,10 @@ 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"; +import FeedbackView from "@/components/FeedbackView"; +import { ProInlineCta } from "@/components/ProCta"; -export type View = "overview" | "issues" | "agents" | "history" | "feature" | "email"; +export type View = "overview" | "issues" | "agents" | "history" | "email" | "feedback"; const TRUST_BANNER = "Your findings stay on your machine. They're rendered here locally in your browser and never uploaded or stored by Strix."; @@ -57,25 +52,12 @@ const TRUST_BANNER = 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(null); const [run, setRun] = useState(null); const [error, setError] = useState(null); const [selectedId, setSelectedId] = useState(null); const [view, setView] = useState("overview"); - const [activeFeature, setActiveFeature] = useState(null); const [auth, setAuth] = useState(null); const [runs, setRuns] = useState(null); const [emailPurpose, setEmailPurpose] = useState<"report" | "verify">("report"); @@ -249,12 +231,6 @@ export default function App() { 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(); @@ -266,11 +242,13 @@ export default function App() { { + // Clicking a sidebar view always lands on that section's top level, + // so leaving a specific issue's detail view and clicking "Issues" + // returns to the full findings list. + setSelectedId(null); if (v === "history") openHistory(); else userSetView(v); }} - activeFeature={activeFeature} - onSelectFeature={selectFeature} issuesCount={run?.vulnerabilities.length ?? 0} agentCount={agentCount} runCount={runs?.count ?? 0} @@ -285,7 +263,7 @@ export default function App() {
{/* Top bar */}
-
+
Strix
- Local results {run && }
{verified && runs && !runs.locked && runs.runs.length > 0 && ( @@ -322,14 +299,20 @@ export default function App() {
-
- {error && !run && view !== "history" && view !== "email" && view !== "feature" && ( +
+ {error && !run && view !== "history" && view !== "email" && (
)} + {/* Keyed wrapper: re-mounts on every view / finding / run change so the + page-in transition replays. */} +
{view === "email" ? ( setView(dest === "history" ? "history" : "overview")} /> - ) : view === "feature" && activeFeature && FEATURES[activeFeature] ? ( - + ) : view === "feedback" ? ( + setView(dest)} + /> ) : view === "history" ? (
@@ -369,7 +355,7 @@ export default function App() { {/* Tab strip: shown on small screens where the sidebar is hidden. */}
userSetView("overview")}> - Overview + Pentest Overview userSetView("issues")}> Issues{run.vulnerabilities.length > 0 ? ` (${run.vulnerabilities.length})` : ""} @@ -412,6 +398,7 @@ export default function App() { )} ) : null} +
@@ -438,33 +425,37 @@ function RunSwitcher({ {open && (
+
+ Switch pentest +
{runs.runs.map((r) => { const active = r.name === activeRun; return ( ); })} @@ -508,7 +499,7 @@ function SummaryHeader({ summary }: { summary: ParsedRunSummary }) { return (

- {runTitle(summary.targets[0] ?? null, summary.runName ?? summary.runId ?? "Scan results")} + {runTitle(summary.targets[0] ?? null, summary.runName ?? summary.runId ?? "Pentest results")}

{summary.targets.length > 0 && ( @@ -547,7 +538,7 @@ function FindingsList({ return (
- {finished ? "No findings in this run." : "No findings yet. The scan is still running…"} + {finished ? "No findings in this run." : "No findings yet. The pentest is still running…"}
{finished && (
@@ -573,7 +564,7 @@ function FindingsList({
- Email report + Export report to PDF
@@ -673,26 +664,32 @@ function OverviewTab({ return (
- +
+ +
{total > 0 && ( -
+
)} {/* Primary CTA: the one primary on Overview. Hidden until the run is finished, since a live scan would only email a partial report. */} - {finished && } + {finished && ( +
+ +
+ )} {sections.length > 0 ? ( -
+
{sections.map((s) => ( ))}
) : reportMarkdown ? ( -
+
) : ( @@ -701,22 +698,6 @@ function OverviewTab({ ) )} - {/* Near Recommendations: act on the fixes. */} -
- {RECOMMENDATION_CTAS.map((item) => ( - - ))} -
- - {/* Continuous coverage for your org (restyled upsell tiles). */} -
-

Continuous coverage for your org

-
- {COVERAGE_CTAS.map((item) => ( - - ))} -
-
); } @@ -746,8 +727,7 @@ function TabButton({ 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. + // Clicking a graph node opens the agent's transcript in a modal; no node selected means no modal. const [selectedId, setSelectedId] = useState(null); const selectedAgent = selectedId ? (agents.find((a) => a.id === selectedId) ?? null) : null; @@ -758,7 +738,7 @@ function AgentsTab({ run, canSteer }: { run: LoadedRun; canSteer: boolean }) {
-