mirror of
https://github.com/usestrix/strix.git
synced 2026-10-13 03:57:59 +00:00
Merge remote-tracking branch 'origin/main' into codex/http-evidence
This commit is contained in:
commit
8ca05a9d5f
30 changed files with 638 additions and 330 deletions
29
README.md
29
README.md
|
|
@ -17,6 +17,9 @@
|
|||
<a href="https://strix.ai"><img src="https://img.shields.io/badge/Website-strix.ai-f0f0f0?style=for-the-badge&logoColor=000000" alt="Website"></a>
|
||||
[](https://discord.gg/strix-ai)
|
||||
|
||||
<a href="https://app.strix.ai?utm_source=github&utm_medium=readme&utm_content=badge_cloud"><img src="https://img.shields.io/badge/Strix%20Cloud-app.strix.ai-2b9246?style=for-the-badge&logoColor=white" alt="Strix Cloud"></a>
|
||||
<a href="https://strix.ai/demo?utm_source=github&utm_medium=readme&utm_content=badge_demo"><img src="https://img.shields.io/badge/Try%20Strix%20Enterprise-555555?style=for-the-badge&logoColor=white" alt="Try Strix Enterprise"></a>
|
||||
|
||||
<a href="https://deepwiki.com/usestrix/strix"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
|
||||
<a href="https://github.com/usestrix/strix"><img src="https://img.shields.io/github/stars/usestrix/strix?style=flat-square" alt="GitHub Stars"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-Apache%202.0-3b82f6?style=flat-square" alt="License"></a>
|
||||
|
|
@ -34,7 +37,7 @@
|
|||
|
||||
|
||||
> [!TIP]
|
||||
> **New!** Strix integrates seamlessly with GitHub Actions and CI/CD pipelines. Automatically scan for vulnerabilities on every pull request and block insecure code before it reaches production - [Get started with no setup required](https://app.strix.ai).
|
||||
> **New!** Strix integrates seamlessly with GitHub Actions and CI/CD pipelines. Automatically scan for vulnerabilities on every pull request and block insecure code before it reaches production - [Get started with no setup required](https://app.strix.ai?utm_source=github&utm_medium=readme&utm_content=tip_ci).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -94,9 +97,17 @@ strix --target ./app-directory
|
|||
|
||||
---
|
||||
|
||||
## ☁️ Strix Platform
|
||||
## Ways to Run Strix
|
||||
|
||||
Try the Strix full-stack penetration testing platform at **[app.strix.ai](https://app.strix.ai)** - sign up for free, connect your repos and domains, and launch a pentest in minutes.
|
||||
- **Open Source** - free, runs locally with Docker and your own LLM key. [Quick Start](https://docs.strix.ai/quickstart)
|
||||
- **Strix Cloud** - no setup, validated findings, one-click autofix, and PR reviews. [Run a pentest →](https://app.strix.ai?intent=pentest&utm_source=github&utm_medium=readme&utm_content=table_cloud)
|
||||
- **Enterprise** - SSO, compliance-ready reports, VPC or self-hosted deployment. [Try Strix Enterprise →](https://strix.ai/demo?utm_source=github&utm_medium=readme&utm_content=table_demo)
|
||||
|
||||
---
|
||||
|
||||
## ☁️ Strix Cloud
|
||||
|
||||
Try the Strix full-stack penetration testing platform at **[app.strix.ai](https://app.strix.ai?utm_source=github&utm_medium=readme&utm_content=cloud_heading)** - sign up for free, connect your repos and domains, and launch a pentest in minutes.
|
||||
|
||||
- **Validated findings with PoCs** - every vulnerability includes a working proof-of-concept exploit and reproduction steps
|
||||
- **One-click autofix** - AI-generated security patches as ready-to-merge pull requests
|
||||
|
|
@ -104,7 +115,13 @@ Try the Strix full-stack penetration testing platform at **[app.strix.ai](https:
|
|||
- **DevSecOps integrations** - GitHub, GitLab, Bitbucket, Slack, Jira, Linear, and CI/CD pipelines
|
||||
- **Continuous learning** - AI that builds on past findings, adapts to your codebase, and reduces false positives over time
|
||||
|
||||
[**Start your first pentest →**](https://app.strix.ai)
|
||||
[**Run a pentest →**](https://app.strix.ai?intent=pentest&utm_source=github&utm_medium=readme&utm_content=cloud_cta)
|
||||
|
||||
## 🏢 Enterprise
|
||||
|
||||
Get the same Strix experience with enterprise-grade controls: SSO (SAML/OIDC), custom compliance-ready penetration testing reports (SOC 2, ISO 27001, PCI DSS), dedicated support and SLA, custom deployment options (VPC or self-hosted), BYOK model support, and tailored AI pentesting agents optimized for your environment.
|
||||
|
||||
[**Try Strix Enterprise →**](https://strix.ai/demo?utm_source=github&utm_medium=readme&utm_content=enterprise_cta)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -333,10 +350,6 @@ Each server's tools are namespaced by `name`, for example `github_list_issues`.
|
|||
|
||||
See the [LLM Providers documentation](https://docs.strix.ai/llm-providers/overview) for all supported providers including Vertex AI, Bedrock, Azure, and local models.
|
||||
|
||||
## Enterprise Pentesting
|
||||
|
||||
Get the same Strix experience with [enterprise-grade](https://strix.ai/demo) controls: SSO (SAML/OIDC), custom compliance-ready penetration testing reports (SOC 2, ISO 27001, PCI DSS), dedicated support & SLA, custom deployment options (VPC/self-hosted), BYOK model support, and tailored AI pentesting agents optimized for your environment. [Learn more](https://strix.ai/demo).
|
||||
|
||||
## Documentation
|
||||
|
||||
Full documentation is available at **[docs.strix.ai](https://docs.strix.ai)** - including detailed guides for usage, CI/CD integrations, skills, and advanced configuration.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "strix-agent"
|
||||
version = "1.6.1"
|
||||
version = "1.6.2"
|
||||
description = "Open-source AI Hackers for your apps"
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
|
|
|
|||
|
|
@ -346,6 +346,9 @@ echo -e "${MUTED}For more information visit ${NC}https://strix.ai"
|
|||
echo -e "${MUTED}Supported models ${NC}https://docs.strix.ai/llm-providers/overview"
|
||||
echo -e "${MUTED}Join our community ${NC}https://discord.gg/strix-ai"
|
||||
echo ""
|
||||
echo -e "${MUTED}Run a pentest in Strix Cloud ${NC}https://app.strix.ai"
|
||||
echo -e "${MUTED}Enterprise ${NC}https://strix.ai/demo"
|
||||
echo ""
|
||||
|
||||
echo -e "${YELLOW}→${NC} Run ${MUTED}source ~/.$(basename $SHELL)rc${NC} or open a new terminal"
|
||||
echo ""
|
||||
|
|
|
|||
|
|
@ -593,17 +593,27 @@ RECOMMENDED_MODEL_NAMES = (
|
|||
|
||||
_RECOMMENDED_MODEL_NAME_SET = frozenset(name.lower() for name in RECOMMENDED_MODEL_NAMES)
|
||||
|
||||
FRONTIER_MODEL_FAMILIES = (
|
||||
(("azure", "azure_ai", "bedrock_mantle", "chatgpt", "openai"), ("gpt-5",)),
|
||||
(
|
||||
("anthropic", "azure_ai", "bedrock", "claude", "databricks", "snowflake", "vertex_ai"),
|
||||
("claude-fable-5", "claude-opus-5", "claude-opus-4", "claude-sonnet-5", "claude-sonnet-4"),
|
||||
),
|
||||
(("google", "gemini", "vertex_ai"), ("gemini-3",)),
|
||||
(("deepseek",), ("deepseek-v4", "deepseek-r1", "deepseek-reasoner")),
|
||||
(("alibaba", "dashscope", "qwen"), ("qwen3.8", "qwen3.7", "qwen3-max")),
|
||||
(("moonshot", "moonshotai", "kimi"), ("kimi-k3", "kimi-k2.7", "kimi-k2.6")),
|
||||
(("zai", "z-ai", "zai-org", "zhipuai"), ("glm-5.3", "glm-5.2")),
|
||||
# Matched against the bare model name only: the route (``openai/``, ``openrouter/``,
|
||||
# a local gateway, ...) says nothing about the model's quality.
|
||||
FRONTIER_MODEL_PREFIXES = (
|
||||
"gpt-5",
|
||||
"claude-fable-5",
|
||||
"claude-opus-5",
|
||||
"claude-opus-4",
|
||||
"claude-sonnet-5",
|
||||
"claude-sonnet-4",
|
||||
"gemini-3",
|
||||
"deepseek-v4",
|
||||
"deepseek-r1",
|
||||
"deepseek-reasoner",
|
||||
"qwen3.8",
|
||||
"qwen3.7",
|
||||
"qwen3-max",
|
||||
"kimi-k3",
|
||||
"kimi-k2.7",
|
||||
"kimi-k2.6",
|
||||
"glm-5.3",
|
||||
"glm-5.2",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -837,11 +847,8 @@ def is_recommended_or_frontier_model(model_name: str) -> bool:
|
|||
return False
|
||||
if name in _RECOMMENDED_MODEL_NAME_SET:
|
||||
return True
|
||||
provider_name, bare_model_name = _split_model_provider(name)
|
||||
return any(
|
||||
_matches_frontier_family(provider_name, bare_model_name, provider_markers, prefixes)
|
||||
for provider_markers, prefixes in FRONTIER_MODEL_FAMILIES
|
||||
)
|
||||
bare_model_name = name.rsplit("/", 1)[-1]
|
||||
return _matches_model_prefix(bare_model_name, FRONTIER_MODEL_PREFIXES)
|
||||
|
||||
|
||||
def _normalized_model_name(model_name: str) -> str:
|
||||
|
|
@ -853,28 +860,6 @@ def _normalized_model_name(model_name: str) -> str:
|
|||
return name
|
||||
|
||||
|
||||
def _split_model_provider(model_name: str) -> tuple[str | None, str]:
|
||||
if "/" not in model_name:
|
||||
return None, model_name
|
||||
provider_name, bare_model_name = model_name.rsplit("/", 1)
|
||||
return provider_name, bare_model_name
|
||||
|
||||
|
||||
def _matches_frontier_family(
|
||||
provider_name: str | None,
|
||||
model_name: str,
|
||||
provider_markers: tuple[str, ...],
|
||||
model_prefixes: tuple[str, ...],
|
||||
) -> bool:
|
||||
if not _matches_model_prefix(model_name, model_prefixes):
|
||||
return False
|
||||
if provider_name is None:
|
||||
return True
|
||||
return _contains_provider_marker(
|
||||
provider_name, provider_markers, split_compound_names=True
|
||||
) or _contains_provider_marker(model_name, provider_markers)
|
||||
|
||||
|
||||
def _matches_model_prefix(model_name: str, model_prefixes: tuple[str, ...]) -> bool:
|
||||
return any(
|
||||
candidate.startswith(prefix)
|
||||
|
|
@ -892,16 +877,6 @@ def _model_name_candidates(model_name: str) -> tuple[str, ...]:
|
|||
return (model_name, *suffixes)
|
||||
|
||||
|
||||
def _contains_provider_marker(
|
||||
value: str, provider_markers: tuple[str, ...], *, split_compound_names: bool = False
|
||||
) -> bool:
|
||||
parts = set(value.replace(".", "/").split("/"))
|
||||
if split_compound_names:
|
||||
for separator in ("_", "-"):
|
||||
parts.update(piece for part in tuple(parts) for piece in part.split(separator))
|
||||
return any(marker in parts for marker in provider_markers)
|
||||
|
||||
|
||||
def is_known_openai_bare_model(model_name: str) -> bool:
|
||||
import litellm
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ from strix.core.paths import run_dir_for, runtime_state_dir
|
|||
from strix.core.sessions import open_agent_session
|
||||
from strix.report.state import get_global_report_state
|
||||
from strix.runtime import session_manager
|
||||
from strix.telemetry import set_scan_phase
|
||||
from strix.telemetry.logging import set_scan_id, setup_scan_logging
|
||||
from strix.tools.output_store import (
|
||||
WORKSPACE_SPILL_DIR,
|
||||
|
|
@ -116,6 +117,13 @@ def _record_mcp_connections(connections: list[ConnectedMcpServer]) -> None:
|
|||
report_state.record_mcp_connections([connection.name for connection in connections])
|
||||
|
||||
|
||||
def _note_exit_reason(reason: str) -> None:
|
||||
"""Record why the scan stopped so the end-of-scan beacon reports it."""
|
||||
report_state = get_global_report_state()
|
||||
if report_state is not None and report_state.scan_ended_exit_reason is None:
|
||||
report_state.scan_ended_exit_reason = reason
|
||||
|
||||
|
||||
def _persist_mcp_status(roster: list[dict[str, Any]]) -> None:
|
||||
"""Write the run's non-secret MCP connection status roster to run.json.
|
||||
|
||||
|
|
@ -313,6 +321,7 @@ async def run_strix_scan(
|
|||
root_id = uuid.uuid4().hex[:8]
|
||||
|
||||
logger.info("Bringing up sandbox session for scan %s", scan_id)
|
||||
set_scan_phase("sandbox_init")
|
||||
bundle = await session_manager.create_or_reuse(
|
||||
scan_id,
|
||||
image=image,
|
||||
|
|
@ -322,6 +331,7 @@ async def run_strix_scan(
|
|||
)
|
||||
report("Waiting for the first model response")
|
||||
logger.info("Sandbox ready for scan %s", scan_id)
|
||||
set_scan_phase("agent_setup")
|
||||
|
||||
sandbox_session = bundle["session"]
|
||||
|
||||
|
|
@ -573,6 +583,7 @@ async def run_strix_scan(
|
|||
async with coordinator._lock:
|
||||
root_status = coordinator.statuses.get(root_id)
|
||||
|
||||
set_scan_phase("agent_loop")
|
||||
result = await run_agent_loop(
|
||||
agent=root_agent,
|
||||
initial_input=initial_input,
|
||||
|
|
@ -610,6 +621,7 @@ async def run_strix_scan(
|
|||
return result # noqa: TRY300
|
||||
except BudgetExceededError as exc:
|
||||
logger.info("Scan %s stopped: %s", scan_id, exc)
|
||||
_note_exit_reason("budget_exceeded")
|
||||
if root_id is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await coordinator.set_status(root_id, "stopped")
|
||||
|
|
@ -622,6 +634,7 @@ async def run_strix_scan(
|
|||
exc,
|
||||
scan_id,
|
||||
)
|
||||
_note_exit_reason("rate_limited")
|
||||
if root_id is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await coordinator.set_status(root_id, "stopped")
|
||||
|
|
|
|||
|
|
@ -98,6 +98,14 @@ Examples:
|
|||
# Extra files placed in the sandbox workspace
|
||||
strix --target ./my-project --workspace-file ./wordlist.txt
|
||||
strix --target https://app.com --workspace-file ./openapi.yaml:specs/openapi.yaml
|
||||
|
||||
Strix Cloud:
|
||||
strix cloud login
|
||||
strix cloud scans start --source . --yes --wait
|
||||
strix cloud # list every cloud resource
|
||||
|
||||
Run a pentest in Strix Cloud https://app.strix.ai
|
||||
Try Strix Enterprise https://strix.ai/demo
|
||||
""",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from strix.interface.utils import (
|
|||
image_exists,
|
||||
process_pull_line,
|
||||
)
|
||||
from strix.telemetry import report_error
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -44,6 +45,7 @@ def validate_environment() -> None:
|
|||
f"[red]STRIX_LLM={settings.llm.model} uses your ChatGPT subscription, "
|
||||
"but you're not signed in.[/] Run [cyan]strix auth login chatgpt[/] first."
|
||||
)
|
||||
report_error("subscription_not_signed_in")
|
||||
sys.exit(1)
|
||||
logger.info("Environment OK (ChatGPT subscription)")
|
||||
return
|
||||
|
|
@ -153,6 +155,7 @@ def validate_environment() -> None:
|
|||
console.print("\n")
|
||||
console.print(panel)
|
||||
console.print()
|
||||
report_error("missing_required_config")
|
||||
sys.exit(1)
|
||||
logger.info(
|
||||
"Environment OK (optional missing: %s)",
|
||||
|
|
@ -180,6 +183,7 @@ def check_docker_installed() -> None:
|
|||
padding=(1, 2),
|
||||
)
|
||||
console.print("\n", panel, "\n")
|
||||
report_error("docker_not_installed")
|
||||
sys.exit(1)
|
||||
logger.debug("Docker CLI present")
|
||||
|
||||
|
|
@ -227,6 +231,7 @@ def pull_docker_image() -> None:
|
|||
padding=(1, 2),
|
||||
)
|
||||
console.print(panel, "\n")
|
||||
report_error("image_pull_failed", e)
|
||||
sys.exit(1)
|
||||
|
||||
logger.info("Docker image %s ready", image)
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@ from strix.interface.update_check import (
|
|||
from strix.interface.utils import (
|
||||
build_final_stats_text,
|
||||
)
|
||||
from strix.telemetry import posthog, scarf
|
||||
from strix.llm.warmup import start_import_warmup, wait_for_import_warmup
|
||||
from strix.telemetry import posthog, report_error, scarf, set_scan_phase
|
||||
from strix.telemetry.logging import configure_dependency_logging
|
||||
|
||||
|
||||
|
|
@ -332,6 +333,11 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) ->
|
|||
"[#60a5fa]docs.strix.ai[/] [dim]·[/] "
|
||||
"[#60a5fa]discord.gg/strix-ai[/]"
|
||||
)
|
||||
if not args.non_interactive:
|
||||
console.print(
|
||||
"[dim]Run a pentest in Strix Cloud[/] [#60a5fa]app.strix.ai[/] [dim]·[/] "
|
||||
"[dim]Enterprise[/] [#60a5fa]strix.ai/demo[/]"
|
||||
)
|
||||
console.print()
|
||||
if not args.non_interactive:
|
||||
notify_update(console)
|
||||
|
|
@ -395,15 +401,18 @@ def _bootstrap_scan(args: argparse.Namespace) -> None:
|
|||
happen inside the TUI so the interface paints immediately instead of
|
||||
waiting on a model round trip.
|
||||
"""
|
||||
set_scan_phase("preflight")
|
||||
try:
|
||||
asyncio.run(warm_up_llm(show_model_warning=True))
|
||||
except ModelConnectionError as exc:
|
||||
report_error("model_connection_failed", exc)
|
||||
_print_model_connection_error(exc, exc.model_name)
|
||||
sys.exit(1)
|
||||
persist_current()
|
||||
try:
|
||||
prepare_run(args)
|
||||
except ValueError as e:
|
||||
report_error("scan_preparation_failed", e)
|
||||
_print_error_panel("SCAN PREPARATION FAILED", str(e))
|
||||
sys.exit(1)
|
||||
telemetry_start(args)
|
||||
|
|
@ -450,8 +459,6 @@ def main() -> None:
|
|||
|
||||
sys.exit(run_cloud(sys.argv[2:]))
|
||||
|
||||
from strix.llm.warmup import start_import_warmup
|
||||
|
||||
start_import_warmup()
|
||||
|
||||
args = parse_arguments()
|
||||
|
|
@ -466,6 +473,9 @@ def main() -> None:
|
|||
pull_docker_image()
|
||||
validate_environment()
|
||||
|
||||
# Everything below imports the scan engine; do not race the warm-up thread.
|
||||
wait_for_import_warmup()
|
||||
|
||||
if args.non_interactive:
|
||||
_bootstrap_scan(args)
|
||||
|
||||
|
|
@ -477,18 +487,21 @@ def main() -> None:
|
|||
from strix.interface.cli import run_cli
|
||||
|
||||
asyncio.run(run_cli(args))
|
||||
# Headless runs have no user to quit: the agent either finished
|
||||
# (already beaconed as finished_by_tool) or stopped on its own.
|
||||
exit_reason = "agent_stopped"
|
||||
else:
|
||||
asyncio.run(run_tui(args))
|
||||
except InteractiveSetupUnavailableError as exc:
|
||||
exit_reason = "error"
|
||||
report_error("interactive_setup_unavailable", exc)
|
||||
_print_error_panel("INTERACTIVE SETUP UNAVAILABLE", str(exc))
|
||||
sys.exit(1)
|
||||
except KeyboardInterrupt:
|
||||
exit_reason = "interrupted"
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
exit_reason = "error"
|
||||
posthog.error("unhandled_exception")
|
||||
scarf.error("unhandled_exception")
|
||||
report_error("unhandled_exception", exc)
|
||||
raise
|
||||
finally:
|
||||
report_state = get_global_report_state()
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@ class TuiController:
|
|||
model_warning = ""
|
||||
if model and not is_recommended_or_frontier_model(model):
|
||||
model_warning = (
|
||||
f"{model} is not a recommended frontier model; pentest quality could be degraded"
|
||||
f"{model} is not a recommended frontier model. Pentest quality could be degraded."
|
||||
)
|
||||
state = {
|
||||
"setup_mode": self.setup_mode,
|
||||
|
|
|
|||
|
|
@ -370,6 +370,17 @@ func TestStartedSnapshotTransitionsToLiveView(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSplashModelWarningRendersTheBackendSentenceOnce(t *testing.T) {
|
||||
warning := "openai/glm-5.3 is not a recommended frontier model. Pentest quality could be degraded."
|
||||
got := ansi.Strip(splashModelWarning("openai/glm-5.3", warning))
|
||||
if got != "⚠ "+warning {
|
||||
t.Fatalf("splash warning = %q, want %q", got, "⚠ "+warning)
|
||||
}
|
||||
if got := ansi.Strip(splashModelWarning("other/model", warning)); got != "⚠ "+warning {
|
||||
t.Fatalf("splash warning with unrelated model = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupStartScreenFitsNarrowTerminal(t *testing.T) {
|
||||
model := New(nil)
|
||||
model.width, model.height = 40, 18
|
||||
|
|
|
|||
|
|
@ -411,7 +411,7 @@ func (m Model) splashView() string {
|
|||
welcome + "\n" + version + "\n" + tagline + "\n\n" +
|
||||
start.String() + "\n\n" + url
|
||||
if warn := m.snapshot.ModelWarning; warn != "" {
|
||||
content += "\n\n" + splashModelWarning(warn)
|
||||
content += "\n\n" + splashModelWarning(m.snapshot.Model, warn)
|
||||
}
|
||||
panel := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(green).Padding(1, 6).Align(lipgloss.Center).Render(content)
|
||||
// #splash_screen background is solid black.
|
||||
|
|
@ -419,12 +419,16 @@ func (m Model) splashView() string {
|
|||
lipgloss.WithWhitespaceBackground(black))
|
||||
}
|
||||
|
||||
// splashModelWarning ports SplashScreen._build_model_warning_text.
|
||||
func splashModelWarning(model string) string {
|
||||
// splashModelWarning renders the backend's full warning sentence, with the
|
||||
// model name highlighted when the sentence leads with it.
|
||||
func splashModelWarning(model, warning string) string {
|
||||
yellow := lipgloss.Color("#eab308")
|
||||
return lipgloss.NewStyle().Bold(true).Foreground(yellow).Render("⚠ ") +
|
||||
lipgloss.NewStyle().Bold(true).Foreground(render.Cyan).Render(model) +
|
||||
lipgloss.NewStyle().Foreground(yellow).Render(" is not a recommended frontier model - pentest quality could be degraded")
|
||||
out := lipgloss.NewStyle().Bold(true).Foreground(yellow).Render("⚠ ")
|
||||
if model != "" && strings.HasPrefix(warning, model) {
|
||||
out += lipgloss.NewStyle().Bold(true).Foreground(render.Cyan).Render(model)
|
||||
warning = strings.TrimPrefix(warning, model)
|
||||
}
|
||||
return out + lipgloss.NewStyle().Foreground(yellow).Render(warning)
|
||||
}
|
||||
|
||||
// chatPaneKey identifies everything the bordered trace depends on.
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ from strix.interface.tui.sidecar import (
|
|||
)
|
||||
from strix.interface.utils import read_workspace_files
|
||||
from strix.report.state import ReportState, set_global_report_state
|
||||
from strix.telemetry import report_error, set_scan_phase
|
||||
from strix.utils.resource_paths import get_strix_resource_path
|
||||
|
||||
|
||||
|
|
@ -138,11 +139,13 @@ class GoTuiRuntime:
|
|||
await self._preflight_model()
|
||||
except Exception as exc:
|
||||
logger.exception("Go TUI setup model preflight failed")
|
||||
report_error("model_connection_failed", exc)
|
||||
raise RuntimeError(f"Model connection failed: {exc}") from exc
|
||||
|
||||
async def _preflight_model(self) -> None:
|
||||
model = (load_settings().llm.model or "").strip()
|
||||
self.controller.add_message("Verifying model connection...")
|
||||
set_scan_phase("preflight")
|
||||
await preflight_model_connection(model)
|
||||
self.model_verified = True
|
||||
|
||||
|
|
@ -181,7 +184,11 @@ class GoTuiRuntime:
|
|||
candidate.target = list(self.controller.targets)
|
||||
candidate.target_list = []
|
||||
build_targets_info(candidate)
|
||||
prepare_run(candidate)
|
||||
try:
|
||||
prepare_run(candidate)
|
||||
except Exception as exc:
|
||||
report_error("scan_preparation_failed", exc)
|
||||
raise
|
||||
telemetry_start(candidate)
|
||||
|
||||
vars(self.args).update(vars(candidate))
|
||||
|
|
@ -195,13 +202,21 @@ class GoTuiRuntime:
|
|||
launch so the interface appears immediately.
|
||||
"""
|
||||
model = (load_settings().llm.model or "").strip()
|
||||
set_scan_phase("preflight")
|
||||
try:
|
||||
await preflight_model_connection(model)
|
||||
except Exception as exc:
|
||||
logger.exception("Go TUI scan preparation failed")
|
||||
report_error("model_connection_failed", exc)
|
||||
self.controller.fail_preparation(str(exc))
|
||||
return
|
||||
try:
|
||||
persist_current()
|
||||
prepare_run(self.args)
|
||||
telemetry_start(self.args)
|
||||
except Exception as exc:
|
||||
logger.exception("Go TUI scan preparation failed")
|
||||
report_error("scan_preparation_failed", exc)
|
||||
self.controller.fail_preparation(str(exc))
|
||||
return
|
||||
self.controller.scan_state = "running"
|
||||
|
|
@ -240,6 +255,9 @@ class GoTuiRuntime:
|
|||
self.controller.scan_state = "completed" if report_status == "completed" else "stopped"
|
||||
except Exception as exc:
|
||||
logger.exception("Go TUI scan failed")
|
||||
report_error("unhandled_exception", exc)
|
||||
if self.report_state is not None and self.report_state.scan_ended_exit_reason is None:
|
||||
self.report_state.scan_ended_exit_reason = "error"
|
||||
self.scan_error = exc
|
||||
self.controller.error = str(exc)
|
||||
self.controller.scan_state = "failed"
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from rich.panel import Panel
|
|||
from rich.text import Text
|
||||
|
||||
from strix.config import load_settings
|
||||
from strix.telemetry import report_error
|
||||
from strix.utils.api_spec import detect_spec_format
|
||||
|
||||
|
||||
|
|
@ -1602,7 +1603,8 @@ def check_docker_connection() -> Any:
|
|||
|
||||
try:
|
||||
return docker.from_env()
|
||||
except DockerException:
|
||||
except DockerException as exc:
|
||||
report_error("docker_unavailable", exc)
|
||||
console = Console()
|
||||
error_text = Text()
|
||||
error_text.append("DOCKER NOT AVAILABLE", style="bold red")
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
Radar,
|
||||
Rocket,
|
||||
ArrowUpRight,
|
||||
Building2,
|
||||
History,
|
||||
} from "lucide-react";
|
||||
import type { Vulnerability, VulnerabilitySeverity } from "@/types/issues";
|
||||
|
|
@ -35,7 +36,7 @@ import {
|
|||
type LoadedRun,
|
||||
type RunsPayload,
|
||||
} from "@/data/serverSource";
|
||||
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
import { SIGNUP_URL, DEMO_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
import { runTitle } from "@/lib/target-utils";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import PastRunsView from "@/components/PastRunsView";
|
||||
|
|
@ -706,6 +707,31 @@ function OverviewTab({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{finished && (
|
||||
<div className="animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<p className="text-sm font-semibold text-white">Strix Cloud</p>
|
||||
<p className="mt-0.5 text-xs text-[#666]">Run your next pentest in Strix Cloud.</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2.5">
|
||||
<ProInlineCta
|
||||
label="Run a pentest in Strix Cloud"
|
||||
desc="Validated findings, autofix, and PR reviews."
|
||||
slug="overview_cloud"
|
||||
surface="overview"
|
||||
icon={Rocket}
|
||||
primary
|
||||
/>
|
||||
<ProInlineCta
|
||||
label="Try Strix Enterprise"
|
||||
desc="SSO, compliance-ready reports, VPC or self-hosted deployment."
|
||||
slug="book_demo"
|
||||
surface="overview"
|
||||
icon={Building2}
|
||||
href={DEMO_URL}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sections.length > 0 ? (
|
||||
<div className="animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5 space-y-8">
|
||||
{sections.map((s) => (
|
||||
|
|
@ -789,14 +815,23 @@ function AgentsTab({ run, canSteer }: { run: LoadedRun; canSteer: boolean }) {
|
|||
{/* Re-run always routes to Strix Cloud. */}
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<p className="text-sm font-semibold text-white">Run this pentest with more depth</p>
|
||||
<p className="mt-0.5 text-xs text-[#666]">Re-run this pentest on managed infra in the cloud.</p>
|
||||
<p className="mt-0.5 text-xs text-[#666]">Run this pentest again in Strix Cloud.</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2.5">
|
||||
<ProInlineCta
|
||||
label="Re-run in Strix Pro with more depth"
|
||||
desc="Run this pentest on managed infra with more depth."
|
||||
desc="More depth, validated findings, and autofix."
|
||||
slug="live_scan"
|
||||
surface="agents"
|
||||
icon={Rocket}
|
||||
primary
|
||||
/>
|
||||
<ProInlineCta
|
||||
label="Try Strix Enterprise"
|
||||
desc="SSO, compliance-ready reports, VPC or self-hosted deployment."
|
||||
slug="book_demo"
|
||||
surface="agents"
|
||||
icon={Building2}
|
||||
href={DEMO_URL}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -48,23 +48,40 @@ export function ProInlineCta({
|
|||
slug,
|
||||
icon: Icon,
|
||||
surface,
|
||||
href = SIGNUP_URL,
|
||||
primary = false,
|
||||
}: {
|
||||
label: string;
|
||||
desc: string;
|
||||
slug: string;
|
||||
icon: React.ElementType;
|
||||
surface?: string;
|
||||
/** Destination before attribution params. Defaults to cloud sign-up. */
|
||||
href?: string;
|
||||
/** Solid white button instead of the outlined default. */
|
||||
primary?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip text={desc}>
|
||||
<a
|
||||
href={ctaUrl(SIGNUP_URL, slug)}
|
||||
href={ctaUrl(href, 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"
|
||||
className={
|
||||
primary
|
||||
? "group inline-flex items-center gap-2 rounded-lg border border-white bg-white px-3 py-2 text-sm font-semibold text-black transition-colors hover:bg-[#e5e5e5]"
|
||||
: "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" />
|
||||
<Icon
|
||||
className={
|
||||
primary
|
||||
? "h-4 w-4 text-black"
|
||||
: "h-4 w-4 text-[#888] transition-colors group-hover:text-white"
|
||||
}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</a>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
WandSparkles,
|
||||
Plug,
|
||||
} from "lucide-react";
|
||||
import { SIGNUP_URL, PRICING_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
import { SIGNUP_URL, PRICING_URL, DEMO_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
|
||||
/**
|
||||
* Dialog shown when a platform feature is clicked in the sidebar: a short
|
||||
|
|
@ -141,6 +141,19 @@ export function UpgradeModal({
|
|||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-[#666]">
|
||||
SSO, compliance reports, or a private deployment?{" "}
|
||||
<a
|
||||
href={ctaUrl(DEMO_URL, "upgrade_book_demo")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("upgrade_book_demo", source)}
|
||||
className="whitespace-nowrap text-[#aaa] underline underline-offset-2 transition-colors hover:text-white"
|
||||
>
|
||||
Try Strix Enterprise
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -6,8 +6,8 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Strix Results</title>
|
||||
<script type="module" crossorigin src="./assets/index-Bpn8GiSb.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-qwPOPAGC.css">
|
||||
<script type="module" crossorigin src="./assets/index-B94ANU8d.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DN__rVv3.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,22 @@
|
|||
"""Background pre-import of the heavy scan dependencies.
|
||||
|
||||
The scan engine's import graph (the agents SDK, OpenAI client, LiteLLM, the
|
||||
Caido SDK, the Docker SDK) costs seconds to import cold, but none of it is
|
||||
needed until a scan actually starts. Importing it on a daemon thread at CLI
|
||||
entry overlaps that cost with the I/O-bound startup work that always precedes
|
||||
a scan (argument parsing, Docker checks, image pull, TUI setup), so by the
|
||||
time the scan begins the modules are already in ``sys.modules``. Any thread
|
||||
that needs one of them before the warm-up finishes just blocks on the normal
|
||||
import lock, so behaviour is unchanged either way.
|
||||
Caido SDK) costs seconds to import cold, but none of it is needed until a scan
|
||||
actually starts. Importing it on a daemon thread at CLI entry overlaps that
|
||||
cost with the I/O-bound startup work that always precedes a scan (argument
|
||||
parsing, Docker checks, image pull, TUI setup). The Docker SDK is not on the
|
||||
list: the Docker checks import it on the main thread during that same window.
|
||||
|
||||
The main thread must call :func:`wait_for_import_warmup` before its first
|
||||
import from that graph. Two threads that enter the same package graph from
|
||||
different modules hold each other's import locks, and CPython breaks the cycle
|
||||
by failing one of the imports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
|
||||
|
||||
|
|
@ -24,45 +26,17 @@ WARMUP_MODULES = (
|
|||
"strix.core.runner",
|
||||
"litellm",
|
||||
"caido_sdk_client",
|
||||
"docker",
|
||||
)
|
||||
|
||||
_lock = threading.Lock()
|
||||
_thread: threading.Thread | None = None
|
||||
|
||||
|
||||
def _purge_orphaned_modules(before: frozenset[str]) -> None:
|
||||
"""Remove submodules stranded by an import attempt that just failed.
|
||||
|
||||
When a package import fails partway (for example CPython's import-lock
|
||||
deadlock avoidance breaking a cross-thread cycle), the failed package is
|
||||
removed from ``sys.modules`` but submodules it already finished stay
|
||||
behind. A later import of one of those submodules then short-circuits on
|
||||
the cached entry without re-importing its parent, and re-entering the
|
||||
parent from inside a submodule crashes with "partially initialized
|
||||
module". Dropping the orphans (cached submodules whose ancestor package is
|
||||
gone) restores a clean slate, and touches nothing another thread imported
|
||||
successfully.
|
||||
"""
|
||||
added = set(sys.modules) - before
|
||||
for name in added:
|
||||
parent = name.rpartition(".")[0]
|
||||
while parent:
|
||||
if parent not in sys.modules:
|
||||
sys.modules.pop(name, None)
|
||||
logger.debug("Import warm-up purged orphaned module %r", name)
|
||||
break
|
||||
parent = parent.rpartition(".")[0]
|
||||
|
||||
|
||||
def _warm(modules: tuple[str, ...]) -> None:
|
||||
for name in modules:
|
||||
before = frozenset(sys.modules)
|
||||
try:
|
||||
importlib.import_module(name)
|
||||
except Exception: # noqa: BLE001 - a failed warm-up must never fail the run.
|
||||
logger.debug("Import warm-up for %r failed", name, exc_info=True)
|
||||
_purge_orphaned_modules(before)
|
||||
|
||||
|
||||
def start_import_warmup(modules: tuple[str, ...] = WARMUP_MODULES) -> threading.Thread:
|
||||
|
|
@ -72,11 +46,15 @@ def start_import_warmup(modules: tuple[str, ...] = WARMUP_MODULES) -> threading.
|
|||
runtime that has no local Docker) warm a narrower set.
|
||||
"""
|
||||
global _thread # noqa: PLW0603
|
||||
with _lock:
|
||||
if _thread is not None:
|
||||
return _thread
|
||||
if _thread is None:
|
||||
_thread = threading.Thread(
|
||||
target=_warm, args=(modules,), name="strix-import-warmup", daemon=True
|
||||
)
|
||||
_thread.start()
|
||||
return _thread
|
||||
return _thread
|
||||
|
||||
|
||||
def wait_for_import_warmup() -> None:
|
||||
"""Block until the warm-up thread has finished, if one was started."""
|
||||
if _thread is not None:
|
||||
_thread.join()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import logging
|
||||
import re
|
||||
import threading
|
||||
from collections import Counter
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
|
@ -8,7 +7,6 @@ from typing import TypeGuard
|
|||
|
||||
import yaml
|
||||
|
||||
from strix.telemetry import posthog, scarf
|
||||
from strix.utils.resource_paths import get_strix_resource_path
|
||||
|
||||
|
||||
|
|
@ -241,16 +239,19 @@ def validate_requested_skills(skill_list: list[str], max_skills: int = 5) -> str
|
|||
return None
|
||||
|
||||
|
||||
_LOADED_SKILLS: set[str] = set()
|
||||
|
||||
|
||||
def _track_skill_loaded(skill_name: str, file_path: Path) -> None:
|
||||
builtin = get_strix_resource_path("skills")
|
||||
if not file_path.is_relative_to(builtin):
|
||||
skill_name = "custom"
|
||||
_LOADED_SKILLS.add(skill_name)
|
||||
|
||||
def _send() -> None:
|
||||
posthog.skill_loaded(skill_name)
|
||||
scarf.skill_loaded(skill_name)
|
||||
|
||||
threading.Thread(target=_send, daemon=True).start()
|
||||
def get_loaded_skill_names() -> list[str]:
|
||||
"""Distinct skills loaded so far in this process (custom skills collapse to ``"custom"``)."""
|
||||
return sorted(_LOADED_SKILLS)
|
||||
|
||||
|
||||
def _candidate_skill_files(skill_name: str) -> list[Path]:
|
||||
|
|
|
|||
|
|
@ -12,11 +12,11 @@ Privacy is our priority. All collected data is anonymized by default. Each sessi
|
|||
|
||||
We collect only very **basic** usage data including:
|
||||
|
||||
**Session Errors:** Duration and error types (not messages or stack traces)\
|
||||
**Session Errors:** Duration, the failure category, the scan phase, and the exception class name (not messages or stack traces)\
|
||||
**System Context:** OS type, architecture, Strix version\
|
||||
**Scan Context:** Scan mode (quick/standard/deep), scan type (whitebox/blackbox)\
|
||||
**Model Usage:** Which LLM model is being used and whether it runs via an API key or a model subscription (not prompts or responses)\
|
||||
**Feature Usage:** Which built-in skills are loaded\
|
||||
**Feature Usage:** Which built-in skills were used during a scan (reported once, at scan end)\
|
||||
**Aggregate Metrics:** Vulnerability counts by severity and weakness category (CWE)
|
||||
|
||||
### What We **Never** Collect
|
||||
|
|
|
|||
|
|
@ -1,7 +1,19 @@
|
|||
from . import posthog, scarf
|
||||
from ._common import set_scan_phase
|
||||
|
||||
|
||||
def report_error(error_type: str, exc: BaseException | None = None) -> None:
|
||||
"""Beacon a failure category, plus the exception class when one is given.
|
||||
|
||||
Only class names travel: never the message, arguments, or traceback.
|
||||
"""
|
||||
posthog.error(error_type, exc)
|
||||
scarf.error(error_type, exc)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"posthog",
|
||||
"report_error",
|
||||
"scarf",
|
||||
"set_scan_phase",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import platform
|
|||
import sys
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
|
|
@ -54,3 +54,43 @@ def base_props() -> dict[str, Any]:
|
|||
"python": f"{sys.version_info.major}.{sys.version_info.minor}",
|
||||
"strix_version": get_version(),
|
||||
}
|
||||
|
||||
|
||||
# Coarse stage of the current run, attached to ``error`` beacons so a failure
|
||||
# can be placed without a message or trace. Process-local, like the rest of the
|
||||
# CLI telemetry: one process runs one scan.
|
||||
_scan_phase = "startup"
|
||||
|
||||
|
||||
def set_scan_phase(phase: str) -> None:
|
||||
global _scan_phase # noqa: PLW0603
|
||||
_scan_phase = phase
|
||||
|
||||
|
||||
def get_scan_phase() -> str:
|
||||
return _scan_phase
|
||||
|
||||
|
||||
def _exception_name(exc: BaseException) -> str:
|
||||
cls = type(exc)
|
||||
package = cls.__module__.split(".")[0]
|
||||
return cls.__name__ if package == "builtins" else f"{package}.{cls.__name__}"
|
||||
|
||||
|
||||
def _unwrap_group(exc: BaseException) -> BaseException:
|
||||
if not isinstance(exc, BaseExceptionGroup):
|
||||
return exc
|
||||
group = cast("BaseExceptionGroup[BaseException]", exc)
|
||||
return group.exceptions[0] if group.exceptions else group
|
||||
|
||||
|
||||
def exception_props(exc: BaseException) -> dict[str, str]:
|
||||
"""Class names only. Messages, arguments, and tracebacks never leave the machine."""
|
||||
exc = _unwrap_group(exc)
|
||||
props = {"exception_type": _exception_name(exc)}
|
||||
cause = exc.__cause__
|
||||
if cause is None and not exc.__suppress_context__:
|
||||
cause = exc.__context__
|
||||
if cause is not None:
|
||||
props["exception_cause"] = _exception_name(cause)
|
||||
return props
|
||||
|
|
|
|||
|
|
@ -4,10 +4,14 @@ from typing import TYPE_CHECKING, Any
|
|||
import requests
|
||||
|
||||
from strix.config import load_settings
|
||||
from strix.skills import get_loaded_skill_names
|
||||
from strix.telemetry._common import (
|
||||
SEND_TIMEOUT,
|
||||
SESSION_ID,
|
||||
base_props,
|
||||
exception_props,
|
||||
get_scan_phase,
|
||||
get_version,
|
||||
is_first_run,
|
||||
)
|
||||
|
||||
|
|
@ -35,7 +39,12 @@ def _send(event: str, properties: dict[str, Any]) -> bool:
|
|||
"api_key": _POSTHOG_PUBLIC_API_KEY,
|
||||
"event": event,
|
||||
"distinct_id": SESSION_ID,
|
||||
"properties": properties,
|
||||
"properties": {
|
||||
**properties,
|
||||
"$lib": "strix-cli",
|
||||
"$lib_version": get_version(),
|
||||
"$process_person_profile": False,
|
||||
},
|
||||
}
|
||||
with requests.post(f"{_POSTHOG_HOST}/capture/", json=payload, timeout=SEND_TIMEOUT):
|
||||
pass
|
||||
|
|
@ -82,16 +91,6 @@ def finding(severity: str, cwe: str | None = None, is_cve: bool = False) -> None
|
|||
)
|
||||
|
||||
|
||||
def skill_loaded(skill_name: str) -> None:
|
||||
_send(
|
||||
"skill_loaded",
|
||||
{
|
||||
**base_props(),
|
||||
"skill": skill_name,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def end(report_state: "ReportState", exit_reason: str = "completed") -> None:
|
||||
if report_state.posthog_scan_ended_sent:
|
||||
return
|
||||
|
|
@ -130,6 +129,7 @@ def end(report_state: "ReportState", exit_reason: str = "completed") -> None:
|
|||
"vulnerabilities_total": len(report_state.vulnerability_reports),
|
||||
**{f"vulnerabilities_{k}": v for k, v in vulnerabilities_counts.items()},
|
||||
**llm_props,
|
||||
"skills": get_loaded_skill_names(),
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -180,6 +180,12 @@ def viewer_agent_steered() -> None:
|
|||
_send("viewer_agent_steered", {**base_props()})
|
||||
|
||||
|
||||
def error(error_type: str) -> None:
|
||||
props = {**base_props(), "error_type": error_type}
|
||||
def error(error_type: str, exc: BaseException | None = None) -> None:
|
||||
props: dict[str, Any] = {
|
||||
**base_props(),
|
||||
"error_type": error_type,
|
||||
"phase": get_scan_phase(),
|
||||
}
|
||||
if exc is not None:
|
||||
props.update(exception_props(exc))
|
||||
_send("error", props)
|
||||
|
|
|
|||
|
|
@ -7,10 +7,13 @@ from typing import TYPE_CHECKING, Any
|
|||
import requests
|
||||
|
||||
from strix.config import load_settings
|
||||
from strix.skills import get_loaded_skill_names
|
||||
from strix.telemetry._common import (
|
||||
SEND_TIMEOUT,
|
||||
SESSION_ID,
|
||||
base_props,
|
||||
exception_props,
|
||||
get_scan_phase,
|
||||
get_version,
|
||||
is_first_run,
|
||||
)
|
||||
|
|
@ -90,17 +93,6 @@ def finding(severity: str, cwe: str | None = None, is_cve: bool = False) -> None
|
|||
)
|
||||
|
||||
|
||||
def skill_loaded(skill_name: str) -> None:
|
||||
_send(
|
||||
"skill_loaded",
|
||||
{
|
||||
**base_props(),
|
||||
"session": SESSION_ID,
|
||||
"skill": skill_name,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def end(report_state: ReportState, exit_reason: str = "completed") -> None:
|
||||
if report_state.scarf_scan_ended_sent:
|
||||
return
|
||||
|
|
@ -140,14 +132,18 @@ def end(report_state: ReportState, exit_reason: str = "completed") -> None:
|
|||
"vulnerabilities_total": len(report_state.vulnerability_reports),
|
||||
**{f"vulnerabilities_{k}": v for k, v in vulnerabilities_counts.items()},
|
||||
**llm_props,
|
||||
"skills": ",".join(get_loaded_skill_names()),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def error(error_type: str) -> None:
|
||||
def error(error_type: str, exc: BaseException | None = None) -> None:
|
||||
props: dict[str, Any] = {
|
||||
**base_props(),
|
||||
"session": SESSION_ID,
|
||||
"error_type": error_type,
|
||||
"phase": get_scan_phase(),
|
||||
}
|
||||
if exc is not None:
|
||||
props.update(exception_props(exc))
|
||||
_send("error", props)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
"""The import warm-up thread must never leave the import system poisoned.
|
||||
"""The import warm-up thread must never race the main thread into the engine.
|
||||
|
||||
Field failure: the warm-up thread's ``strix.core.runner`` import and the main
|
||||
thread's ``strix.report`` import both walked the agents SDK graph, and the two
|
||||
held each other's import locks (report -> dedupe -> agents while runner ->
|
||||
hooks -> report.state). CPython's deadlock avoidance breaks such a cycle by
|
||||
failing one import, which strands finished submodules in ``sys.modules`` with
|
||||
their parent package gone — and the next import of one of those submodules
|
||||
crashes with "partially initialized module".
|
||||
Two threads that enter the same package graph from different modules hold
|
||||
each other's import locks (warm-up: ``strix.core.runner`` -> ``agents``;
|
||||
main: ``agents.models.interface``). CPython breaks such a cycle by failing one
|
||||
of the imports, so the main thread waits for the warm-up before its first
|
||||
engine import.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -14,10 +12,16 @@ from __future__ import annotations
|
|||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
import threading
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from strix.llm import warmup
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pytest
|
||||
|
||||
|
||||
def _run(code: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run( # noqa: S603
|
||||
[sys.executable, "-c", textwrap.dedent(code)],
|
||||
|
|
@ -56,44 +60,49 @@ def test_check_duplicate_resolves_lazily() -> None:
|
|||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_failed_warm_import_purges_orphaned_submodules() -> None:
|
||||
def test_wait_for_import_warmup_lets_main_thread_import_the_agents_graph() -> None:
|
||||
result = _run(
|
||||
"""
|
||||
import sys
|
||||
|
||||
from strix.llm.warmup import _warm
|
||||
from strix.llm.warmup import start_import_warmup, wait_for_import_warmup
|
||||
|
||||
# A package whose import fails after a submodule already completed:
|
||||
# CPython removes the package but leaves the submodule stranded.
|
||||
import pathlib
|
||||
import tempfile
|
||||
# Same shape as the CLI: warm-up starts, then the main thread needs a
|
||||
# module from the middle of the agents graph.
|
||||
start_import_warmup()
|
||||
wait_for_import_warmup()
|
||||
|
||||
root = pathlib.Path(tempfile.mkdtemp())
|
||||
pkg = root / "stranded_pkg"
|
||||
pkg.mkdir()
|
||||
(pkg / "ok.py").write_text("VALUE = 1")
|
||||
(pkg / "__init__.py").write_text("from . import ok\\nraise RuntimeError('boom')")
|
||||
sys.path.insert(0, str(root))
|
||||
from agents.models.interface import ModelTracing # noqa: F401
|
||||
|
||||
_warm(("stranded_pkg",))
|
||||
|
||||
assert "stranded_pkg" not in sys.modules
|
||||
assert "stranded_pkg.ok" not in sys.modules, "orphan survived the purge"
|
||||
|
||||
# And the subtree imports cleanly afterwards up to the real error.
|
||||
try:
|
||||
import stranded_pkg # noqa: F401
|
||||
except RuntimeError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected the package's own error")
|
||||
assert "agents" in sys.modules
|
||||
assert "agents.models" in sys.modules
|
||||
assert "strix.core.runner" in sys.modules
|
||||
"""
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_purge_does_not_touch_preexisting_or_healthy_modules() -> None:
|
||||
before = frozenset(sys.modules) - {"strix.llm.warmup"}
|
||||
warmup._purge_orphaned_modules(before)
|
||||
assert "strix.llm.warmup" in sys.modules # parent chain intact -> kept
|
||||
assert "strix" in sys.modules
|
||||
def test_wait_for_import_warmup_blocks_until_the_thread_finishes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
release = threading.Event()
|
||||
monkeypatch.setattr(warmup, "_warm", lambda _modules: release.wait())
|
||||
monkeypatch.setattr(warmup, "_thread", None)
|
||||
warmup.start_import_warmup(())
|
||||
|
||||
waiter = threading.Thread(target=warmup.wait_for_import_warmup)
|
||||
waiter.start()
|
||||
waiter.join(0.2)
|
||||
assert waiter.is_alive(), "returned before the warm-up finished"
|
||||
|
||||
release.set()
|
||||
waiter.join(5)
|
||||
assert not waiter.is_alive()
|
||||
|
||||
|
||||
def test_failed_warm_import_does_not_raise() -> None:
|
||||
warmup._warm(("strix_no_such_module_for_warmup_test",))
|
||||
|
||||
|
||||
def test_wait_for_import_warmup_is_a_no_op_without_a_thread() -> None:
|
||||
warmup.wait_for_import_warmup()
|
||||
|
|
|
|||
|
|
@ -79,6 +79,14 @@ def test_recommended_models_are_matched_case_insensitively() -> None:
|
|||
"zai/glm-5.3-flash",
|
||||
"openrouter/z-ai/glm-5.3",
|
||||
"novita/zai-org/glm-5.2",
|
||||
"openai/glm-5.3",
|
||||
"openai/zai-org/glm-5.3",
|
||||
"hosted_vllm/glm-5.3",
|
||||
"openai/claude-opus-4-8",
|
||||
"openai/deepseek-v4-pro",
|
||||
"custom-ollama/gpt-5-mini-local",
|
||||
"custom-provider/claude-opus-4-local",
|
||||
"custom-provider/glm-5.3-local",
|
||||
],
|
||||
)
|
||||
def test_frontier_model_families_are_accepted(model_name: str) -> None:
|
||||
|
|
@ -93,15 +101,13 @@ def test_frontier_model_families_are_accepted(model_name: str) -> None:
|
|||
"anthropic/claude-3-5-sonnet-latest",
|
||||
"ollama/llama3.1",
|
||||
"deepseek/deepseek-chat",
|
||||
"custom-ollama/gpt-5-mini-local",
|
||||
"custom-provider/claude-opus-4-local",
|
||||
"xai/grok-4.5",
|
||||
"openrouter/x-ai/grok-4",
|
||||
"mistral/mistral-medium-3-5",
|
||||
"mistral/magistral-medium-latest",
|
||||
"zai/glm-4.7",
|
||||
"openai/glm-4.7",
|
||||
"openrouter/z-ai/glm-5",
|
||||
"custom-provider/glm-5.3-local",
|
||||
],
|
||||
)
|
||||
def test_non_frontier_models_are_rejected(model_name: str) -> None:
|
||||
|
|
|
|||
125
tests/test_telemetry_errors.py
Normal file
125
tests/test_telemetry_errors.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""Error beacons carry a category, phase, and exception class — never a message."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from strix.report.state import ReportState
|
||||
from strix.telemetry import posthog, report_error, scarf, set_scan_phase
|
||||
from strix.telemetry._common import exception_props
|
||||
|
||||
|
||||
PRIVATE_MESSAGE = "private message that must stay on the machine"
|
||||
|
||||
|
||||
def _capture(sent: list[dict[str, Any]], event: str, props: dict[str, Any]) -> bool:
|
||||
sent.append({"event": event, **props})
|
||||
return True
|
||||
|
||||
|
||||
def test_exception_props_uses_bare_name_for_builtins() -> None:
|
||||
assert exception_props(ValueError(PRIVATE_MESSAGE)) == {"exception_type": "ValueError"}
|
||||
|
||||
|
||||
def test_exception_props_prefixes_third_party_top_level_package() -> None:
|
||||
props = exception_props(requests.exceptions.ConnectTimeout(PRIVATE_MESSAGE))
|
||||
assert props == {"exception_type": "requests.ConnectTimeout"}
|
||||
|
||||
|
||||
def _chained(cause: BaseException | None, *, explicit: bool) -> RuntimeError:
|
||||
exc = RuntimeError("wrapped")
|
||||
if explicit:
|
||||
exc.__cause__ = cause
|
||||
exc.__suppress_context__ = True
|
||||
else:
|
||||
exc.__context__ = cause
|
||||
return exc
|
||||
|
||||
|
||||
def test_exception_props_reports_explicit_cause() -> None:
|
||||
props = exception_props(_chained(ConnectionError(PRIVATE_MESSAGE), explicit=True))
|
||||
assert props == {"exception_type": "RuntimeError", "exception_cause": "ConnectionError"}
|
||||
|
||||
|
||||
def test_exception_props_reports_implicit_context() -> None:
|
||||
props = exception_props(_chained(KeyError("k"), explicit=False))
|
||||
assert props["exception_cause"] == "KeyError"
|
||||
|
||||
|
||||
def test_exception_props_ignores_suppressed_context() -> None:
|
||||
exc = _chained(None, explicit=True)
|
||||
exc.__context__ = KeyError("k")
|
||||
assert exception_props(exc) == {"exception_type": "RuntimeError"}
|
||||
|
||||
|
||||
def test_exception_props_unwraps_exception_group() -> None:
|
||||
group = ExceptionGroup("tasks", [TimeoutError("t"), ValueError("v")])
|
||||
assert exception_props(group) == {"exception_type": "TimeoutError"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("telemetry", [posthog, scarf])
|
||||
def test_error_event_carries_phase_and_class_but_no_message(
|
||||
telemetry: Any,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
sent: list[dict[str, Any]] = []
|
||||
monkeypatch.setattr(telemetry, "_send", lambda event, props: _capture(sent, event, props))
|
||||
set_scan_phase("sandbox_init")
|
||||
|
||||
telemetry.error("scan_failed", RuntimeError(PRIVATE_MESSAGE))
|
||||
|
||||
assert len(sent) == 1
|
||||
event = sent[0]
|
||||
assert event["event"] == "error"
|
||||
assert event["error_type"] == "scan_failed"
|
||||
assert event["phase"] == "sandbox_init"
|
||||
assert event["exception_type"] == "RuntimeError"
|
||||
assert PRIVATE_MESSAGE not in repr(event)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("telemetry", [posthog, scarf])
|
||||
def test_error_event_without_exception_omits_exception_fields(
|
||||
telemetry: Any,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
sent: list[dict[str, Any]] = []
|
||||
monkeypatch.setattr(telemetry, "_send", lambda event, props: _capture(sent, event, props))
|
||||
set_scan_phase("startup")
|
||||
|
||||
telemetry.error("docker_not_installed")
|
||||
|
||||
assert sent[0]["error_type"] == "docker_not_installed"
|
||||
assert sent[0]["phase"] == "startup"
|
||||
assert "exception_type" not in sent[0]
|
||||
assert "exception_cause" not in sent[0]
|
||||
|
||||
|
||||
def test_report_error_fans_out_to_both_backends(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
sent: list[dict[str, Any]] = []
|
||||
monkeypatch.setattr(posthog, "_send", lambda event, props: _capture(sent, event, props))
|
||||
monkeypatch.setattr(scarf, "_send", lambda event, props: _capture(sent, event, props))
|
||||
|
||||
report_error("model_connection_failed", TimeoutError(PRIVATE_MESSAGE))
|
||||
|
||||
assert len(sent) == 2
|
||||
assert {e["error_type"] for e in sent} == {"model_connection_failed"}
|
||||
assert {e["exception_type"] for e in sent} == {"TimeoutError"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("telemetry", [posthog, scarf])
|
||||
def test_scan_ended_prefers_recorded_exit_reason(
|
||||
telemetry: Any,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
state = ReportState()
|
||||
state.scan_ended_exit_reason = "budget_exceeded"
|
||||
sent: list[dict[str, Any]] = []
|
||||
monkeypatch.setattr(telemetry, "_send", lambda event, props: _capture(sent, event, props))
|
||||
|
||||
telemetry.end(state, exit_reason="user_exit")
|
||||
|
||||
assert sent[0]["event"] == "scan_ended"
|
||||
assert sent[0]["exit_reason"] == "budget_exceeded"
|
||||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -2378,7 +2378,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "strix-agent"
|
||||
version = "1.6.1"
|
||||
version = "1.6.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "caido-sdk-client" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue