mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 09:26:39 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10d8db53fe | ||
|
|
d91e91129f | ||
|
|
318b54ccfc | ||
|
|
b0a0363754 | ||
|
|
ea6d7bab32 | ||
|
|
4524691355 |
@@ -6,9 +6,6 @@ on:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
@@ -27,15 +24,13 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||
- uses: astral-sh/setup-uv@v5
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
@@ -55,7 +50,7 @@ jobs:
|
||||
tar -C dist/release -czvf "dist/release/strix-${VERSION}-${{ matrix.target }}.tar.gz" "strix-${VERSION}-${{ matrix.target }}"
|
||||
fi
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: strix-${{ matrix.target }}
|
||||
path: |
|
||||
@@ -70,13 +65,13 @@ jobs:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: release
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
generate_release_notes: true
|
||||
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
# Node / local-viewer SPA source (the built bundle in
|
||||
# strix/interface/viewer/static/ is committed and shipped; do not ignore it)
|
||||
# strix/viewer/static/ is committed and shipped; do not ignore it)
|
||||
node_modules/
|
||||
strix/interface/viewer/frontend/node_modules/
|
||||
strix/interface/viewer/frontend/.vite/
|
||||
strix/viewer/frontend/node_modules/
|
||||
strix/viewer/frontend/.vite/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
|
||||
+5
-5
@@ -102,16 +102,16 @@ We welcome feature ideas! Please:
|
||||
## 🖥️ Local viewer SPA
|
||||
|
||||
`strix view` serves a prebuilt web UI whose source lives in
|
||||
`strix/interface/viewer/frontend/` (a Vite + React project) and whose built output is
|
||||
committed to `strix/interface/viewer/static/` and shipped in the package. End users never
|
||||
run a JS build. If you change anything under `strix/interface/viewer/frontend/`, rebuild
|
||||
`strix/viewer/frontend/` (a Vite + React project) and whose built output is
|
||||
committed to `strix/viewer/static/` and shipped in the package. End users never
|
||||
run a JS build. If you change anything under `strix/viewer/frontend/`, rebuild
|
||||
and commit the output:
|
||||
|
||||
```bash
|
||||
make viewer # or: cd strix/interface/viewer/frontend && npm ci && npm run build
|
||||
make viewer # or: cd strix/viewer/frontend && npm ci && npm run build
|
||||
```
|
||||
|
||||
Commit both the source change and the regenerated `strix/interface/viewer/static/`.
|
||||
Commit both the source change and the regenerated `strix/viewer/static/`.
|
||||
|
||||
## 🤝 Community
|
||||
|
||||
|
||||
@@ -69,8 +69,8 @@ clean:
|
||||
|
||||
viewer:
|
||||
@echo "🖥️ Building the local-viewer SPA..."
|
||||
cd strix/interface/viewer/frontend && npm ci && npm run build
|
||||
@echo "✅ Viewer built to strix/interface/viewer/static/ (commit the changes)."
|
||||
cd strix/viewer/frontend && npm ci && npm run build
|
||||
@echo "✅ Viewer built to strix/viewer/static/ (commit the changes)."
|
||||
|
||||
dev: format lint type-check
|
||||
@echo "✅ Development cycle complete!"
|
||||
|
||||
@@ -267,20 +267,6 @@ export STRIX_REASONING_EFFORT="high" # control thinking effort (default: high,
|
||||
> [!NOTE]
|
||||
> Strix automatically saves your configuration to `~/.strix/cli-config.json`, so you don't have to re-enter it on every run.
|
||||
|
||||
#### Sign in with a ChatGPT subscription
|
||||
|
||||
Instead of a metered API key, you can run Strix on your ChatGPT Plus/Pro subscription:
|
||||
|
||||
```bash
|
||||
strix auth login chatgpt # sign in with your ChatGPT account
|
||||
|
||||
export STRIX_LLM="chatgpt/gpt-5.4" # chatgpt/<model> runs on the subscription
|
||||
strix --target ./app-directory
|
||||
|
||||
strix auth status # show the active sign-in
|
||||
strix auth logout # forget the sign-in
|
||||
```
|
||||
|
||||
**Recommended models for best results:**
|
||||
|
||||
- [OpenAI GPT-5.4](https://openai.com/api/) - `openai/gpt-5.4`
|
||||
|
||||
+4
-12
@@ -16,7 +16,8 @@ RUN mkdir -p /out/bin && \
|
||||
go install -v github.com/projectdiscovery/katana/cmd/katana@latest && \
|
||||
go install -v github.com/projectdiscovery/cvemap/cmd/vulnx@latest && \
|
||||
go install -v github.com/jaeles-project/gospider@latest && \
|
||||
go install -v github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest
|
||||
go install -v github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest && \
|
||||
go install -v github.com/ropnop/kerbrute@latest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime stage
|
||||
@@ -40,7 +41,7 @@ RUN mkdir -p /home/pentester/tools /app/certs && \
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
wget curl git vim nano unzip tar \
|
||||
wget curl git nano unzip tar \
|
||||
apt-transport-https ca-certificates gnupg lsb-release \
|
||||
software-properties-common \
|
||||
gcc libc6-dev \
|
||||
@@ -83,8 +84,6 @@ USER root
|
||||
RUN cp /app/certs/ca.crt /usr/local/share/ca-certificates/ca.crt && \
|
||||
update-ca-certificates
|
||||
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh
|
||||
|
||||
USER pentester
|
||||
WORKDIR /tmp
|
||||
|
||||
@@ -154,14 +153,7 @@ RUN git clone https://github.com/aravind0x7/JS-Snooper.git && \
|
||||
|
||||
USER root
|
||||
|
||||
# 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 curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh -s -- -b /usr/local/bin
|
||||
RUN set -eux; \
|
||||
ARCH="$(uname -m)"; \
|
||||
case "$ARCH" in \
|
||||
|
||||
@@ -35,31 +35,6 @@ Configure Strix using environment variables or a config file.
|
||||
Timeout in seconds for memory compression operations (context summarization).
|
||||
</ParamField>
|
||||
|
||||
### Dedicated deduplication model
|
||||
|
||||
Finding deduplication is a cheap, structured classification task. By default it
|
||||
runs on the main model, but you can route it to a smaller/cheaper model without
|
||||
affecting the agents that do the actual testing.
|
||||
|
||||
<ParamField path="STRIX_DEDUPE_MODEL" type="string">
|
||||
Model used to judge whether a candidate finding duplicates an existing report.
|
||||
Falls back to `STRIX_LLM` when unset.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="DEDUPE_LLM_API_KEY" type="string">
|
||||
Optional provider key for the deduplication model.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="DEDUPE_LLM_API_BASE" type="string">
|
||||
Optional custom API base URL for the deduplication model. Use when the dedupe
|
||||
model runs on a different endpoint than the main model.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="STRIX_DEDUPE_REASONING_EFFORT" type="string">
|
||||
Reasoning effort for the deduplication model. Defaults to the model's own
|
||||
baseline when unset.
|
||||
</ParamField>
|
||||
|
||||
## Optional Features
|
||||
|
||||
<ParamField path="PERPLEXITY_API_KEY" type="string">
|
||||
|
||||
+1
-2
@@ -116,6 +116,5 @@ strix --mount ./huge-monorepo
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | Scan completed successfully (interactive mode always exits `0`; in headless mode, `0` means no vulnerabilities were found) |
|
||||
| 1 | A fatal error occurred before or during the scan (e.g. missing environment variables, Docker unavailable, invalid config file, diff-scope resolution failure, or an unhandled error) |
|
||||
| 0 | Scan completed, no vulnerabilities found |
|
||||
| 2 | Vulnerabilities found (headless mode only) |
|
||||
|
||||
+9
-23
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "strix-agent"
|
||||
version = "1.3.1"
|
||||
version = "1.2.0"
|
||||
description = "Open-source AI Hackers for your apps"
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
@@ -46,9 +46,7 @@ dependencies = [
|
||||
"caido-sdk-client>=0.2.0",
|
||||
"reportlab>=4.0",
|
||||
"pypdf>=5.0",
|
||||
# Cap <49: 49.x drops the universal2 macOS wheel (arm64-only), which breaks
|
||||
# the Intel macOS (macos-x86_64) release build's `uv sync --frozen`.
|
||||
"cryptography>=48.0.1,<49",
|
||||
"cryptography>=42",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -79,10 +77,10 @@ build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["strix"]
|
||||
# The prebuilt viewer bundle under strix/interface/viewer/static/ ships automatically
|
||||
# The prebuilt viewer bundle under strix/viewer/static/ ships automatically
|
||||
# (hatchling includes non-.py files under the package). The Vite SOURCE lives
|
||||
# under the package dir too (strix/interface/viewer/frontend/) but must never ship in the wheel.
|
||||
exclude = ["strix/interface/viewer/frontend", "strix/interface/viewer/frontend/**"]
|
||||
# under the package dir too (strix/viewer/frontend/) but must never ship in the wheel.
|
||||
exclude = ["strix/viewer/frontend", "strix/viewer/frontend/**"]
|
||||
|
||||
# ============================================================================
|
||||
# Type Checking Configuration
|
||||
@@ -122,7 +120,6 @@ module = [
|
||||
"pydantic_settings.*",
|
||||
"reportlab.*",
|
||||
"pypdf.*",
|
||||
"pygments.*",
|
||||
]
|
||||
ignore_missing_imports = true
|
||||
disable_error_code = ["import-untyped"]
|
||||
@@ -216,16 +213,12 @@ ignore = [
|
||||
# Test doubles use fixture tokens/passwords and match a callee signature whose
|
||||
# args they intentionally ignore.
|
||||
"tests/test_viewer_auth.py" = ["S105", "S106", "ARG001"]
|
||||
"tests/test_codex_auth.py" = ["S105", "S106", "SLF001"]
|
||||
# Stdlib HTTP handler overrides (do_GET/do_POST).
|
||||
"strix/interface/auth_cli.py" = ["N802"]
|
||||
"tests/test_codex_streaming.py" = ["N802"]
|
||||
"tests/test_report_pdf.py" = ["S105", "S106"]
|
||||
# Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a
|
||||
# circular dependency with strix.telemetry / strix.interface.viewer.report_pdf.
|
||||
"strix/interface/viewer/server.py" = ["N802", "PLC0415"]
|
||||
# circular dependency with strix.telemetry / strix.viewer.report_pdf.
|
||||
"strix/viewer/server.py" = ["N802", "PLC0415"]
|
||||
# Lazy telemetry import to avoid importing PostHog before the viewer starts.
|
||||
"strix/interface/viewer/cli.py" = ["PLC0415"]
|
||||
"strix/viewer/cli.py" = ["PLC0415"]
|
||||
# Lazy imports inside functions to avoid circular dependency with
|
||||
# strix.telemetry / strix.report.dedupe / cvss.
|
||||
"strix/tools/notes/tools.py" = ["PLC0415", "TC002"]
|
||||
@@ -258,16 +251,9 @@ ignore = [
|
||||
"strix/core/runner.py" = ["TC003", "PLR0912", "PLR0915", "PLC0415"]
|
||||
# ReportState carries scan artifact/report fields and
|
||||
# a runtime ``Callable`` annotation on ``vulnerability_found_callback``.
|
||||
"strix/report/state.py" = ["TC003", "PLR0912", "PLR0915", "E501", "PERF401", "PLC0415"]
|
||||
"strix/report/state.py" = ["TC003", "PLR0912", "PLR0915", "E501", "PERF401"]
|
||||
"strix/report/usage.py" = ["PLC0415"]
|
||||
# Lazy import of strix.config.models avoids a circular dependency between the
|
||||
# report pipeline and the config layer.
|
||||
"strix/report/dedupe.py" = ["PLC0415"]
|
||||
"strix/telemetry/logging.py" = ["PLC0415"]
|
||||
"strix/config/models.py" = ["PLC0415"]
|
||||
# Heavy inference deps (httpx, openai) imported lazily so auth-status checks
|
||||
# don't pull them in.
|
||||
"strix/config/codex.py" = ["PLC0415"]
|
||||
# Interface utility branches per scope-mode / target-type combination;
|
||||
# splitting would obscure the decision tree without simplifying it.
|
||||
"strix/interface/utils.py" = ["PLR0912", "BLE001", "PLC0415"]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ set -euo pipefail
|
||||
|
||||
APP=strix
|
||||
REPO="usestrix/strix"
|
||||
STRIX_IMAGE="ghcr.io/usestrix/strix-sandbox:1.1.0"
|
||||
STRIX_IMAGE="ghcr.io/usestrix/strix-sandbox:1.0.0"
|
||||
|
||||
MUTED='\033[0;2m'
|
||||
RED='\033[0;31m'
|
||||
|
||||
+7
-7
@@ -26,7 +26,7 @@ for tcss_file in strix_root.rglob('*.tcss'):
|
||||
datas.append((str(tcss_file), str(rel_path.parent)))
|
||||
|
||||
# Prebuilt local-viewer SPA (served by `strix view`).
|
||||
viewer_static = strix_root / 'interface' / 'viewer' / 'static'
|
||||
viewer_static = strix_root / 'viewer' / 'static'
|
||||
for asset in viewer_static.rglob('*'):
|
||||
if asset.is_file():
|
||||
rel_path = asset.relative_to(project_root)
|
||||
@@ -158,12 +158,12 @@ hiddenimports = [
|
||||
'strix.report.dedupe',
|
||||
'strix.report.state',
|
||||
'strix.report.writer',
|
||||
'strix.interface.viewer',
|
||||
'strix.interface.viewer.auth',
|
||||
'strix.interface.viewer.cli',
|
||||
'strix.interface.viewer.report_pdf',
|
||||
'strix.interface.viewer.server',
|
||||
'strix.interface.viewer.transcript',
|
||||
'strix.viewer',
|
||||
'strix.viewer.auth',
|
||||
'strix.viewer.cli',
|
||||
'strix.viewer.report_pdf',
|
||||
'strix.viewer.server',
|
||||
'strix.viewer.transcript',
|
||||
|
||||
# PDF report generation + encryption
|
||||
'reportlab',
|
||||
|
||||
+14
-91
@@ -16,7 +16,6 @@ from agents.tool import CustomTool, FunctionTool, Tool
|
||||
from pydantic import ValidationError
|
||||
|
||||
from strix.agents.prompt import render_system_prompt
|
||||
from strix.config import load_settings
|
||||
from strix.tools.agents_graph.tools import (
|
||||
agent_finish,
|
||||
create_agent,
|
||||
@@ -34,7 +33,6 @@ from strix.tools.notes.tools import (
|
||||
list_notes,
|
||||
update_note,
|
||||
)
|
||||
from strix.tools.output_store import bound_and_store, bound_text
|
||||
from strix.tools.proxy.tools import (
|
||||
list_requests,
|
||||
list_sitemap,
|
||||
@@ -43,12 +41,7 @@ from strix.tools.proxy.tools import (
|
||||
view_request,
|
||||
view_sitemap_entry,
|
||||
)
|
||||
from strix.tools.reporting.tool import (
|
||||
create_dependency_report,
|
||||
create_vulnerability_report,
|
||||
get_report,
|
||||
list_reports,
|
||||
)
|
||||
from strix.tools.reporting.tool import create_dependency_report, create_vulnerability_report
|
||||
from strix.tools.thinking.tool import think
|
||||
from strix.tools.todo.tools import (
|
||||
create_todo,
|
||||
@@ -110,36 +103,8 @@ def _extract_custom_input(tool: CustomTool, raw_input: str | dict[str, Any]) ->
|
||||
return value if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _tool_output_limits() -> tuple[int, int]:
|
||||
context = load_settings().context
|
||||
return context.tool_output_max_lines, context.tool_output_max_bytes
|
||||
|
||||
|
||||
async def _bound_result(result: Any) -> Any:
|
||||
if not isinstance(result, str):
|
||||
return result
|
||||
max_lines, max_bytes = _tool_output_limits()
|
||||
return await bound_and_store(result, max_lines=max_lines, max_bytes=max_bytes)
|
||||
|
||||
|
||||
def _format_tool_error(exc: Exception) -> str:
|
||||
message = str(exc) or exc.__class__.__name__
|
||||
max_lines, max_bytes = _tool_output_limits()
|
||||
return bound_text(message, max_lines=max_lines, max_bytes=max_bytes)
|
||||
|
||||
|
||||
def _with_bounded_result(tool: FunctionTool) -> FunctionTool:
|
||||
"""Cap a tool's result size before it enters history (idempotent)."""
|
||||
if getattr(tool, "_strix_bounded", False):
|
||||
return tool
|
||||
invoke_tool = tool.on_invoke_tool
|
||||
|
||||
async def invoke(ctx: Any, raw_input: str) -> Any:
|
||||
return await _bound_result(await invoke_tool(ctx, raw_input))
|
||||
|
||||
tool.on_invoke_tool = invoke
|
||||
tool._strix_bounded = True # type: ignore[attr-defined]
|
||||
return tool
|
||||
return str(exc) or exc.__class__.__name__
|
||||
|
||||
|
||||
def _function_tool_with_error_result(tool: FunctionTool) -> FunctionTool:
|
||||
@@ -147,7 +112,7 @@ def _function_tool_with_error_result(tool: FunctionTool) -> FunctionTool:
|
||||
|
||||
async def invoke(ctx: Any, raw_input: str) -> Any:
|
||||
try:
|
||||
return await _bound_result(await invoke_tool(ctx, raw_input))
|
||||
return await invoke_tool(ctx, raw_input)
|
||||
except Exception as exc: # noqa: BLE001 - tool errors should be model-visible results.
|
||||
logger.debug("Tool %s failed; returning error as result", tool.name, exc_info=True)
|
||||
return _format_tool_error(exc)
|
||||
@@ -162,7 +127,7 @@ def _custom_tool_as_function_tool(tool: CustomTool) -> FunctionTool:
|
||||
if not custom_input:
|
||||
return f"`{_custom_tool_input_field(tool)}` must be a non-empty string."
|
||||
try:
|
||||
return await _bound_result(await tool.on_invoke_tool(ctx, custom_input))
|
||||
return await tool.on_invoke_tool(ctx, custom_input)
|
||||
except Exception as exc: # noqa: BLE001 - matches SDK CustomTool error-as-result behavior.
|
||||
logger.debug("Tool %s failed; returning error as result", tool.name, exc_info=True)
|
||||
return _format_tool_error(exc)
|
||||
@@ -194,35 +159,12 @@ def _custom_tool_as_function_tool(tool: CustomTool) -> FunctionTool:
|
||||
)
|
||||
|
||||
|
||||
def _bound_custom_tool(tool: CustomTool) -> CustomTool:
|
||||
"""Bound a native ``CustomTool`` result in place (Responses path)."""
|
||||
invoke_tool = tool.on_invoke_tool
|
||||
|
||||
async def invoke(ctx: Any, raw_input: str) -> Any:
|
||||
return await _bound_result(await invoke_tool(ctx, raw_input))
|
||||
|
||||
tool.on_invoke_tool = invoke
|
||||
return tool
|
||||
|
||||
|
||||
def _configure_filesystem_tools(toolset: Any, *, chat_completions: bool) -> None:
|
||||
def _configure_chat_completions_filesystem_tools(toolset: Any) -> None:
|
||||
for name, tool in vars(toolset).items():
|
||||
if chat_completions:
|
||||
if isinstance(tool, CustomTool):
|
||||
setattr(toolset, name, _custom_tool_as_function_tool(tool))
|
||||
elif isinstance(tool, FunctionTool):
|
||||
setattr(toolset, name, _function_tool_with_error_result(tool))
|
||||
elif isinstance(tool, CustomTool):
|
||||
setattr(toolset, name, _bound_custom_tool(tool))
|
||||
if isinstance(tool, CustomTool):
|
||||
setattr(toolset, name, _custom_tool_as_function_tool(tool))
|
||||
elif isinstance(tool, FunctionTool):
|
||||
setattr(toolset, name, _with_bounded_result(tool))
|
||||
|
||||
|
||||
def _make_filesystem_configurator(*, chat_completions: bool) -> Any:
|
||||
def configure(toolset: Any) -> None:
|
||||
_configure_filesystem_tools(toolset, chat_completions=chat_completions)
|
||||
|
||||
return configure
|
||||
setattr(toolset, name, _function_tool_with_error_result(tool))
|
||||
|
||||
|
||||
_CHARS_ESCAPE_RE = re.compile(r"\\(?:u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[0abtnvfr\\])")
|
||||
@@ -263,16 +205,6 @@ def _format_validation_error(tool_name: str, exc: ValidationError) -> str:
|
||||
return f"{tool_name}: invalid arguments — " + "; ".join(parts)
|
||||
|
||||
|
||||
def _apply_shell_output_cap(parsed: dict[str, Any]) -> None:
|
||||
"""Clamp the SDK shell tools' ``max_output_tokens`` to the configured
|
||||
ceiling; a smaller explicit value is respected."""
|
||||
ceiling = load_settings().context.tool_output_max_tokens
|
||||
requested = parsed.get("max_output_tokens")
|
||||
parsed["max_output_tokens"] = (
|
||||
ceiling if not isinstance(requested, int) or requested > ceiling else requested
|
||||
)
|
||||
|
||||
|
||||
def _wrap_exec_command(tool: FunctionTool) -> FunctionTool:
|
||||
invoke_tool = tool.on_invoke_tool
|
||||
|
||||
@@ -281,10 +213,8 @@ def _wrap_exec_command(tool: FunctionTool) -> FunctionTool:
|
||||
parsed = json.loads(raw_input)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
parsed = None
|
||||
if isinstance(parsed, dict):
|
||||
if "shell" not in parsed:
|
||||
parsed["shell"] = "bash"
|
||||
_apply_shell_output_cap(parsed)
|
||||
if isinstance(parsed, dict) and "shell" not in parsed:
|
||||
parsed["shell"] = "bash"
|
||||
raw_input = json.dumps(parsed)
|
||||
try:
|
||||
return await invoke_tool(ctx, raw_input)
|
||||
@@ -310,10 +240,8 @@ def _wrap_write_stdin(tool: FunctionTool) -> FunctionTool:
|
||||
parsed = json.loads(raw_input)
|
||||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
if isinstance(parsed, dict):
|
||||
if isinstance(parsed.get("chars"), str):
|
||||
parsed["chars"] = _decode_chars_escape(parsed["chars"])
|
||||
_apply_shell_output_cap(parsed)
|
||||
if isinstance(parsed, dict) and isinstance(parsed.get("chars"), str):
|
||||
parsed["chars"] = _decode_chars_escape(parsed["chars"])
|
||||
raw_input = json.dumps(parsed)
|
||||
try:
|
||||
return await invoke_tool(ctx, raw_input)
|
||||
@@ -415,8 +343,6 @@ _BASE_TOOLS: tuple[Tool, ...] = (
|
||||
web_search,
|
||||
create_vulnerability_report,
|
||||
create_dependency_report,
|
||||
list_reports,
|
||||
get_report,
|
||||
list_requests,
|
||||
view_request,
|
||||
repeat_request,
|
||||
@@ -514,9 +440,6 @@ def build_strix_agent(
|
||||
else:
|
||||
tools = [*_BASE_TOOLS, *agent_tools, agent_finish]
|
||||
_ensure_unique_tool_names(tools)
|
||||
tools = [
|
||||
_with_bounded_result(tool) if isinstance(tool, FunctionTool) else tool for tool in tools
|
||||
]
|
||||
|
||||
logger.info(
|
||||
"Built %s agent '%s' (skills=%d, tools=%d, scan_mode=%s, whitebox=%s)",
|
||||
@@ -536,8 +459,8 @@ def build_strix_agent(
|
||||
model=None,
|
||||
capabilities=[
|
||||
Filesystem(
|
||||
configure_tools=_make_filesystem_configurator(
|
||||
chat_completions=chat_completions_tools,
|
||||
configure_tools=(
|
||||
_configure_chat_completions_filesystem_tools if chat_completions_tools else None
|
||||
),
|
||||
),
|
||||
Shell(
|
||||
|
||||
@@ -188,7 +188,7 @@ EFFICIENCY TACTICS:
|
||||
script fail with `ModuleNotFoundError`.
|
||||
- `exec_command` runs each command in a fresh non-interactive shell (plain
|
||||
pipes, no TTY). To drive an interactive or long-running process with
|
||||
`write_stdin` — REPLs, `ssh`/`nc`/`ftp`, `sqlmap`, or to send Ctrl-C —
|
||||
`write_stdin` — REPLs, `ssh`/`nc`/`ftp`, `msfconsole`, or to send Ctrl-C —
|
||||
you MUST start it with `exec_command(cmd="...", tty=true)` and then
|
||||
`write_stdin(session_id=<id>, chars="...")`. Calling `write_stdin` on a
|
||||
default (non-TTY) command or on a process that has already exited fails with
|
||||
@@ -215,7 +215,6 @@ VALIDATION REQUIREMENTS:
|
||||
- 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
|
||||
- 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
|
||||
- REVIEWING FILED FINDINGS (orchestrator/root agent): use list_reports to see every vulnerability filed so far in this scan (by any agent, root or child) — metadata-first with per-severity counts — and get_report to read one finding in full by its id. These are read-only orchestration tools: the root agent uses them to track coverage, avoid dispatching work on already-covered ground, assemble the finish_scan executive summary, and reason about attack-chaining across confirmed findings. Leaf/specialist agents should NOT call them — just do your assigned testing and file findings. Each entry shows which agent filed it (agent_name), and your own entries are flagged by_you. list_notes/get_note do the same for notes.
|
||||
</execution_guidelines>
|
||||
|
||||
<vulnerability_focus>
|
||||
@@ -450,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, Node.js/npm
|
||||
- Python 3, Node.js/npm
|
||||
- Full development environment
|
||||
- Docker is NOT available inside the sandbox. Do not run docker; rely on provided tools to run locally.
|
||||
- You can install any additional tools/packages needed based on the task/context using package managers (apt, pip, npm, etc.)
|
||||
- You can install any additional tools/packages needed based on the task/context using package managers (apt, pip, npm, etc.). The Go toolchain is not bundled, so `go install` is unavailable; the Go-based scanners are prebuilt and already on PATH.
|
||||
|
||||
Directories:
|
||||
- /workspace - where you should work.
|
||||
|
||||
@@ -17,8 +17,6 @@ from strix.config.loader import (
|
||||
persist_current,
|
||||
)
|
||||
from strix.config.settings import (
|
||||
ContextSettings,
|
||||
DedupeSettings,
|
||||
IntegrationSettings,
|
||||
LlmSettings,
|
||||
RuntimeSettings,
|
||||
@@ -28,8 +26,6 @@ from strix.config.settings import (
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ContextSettings",
|
||||
"DedupeSettings",
|
||||
"IntegrationSettings",
|
||||
"LlmSettings",
|
||||
"RuntimeSettings",
|
||||
|
||||
@@ -1,411 +0,0 @@
|
||||
"""ChatGPT (Codex) subscription auth: OAuth login, token refresh, and the OpenAI
|
||||
client that routes inference through the ChatGPT backend.
|
||||
|
||||
Mirrors OpenAI's Codex CLI: OAuth 2.0 + PKCE against ``auth.openai.com``, with the
|
||||
access token sent as a ``Bearer`` token to ``chatgpt.com/backend-api/codex``. Using
|
||||
a ChatGPT subscription outside OpenAI's own products is not officially supported by
|
||||
OpenAI; the user chooses this path knowingly. The OAuth constants are OpenAI's own
|
||||
Codex CLI values (the backend only accepts that client).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
PROVIDER = "codex"
|
||||
|
||||
CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"
|
||||
TOKEN_URL = "https://auth.openai.com/oauth/token" # noqa: S105 # nosec B105 - URL, not a secret
|
||||
CALLBACK_HOST = "localhost"
|
||||
CALLBACK_PORT = 1455
|
||||
CALLBACK_PATH = "/auth/callback"
|
||||
REDIRECT_URI = f"http://{CALLBACK_HOST}:{CALLBACK_PORT}{CALLBACK_PATH}"
|
||||
SCOPE = "openid profile email offline_access"
|
||||
|
||||
CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
||||
ORIGINATOR = "codex_cli_rs"
|
||||
_ACCOUNT_CLAIM = "https://api.openai.com/auth"
|
||||
|
||||
_TOKEN_TIMEOUT = 30
|
||||
_EXPIRY_SKEW_S = 300
|
||||
|
||||
_refresh_lock = threading.Lock()
|
||||
|
||||
# Kept separate from cli-config.json so OAuth tokens never land in the env-var config.
|
||||
AUTH_PATH = Path.home() / ".strix" / "subscription-auth.json"
|
||||
|
||||
|
||||
def _read_store() -> dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(AUTH_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _write_store(data: dict[str, Any]) -> None:
|
||||
AUTH_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = AUTH_PATH.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
with contextlib.suppress(OSError):
|
||||
tmp.chmod(0o600)
|
||||
tmp.replace(AUTH_PATH)
|
||||
with contextlib.suppress(OSError):
|
||||
AUTH_PATH.chmod(0o600)
|
||||
|
||||
|
||||
def read_record() -> dict[str, Any] | None:
|
||||
record = _read_store().get(PROVIDER)
|
||||
if not isinstance(record, dict) or record.get("type") != "oauth":
|
||||
return None
|
||||
if not (record.get("access") and record.get("refresh") and record.get("account_id")):
|
||||
return None
|
||||
return record
|
||||
|
||||
|
||||
def is_authenticated() -> bool:
|
||||
return read_record() is not None
|
||||
|
||||
|
||||
def save_record(record: dict[str, Any]) -> None:
|
||||
data = _read_store()
|
||||
data[PROVIDER] = record
|
||||
_write_store(data)
|
||||
|
||||
|
||||
def logout() -> None:
|
||||
data = _read_store()
|
||||
if PROVIDER not in data:
|
||||
return
|
||||
del data[PROVIDER]
|
||||
if data:
|
||||
_write_store(data)
|
||||
return
|
||||
with contextlib.suppress(OSError):
|
||||
AUTH_PATH.unlink()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _refresh_guard() -> Iterator[None]:
|
||||
"""Serialize token refresh within (lock) and across (flock) Strix processes,
|
||||
so concurrent runs can't both spend the single-use refresh token."""
|
||||
with _refresh_lock:
|
||||
try:
|
||||
import fcntl
|
||||
|
||||
lock_path = AUTH_PATH.with_suffix(".lock")
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handle = lock_path.open("w")
|
||||
except (ImportError, OSError):
|
||||
yield
|
||||
return
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
with contextlib.suppress(OSError):
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
handle.close()
|
||||
|
||||
|
||||
class CodexAuthError(Exception):
|
||||
def __init__(self, code: str, message: str | None = None) -> None:
|
||||
self.code = code
|
||||
super().__init__(message or code)
|
||||
|
||||
|
||||
class CodexContentGuardrailError(Exception):
|
||||
"""The ChatGPT backend refused a request via its content guardrail.
|
||||
Terminal — retrying identical content never clears the block."""
|
||||
|
||||
def __init__(self, model: str, original: BaseException | None = None) -> None:
|
||||
self.model = model
|
||||
self.original = original
|
||||
super().__init__(
|
||||
f"'{model}' was blocked by ChatGPT's content guardrails "
|
||||
f"(flagged as a possible cybersecurity risk). "
|
||||
f"Set STRIX_LLM to a model that isn't blocked and re-run."
|
||||
)
|
||||
|
||||
|
||||
_GUARDRAIL_MARKERS = (
|
||||
"flagged for possible cybersecurity risk",
|
||||
"trusted access for cyber",
|
||||
)
|
||||
|
||||
|
||||
def is_content_guardrail_error(exc: BaseException) -> bool:
|
||||
if isinstance(exc, CodexContentGuardrailError):
|
||||
return True
|
||||
text = str(exc).lower()
|
||||
return any(marker in text for marker in _GUARDRAIL_MARKERS)
|
||||
|
||||
|
||||
def _b64url(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def generate_pkce() -> tuple[str, str]:
|
||||
verifier = _b64url(secrets.token_bytes(64))
|
||||
challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
|
||||
return verifier, challenge
|
||||
|
||||
|
||||
def create_state() -> str:
|
||||
return secrets.token_hex(16)
|
||||
|
||||
|
||||
def build_authorize_url(challenge: str, state: str) -> str:
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": CLIENT_ID,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
"scope": SCOPE,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"state": state,
|
||||
"id_token_add_organizations": "true",
|
||||
"codex_cli_simplified_flow": "true",
|
||||
"originator": ORIGINATOR,
|
||||
}
|
||||
return f"{AUTHORIZE_URL}?{urllib.parse.urlencode(params)}"
|
||||
|
||||
|
||||
def parse_redirect_input(value: str) -> tuple[str | None, str | None]:
|
||||
"""Extract ``(code, state)`` from a pasted redirect URL, ``code#state``,
|
||||
query string, or bare code."""
|
||||
value = (value or "").strip()
|
||||
if not value:
|
||||
return None, None
|
||||
with contextlib.suppress(ValueError):
|
||||
parsed = urllib.parse.urlparse(value)
|
||||
if parsed.scheme and parsed.query:
|
||||
query = urllib.parse.parse_qs(parsed.query)
|
||||
return _first(query, "code"), _first(query, "state")
|
||||
if "#" in value:
|
||||
code, _, state = value.partition("#")
|
||||
return code or None, state or None
|
||||
if "code=" in value:
|
||||
query = urllib.parse.parse_qs(value)
|
||||
return _first(query, "code"), _first(query, "state")
|
||||
return value, None
|
||||
|
||||
|
||||
def _first(query: dict[str, list[str]], key: str) -> str | None:
|
||||
values = query.get(key)
|
||||
return values[0] if values else None
|
||||
|
||||
|
||||
def _post_form(payload: dict[str, str]) -> dict[str, Any]:
|
||||
body = urllib.parse.urlencode(payload).encode("ascii")
|
||||
request = urllib.request.Request( # noqa: S310 - fixed https OAuth endpoint
|
||||
TOKEN_URL,
|
||||
data=body,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen( # noqa: S310 # nosec B310 - fixed https endpoint
|
||||
request, timeout=_TOKEN_TIMEOUT
|
||||
) as response:
|
||||
data = json.loads(response.read() or b"{}")
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", "replace")[:300]
|
||||
raise CodexAuthError("token_http_error", f"HTTP {exc.code}: {detail}") from exc
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||
raise CodexAuthError("unavailable", str(exc)) from exc
|
||||
if not isinstance(data, dict):
|
||||
raise CodexAuthError("bad_response", "token endpoint returned non-object")
|
||||
return data
|
||||
|
||||
|
||||
def _record_from_token_response(
|
||||
data: dict[str, Any], refresh_fallback: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
access = data.get("access_token")
|
||||
# A refresh response may omit refresh_token when it isn't rotated; keep the old one.
|
||||
refresh = data.get("refresh_token") or refresh_fallback
|
||||
expires_in = data.get("expires_in")
|
||||
if not isinstance(access, str) or not access:
|
||||
raise CodexAuthError("bad_response", "token response missing access_token")
|
||||
if not isinstance(refresh, str) or not refresh:
|
||||
raise CodexAuthError("bad_response", "token response missing refresh_token")
|
||||
account_id = _account_id_from_jwt(access) or _account_id_from_jwt(
|
||||
data.get("id_token") if isinstance(data.get("id_token"), str) else ""
|
||||
)
|
||||
if not account_id:
|
||||
raise CodexAuthError("no_account_id", "could not read chatgpt_account_id from token")
|
||||
ttl = expires_in if isinstance(expires_in, int | float) else 3600
|
||||
return {
|
||||
"type": "oauth",
|
||||
"provider": PROVIDER,
|
||||
"access": access,
|
||||
"refresh": refresh,
|
||||
"account_id": account_id,
|
||||
"expires_at": time.time() + ttl,
|
||||
}
|
||||
|
||||
|
||||
def exchange_code(code: str, verifier: str) -> dict[str, Any]:
|
||||
data = _post_form(
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": CLIENT_ID,
|
||||
"code": code,
|
||||
"code_verifier": verifier,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
}
|
||||
)
|
||||
return _record_from_token_response(data)
|
||||
|
||||
|
||||
def refresh_tokens(refresh_token: str) -> dict[str, Any]:
|
||||
data = _post_form(
|
||||
{
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": CLIENT_ID,
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
)
|
||||
return _record_from_token_response(data, refresh_fallback=refresh_token)
|
||||
|
||||
|
||||
def _account_id_from_jwt(token: str | None) -> str | None:
|
||||
"""Read the account id claim without verifying the JWT (the server enforces
|
||||
authenticity on use); it feeds the ``chatgpt-account-id`` header."""
|
||||
if not token or token.count(".") != 2:
|
||||
return None
|
||||
payload_b64 = token.split(".")[1]
|
||||
padding = "=" * (-len(payload_b64) % 4)
|
||||
try:
|
||||
payload = json.loads(base64.urlsafe_b64decode(payload_b64 + padding))
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
auth = payload.get(_ACCOUNT_CLAIM)
|
||||
if isinstance(auth, dict):
|
||||
account_id = auth.get("chatgpt_account_id")
|
||||
if isinstance(account_id, str) and account_id:
|
||||
return account_id
|
||||
organizations = payload.get("organizations")
|
||||
if isinstance(organizations, list) and organizations and isinstance(organizations[0], dict):
|
||||
org_id = organizations[0].get("id")
|
||||
if isinstance(org_id, str) and org_id:
|
||||
return org_id
|
||||
return None
|
||||
|
||||
|
||||
def _near_expiry(record: dict[str, Any]) -> bool:
|
||||
expires_at = record.get("expires_at")
|
||||
if not isinstance(expires_at, int | float):
|
||||
return True
|
||||
return expires_at - _EXPIRY_SKEW_S <= time.time()
|
||||
|
||||
|
||||
def get_valid_token() -> tuple[str, str]:
|
||||
"""Return ``(access_token, account_id)``, refreshing under the cross-process
|
||||
guard if near expiry."""
|
||||
record = read_record()
|
||||
if record is None:
|
||||
raise CodexAuthError("not_authenticated", "not signed in; run: strix auth login")
|
||||
if not _near_expiry(record):
|
||||
return record["access"], record["account_id"]
|
||||
with _refresh_guard():
|
||||
record = read_record()
|
||||
if record is None:
|
||||
raise CodexAuthError("not_authenticated", "not signed in; run: strix auth login")
|
||||
if not _near_expiry(record):
|
||||
return record["access"], record["account_id"]
|
||||
try:
|
||||
refreshed = refresh_tokens(record["refresh"])
|
||||
except CodexAuthError:
|
||||
# A peer process may have already spent this single-use refresh token.
|
||||
latest = read_record()
|
||||
if latest and latest["refresh"] != record["refresh"] and not _near_expiry(latest):
|
||||
return latest["access"], latest["account_id"]
|
||||
raise
|
||||
save_record(refreshed)
|
||||
return refreshed["access"], refreshed["account_id"]
|
||||
|
||||
|
||||
def build_openai_client() -> AsyncOpenAI:
|
||||
"""An ``AsyncOpenAI`` for the ChatGPT backend. A per-request hook re-stamps a
|
||||
fresh bearer token so long scans survive token expiry."""
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
get_valid_token() # fail fast at configure time if the sign-in is dead
|
||||
|
||||
async def _auth_hook(request: httpx.Request) -> None:
|
||||
access, account_id = await asyncio.to_thread(get_valid_token)
|
||||
request.headers["Authorization"] = f"Bearer {access}"
|
||||
request.headers["chatgpt-account-id"] = account_id
|
||||
|
||||
http_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(600.0, connect=30.0),
|
||||
event_hooks={"request": [_auth_hook]},
|
||||
)
|
||||
return AsyncOpenAI(
|
||||
api_key="strix-codex-oauth", # placeholder; the hook overwrites Authorization
|
||||
base_url=CODEX_BASE_URL,
|
||||
http_client=http_client,
|
||||
default_headers={
|
||||
"OpenAI-Beta": "responses=experimental",
|
||||
"originator": ORIGINATOR,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
_subscription_client: AsyncOpenAI | None = None
|
||||
|
||||
|
||||
def get_subscription_client() -> AsyncOpenAI:
|
||||
global _subscription_client # noqa: PLW0603
|
||||
if _subscription_client is None:
|
||||
_subscription_client = build_openai_client()
|
||||
return _subscription_client
|
||||
|
||||
|
||||
SUBSCRIPTION_PREFIX = "chatgpt/"
|
||||
|
||||
|
||||
def subscription_model(model_name: str | None) -> str | None:
|
||||
"""The model slug behind a ``chatgpt/<model>`` STRIX_LLM, or None."""
|
||||
name = (model_name or "").strip()
|
||||
if not name.lower().startswith(SUBSCRIPTION_PREFIX):
|
||||
return None
|
||||
return name[len(SUBSCRIPTION_PREFIX) :] or None
|
||||
|
||||
|
||||
def auth_mode(model_name: str | None) -> str:
|
||||
return "subscription" if subscription_model(model_name) else "api_key"
|
||||
+12
-192
@@ -2,38 +2,23 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import inspect
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from agents import (
|
||||
set_default_openai_api,
|
||||
set_default_openai_key,
|
||||
set_tracing_disabled,
|
||||
)
|
||||
from agents.model_settings import ModelSettings
|
||||
from agents import set_default_openai_api, set_default_openai_key, set_tracing_disabled
|
||||
from agents.models.multi_provider import MultiProvider
|
||||
from agents.models.openai_responses import OpenAIResponsesModel
|
||||
from agents.retry import (
|
||||
ModelRetryBackoffSettings,
|
||||
ModelRetrySettings,
|
||||
RetryPolicyContext,
|
||||
retry_policies,
|
||||
)
|
||||
from openai.types.shared import Reasoning
|
||||
|
||||
from strix.config import codex
|
||||
from strix.config.loader import load_settings
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
from agents.models.interface import ModelProvider
|
||||
|
||||
from agents.models.interface import Model, ModelProvider
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from strix.config.settings import ReasoningEffort, Settings
|
||||
from strix.config.settings import Settings
|
||||
|
||||
|
||||
def request_timeout_extra_args(timeout_s: float | None) -> dict[str, float] | None:
|
||||
@@ -48,93 +33,9 @@ def _retry_statusless_provider_errors(context: RetryPolicyContext) -> bool:
|
||||
normalized = context.normalized
|
||||
if normalized.is_abort:
|
||||
return False
|
||||
if codex.is_content_guardrail_error(context.error):
|
||||
return False
|
||||
return normalized.status_code is None
|
||||
|
||||
|
||||
class _CodexResponsesModel(OpenAIResponsesModel):
|
||||
"""Responses model for the ChatGPT subscription backend (always streamed, stateless)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str,
|
||||
openai_client: AsyncOpenAI,
|
||||
*,
|
||||
reasoning_effort: ReasoningEffort | None = None,
|
||||
) -> None:
|
||||
super().__init__(model, openai_client)
|
||||
self._reasoning_effort = reasoning_effort
|
||||
|
||||
def _codex_settings(self, model_settings: ModelSettings) -> ModelSettings:
|
||||
overrides = ModelSettings(store=False, response_include=["reasoning.encrypted_content"])
|
||||
effort = self._reasoning_effort
|
||||
if effort and effort != "none":
|
||||
# Clamp to efforts the backend accepts.
|
||||
if effort == "minimal":
|
||||
effort = "low"
|
||||
elif effort == "xhigh":
|
||||
effort = "high"
|
||||
overrides = overrides.resolve(ModelSettings(reasoning=Reasoning(effort=effort)))
|
||||
return model_settings.resolve(overrides)
|
||||
|
||||
async def _fetch_response(self, *args: Any, stream: bool = False, **kwargs: Any) -> Any:
|
||||
if len(args) >= 3: # model_settings is positional arg 2
|
||||
args = (*args[:2], self._codex_settings(args[2]), *args[3:])
|
||||
try:
|
||||
events = await super()._fetch_response(*args, stream=True, **kwargs) # type: ignore[call-overload]
|
||||
except Exception as exc:
|
||||
guardrail = self._as_guardrail(exc)
|
||||
if guardrail is not None:
|
||||
raise guardrail from exc
|
||||
raise
|
||||
guarded = self._guarded(events)
|
||||
if stream:
|
||||
return guarded
|
||||
final_response = None
|
||||
async for event in guarded:
|
||||
if getattr(event, "type", None) == "response.completed":
|
||||
final_response = event.response
|
||||
if final_response is None:
|
||||
msg = "ChatGPT backend stream ended without a completed response"
|
||||
raise RuntimeError(msg)
|
||||
return final_response
|
||||
|
||||
def _as_guardrail(self, exc: BaseException) -> codex.CodexContentGuardrailError | None:
|
||||
if isinstance(exc, codex.CodexContentGuardrailError):
|
||||
return exc
|
||||
if codex.is_content_guardrail_error(exc):
|
||||
return codex.CodexContentGuardrailError(self.model, exc)
|
||||
return None
|
||||
|
||||
async def _guarded(self, events: Any) -> AsyncIterator[Any]:
|
||||
"""Convert mid-stream guardrail rejections and close the stream on exit."""
|
||||
try:
|
||||
async for event in events:
|
||||
yield event
|
||||
except Exception as exc:
|
||||
guardrail = self._as_guardrail(exc)
|
||||
if guardrail is not None:
|
||||
raise guardrail from exc
|
||||
raise
|
||||
finally:
|
||||
await self._aclose(events)
|
||||
|
||||
@staticmethod
|
||||
async def _aclose(events: Any) -> None:
|
||||
aclose = getattr(events, "aclose", None)
|
||||
if callable(aclose):
|
||||
with contextlib.suppress(Exception):
|
||||
await aclose()
|
||||
return
|
||||
close = getattr(events, "close", None)
|
||||
if callable(close):
|
||||
with contextlib.suppress(Exception):
|
||||
result = close()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
|
||||
class StrixProvider(MultiProvider):
|
||||
"""Route any non-OpenAI prefix through LiteLLM with the prefix preserved,
|
||||
so users type ``deepseek/deepseek-chat`` rather than
|
||||
@@ -158,16 +59,6 @@ class StrixProvider(MultiProvider):
|
||||
return self._get_fallback_provider("litellm"), f"ollama_chat/{stripped_model_name}"
|
||||
return self._get_fallback_provider("litellm"), original_model_name
|
||||
|
||||
def get_model(self, model_name: str | None) -> Model:
|
||||
slug = codex.subscription_model(model_name)
|
||||
if slug:
|
||||
return _CodexResponsesModel(
|
||||
slug,
|
||||
codex.get_subscription_client(),
|
||||
reasoning_effort=load_settings().llm.reasoning_effort,
|
||||
)
|
||||
return super().get_model(model_name)
|
||||
|
||||
|
||||
DEFAULT_MODEL_RETRY = ModelRetrySettings(
|
||||
max_retries=5,
|
||||
@@ -186,42 +77,39 @@ DEFAULT_MODEL_RETRY = ModelRetrySettings(
|
||||
)
|
||||
|
||||
RECOMMENDED_MODEL_NAMES = (
|
||||
"openai/gpt-5.6",
|
||||
"openai/gpt-5.6-sol",
|
||||
"openai/gpt-5.6-terra",
|
||||
"openai/gpt-5.6-luna",
|
||||
"openai/gpt-5.6",
|
||||
"openai/gpt-5.5-pro",
|
||||
"openai/gpt-5.5",
|
||||
"openai/gpt-5.5-pro",
|
||||
"openai/gpt-5.4",
|
||||
"openai/gpt-5.3-codex",
|
||||
"anthropic/claude-fable-5",
|
||||
"anthropic/claude-opus-5",
|
||||
"anthropic/claude-opus-4-8",
|
||||
"anthropic/claude-opus-4-7",
|
||||
"anthropic/claude-sonnet-5",
|
||||
"anthropic/claude-sonnet-4-6",
|
||||
"vertex_ai/gemini-3.1-pro-preview",
|
||||
"gemini/gemini-3.1-pro-preview",
|
||||
"gemini/gemini-3.6-flash",
|
||||
"deepseek/deepseek-v4-pro",
|
||||
"deepseek/deepseek-v4-flash",
|
||||
"dashscope/qwen3.8-max",
|
||||
"dashscope/qwen3.7-max-2026-06-08",
|
||||
"moonshot/kimi-k3",
|
||||
"moonshot/kimi-k2.7-code",
|
||||
"moonshot/kimi-k2.6",
|
||||
)
|
||||
|
||||
_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",)),
|
||||
(("azure", "azure_ai", "bedrock_mantle", "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"),
|
||||
("claude-fable-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")),
|
||||
(("alibaba", "dashscope", "qwen"), ("qwen3.7", "qwen3.5", "qwen3-max")),
|
||||
(("moonshot", "moonshotai", "kimi"), ("kimi-k2.7", "kimi-k2.6", "kimi-k2.5")),
|
||||
)
|
||||
|
||||
|
||||
@@ -229,8 +117,6 @@ def configure_sdk_model_defaults(settings: Settings) -> None:
|
||||
"""Apply Strix config to SDK-native defaults."""
|
||||
llm = settings.llm
|
||||
set_tracing_disabled(True)
|
||||
if codex.subscription_model(llm.model):
|
||||
return
|
||||
_configure_litellm_compatibility()
|
||||
_configure_openrouter_attribution(llm.model)
|
||||
if llm.api_key:
|
||||
@@ -325,8 +211,6 @@ def _configure_litellm_default(name: str, value: str) -> None:
|
||||
|
||||
def uses_chat_completions_tool_schema(model_name: str, settings: Settings) -> bool:
|
||||
"""Return whether the resolved SDK route can only receive JSON function tools."""
|
||||
if codex.subscription_model(model_name):
|
||||
return False
|
||||
model = model_name.strip().lower()
|
||||
if "/" in model and not model.startswith("openai/"):
|
||||
return True
|
||||
@@ -429,67 +313,3 @@ def is_known_openai_bare_model(model_name: str) -> bool:
|
||||
return False
|
||||
entry = litellm.model_cost.get(name)
|
||||
return bool(entry and entry.get("litellm_provider") == "openai")
|
||||
|
||||
|
||||
def is_claude_model(model_name: str) -> bool:
|
||||
return "claude" in (model_name or "").strip().lower()
|
||||
|
||||
|
||||
def is_bedrock_route(model_name: str) -> bool:
|
||||
"""Whether ``model_name`` resolves to an AWS Bedrock route.
|
||||
|
||||
Matches the ``bedrock/...`` LiteLLM route prefix and bare Bedrock model ids
|
||||
(``[region.]anthropic.claude-...``).
|
||||
"""
|
||||
name = (model_name or "").strip().lower()
|
||||
return name.startswith("bedrock/") or "anthropic." in name
|
||||
|
||||
|
||||
def _prompt_cache_name_candidates(model_name: str) -> list[str]:
|
||||
"""Candidate LiteLLM model-map keys for ``model_name``, most→least specific.
|
||||
|
||||
LiteLLM keys the same model under several names (``bedrock/global.anthropic.
|
||||
claude-opus-4-1``, ``anthropic.claude-opus-4-1``, ``claude-opus-4-1``) and not
|
||||
every provider/region-prefixed variant is present for every model. Strip the
|
||||
LiteLLM route prefix, then leading dotted segments (region, then provider) so
|
||||
a prefixed name still resolves to a bare key.
|
||||
"""
|
||||
name = (model_name or "").strip().lower()
|
||||
for prefix in ("litellm/", "bedrock/"):
|
||||
if name.startswith(prefix):
|
||||
name = name[len(prefix) :]
|
||||
break
|
||||
candidates = [name]
|
||||
rest = name
|
||||
while "." in rest:
|
||||
rest = rest.split(".", 1)[1]
|
||||
candidates.append(rest)
|
||||
return candidates
|
||||
|
||||
|
||||
def bedrock_route_supports_prompt_caching(model_name: str) -> bool:
|
||||
"""Whether LiteLLM can confirm this Bedrock model supports prompt caching.
|
||||
|
||||
Bedrock's Converse API rejects unknown request fields outright
|
||||
(``ValidationException: cache_control_injection_points: Extra inputs are
|
||||
not permitted``), and LiteLLM only consumes the cache marker for models its
|
||||
(statically bundled) model map recognises as cache-capable. For a Bedrock
|
||||
model missing from that map — a just-released model, or any model when the
|
||||
remote model-map refresh fails and a stale local copy is used — the marker
|
||||
would pass straight through and fail every call, so callers must withhold
|
||||
it unless support is confirmed here.
|
||||
"""
|
||||
import litellm
|
||||
|
||||
checker = getattr(getattr(litellm, "utils", None), "supports_prompt_caching", None)
|
||||
for cand in _prompt_cache_name_candidates(model_name):
|
||||
if checker is not None:
|
||||
# supports_prompt_caching raises for models missing from the map;
|
||||
# keep checking the remaining name candidates.
|
||||
with contextlib.suppress(Exception):
|
||||
if checker(cand):
|
||||
return True
|
||||
entry = litellm.model_cost.get(cand)
|
||||
if entry and entry.get("supports_prompt_caching"):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -40,50 +40,14 @@ class LlmSettings(BaseSettings):
|
||||
default=False,
|
||||
alias="STRIX_FORCE_REQUIRED_TOOL_CHOICE",
|
||||
)
|
||||
prompt_cache: bool = Field(
|
||||
default=True,
|
||||
alias="STRIX_PROMPT_CACHE",
|
||||
)
|
||||
timeout: int = Field(default=300, alias="LLM_TIMEOUT")
|
||||
|
||||
|
||||
class DedupeSettings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
model: str | None = Field(default=None, alias="STRIX_DEDUPE_MODEL")
|
||||
reasoning_effort: ReasoningEffort | None = Field(
|
||||
default=None,
|
||||
alias="STRIX_DEDUPE_REASONING_EFFORT",
|
||||
)
|
||||
api_key: str | None = Field(default=None, alias="DEDUPE_LLM_API_KEY")
|
||||
api_base: str | None = Field(default=None, alias="DEDUPE_LLM_API_BASE")
|
||||
|
||||
|
||||
class ContextSettings(BaseSettings):
|
||||
"""Context-window management: per-tool-output caps and history compaction."""
|
||||
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
auto_compact: bool = Field(default=True, alias="STRIX_CONTEXT_AUTO_COMPACT")
|
||||
compact_buffer_tokens: int = Field(default=20_000, gt=0, alias="STRIX_CONTEXT_BUFFER_TOKENS")
|
||||
keep_tokens: int = Field(default=8_000, gt=0, alias="STRIX_CONTEXT_KEEP_TOKENS")
|
||||
fallback_context_tokens: int = Field(
|
||||
default=200_000, gt=0, alias="STRIX_CONTEXT_FALLBACK_TOKENS"
|
||||
)
|
||||
summary_max_tokens: int = Field(default=4_096, gt=0, alias="STRIX_CONTEXT_SUMMARY_TOKENS")
|
||||
tool_output_max_tokens: int = Field(default=8_000, gt=0, alias="STRIX_TOOL_OUTPUT_MAX_TOKENS")
|
||||
tool_output_max_lines: int = Field(default=2_000, gt=0, alias="STRIX_TOOL_OUTPUT_MAX_LINES")
|
||||
# Floor above the truncation-notice size so a preview always fits.
|
||||
tool_output_max_bytes: int = Field(
|
||||
default=50 * 1024, ge=1024, alias="STRIX_TOOL_OUTPUT_MAX_BYTES"
|
||||
)
|
||||
|
||||
|
||||
class RuntimeSettings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
image: str = Field(
|
||||
default="ghcr.io/usestrix/strix-sandbox:1.1.0",
|
||||
default="ghcr.io/usestrix/strix-sandbox:1.0.0",
|
||||
alias="STRIX_IMAGE",
|
||||
)
|
||||
backend: str = Field(default="docker", alias="STRIX_RUNTIME_BACKEND")
|
||||
@@ -121,9 +85,7 @@ class Settings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
llm: LlmSettings = Field(default_factory=LlmSettings)
|
||||
dedupe: DedupeSettings = Field(default_factory=DedupeSettings)
|
||||
runtime: RuntimeSettings = Field(default_factory=RuntimeSettings)
|
||||
context: ContextSettings = Field(default_factory=ContextSettings)
|
||||
telemetry: TelemetrySettings = Field(default_factory=TelemetrySettings)
|
||||
integrations: IntegrationSettings = Field(default_factory=IntegrationSettings)
|
||||
viewer: ViewerSettings = Field(default_factory=ViewerSettings)
|
||||
|
||||
+3
-18
@@ -41,7 +41,6 @@ class AgentCoordinator:
|
||||
self.names: dict[str, str] = {}
|
||||
self.metadata: dict[str, dict[str, Any]] = {}
|
||||
self.pending_counts: dict[str, int] = {}
|
||||
self.errors: dict[str, str] = {}
|
||||
self.runtimes: dict[str, AgentRuntime] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
self._snapshot_path: Path | None = None
|
||||
@@ -108,23 +107,16 @@ class AgentCoordinator:
|
||||
async with self._lock:
|
||||
if agent_id in self.statuses:
|
||||
self.statuses[agent_id] = "running"
|
||||
self.errors.pop(agent_id, None)
|
||||
await self._maybe_snapshot()
|
||||
|
||||
async def park_waiting(self, agent_id: str) -> None:
|
||||
await self.set_status(agent_id, "waiting")
|
||||
|
||||
async def set_status(
|
||||
self, agent_id: str, status: Status | str, *, error: str | None = None
|
||||
) -> None:
|
||||
async def set_status(self, agent_id: str, status: Status | str) -> None:
|
||||
async with self._lock:
|
||||
if agent_id not in self.statuses:
|
||||
return
|
||||
self.statuses[agent_id] = status # type: ignore[assignment]
|
||||
if error is not None:
|
||||
self.errors[agent_id] = error
|
||||
elif status == "running":
|
||||
self.errors.pop(agent_id, None)
|
||||
runtime = self.runtimes.setdefault(agent_id, AgentRuntime())
|
||||
runtime.wake.set()
|
||||
logger.info("agent.status %s=%s", agent_id, status)
|
||||
@@ -254,14 +246,9 @@ class AgentCoordinator:
|
||||
|
||||
async def graph_snapshot(
|
||||
self,
|
||||
) -> tuple[dict[str, str | None], dict[str, Status], dict[str, str], dict[str, str]]:
|
||||
) -> tuple[dict[str, str | None], dict[str, Status], dict[str, str]]:
|
||||
async with self._lock:
|
||||
return (
|
||||
dict(self.parent_of),
|
||||
dict(self.statuses),
|
||||
dict(self.names),
|
||||
dict(self.errors),
|
||||
)
|
||||
return dict(self.parent_of), dict(self.statuses), dict(self.names)
|
||||
|
||||
def _message_to_session_item(self, message: dict[str, Any]) -> TResponseInputItem:
|
||||
sender = str(message.get("from", "unknown"))
|
||||
@@ -299,7 +286,6 @@ class AgentCoordinator:
|
||||
"names": dict(self.names),
|
||||
"metadata": {aid: dict(md) for aid, md in self.metadata.items()},
|
||||
"pending_counts": dict(self.pending_counts),
|
||||
"errors": dict(self.errors),
|
||||
}
|
||||
|
||||
async def restore(self, snap: dict[str, Any]) -> None:
|
||||
@@ -309,7 +295,6 @@ class AgentCoordinator:
|
||||
self.names = dict(snap.get("names", {}))
|
||||
self.metadata = {aid: dict(md) for aid, md in snap.get("metadata", {}).items()}
|
||||
self.pending_counts = dict(snap.get("pending_counts", {}))
|
||||
self.errors = dict(snap.get("errors", {}))
|
||||
for aid in self.statuses:
|
||||
self.runtimes.setdefault(aid, AgentRuntime())
|
||||
|
||||
|
||||
+3
-61
@@ -22,7 +22,6 @@ from strix.core.sessions import (
|
||||
open_agent_session,
|
||||
strip_all_images_from_session,
|
||||
)
|
||||
from strix.llm.compaction import is_context_overflow, maybe_compact
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -41,41 +40,6 @@ logger = logging.getLogger(__name__)
|
||||
StreamEventSink = Callable[[str, Any], None]
|
||||
|
||||
_INPUT_REJECTION_CODES = frozenset({400, 404, 422})
|
||||
_MAX_COMPACTIONS_PER_CYCLE = 2
|
||||
|
||||
|
||||
def _run_config_model(run_config: RunConfig) -> str | None:
|
||||
return run_config.model if isinstance(run_config.model, str) else None
|
||||
|
||||
|
||||
def _agent_instructions(agent: Any) -> str:
|
||||
instructions = getattr(agent, "instructions", None)
|
||||
return instructions if isinstance(instructions, str) else ""
|
||||
|
||||
|
||||
def _agent_tools_text(agent: Any) -> str:
|
||||
parts: list[str] = []
|
||||
for tool in getattr(agent, "tools", []) or []:
|
||||
name = getattr(tool, "name", "")
|
||||
description = getattr(tool, "description", "") or ""
|
||||
schema = getattr(tool, "params_json_schema", "") or ""
|
||||
parts.append(f"{name} {description} {schema}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
async def _compact_session(
|
||||
agent: Any, session: Session, run_config: RunConfig, *, force: bool
|
||||
) -> bool:
|
||||
model = _run_config_model(run_config)
|
||||
if session is None or model is None:
|
||||
return False
|
||||
return await maybe_compact(
|
||||
session,
|
||||
model=model,
|
||||
instructions=_agent_instructions(agent),
|
||||
tools_text=_agent_tools_text(agent),
|
||||
force=force,
|
||||
)
|
||||
|
||||
|
||||
async def run_agent_loop(
|
||||
@@ -386,7 +350,6 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
||||
hooks: RunHooks[dict[str, Any]] | None,
|
||||
) -> RunResultBase | None:
|
||||
image_strips = 0
|
||||
compactions = 0
|
||||
while True:
|
||||
try:
|
||||
await coordinator.mark_running(agent_id)
|
||||
@@ -397,10 +360,6 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
||||
await enforce_image_budget(session, max_images)
|
||||
except Exception:
|
||||
logger.exception("image-budget enforcement failed for %s", agent_id)
|
||||
try:
|
||||
await _compact_session(agent, session, run_config, force=False)
|
||||
except Exception:
|
||||
logger.exception("proactive compaction failed for %s", agent_id)
|
||||
stream = Runner.run_streamed(
|
||||
agent,
|
||||
input=input_data,
|
||||
@@ -469,25 +428,6 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
||||
)
|
||||
input_data = []
|
||||
continue
|
||||
if (
|
||||
compactions < _MAX_COMPACTIONS_PER_CYCLE
|
||||
and session is not None
|
||||
and is_context_overflow(exc)
|
||||
):
|
||||
try:
|
||||
compacted = await _compact_session(agent, session, run_config, force=True)
|
||||
except Exception:
|
||||
logger.exception("overflow compaction recovery failed for %s", agent_id)
|
||||
compacted = False
|
||||
if compacted:
|
||||
compactions += 1
|
||||
logger.info(
|
||||
"Compacted %s session after context overflow; retrying (%d)",
|
||||
agent_id,
|
||||
compactions,
|
||||
)
|
||||
input_data = []
|
||||
continue
|
||||
if not interactive:
|
||||
raise
|
||||
if isinstance(exc, MaxTurnsExceeded):
|
||||
@@ -497,8 +437,10 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
||||
else:
|
||||
status = "crashed"
|
||||
logger.exception("agent run failed for %s; parking as %s", agent_id, status)
|
||||
await coordinator.set_status(agent_id, status, error=str(exc) or type(exc).__name__)
|
||||
await coordinator.set_status(agent_id, status)
|
||||
await _notify_parent_on_crash(coordinator, agent_id, status)
|
||||
if context.get("parent_id") is None and status in {"failed", "crashed"}:
|
||||
raise
|
||||
return None
|
||||
else:
|
||||
await _settle_run_result(coordinator, agent_id, interactive)
|
||||
|
||||
@@ -10,9 +10,6 @@ from openai.types.shared import Reasoning
|
||||
|
||||
from strix.config.models import (
|
||||
DEFAULT_MODEL_RETRY,
|
||||
bedrock_route_supports_prompt_caching,
|
||||
is_bedrock_route,
|
||||
is_claude_model,
|
||||
is_known_openai_bare_model,
|
||||
model_supports_reasoning,
|
||||
request_timeout_extra_args,
|
||||
@@ -131,7 +128,6 @@ def make_model_settings(
|
||||
model_name: str,
|
||||
force_required_tool_choice: bool = False,
|
||||
request_timeout: float | None = None,
|
||||
prompt_cache: bool = True,
|
||||
) -> ModelSettings:
|
||||
model_settings = ModelSettings(
|
||||
parallel_tool_calls=False,
|
||||
@@ -149,95 +145,9 @@ def make_model_settings(
|
||||
)
|
||||
if force_required_tool_choice and _accepts_required_tool_choice(model_name):
|
||||
model_settings = model_settings.resolve(ModelSettings(tool_choice="required"))
|
||||
|
||||
cache_extra_args = _prompt_cache_extra_args(model_name) if prompt_cache else None
|
||||
if cache_extra_args:
|
||||
# Merge into any existing extra_args (e.g. the request timeout) rather
|
||||
# than relying on resolve()'s dict-merge semantics, so it is obvious at
|
||||
# the call site that unrelated LiteLLM options are preserved.
|
||||
model_settings = model_settings.resolve(
|
||||
ModelSettings(
|
||||
extra_args={**(model_settings.extra_args or {}), **cache_extra_args},
|
||||
),
|
||||
)
|
||||
return model_settings
|
||||
|
||||
|
||||
def _prompt_cache_extra_args(model_name: str) -> dict[str, Any] | None:
|
||||
"""LiteLLM ``extra_args`` that enable Anthropic/Bedrock prompt caching.
|
||||
|
||||
A Strix scan is a long, multi-turn agentic loop that re-sends a large,
|
||||
STABLE prefix every turn — the system prompt plus the tool schemas — AND an
|
||||
append-only conversation transcript that only grows. Without caching
|
||||
breakpoints the whole request is re-tokenised and billed at the full input
|
||||
rate on every turn; on Claude that is the single biggest lever on scan cost
|
||||
(measured: ``cache-read 0% -> ~66%`` on a real scan once these points are set).
|
||||
|
||||
This mirrors the caching policy of production agent harnesses (e.g.
|
||||
anomalyco/opencode's ``cache-policy``): cache the tool schemas, the system
|
||||
prompt, and the latest conversation message, capped at Anthropic's 4
|
||||
breakpoints. We express it through LiteLLM's ``cache_control_injection_points``
|
||||
— the ``AnthropicCacheControlHook`` fires on that kwarg and emits the
|
||||
provider-appropriate breakpoint (Anthropic ``cache_control``; Bedrock
|
||||
Converse ``cachePoint``). ``LitellmModel`` forwards ``ModelSettings.extra_args``
|
||||
straight into ``litellm.acompletion()``, so passing the points there is all
|
||||
that is required; this is the LiteLLM-config-layer approach the Agents SDK
|
||||
maintainer prescribed over a native ``ModelSettings`` caching flag
|
||||
(openai/openai-agents-python#3008 / #3009).
|
||||
|
||||
Returns ``None`` (a strict no-op — the hook never fires) for every route
|
||||
that would not benefit or could break:
|
||||
- non-Claude models, and
|
||||
- Bedrock Claude routes LiteLLM can't confirm as cache-capable. Bedrock's
|
||||
Converse API rejects unknown request fields outright
|
||||
(``ValidationException: cache_control_injection_points: Extra inputs are
|
||||
not permitted``) and LiteLLM only consumes the marker for models its
|
||||
model map recognises; an unmapped Bedrock model would pass the marker
|
||||
straight through and crash the first call. Only Bedrock hard-rejects, so
|
||||
only Bedrock is guarded — gating Anthropic-native/Vertex/OpenRouter on
|
||||
confirmed support would needlessly disable caching for capable models.
|
||||
"""
|
||||
if not is_claude_model(model_name):
|
||||
return None
|
||||
if is_bedrock_route(model_name) and not bedrock_route_supports_prompt_caching(model_name):
|
||||
return None
|
||||
|
||||
points = _prompt_cache_injection_points(model_name)
|
||||
return {"cache_control_injection_points": points}
|
||||
|
||||
|
||||
def _prompt_cache_injection_points(model_name: str) -> list[dict[str, Any]]:
|
||||
"""Cache breakpoints for a Claude route (system + tools + latest message).
|
||||
|
||||
At most 3 of Anthropic's 4 allowed breakpoints, leaving headroom:
|
||||
- system prompt (``role: system``) — the largest repeated span.
|
||||
- tool schemas (``tool_config``) — Bedrock Converse ONLY. LiteLLM's
|
||||
``tool_config`` location is implemented solely by the Bedrock Converse
|
||||
transform (which appends a ``cachePoint`` to the tool list); on any other
|
||||
route it is not consumed and would leak onto the wire as an unknown
|
||||
top-level ``cache_control_injection_points`` field. It is also redundant
|
||||
elsewhere: Anthropic orders tools BEFORE the system prompt, so the system
|
||||
breakpoint already caches the tool schemas in the shared prefix.
|
||||
- latest message (``index: -1``) — a ROLLING breakpoint on the last
|
||||
message. A scan transcript is append-only (prior turns are immutable, each
|
||||
turn just appends new assistant/tool messages), so without it the growing
|
||||
body is re-sent at full input price every turn and cache-read decays as a
|
||||
denominator effect even though the prefix keeps hitting. Re-caching the
|
||||
whole prefix-so-far each turn keeps cache-read high on long scans. (This
|
||||
is the Strix analogue of opencode's ``latest-user-message``; ``index: -1``
|
||||
tracks the true tail because Strix appends tool-role, not user-role,
|
||||
messages each turn.)
|
||||
|
||||
Unrecognised locations degrade gracefully on older LiteLLM — they are simply
|
||||
not injected (no error).
|
||||
"""
|
||||
points: list[dict[str, Any]] = [{"location": "message", "role": "system"}]
|
||||
if is_bedrock_route(model_name):
|
||||
points.append({"location": "tool_config"})
|
||||
points.append({"location": "message", "index": -1})
|
||||
return points
|
||||
|
||||
|
||||
def child_initial_input(
|
||||
*,
|
||||
name: str,
|
||||
|
||||
+2
-24
@@ -3,12 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from agents import RunConfig
|
||||
@@ -42,10 +40,6 @@ from strix.core.paths import run_dir_for, runtime_state_dir
|
||||
from strix.core.sessions import open_agent_session
|
||||
from strix.runtime import session_manager
|
||||
from strix.telemetry.logging import set_scan_id, setup_scan_logging
|
||||
from strix.tools.output_store import (
|
||||
WORKSPACE_SPILL_DIR,
|
||||
configure_spill_writer,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -209,20 +203,6 @@ async def run_strix_scan(
|
||||
)
|
||||
logger.info("Sandbox ready for scan %s", scan_id)
|
||||
|
||||
sandbox_session = bundle["session"]
|
||||
|
||||
async def _spill_to_workspace(output_id: str, text: str) -> str | None:
|
||||
"""Write an oversized tool result into the sandbox; return its path or None."""
|
||||
path = f"{WORKSPACE_SPILL_DIR}/{output_id}.txt"
|
||||
try:
|
||||
await sandbox_session.write(Path(path), io.BytesIO(text.encode("utf-8")))
|
||||
except Exception:
|
||||
logger.exception("failed to spill tool output to sandbox workspace")
|
||||
return None
|
||||
return path
|
||||
|
||||
configure_spill_writer(_spill_to_workspace)
|
||||
|
||||
sessions_to_close: list[SQLiteSession] = []
|
||||
|
||||
try:
|
||||
@@ -236,7 +216,6 @@ async def run_strix_scan(
|
||||
model_name=resolved_model,
|
||||
force_required_tool_choice=settings.llm.force_required_tool_choice,
|
||||
request_timeout=settings.llm.timeout,
|
||||
prompt_cache=settings.llm.prompt_cache,
|
||||
)
|
||||
run_config = RunConfig(
|
||||
model=resolved_model,
|
||||
@@ -259,7 +238,7 @@ async def run_strix_scan(
|
||||
)
|
||||
|
||||
root_agent = build_strix_agent(
|
||||
name="Strix",
|
||||
name="strix",
|
||||
skills=skills,
|
||||
is_root=True,
|
||||
scan_mode=scan_mode,
|
||||
@@ -273,7 +252,7 @@ async def run_strix_scan(
|
||||
if not is_resume:
|
||||
await coordinator.register(
|
||||
root_id,
|
||||
"Strix",
|
||||
"strix",
|
||||
parent_id=None,
|
||||
task=root_task,
|
||||
skills=skills,
|
||||
@@ -420,7 +399,6 @@ async def run_strix_scan(
|
||||
await coordinator.set_status(root_id, "failed")
|
||||
raise
|
||||
finally:
|
||||
configure_spill_writer(None)
|
||||
for s in sessions_to_close:
|
||||
with contextlib.suppress(Exception):
|
||||
s.close()
|
||||
|
||||
@@ -92,39 +92,6 @@ async def _rewrite_session(
|
||||
return True
|
||||
|
||||
|
||||
async def replace_session_items(
|
||||
session: Session,
|
||||
new_items: list[Any],
|
||||
*,
|
||||
expected_len: int | None = None,
|
||||
) -> bool:
|
||||
"""Overwrite the session's items, restoring the originals on failure.
|
||||
|
||||
When ``expected_len`` is given, the rewrite is skipped if the session no
|
||||
longer has that many items (a concurrent writer changed it), so a slow
|
||||
compaction summary can't clobber newer turns.
|
||||
"""
|
||||
async with session_write_lock(session):
|
||||
original = list(await session.get_items())
|
||||
if expected_len is not None and len(original) != expected_len:
|
||||
logger.warning(
|
||||
"skipping session rewrite: expected %d items, found %d",
|
||||
expected_len,
|
||||
len(original),
|
||||
)
|
||||
return False
|
||||
rebuilt = cast("list[TResponseInputItem]", new_items)
|
||||
await session.clear_session()
|
||||
try:
|
||||
await session.add_items(rebuilt)
|
||||
except Exception:
|
||||
logger.exception("session rewrite failed; restoring original items")
|
||||
await session.clear_session()
|
||||
await session.add_items(original)
|
||||
raise
|
||||
return True
|
||||
|
||||
|
||||
async def strip_all_images_from_session(session: Session) -> bool:
|
||||
"""Replace every image tool output with a text placeholder (rejection recovery)."""
|
||||
|
||||
|
||||
@@ -1,419 +0,0 @@
|
||||
"""`strix auth` — ChatGPT subscription sign-in (login / status / logout).
|
||||
|
||||
Signing in only stores OAuth tokens (``~/.strix/subscription-auth.json``); model
|
||||
selection stays with ``STRIX_LLM``. A ``chatgpt/<model>`` STRIX_LLM runs on the
|
||||
subscription.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import logging
|
||||
import threading
|
||||
import webbrowser
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
|
||||
from strix.config import codex, load_settings
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CALLBACK_TIMEOUT_S = 300
|
||||
|
||||
# CLI-facing name for the login provider. Internally this is the Codex OAuth
|
||||
# flow (``codex.PROVIDER``), but users know it as ChatGPT, so that's what the
|
||||
# command and messaging say. ``codex`` is accepted as an alias.
|
||||
LOGIN_PROVIDER = "chatgpt"
|
||||
_ACCEPTED_PROVIDERS = frozenset({LOGIN_PROVIDER, codex.PROVIDER})
|
||||
|
||||
_USAGE = "Usage:\n strix auth login chatgpt [--manual]\n strix auth status\n strix auth logout"
|
||||
|
||||
|
||||
def run_auth(argv: list[str]) -> int:
|
||||
"""Entry point for ``strix auth …``. Returns a process exit code."""
|
||||
console = Console()
|
||||
# Bare `strix auth` (no subcommand) defaults to login.
|
||||
subcommand = argv[0] if argv else "login"
|
||||
rest = argv[1:]
|
||||
|
||||
if subcommand in ("-h", "--help", "help"):
|
||||
console.print(_USAGE)
|
||||
return 0
|
||||
|
||||
handlers: dict[str, Callable[[], int]] = {
|
||||
"login": lambda: _login(console, rest),
|
||||
"status": lambda: _status(console),
|
||||
"logout": lambda: _logout(console),
|
||||
}
|
||||
handler = handlers.get(subcommand)
|
||||
if handler is not None:
|
||||
return handler()
|
||||
|
||||
console.print(f"[red]Unknown auth command:[/] {subcommand}\n")
|
||||
console.print(_USAGE)
|
||||
return 2
|
||||
|
||||
|
||||
def _login(console: Console, argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(prog="strix auth login", add_help=True)
|
||||
parser.add_argument(
|
||||
"provider",
|
||||
nargs="?",
|
||||
default=LOGIN_PROVIDER,
|
||||
help="Model provider to sign in with (default: chatgpt).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--manual",
|
||||
action="store_true",
|
||||
help="Skip the local callback server and paste the redirect URL by hand.",
|
||||
)
|
||||
try:
|
||||
args = parser.parse_args(argv)
|
||||
except SystemExit as exc: # argparse already printed the message
|
||||
return int(exc.code or 2)
|
||||
|
||||
if args.provider.lower() not in _ACCEPTED_PROVIDERS:
|
||||
console.print(
|
||||
f"[red]Unsupported provider:[/] {args.provider}. "
|
||||
f"Only '{LOGIN_PROVIDER}' (ChatGPT subscription) is supported."
|
||||
)
|
||||
return 2
|
||||
|
||||
verifier, challenge = codex.generate_pkce()
|
||||
state = codex.create_state()
|
||||
authorize_url = codex.build_authorize_url(challenge, state)
|
||||
|
||||
console.print()
|
||||
console.print("[bold]Signing in with ChatGPT[/] [dim](provider: chatgpt)[/]")
|
||||
console.print(
|
||||
"[dim]This uses your ChatGPT Plus/Pro plan for inference instead of a metered API key.[/]"
|
||||
)
|
||||
console.print()
|
||||
|
||||
try:
|
||||
record = _run_oauth_flow(console, authorize_url, verifier, state, manual=args.manual)
|
||||
except codex.CodexAuthError as exc:
|
||||
return _fail(console, exc)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[yellow]Sign-in cancelled.[/]")
|
||||
return 130
|
||||
|
||||
codex.save_record(record)
|
||||
_print_success(console)
|
||||
return 0
|
||||
|
||||
|
||||
def _run_oauth_flow(
|
||||
console: Console,
|
||||
authorize_url: str,
|
||||
verifier: str,
|
||||
state: str,
|
||||
*,
|
||||
manual: bool,
|
||||
) -> dict[str, Any]:
|
||||
"""Drive the browser (or manual) OAuth flow and return a token record."""
|
||||
server = None if manual else _try_start_callback_server()
|
||||
|
||||
console.print("Open this URL in your browser to authorize:")
|
||||
console.print(f"[cyan]{authorize_url}[/]")
|
||||
console.print()
|
||||
if not manual:
|
||||
try:
|
||||
webbrowser.open(authorize_url)
|
||||
except Exception: # noqa: BLE001 - opening a browser is best-effort
|
||||
logger.debug("could not open browser", exc_info=True)
|
||||
|
||||
if server is not None:
|
||||
console.print("[dim]Waiting for you to finish signing in…[/]")
|
||||
result = server.wait(_CALLBACK_TIMEOUT_S)
|
||||
server.shutdown()
|
||||
if result is not None:
|
||||
code, returned_state, error = result
|
||||
if error:
|
||||
raise codex.CodexAuthError("oauth_error", error)
|
||||
return _finish(code, returned_state, verifier, state, require_state=True)
|
||||
console.print("[yellow]Timed out waiting for the browser. Falling back to manual paste.[/]")
|
||||
|
||||
# Manual fallback: the user completes sign-in and pastes the redirect URL
|
||||
# (the browser lands on a localhost page that won't load if no server is up;
|
||||
# the address bar still holds the code+state).
|
||||
console.print()
|
||||
try:
|
||||
pasted = console.input("Paste the full redirect URL (or code#state): ").strip()
|
||||
except EOFError as exc:
|
||||
raise codex.CodexAuthError("no_input", "no redirect URL provided") from exc
|
||||
code, returned_state = codex.parse_redirect_input(pasted)
|
||||
return _finish(code, returned_state, verifier, state, require_state=False)
|
||||
|
||||
|
||||
def _finish(
|
||||
code: str | None,
|
||||
returned_state: str | None,
|
||||
verifier: str,
|
||||
expected_state: str,
|
||||
*,
|
||||
require_state: bool,
|
||||
) -> dict[str, Any]:
|
||||
if not code:
|
||||
raise codex.CodexAuthError("no_code", "no authorization code found in the redirect")
|
||||
# The loopback callback from OpenAI always carries state, so a missing or
|
||||
# mismatched value there is forged (CSRF) and must be rejected. Manual paste
|
||||
# is user-initiated (the user copies their own redirect), so state is only
|
||||
# validated when the pasted value includes it.
|
||||
if require_state and returned_state is None:
|
||||
raise codex.CodexAuthError("state_mismatch", "missing state in callback; possible CSRF")
|
||||
if returned_state is not None and returned_state != expected_state:
|
||||
raise codex.CodexAuthError("state_mismatch", "state did not match; possible CSRF")
|
||||
return codex.exchange_code(code, verifier)
|
||||
|
||||
|
||||
class _CallbackServer:
|
||||
"""A one-shot local HTTP server that catches the OAuth redirect."""
|
||||
|
||||
def __init__(self, httpd: HTTPServer, event: threading.Event, holder: dict[str, Any]) -> None:
|
||||
self._httpd = httpd
|
||||
self._event = event
|
||||
self._holder = holder
|
||||
self._thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def wait(self, timeout: float) -> tuple[str | None, str | None, str | None] | None:
|
||||
if not self._event.wait(timeout):
|
||||
return None
|
||||
return (
|
||||
self._holder.get("code"),
|
||||
self._holder.get("state"),
|
||||
self._holder.get("error"),
|
||||
)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self._httpd.shutdown()
|
||||
self._httpd.server_close()
|
||||
|
||||
|
||||
def _try_start_callback_server() -> _CallbackServer | None:
|
||||
event = threading.Event()
|
||||
holder: dict[str, Any] = {}
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args: Any) -> None: # silence default stderr logging
|
||||
pass
|
||||
|
||||
def do_GET(self) -> None:
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path != codex.CALLBACK_PATH:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
query = parse_qs(parsed.query)
|
||||
holder["code"] = _first(query, "code")
|
||||
holder["state"] = _first(query, "state")
|
||||
holder["error"] = _first(query, "error_description") or _first(query, "error")
|
||||
body = _render_callback_html().encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
event.set()
|
||||
|
||||
try:
|
||||
httpd = HTTPServer(("127.0.0.1", codex.CALLBACK_PORT), Handler)
|
||||
except OSError:
|
||||
logger.debug("could not bind callback port %d", codex.CALLBACK_PORT, exc_info=True)
|
||||
return None
|
||||
return _CallbackServer(httpd, event, holder)
|
||||
|
||||
|
||||
def _first(query: dict[str, list[str]], key: str) -> str | None:
|
||||
values = query.get(key)
|
||||
return values[0] if values else None
|
||||
|
||||
|
||||
def _status(console: Console) -> int:
|
||||
record = codex.read_record()
|
||||
if record is None:
|
||||
console.print("[yellow]Not signed in.[/] Run [cyan]strix auth login chatgpt[/] to sign in.")
|
||||
return 1
|
||||
settings = load_settings()
|
||||
console.print("[green]Signed in[/] with a ChatGPT subscription.")
|
||||
console.print(f" Account: [bold]{record.get('account_id')}[/]")
|
||||
if codex.subscription_model(settings.llm.model):
|
||||
console.print(f" Runs use the subscription (STRIX_LLM=[bold]{settings.llm.model}[/]).")
|
||||
else:
|
||||
console.print(
|
||||
" [yellow]Note:[/] set [cyan]STRIX_LLM[/] to e.g. [cyan]chatgpt/gpt-5.4[/] "
|
||||
"to run on the subscription."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _logout(console: Console) -> int:
|
||||
codex.logout()
|
||||
console.print("[green]Signed out.[/] Stored subscription credentials removed.")
|
||||
return 0
|
||||
|
||||
|
||||
def _fail(console: Console, exc: codex.CodexAuthError) -> int:
|
||||
error_text = Text()
|
||||
error_text.append("SIGN-IN FAILED", style="bold red")
|
||||
error_text.append("\n\n", style="white")
|
||||
error_text.append(f"{exc}", style="white")
|
||||
console.print()
|
||||
console.print(
|
||||
Panel(
|
||||
error_text,
|
||||
title="[bold white]STRIX",
|
||||
title_align="left",
|
||||
border_style="red",
|
||||
padding=(1, 2),
|
||||
)
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
def _print_success(console: Console) -> None:
|
||||
text = Text()
|
||||
text.append("Signed in with your ChatGPT subscription", style="bold #22c55e")
|
||||
text.append("\n\n", style="white")
|
||||
text.append("Set ", style="white")
|
||||
text.append("STRIX_LLM", style="bold white")
|
||||
text.append(" to a ", style="white")
|
||||
text.append("chatgpt/", style="bold cyan")
|
||||
text.append(" model (e.g. ", style="white")
|
||||
text.append("chatgpt/gpt-5.4", style="bold cyan")
|
||||
text.append(") — runs are billed to your ChatGPT plan.", style="white")
|
||||
text.append("\n\n", style="white")
|
||||
text.append("Run a scan as usual, e.g. ", style="white")
|
||||
text.append("strix --target https://example.com", style="bold cyan")
|
||||
console.print()
|
||||
console.print(
|
||||
Panel(
|
||||
text,
|
||||
title="[bold white]STRIX",
|
||||
title_align="left",
|
||||
border_style="#22c55e",
|
||||
padding=(1, 2),
|
||||
)
|
||||
)
|
||||
console.print()
|
||||
|
||||
|
||||
_LOGO_PATH = Path(__file__).resolve().parent.parent / "viewer" / "static" / "logo.png"
|
||||
|
||||
|
||||
def _logo_img_tag() -> str:
|
||||
"""Return an ``<img>`` for the Strix logo as an inline data URI, or "".
|
||||
|
||||
The callback page is served offline by the local OAuth server, so the logo
|
||||
is embedded rather than linked. Missing/unreadable file degrades to just the
|
||||
"Strix" wordmark.
|
||||
"""
|
||||
try:
|
||||
data = _LOGO_PATH.read_bytes()
|
||||
except OSError:
|
||||
return ""
|
||||
encoded = base64.b64encode(data).decode("ascii")
|
||||
return f'<img class="logo" src="data:image/png;base64,{encoded}" alt="" />'
|
||||
|
||||
|
||||
def _render_callback_html() -> str:
|
||||
return _CALLBACK_HTML.replace("<!--LOGO-->", _logo_img_tag())
|
||||
|
||||
|
||||
_CALLBACK_HTML = """<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Strix — signed in</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; min-height: 100vh; padding: 24px;
|
||||
font-family: 'Geist', 'Geist Sans', ui-sans-serif, system-ui, -apple-system,
|
||||
"Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
|
||||
background: #000; color: #ededed;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
}
|
||||
.topbar {
|
||||
position: absolute; top: 20px; left: 22px;
|
||||
display: flex; align-items: center; gap: 6px; text-decoration: none;
|
||||
}
|
||||
.topbar .logo { width: 40px; height: 40px; display: block; }
|
||||
.topbar span {
|
||||
font-size: 1.1rem; font-weight: 600; letter-spacing: -.01em; color: #fff;
|
||||
transition: color .15s ease;
|
||||
}
|
||||
.topbar:hover span { color: #c9c9c9; }
|
||||
.brand {
|
||||
font-size: 2.1rem; font-weight: 700; letter-spacing: -.02em; color: #fff;
|
||||
text-align: center; margin: 0 0 10px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.35rem; font-weight: 600; letter-spacing: -.01em; color: #f5f5f5;
|
||||
text-align: center; margin: 0 0 28px;
|
||||
}
|
||||
.card {
|
||||
width: 100%; max-width: 430px; text-align: center;
|
||||
background: #171717; border: 1px solid rgba(255, 255, 255, .06);
|
||||
border-radius: 24px; padding: 40px 40px 34px;
|
||||
}
|
||||
.badge {
|
||||
margin: 0 auto 22px; width: 52px; height: 52px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 23px; color: #fff;
|
||||
background: rgba(255, 255, 255, .05); border: 1px solid rgba(255, 255, 255, .14);
|
||||
}
|
||||
.msg { margin: 0 auto; max-width: 34ch; color: #b5b5b5; line-height: 1.6; font-size: .98rem; }
|
||||
.rule { height: 1px; background: rgba(255, 255, 255, .07); margin: 26px 0 0; }
|
||||
.tagline { margin: 22px 0 0; color: #7c7c7c; font-size: .9rem; line-height: 1.55; }
|
||||
.tagline b { color: #ededed; font-weight: 500; }
|
||||
.links {
|
||||
margin-top: 18px; display: flex; gap: 8px; justify-content: center;
|
||||
align-items: center; flex-wrap: wrap; font-size: .84rem;
|
||||
}
|
||||
.links a { color: #a3a3a3; text-decoration: none; transition: color .15s ease; }
|
||||
.links a:hover { color: #fff; }
|
||||
.links .dot { color: #3a3a3a; }
|
||||
.close { margin: 24px 0 0; color: #5a5a5a; font-size: .78rem; text-align: center; }
|
||||
</style></head>
|
||||
<body>
|
||||
<a class="topbar" href="https://strix.ai" target="_blank" rel="noopener"
|
||||
aria-label="Strix — strix.ai">
|
||||
<!--LOGO-->
|
||||
<span>Strix</span>
|
||||
</a>
|
||||
<div class="brand">Strix</div>
|
||||
<h1>You're signed in</h1>
|
||||
<main class="card">
|
||||
<div class="badge">✓</div>
|
||||
<p class="msg">Strix is connected to your ChatGPT subscription. Head back to your
|
||||
terminal — your security test runs there.</p>
|
||||
<div class="rule"></div>
|
||||
<p class="tagline">Autonomous AI hackers that <b>find and fix</b> your app's
|
||||
vulnerabilities.</p>
|
||||
<nav class="links">
|
||||
<a href="https://strix.ai" target="_blank" rel="noopener">strix.ai</a>
|
||||
<span class="dot">·</span>
|
||||
<a href="https://docs.strix.ai" target="_blank" rel="noopener">docs</a>
|
||||
<span class="dot">·</span>
|
||||
<a href="https://discord.gg/strix-ai" target="_blank" rel="noopener">community</a>
|
||||
</nav>
|
||||
</main>
|
||||
<p class="close">You can close this tab.</p>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
__all__ = ["run_auth"]
|
||||
+70
-129
@@ -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
|
||||
|
||||
@@ -20,7 +20,6 @@ from rich.text import Text
|
||||
|
||||
from strix.config import (
|
||||
apply_config_override,
|
||||
codex,
|
||||
load_settings,
|
||||
persist_current,
|
||||
)
|
||||
@@ -34,13 +33,6 @@ 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,
|
||||
@@ -93,16 +85,6 @@ def validate_environment() -> None:
|
||||
|
||||
settings = load_settings()
|
||||
|
||||
if codex.subscription_model(settings.llm.model):
|
||||
if not codex.is_authenticated():
|
||||
console.print(
|
||||
f"[red]STRIX_LLM={settings.llm.model} uses your ChatGPT subscription, "
|
||||
"but you're not signed in.[/] Run [cyan]strix auth login chatgpt[/] first."
|
||||
)
|
||||
sys.exit(1)
|
||||
logger.info("Environment OK (ChatGPT subscription)")
|
||||
return
|
||||
|
||||
if not settings.llm.model:
|
||||
missing_required_vars.append("STRIX_LLM")
|
||||
|
||||
@@ -285,29 +267,6 @@ def _provider_import_hint(exc: BaseException, model: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _subscription_error_hint(exc: BaseException) -> str | None:
|
||||
"""Return an actionable hint for a known ChatGPT-subscription error, or None."""
|
||||
if not codex.subscription_model(load_settings().llm.model):
|
||||
return None
|
||||
joined = " ".join(_exception_messages(exc)).lower()
|
||||
if "not supported when using codex with a chatgpt account" in joined:
|
||||
return (
|
||||
"This model isn't available on your ChatGPT subscription. "
|
||||
"Set STRIX_LLM to a model your plan includes (e.g. chatgpt/gpt-5.4)."
|
||||
)
|
||||
if (
|
||||
"error code: 401" in joined
|
||||
or "http 401" in joined
|
||||
or "unauthorized" in joined
|
||||
or "invalid_grant" in joined
|
||||
):
|
||||
return (
|
||||
"Your ChatGPT sign-in has expired or was revoked. Sign in again:\n"
|
||||
" strix auth login chatgpt"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def warm_up_llm(show_model_warning: bool = True) -> None:
|
||||
console = Console()
|
||||
logger.info("Warming up LLM connection")
|
||||
@@ -317,8 +276,8 @@ async def warm_up_llm(show_model_warning: bool = True) -> None:
|
||||
settings = load_settings()
|
||||
configure_sdk_model_defaults(settings)
|
||||
llm = settings.llm
|
||||
raw_model = (llm.model or "").strip()
|
||||
|
||||
raw_model = (llm.model or "").strip()
|
||||
if (
|
||||
raw_model
|
||||
and "/" not in raw_model
|
||||
@@ -394,63 +353,23 @@ async def warm_up_llm(show_model_warning: bool = True) -> None:
|
||||
)
|
||||
logger.info("LLM warm-up succeeded for model %s", (llm.model or "").strip())
|
||||
|
||||
if settings.dedupe.model:
|
||||
from strix.report.dedupe import _dedupe_extra_args
|
||||
|
||||
dedupe_model = settings.dedupe.model.strip()
|
||||
raw_model = dedupe_model
|
||||
deduper = StrixProvider().get_model(dedupe_model)
|
||||
# Match the runtime path: send the dedupe key/endpoint per call so a
|
||||
# separate-provider dedupe model authenticates during warm-up too.
|
||||
deduper_extra = _dedupe_extra_args(settings.dedupe)
|
||||
deduper_settings = ModelSettings(extra_args=deduper_extra or None)
|
||||
await asyncio.wait_for(
|
||||
deduper.get_response(
|
||||
system_instructions="You are a helpful assistant.",
|
||||
input="Reply with just 'OK'.",
|
||||
model_settings=deduper_settings,
|
||||
tools=[],
|
||||
output_schema=None,
|
||||
handoffs=[],
|
||||
tracing=ModelTracing.DISABLED,
|
||||
previous_response_id=None,
|
||||
conversation_id=None,
|
||||
prompt=None,
|
||||
),
|
||||
timeout=llm.timeout,
|
||||
)
|
||||
logger.info("LLM warm-up succeeded for dedupe model %s", dedupe_model)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("LLM warm-up failed")
|
||||
error_text = Text()
|
||||
sub_hint = _subscription_error_hint(e)
|
||||
if sub_hint is not None:
|
||||
# The model/backend answered with a clear, actionable rejection —
|
||||
# show that instead of a generic "connection failed".
|
||||
border_style = "yellow"
|
||||
error_text.append("MODEL NOT AVAILABLE ON SUBSCRIPTION", style="bold yellow")
|
||||
error_text.append("\n\n", style="white")
|
||||
error_text.append(f"{sub_hint}\n", style="white")
|
||||
error_text.append(f"\nDetails: {e}", style="dim white")
|
||||
else:
|
||||
border_style = "red"
|
||||
error_text.append("LLM CONNECTION FAILED", style="bold red")
|
||||
error_text.append("\n\n", style="white")
|
||||
error_text.append(
|
||||
"Could not establish connection to the language model.\n", style="white"
|
||||
)
|
||||
error_text.append("Please check your configuration and try again.\n", style="white")
|
||||
hint = _provider_import_hint(e, raw_model)
|
||||
if hint is not None:
|
||||
error_text.append(f"\n{hint}\n", style="bold yellow")
|
||||
error_text.append(f"\nError: {e}", style="dim white")
|
||||
error_text.append("LLM CONNECTION FAILED", style="bold red")
|
||||
error_text.append("\n\n", style="white")
|
||||
error_text.append("Could not establish connection to the language model.\n", style="white")
|
||||
error_text.append("Please check your configuration and try again.\n", style="white")
|
||||
hint = _provider_import_hint(e, raw_model)
|
||||
if hint is not None:
|
||||
error_text.append(f"\n{hint}\n", style="bold yellow")
|
||||
error_text.append(f"\nError: {e}", style="dim white")
|
||||
|
||||
panel = Panel(
|
||||
error_text,
|
||||
title="[bold white]STRIX",
|
||||
title_align="left",
|
||||
border_style=border_style,
|
||||
border_style="red",
|
||||
padding=(1, 2),
|
||||
)
|
||||
|
||||
@@ -529,14 +448,6 @@ 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",
|
||||
@@ -655,9 +566,6 @@ 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."
|
||||
@@ -756,7 +664,6 @@ def _persist_run_record(args: argparse.Namespace) -> None:
|
||||
"status": "running",
|
||||
"start_time": datetime.now(UTC).isoformat(),
|
||||
"end_time": None,
|
||||
"auth_mode": codex.auth_mode(load_settings().llm.model),
|
||||
"targets_info": args.targets_info,
|
||||
"scan_mode": args.scan_mode,
|
||||
"instruction": args.instruction,
|
||||
@@ -814,7 +721,9 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser
|
||||
args.scan_mode = persisted_scan_mode
|
||||
|
||||
|
||||
def display_completion_message(args: argparse.Namespace, results_path: Path) -> None:
|
||||
def display_completion_message(
|
||||
args: argparse.Namespace, results_path: Path, web_url: str | None = None
|
||||
) -> None:
|
||||
console = Console()
|
||||
report_state = get_global_report_state()
|
||||
|
||||
@@ -853,12 +762,28 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) ->
|
||||
results_text.append(str(results_path), style="#60a5fa")
|
||||
panel_parts.extend(["\n", results_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 web_url:
|
||||
web_text = Text()
|
||||
web_text.append("\n")
|
||||
web_text.append("View in web", style="dim")
|
||||
web_text.append(" ")
|
||||
# OSC-8 hyperlink: clickable in modern terminals, falls back to the URL.
|
||||
web_text.append(web_url, style=f"#60a5fa link {web_url}")
|
||||
panel_parts.extend(["\n", web_text])
|
||||
|
||||
reopen_text = Text()
|
||||
reopen_text.append("\n")
|
||||
reopen_text.append("Reopen", style="dim")
|
||||
reopen_text.append(" ")
|
||||
reopen_text.append(f"strix view {args.run_name}", style="#22c55e")
|
||||
panel_parts.extend(["\n", reopen_text])
|
||||
else:
|
||||
view_text = Text()
|
||||
view_text.append("\n")
|
||||
view_text.append("View", style="dim")
|
||||
view_text.append(" ")
|
||||
view_text.append(f"strix view {args.run_name}", style="#22c55e")
|
||||
panel_parts.extend(["\n", view_text])
|
||||
|
||||
if not scan_completed:
|
||||
resume_text = Text()
|
||||
@@ -889,8 +814,6 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) ->
|
||||
"[#60a5fa]discord.gg/strix-ai[/]"
|
||||
)
|
||||
console.print()
|
||||
if not args.non_interactive:
|
||||
notify_update(console)
|
||||
|
||||
|
||||
def pull_docker_image() -> None:
|
||||
@@ -952,29 +875,16 @@ def main() -> None:
|
||||
# `strix view [<run>]` is a viewer-only subcommand, dispatched before the
|
||||
# scan argument parser (which requires a target) and before any scan setup.
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "view":
|
||||
from strix.interface.viewer.cli import run_view
|
||||
from strix.viewer.cli import run_view
|
||||
|
||||
run_view(sys.argv[2:])
|
||||
return
|
||||
|
||||
# `strix auth …` manages model-subscription sign-in and exits; it needs no
|
||||
# target, Docker, or scan setup.
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "auth":
|
||||
from strix.interface.auth_cli import run_auth
|
||||
|
||||
sys.exit(run_auth(sys.argv[2:]))
|
||||
|
||||
args = parse_arguments()
|
||||
|
||||
if args.config:
|
||||
apply_config_override(validate_config_file(args.config))
|
||||
|
||||
start_background_check()
|
||||
if not args.non_interactive and prompt_update_if_available(Console()):
|
||||
if is_binary_install() and sys.platform != "win32":
|
||||
os.execv(sys.executable, sys.argv) # noqa: S606 # nosec B606
|
||||
sys.exit(0)
|
||||
|
||||
check_docker_installed()
|
||||
pull_docker_image()
|
||||
|
||||
@@ -1031,7 +941,6 @@ def main() -> None:
|
||||
|
||||
_telemetry_start_kwargs = {
|
||||
"model": load_settings().llm.model,
|
||||
"auth_mode": codex.auth_mode(load_settings().llm.model),
|
||||
"scan_mode": args.scan_mode,
|
||||
"is_whitebox": is_whitebox_scan(args.targets_info),
|
||||
"interactive": not args.non_interactive,
|
||||
@@ -1066,7 +975,39 @@ def main() -> None:
|
||||
|
||||
results_path = run_dir_for(args.run_name)
|
||||
|
||||
display_completion_message(args, results_path)
|
||||
# For an interactive run, host the local viewer so the completion panel can
|
||||
# show a clickable "View in web" link. Skipped in non-interactive/CI runs
|
||||
# (no TTY to serve and it would block the process).
|
||||
viewer_httpd = None
|
||||
web_url = None
|
||||
if not args.non_interactive and sys.stdout.isatty():
|
||||
from strix.viewer.server import authorized_url, bundle_is_built, serve
|
||||
|
||||
if bundle_is_built():
|
||||
try:
|
||||
viewer_httpd, base_url, token = serve(results_path, open_browser=False)
|
||||
# The completion panel's "View in web" link must authorize the
|
||||
# browser, so hand it the tokened URL rather than the bare host.
|
||||
web_url = authorized_url(base_url, token)
|
||||
posthog.viewer_opened(source="post_scan", live=False)
|
||||
except Exception:
|
||||
logger.debug("could not start local viewer", exc_info=True)
|
||||
viewer_httpd, web_url = None, None
|
||||
|
||||
display_completion_message(args, results_path, web_url=web_url)
|
||||
|
||||
if viewer_httpd is not None:
|
||||
console = Console()
|
||||
console.print("[dim]Hosting the local viewer. Press Ctrl-C to stop.[/]")
|
||||
console.print()
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[dim]Viewer stopped.[/]")
|
||||
finally:
|
||||
viewer_httpd.shutdown()
|
||||
viewer_httpd.server_close()
|
||||
|
||||
if args.non_interactive:
|
||||
report_state = get_global_report_state()
|
||||
|
||||
+16
-40
@@ -42,12 +42,6 @@ from strix.interface.tui.renderers.agent_message_renderer import AgentMessageRen
|
||||
from strix.interface.tui.renderers.user_message_renderer import UserMessageRenderer
|
||||
from strix.interface.utils import build_tui_stats_text
|
||||
from strix.report.state import ReportState, set_global_report_state
|
||||
from strix.report.writer import (
|
||||
guess_language_name,
|
||||
parse_fenced_code,
|
||||
resolve_lexer,
|
||||
safe_fence,
|
||||
)
|
||||
from strix.runtime import session_manager
|
||||
|
||||
|
||||
@@ -336,11 +330,12 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
||||
return "#65a30d"
|
||||
return "#6b7280"
|
||||
|
||||
def _highlight_python(self, code: str, language: str | None = None) -> Text:
|
||||
def _highlight_python(self, code: str) -> Text:
|
||||
try:
|
||||
from pygments.lexers import PythonLexer
|
||||
from pygments.styles import get_style_by_name
|
||||
|
||||
lexer = resolve_lexer(language, code)
|
||||
lexer = PythonLexer()
|
||||
style = get_style_by_name("native")
|
||||
colors = {
|
||||
token: f"#{style_def['color']}" for token, style_def in style if style_def["color"]
|
||||
@@ -506,11 +501,10 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
||||
|
||||
poc_script_code = vuln.get("poc_script_code", "")
|
||||
if poc_script_code:
|
||||
poc_language, poc_code = parse_fenced_code(poc_script_code)
|
||||
text.append("\n\n")
|
||||
text.append("PoC Code", style=self.FIELD_STYLE)
|
||||
text.append("\n")
|
||||
text.append_text(self._highlight_python(poc_code, poc_language))
|
||||
text.append_text(self._highlight_python(poc_script_code))
|
||||
|
||||
remediation_steps = vuln.get("remediation_steps", "")
|
||||
if remediation_steps:
|
||||
@@ -607,12 +601,9 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
||||
lines.append(vuln["poc_description"])
|
||||
lines.append("")
|
||||
if vuln.get("poc_script_code"):
|
||||
poc_language, poc_code = parse_fenced_code(vuln["poc_script_code"])
|
||||
fence_lang = poc_language or guess_language_name(poc_code)
|
||||
fence = safe_fence(poc_code)
|
||||
lines.append(f"{fence}{fence_lang}")
|
||||
lines.append(poc_code)
|
||||
lines.append(fence)
|
||||
lines.append("```python")
|
||||
lines.append(vuln["poc_script_code"])
|
||||
lines.append("```")
|
||||
|
||||
if vuln.get("code_locations"):
|
||||
lines.extend(["", "## Code Analysis", ""])
|
||||
@@ -628,9 +619,7 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
||||
if loc.get("label"):
|
||||
lines.append(f" {loc['label']}")
|
||||
if loc.get("snippet"):
|
||||
snippet = str(loc["snippet"])
|
||||
snippet_fence = safe_fence(snippet)
|
||||
lines.append(f"{snippet_fence}\n{snippet}\n{snippet_fence}")
|
||||
lines.append(f"```\n{loc['snippet']}\n```")
|
||||
if loc.get("fix_before") or loc.get("fix_after"):
|
||||
lines.append("**Suggested Fix:**")
|
||||
lines.append("```diff")
|
||||
@@ -813,7 +802,6 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
self._scan_stop_event = threading.Event()
|
||||
self._scan_completed = threading.Event()
|
||||
self._scan_error: BaseException | None = None
|
||||
self._error_noted_agents: set[str] = set()
|
||||
|
||||
self._spinner_frame_index: int = 0
|
||||
self._sweep_num_squares: int = 6
|
||||
@@ -1027,32 +1015,22 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
else:
|
||||
self._agent_graph_sync_future = None
|
||||
try:
|
||||
parent_of, statuses, names, errors = future.result()
|
||||
parent_of, statuses, names = future.result()
|
||||
except Exception:
|
||||
logger.exception("TUI agent graph sync failed")
|
||||
else:
|
||||
for agent_id, status in statuses.items():
|
||||
error = errors.get(agent_id)
|
||||
self.live_view.upsert_agent(
|
||||
agent_id,
|
||||
name=names.get(agent_id, agent_id),
|
||||
parent_id=parent_of.get(agent_id),
|
||||
status=status,
|
||||
error_message=error,
|
||||
)
|
||||
if status in {"failed", "crashed"} and error:
|
||||
if agent_id not in self._error_noted_agents:
|
||||
self._error_noted_agents.add(agent_id)
|
||||
self.live_view.record_agent_error(agent_id, error)
|
||||
else:
|
||||
self._error_noted_agents.discard(agent_id)
|
||||
|
||||
if self._scan_loop is None or self._scan_loop.is_closed():
|
||||
return
|
||||
|
||||
async def collect() -> tuple[
|
||||
dict[str, str | None], dict[str, Any], dict[str, str], dict[str, str]
|
||||
]:
|
||||
async def collect() -> tuple[dict[str, str | None], dict[str, Any], dict[str, str]]:
|
||||
return await self.coordinator.graph_snapshot()
|
||||
|
||||
self._agent_graph_sync_future = asyncio.run_coroutine_threadsafe(collect(), self._scan_loop)
|
||||
@@ -1071,7 +1049,6 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
"waiting": "⏸",
|
||||
"completed": "🟢",
|
||||
"failed": "🔴",
|
||||
"crashed": "🔴",
|
||||
"stopped": "■",
|
||||
}
|
||||
|
||||
@@ -1257,12 +1234,13 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
text.append(msg)
|
||||
return (text, Text(), False)
|
||||
|
||||
if status in {"failed", "crashed"}:
|
||||
if status == "failed":
|
||||
error_msg = agent_data.get("error_message", "")
|
||||
text = Text()
|
||||
text.append(error_msg or "Agent failed", style="red")
|
||||
text.append(" · ", style="dim")
|
||||
text.append("Send message to resume", style="dim")
|
||||
if error_msg:
|
||||
text.append(error_msg, style="red")
|
||||
else:
|
||||
text.append("Scan failed", style="red")
|
||||
self._stop_dot_animation()
|
||||
return (text, Text(), False)
|
||||
|
||||
@@ -1561,7 +1539,6 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
"waiting": "⏸",
|
||||
"completed": "🟢",
|
||||
"failed": "🔴",
|
||||
"crashed": "🔴",
|
||||
"stopped": "■",
|
||||
}
|
||||
|
||||
@@ -1607,7 +1584,6 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
"waiting": "⏸",
|
||||
"completed": "🟢",
|
||||
"failed": "🔴",
|
||||
"crashed": "🔴",
|
||||
"stopped": "■",
|
||||
}
|
||||
|
||||
@@ -1862,7 +1838,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
webbrowser.open(self._viewer_url)
|
||||
return
|
||||
try:
|
||||
from strix.interface.viewer.server import authorized_url, bundle_is_built, serve
|
||||
from strix.viewer.server import authorized_url, bundle_is_built, serve
|
||||
|
||||
if not bundle_is_built():
|
||||
self._set_viewer_cta("[#eab308]Viewer UI not built[/]")
|
||||
|
||||
@@ -86,17 +86,6 @@ class TuiLiveView:
|
||||
current["error_message"] = error_message
|
||||
current["updated_at"] = now
|
||||
|
||||
def record_agent_error(self, agent_id: str, error: str) -> None:
|
||||
self._append_event(
|
||||
agent_id,
|
||||
"chat",
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": (f"An error occurred: {error}\nI'm now waiting for new instructions."),
|
||||
"metadata": {"source": "agent_error"},
|
||||
},
|
||||
)
|
||||
|
||||
def record_user_message(self, agent_id: str, content: str) -> None:
|
||||
self._append_event(
|
||||
agent_id,
|
||||
|
||||
@@ -7,13 +7,6 @@ from .base_renderer import BaseToolRenderer
|
||||
from .registry import register_tool_renderer
|
||||
|
||||
|
||||
def _author_label(note: dict[str, Any]) -> str:
|
||||
if note.get("by_you"):
|
||||
return "you"
|
||||
agent_name = note.get("agent_name")
|
||||
return str(agent_name).strip() if agent_name else ""
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
class CreateNoteRenderer(BaseToolRenderer):
|
||||
tool_name: ClassVar[str] = "create_note"
|
||||
@@ -130,9 +123,6 @@ class ListNotesRenderer(BaseToolRenderer):
|
||||
text.append("\n - ")
|
||||
text.append(title)
|
||||
text.append(f" ({category})", style="dim")
|
||||
author = _author_label(note)
|
||||
if author:
|
||||
text.append(f" by {author}", style="dim")
|
||||
|
||||
if note_content:
|
||||
text.append("\n ")
|
||||
@@ -166,9 +156,6 @@ class GetNoteRenderer(BaseToolRenderer):
|
||||
text.append("\n ")
|
||||
text.append(title)
|
||||
text.append(f" ({category})", style="dim")
|
||||
author = _author_label(note)
|
||||
if author:
|
||||
text.append(f" by {author}", style="dim")
|
||||
if content:
|
||||
text.append("\n ")
|
||||
text.append(content, style="dim")
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
from functools import cache
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from pygments.lexers import PythonLexer
|
||||
from pygments.styles import get_style_by_name
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from strix.report.writer import parse_fenced_code, resolve_lexer
|
||||
|
||||
from .base_renderer import BaseToolRenderer
|
||||
from .registry import register_tool_renderer
|
||||
|
||||
@@ -62,8 +61,8 @@ class CreateVulnerabilityReportRenderer(BaseToolRenderer):
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _highlight_code(cls, code: str, language: str | None) -> Text:
|
||||
lexer = resolve_lexer(language, code)
|
||||
def _highlight_python(cls, code: str) -> Text:
|
||||
lexer = PythonLexer()
|
||||
text = Text()
|
||||
|
||||
for token_type, token_value in lexer.get_tokens(code):
|
||||
@@ -235,11 +234,10 @@ class CreateVulnerabilityReportRenderer(BaseToolRenderer):
|
||||
text.append(poc_description)
|
||||
|
||||
if poc_script_code:
|
||||
poc_language, poc_code = parse_fenced_code(poc_script_code)
|
||||
text.append("\n\n")
|
||||
text.append("PoC Code", style=FIELD_STYLE)
|
||||
text.append("\n")
|
||||
text.append_text(cls._highlight_code(poc_code, poc_language))
|
||||
text.append_text(cls._highlight_python(poc_script_code))
|
||||
|
||||
if remediation_steps:
|
||||
text.append("\n\n")
|
||||
@@ -431,117 +429,3 @@ class CreateDependencyReportRenderer(BaseToolRenderer):
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(padded, classes=css_classes)
|
||||
|
||||
|
||||
_LIST_SEVERITY_COLORS = {
|
||||
"critical": "#dc2626",
|
||||
"high": "#ea580c",
|
||||
"medium": "#d97706",
|
||||
"low": "#65a30d",
|
||||
"info": "#0284c7",
|
||||
"none": "#6b7280",
|
||||
}
|
||||
|
||||
|
||||
def _severity_style(severity: Any) -> str:
|
||||
return _LIST_SEVERITY_COLORS.get(str(severity or "").lower(), "#d97706")
|
||||
|
||||
|
||||
def _author_label(report: dict[str, Any]) -> str:
|
||||
if report.get("by_you"):
|
||||
return "you"
|
||||
agent_name = report.get("agent_name")
|
||||
return str(agent_name).strip() if agent_name else ""
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
class ListReportsRenderer(BaseToolRenderer):
|
||||
tool_name: ClassVar[str] = "list_reports"
|
||||
css_classes: ClassVar[list[str]] = ["tool-call", "reporting-tool"]
|
||||
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
result = _coerce_dict(tool_data.get("result"))
|
||||
|
||||
text = Text()
|
||||
text.append("◆ ", style="#ef4444")
|
||||
text.append("reports", style="dim")
|
||||
|
||||
if isinstance(tool_data.get("result"), str) and str(tool_data["result"]).strip():
|
||||
text.append("\n ")
|
||||
text.append(str(tool_data["result"]).strip(), style="dim")
|
||||
elif result.get("success"):
|
||||
total = result.get("total_count", 0)
|
||||
reports = _coerce_list_of_dicts(result.get("reports"))
|
||||
counts = _coerce_dict(result.get("severity_counts"))
|
||||
|
||||
text.append(f" ({total})", style="dim")
|
||||
for sev, count in counts.items():
|
||||
text.append(" ")
|
||||
text.append(f"{sev} {count}", style=_severity_style(sev))
|
||||
|
||||
if not reports:
|
||||
text.append("\n ")
|
||||
text.append("No reports filed yet", style="dim")
|
||||
else:
|
||||
for report in reports:
|
||||
rid = str(report.get("id", "")).strip()
|
||||
title = str(report.get("title", "")).strip() or "(untitled)"
|
||||
severity = str(report.get("severity", "")).strip()
|
||||
text.append("\n - ")
|
||||
if severity:
|
||||
text.append(severity.upper(), style=f"bold {_severity_style(severity)}")
|
||||
text.append(" ")
|
||||
if rid:
|
||||
text.append(f"{rid} ", style="dim")
|
||||
text.append(title)
|
||||
author = _author_label(report)
|
||||
if author:
|
||||
text.append(f" ({author})", style="dim")
|
||||
else:
|
||||
text.append("\n ")
|
||||
text.append("Loading...", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
class GetReportRenderer(BaseToolRenderer):
|
||||
tool_name: ClassVar[str] = "get_report"
|
||||
css_classes: ClassVar[list[str]] = ["tool-call", "reporting-tool"]
|
||||
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
result = _coerce_dict(tool_data.get("result"))
|
||||
|
||||
text = Text()
|
||||
text.append("◆ ", style="#ef4444")
|
||||
text.append("report read", style="dim")
|
||||
|
||||
report = _coerce_dict(result.get("report")) if result.get("success") else {}
|
||||
if report:
|
||||
rid = str(report.get("id", "")).strip()
|
||||
title = str(report.get("title", "")).strip() or "(untitled)"
|
||||
severity = str(report.get("severity", "")).strip()
|
||||
text.append("\n ")
|
||||
if severity:
|
||||
text.append(severity.upper(), style=f"bold {_severity_style(severity)}")
|
||||
text.append(" ")
|
||||
if rid:
|
||||
text.append(f"{rid} ", style="dim")
|
||||
text.append(title)
|
||||
author = _author_label(report)
|
||||
if author:
|
||||
text.append(f" ({author})", style="dim")
|
||||
target = str(report.get("target", "")).strip()
|
||||
if target:
|
||||
text.append("\n ")
|
||||
text.append(target, style="dim")
|
||||
else:
|
||||
text.append("\n ")
|
||||
detail = result.get("error") if result.get("success") is False else None
|
||||
text.append(str(detail) if detail else "Loading...", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
@@ -1,389 +0,0 @@
|
||||
"""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
|
||||
@@ -253,20 +253,6 @@ def _llm_usage(report_state: Any) -> dict[str, Any]:
|
||||
return usage if isinstance(usage, dict) else {}
|
||||
|
||||
|
||||
def _is_subscription(report_state: Any) -> bool:
|
||||
"""Whether this run uses a model subscription (no metered cost).
|
||||
|
||||
Prefers the run record so it's correct for hydrated/resumed runs; falls back
|
||||
to current settings.
|
||||
"""
|
||||
record = getattr(report_state, "run_record", None)
|
||||
if isinstance(record, dict) and record.get("auth_mode"):
|
||||
return record.get("auth_mode") == "subscription"
|
||||
from strix.config import codex
|
||||
|
||||
return codex.auth_mode(load_settings().llm.model) == "subscription"
|
||||
|
||||
|
||||
def _int_stat(usage: dict[str, Any], key: str) -> int:
|
||||
try:
|
||||
return max(0, int(usage.get(key) or 0))
|
||||
@@ -297,16 +283,11 @@ def _build_llm_usage_stats(
|
||||
*,
|
||||
live: bool = False,
|
||||
) -> None:
|
||||
subscription = _is_subscription(report_state)
|
||||
usage = _llm_usage(report_state)
|
||||
if not usage or _int_stat(usage, "requests") <= 0:
|
||||
stats_text.append("\n")
|
||||
stats_text.append("Cost ", style="dim")
|
||||
if subscription:
|
||||
stats_text.append("$0.00 ", style="#22c55e")
|
||||
stats_text.append("(subscription) ", style="dim")
|
||||
else:
|
||||
stats_text.append("$0.0000 ", style="#fbbf24")
|
||||
stats_text.append("$0.0000 ", style="#fbbf24")
|
||||
stats_text.append("· ", style="dim white")
|
||||
stats_text.append("Tokens ", style="dim")
|
||||
stats_text.append("0", style="white")
|
||||
@@ -331,12 +312,7 @@ def _build_llm_usage_stats(
|
||||
stats_text.append("Output Tokens ", style="dim")
|
||||
stats_text.append(format_token_count(output_tokens), style="white")
|
||||
|
||||
if subscription:
|
||||
stats_text.append(" · ", style="dim white")
|
||||
stats_text.append("Cost ", style="dim")
|
||||
stats_text.append("$0.00", style="#22c55e")
|
||||
stats_text.append(" (subscription)", style="dim")
|
||||
elif live or cost > 0:
|
||||
if live or cost > 0:
|
||||
stats_text.append(" · ", style="dim white")
|
||||
stats_text.append("Cost ", style="dim")
|
||||
stats_text.append(f"${cost:.4f}", style="#fbbf24")
|
||||
@@ -361,9 +337,6 @@ def build_live_stats_text(report_state: Any) -> Text:
|
||||
model = load_settings().llm.model or "unknown"
|
||||
stats_text.append("Model ", style="dim")
|
||||
stats_text.append(str(model), style="white")
|
||||
if _is_subscription(report_state):
|
||||
stats_text.append(" · ", style="dim white")
|
||||
stats_text.append("ChatGPT subscription", style="#22c55e")
|
||||
stats_text.append("\n")
|
||||
|
||||
vuln_count = len(report_state.vulnerability_reports)
|
||||
@@ -406,10 +379,6 @@ def build_tui_stats_text(report_state: Any) -> Text:
|
||||
|
||||
model = load_settings().llm.model or "unknown"
|
||||
stats_text.append(str(model), style="white")
|
||||
subscription = _is_subscription(report_state)
|
||||
if subscription:
|
||||
stats_text.append("\n")
|
||||
stats_text.append("ChatGPT subscription", style="#22c55e")
|
||||
|
||||
usage = _llm_usage(report_state)
|
||||
if usage and _int_stat(usage, "total_tokens") > 0:
|
||||
@@ -419,10 +388,7 @@ def build_tui_stats_text(report_state: Any) -> Text:
|
||||
style="white",
|
||||
)
|
||||
cost = _float_stat(usage, "cost")
|
||||
if subscription:
|
||||
stats_text.append(" · ", style="white")
|
||||
stats_text.append("$0.00", style="white")
|
||||
elif cost > 0:
|
||||
if cost > 0:
|
||||
stats_text.append(" · ", style="white")
|
||||
stats_text.append(f"${cost:.2f}", style="white")
|
||||
|
||||
@@ -1181,7 +1147,9 @@ def read_target_list_file(path_str: str) -> list[str]:
|
||||
if (target := line.strip()) and not target.startswith("#")
|
||||
]
|
||||
except UnicodeDecodeError as e:
|
||||
raise ValueError(f"Target list file '{path_str}' must be valid UTF-8 text: {e!s}") from e
|
||||
raise ValueError(
|
||||
f"Target list file '{path_str}' must be valid UTF-8 text: {e!s}"
|
||||
) from e
|
||||
except OSError as e:
|
||||
raise ValueError(f"Failed to read target list file '{path_str}': {e!s}") from e
|
||||
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { ArrowLeft, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import { IoChatbubblesOutline } from "react-icons/io5";
|
||||
import { submitFeedback } from "@/data/serverSource";
|
||||
import type { View } from "@/App";
|
||||
|
||||
const MAX_MESSAGE = 5000;
|
||||
|
||||
const ERROR_COPY: Record<string, string> = {
|
||||
invalid_email: "That email doesn't look right.",
|
||||
invalid_message: "Please write a little more.",
|
||||
unavailable: "Couldn't send that just now. Try again.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Feedback & support form. Collects a message plus a work email (no
|
||||
* verification — the email is taken as-is) and relays it to Strix via the local
|
||||
* server. Mirrors EmailReportView's centered-card styling and palette.
|
||||
*/
|
||||
export default function FeedbackView({
|
||||
defaultEmail,
|
||||
onExit,
|
||||
}: {
|
||||
defaultEmail: string | null;
|
||||
onExit: (dest: View) => void;
|
||||
}) {
|
||||
const [message, setMessage] = useState("");
|
||||
const [email, setEmail] = useState(defaultEmail ?? "");
|
||||
const [step, setStep] = useState<"form" | "sending" | "sent">("form");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const canSend = message.trim().length > 0 && email.trim().length > 0 && step !== "sending";
|
||||
|
||||
const send = async () => {
|
||||
if (!canSend) return;
|
||||
setStep("sending");
|
||||
setError(null);
|
||||
const result = await submitFeedback(message.trim(), email.trim());
|
||||
if (result.ok) {
|
||||
setStep("sent");
|
||||
return;
|
||||
}
|
||||
setStep("form");
|
||||
setError(ERROR_COPY[result.error] ?? ERROR_COPY.unavailable);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl space-y-4">
|
||||
<button
|
||||
onClick={() => onExit("overview")}
|
||||
className="cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] transition-colors hover:text-white"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to results
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<IoChatbubblesOutline className="h-5 w-5 text-[#888]" aria-hidden="true" />
|
||||
<h1 className="text-2xl font-semibold text-white">Feedback & support</h1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="w-full rounded-2xl bg-[rgba(255,255,255,0.02)] p-6"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
>
|
||||
{step === "sent" ? (
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="mt-0.5 h-5 w-5 flex-shrink-0 text-emerald-400" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-white">Thanks, we got it.</p>
|
||||
<p className="mt-1 text-xs text-[#888]">
|
||||
We read every message. If it needs a reply, we'll reach out to the email you gave.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMessage("");
|
||||
setStep("form");
|
||||
}}
|
||||
className="mt-4 cursor-pointer text-xs text-[#888] transition-colors hover:text-white"
|
||||
>
|
||||
Send more feedback
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-4 text-xs text-[#666]">
|
||||
Bugs, feature requests, or anything else. Tell us what's on your mind.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-red-400" aria-hidden="true" />
|
||||
<p className="text-xs text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs text-[#888]">Your feedback</span>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={message}
|
||||
maxLength={MAX_MESSAGE}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={5}
|
||||
placeholder="What's working, what's not, what you'd love to see…"
|
||||
className="w-full resize-y rounded-lg border border-[#2a2a2a] bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-white/50 focus:ring-2 focus:ring-white/10"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mt-4 block">
|
||||
<span className="mb-1.5 block text-xs text-[#888]">Your work email</span>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@company.com"
|
||||
className="w-full rounded-lg border border-[#2a2a2a] bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-white/50 focus:ring-2 focus:ring-white/10"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
onClick={() => void send()}
|
||||
disabled={!canSend}
|
||||
className="mt-4 flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60"
|
||||
>
|
||||
{step === "sending" ? "Sending…" : "Send feedback"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
|
||||
/**
|
||||
* Lightweight hover tooltip. Wraps a trigger and reveals `text` above it on
|
||||
* hover/focus. Plain Tailwind + local state (no radix vendored).
|
||||
*/
|
||||
export function Tooltip({
|
||||
text,
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
text: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<span
|
||||
className={`relative inline-flex ${className}`}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
>
|
||||
{children}
|
||||
{open && (
|
||||
<span
|
||||
role="tooltip"
|
||||
className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 w-max max-w-[240px] -translate-x-1/2 rounded-md px-2.5 py-1.5 text-xs text-[#ddd] shadow-lg"
|
||||
style={{ border: "1px solid #2a2a2a", background: "#0a0a0a" }}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact inline CTA button that links out to sign-up in a new tab, with a
|
||||
* hover tooltip one-liner. Used in per-surface rows where a full card is too
|
||||
* heavy.
|
||||
*/
|
||||
export function ProInlineCta({
|
||||
label,
|
||||
desc,
|
||||
slug,
|
||||
icon: Icon,
|
||||
surface,
|
||||
}: {
|
||||
label: string;
|
||||
desc: string;
|
||||
slug: string;
|
||||
icon: React.ElementType;
|
||||
surface?: string;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip text={desc}>
|
||||
<a
|
||||
href={ctaUrl(SIGNUP_URL, slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta(slug, surface)}
|
||||
className="group inline-flex items-center gap-2 rounded-lg border border-[#222] bg-[rgba(255,255,255,0.02)] px-3 py-2 text-sm text-[#aaa] transition-colors hover:border-[#444] hover:text-white"
|
||||
>
|
||||
<Icon className="h-4 w-4 text-[#888] transition-colors group-hover:text-white" aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
</a>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,435 +0,0 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bot,
|
||||
Users,
|
||||
History,
|
||||
Mail,
|
||||
LogOut,
|
||||
ChevronsUpDown,
|
||||
} from "lucide-react";
|
||||
import { LuGitPullRequestArrow } from "react-icons/lu";
|
||||
import { VscExtensions } from "react-icons/vsc";
|
||||
import { IoChatbubblesOutline } from "react-icons/io5";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ctaUrl, trackCta } from "@/lib/cta";
|
||||
import { UpgradeModal } from "@/components/UpgradeModal";
|
||||
import type { View } from "@/App";
|
||||
|
||||
/**
|
||||
* Persistent left rail: a black rail with a right hairline border, an
|
||||
* account-switcher-style header, a single ungrouped list of h-9 nav rows (36px
|
||||
* icon slot, 14px label, rgba(255,255,255,0.12) active fill), a hairline
|
||||
* separator, and a user footer. Drag the right edge to resize; drag past the
|
||||
* collapse threshold to hide it, then click the left pull-zone to bring it back.
|
||||
*/
|
||||
|
||||
const MIN_WIDTH = 160;
|
||||
const DEFAULT_WIDTH = 260;
|
||||
const MAX_WIDTH = 400;
|
||||
const COLLAPSE_THRESHOLD = 140;
|
||||
|
||||
const WIDTH_KEY = "strix_viewer_sidebar_width";
|
||||
const COLLAPSE_KEY = "strix_viewer_sidebar_collapsed";
|
||||
|
||||
interface SidebarProps {
|
||||
view: View;
|
||||
onSelectView: (view: View) => void;
|
||||
issuesCount: number;
|
||||
agentCount: number;
|
||||
runCount: number;
|
||||
finished: boolean;
|
||||
verified: boolean;
|
||||
email: string | null;
|
||||
onOpenEmail: () => void;
|
||||
onOpenHistory: () => void;
|
||||
onForget: () => void;
|
||||
}
|
||||
|
||||
function readInt(key: string, fallback: number): number {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
const n = raw ? parseInt(raw, 10) : NaN;
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
view,
|
||||
onSelectView,
|
||||
issuesCount,
|
||||
agentCount,
|
||||
runCount,
|
||||
finished,
|
||||
verified,
|
||||
email,
|
||||
onOpenEmail,
|
||||
onOpenHistory,
|
||||
onForget,
|
||||
}: SidebarProps) {
|
||||
const [width, setWidth] = useState(() => {
|
||||
const w = readInt(WIDTH_KEY, DEFAULT_WIDTH);
|
||||
return Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, w));
|
||||
});
|
||||
const [collapsed, setCollapsed] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(COLLAPSE_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const [upgradeFeature, setUpgradeFeature] = useState<string | null>(null);
|
||||
const userMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Open the upgrade dialog for a platform feature, recording which feature
|
||||
// drove the open (the dialog's own CTAs track the deeper conversion).
|
||||
const openUpgrade = (slug: string, description: string) => {
|
||||
trackCta(slug, "sidebar");
|
||||
setUpgradeFeature(description);
|
||||
};
|
||||
|
||||
const persistWidth = useCallback((w: number) => {
|
||||
setWidth(w);
|
||||
try {
|
||||
localStorage.setItem(WIDTH_KEY, String(w));
|
||||
} catch {
|
||||
/* best-effort persistence */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const persistCollapsed = useCallback((c: boolean) => {
|
||||
setCollapsed(c);
|
||||
try {
|
||||
localStorage.setItem(COLLAPSE_KEY, c ? "1" : "0");
|
||||
} catch {
|
||||
/* best-effort persistence */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const expandSidebar = useCallback(() => {
|
||||
persistCollapsed(false);
|
||||
persistWidth(DEFAULT_WIDTH);
|
||||
}, [persistCollapsed, persistWidth]);
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsResizing(true);
|
||||
}, []);
|
||||
|
||||
// Global drag handlers for the resize handle. Dragging below the collapse
|
||||
// threshold hides the rail entirely.
|
||||
useEffect(() => {
|
||||
if (!isResizing || collapsed) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const newWidth = e.clientX;
|
||||
if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
|
||||
setWidth(newWidth);
|
||||
} else if (newWidth > MAX_WIDTH) {
|
||||
setWidth(MAX_WIDTH);
|
||||
}
|
||||
};
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
const finalWidth = e.clientX;
|
||||
if (finalWidth < COLLAPSE_THRESHOLD) {
|
||||
persistCollapsed(true);
|
||||
persistWidth(DEFAULT_WIDTH);
|
||||
} else {
|
||||
persistWidth(Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, finalWidth)));
|
||||
}
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [isResizing, collapsed, persistCollapsed, persistWidth]);
|
||||
|
||||
// Close the user menu when clicking outside it.
|
||||
useEffect(() => {
|
||||
if (!showUserMenu) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) {
|
||||
setShowUserMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDown);
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
}, [showUserMenu]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Left-edge pull zone: click to bring the rail back when collapsed. */}
|
||||
{collapsed && (
|
||||
<div
|
||||
className="fixed left-0 top-0 z-40 hidden h-full w-4 cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.08)] lg:block"
|
||||
onClick={expandSidebar}
|
||||
title="Expand sidebar"
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
"sticky top-0 z-20 hidden h-screen flex-shrink-0 flex-col overflow-hidden border-r border-[rgba(255,255,255,0.08)] bg-black lg:flex",
|
||||
!isResizing && "transition-[width] duration-200 ease-out"
|
||||
)}
|
||||
style={{ width: collapsed ? 0 : width }}
|
||||
>
|
||||
{/* Header — account-switcher stand-in (links out to Strix Cloud). */}
|
||||
<header className="relative flex flex-col gap-1 pt-1 min-w-[160px]">
|
||||
<div className="flex flex-row py-1 px-2">
|
||||
<div className="flex h-10 w-full flex-row items-center">
|
||||
<a
|
||||
href={ctaUrl("https://app.strix.ai", "logo")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("logo", "sidebar")}
|
||||
className="flex flex-1 flex-row items-center gap-2 rounded-md py-2 pl-2.5 pr-1 min-w-0 transition-colors hover:bg-[rgba(255,255,255,0.06)]"
|
||||
title="Open Strix Cloud"
|
||||
>
|
||||
<span
|
||||
className="flex flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500"
|
||||
style={{ width: 20, height: 20 }}
|
||||
>
|
||||
<span className="text-[10px] font-semibold text-white">S</span>
|
||||
</span>
|
||||
<span className="flex flex-1 flex-row items-center gap-2 min-w-0">
|
||||
<span className="truncate min-w-0 text-[14px] font-medium text-[#ededed]">Strix</span>
|
||||
<span className="flex h-5 flex-shrink-0 items-center rounded px-2 text-[11px] font-medium text-[#888] bg-[rgba(255,255,255,0.08)]">
|
||||
Local
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href={ctaUrl("https://app.strix.ai", "logo")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("logo", "sidebar")}
|
||||
className="flex flex-none items-center rounded-md px-1.5 py-2 transition-colors hover:bg-[rgba(255,255,255,0.06)]"
|
||||
aria-label="Open Strix Cloud"
|
||||
>
|
||||
<ChevronsUpDown className="h-4 w-4 text-[#666]" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="relative min-w-[160px] flex-1 overflow-y-auto overflow-x-clip scrollbar-thin pb-10 pt-2">
|
||||
<div className="relative flex flex-col gap-px px-2">
|
||||
<NavItem
|
||||
icon={<ProjectsIcon />}
|
||||
label="Pentest Overview"
|
||||
active={view === "overview"}
|
||||
onClick={() => onSelectView("overview")}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<AlertTriangle className="h-4 w-4" />}
|
||||
label="Issues"
|
||||
count={issuesCount > 0 ? issuesCount : undefined}
|
||||
active={view === "issues"}
|
||||
onClick={() => onSelectView("issues")}
|
||||
/>
|
||||
{agentCount > 0 && (
|
||||
<NavItem
|
||||
icon={<Bot className="h-4 w-4" />}
|
||||
label="Agents"
|
||||
count={agentCount}
|
||||
active={view === "agents"}
|
||||
onClick={() => onSelectView("agents")}
|
||||
/>
|
||||
)}
|
||||
<NavItem
|
||||
icon={<History className="h-4 w-4" />}
|
||||
label="Past runs"
|
||||
count={runCount > 0 ? runCount : undefined}
|
||||
active={view === "history"}
|
||||
onClick={onOpenHistory}
|
||||
/>
|
||||
{finished && (
|
||||
<NavItem
|
||||
icon={<Mail className="h-4 w-4" />}
|
||||
label="Export report"
|
||||
active={view === "email"}
|
||||
onClick={onOpenEmail}
|
||||
/>
|
||||
)}
|
||||
<NavItem
|
||||
icon={<IoChatbubblesOutline className="h-4 w-4" />}
|
||||
label="Feedback & support"
|
||||
active={view === "feedback"}
|
||||
onClick={() => onSelectView("feedback")}
|
||||
/>
|
||||
|
||||
<hr className="mx-0 my-1 h-px w-full border-0 bg-[rgba(255,255,255,0.08)]" />
|
||||
|
||||
<NavItem
|
||||
icon={<LuGitPullRequestArrow className="h-4 w-4" />}
|
||||
label="PR Security Reviews"
|
||||
active={false}
|
||||
onClick={() =>
|
||||
openUpgrade(
|
||||
"pr_reviews",
|
||||
"Strix reviews every pull request and flags exploitable changes before they merge."
|
||||
)
|
||||
}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<VscExtensions className="h-4 w-4" />}
|
||||
label="Integrations"
|
||||
active={false}
|
||||
onClick={() =>
|
||||
openUpgrade(
|
||||
"integrations",
|
||||
"Sync findings to Jira, Linear, and Slack so fixes happen where your team already works."
|
||||
)
|
||||
}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
label="Members"
|
||||
active={false}
|
||||
onClick={() =>
|
||||
openUpgrade(
|
||||
"members",
|
||||
"Invite your team, set roles, and share findings and run history across your org."
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* User footer — verified-email footer. */}
|
||||
<section className="flex min-w-[160px] flex-col gap-0.5" ref={userMenuRef}>
|
||||
<div className="relative p-2">
|
||||
{verified && email ? (
|
||||
<button
|
||||
onClick={() => setShowUserMenu((v) => !v)}
|
||||
className="relative flex w-full cursor-pointer items-center gap-2 rounded-md bg-transparent px-2.5 py-2 transition-colors hover:bg-[rgba(255,255,255,0.06)]"
|
||||
>
|
||||
<span
|
||||
className="flex flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500"
|
||||
style={{ width: 20, height: 20 }}
|
||||
>
|
||||
<span className="text-[9px] font-semibold text-white">
|
||||
{email[0]?.toUpperCase() || "U"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col text-left">
|
||||
<span className="truncate text-[13px] font-medium text-[#ededed]">{email}</span>
|
||||
<span className="truncate text-[11px] text-[#555]">Linked to this machine</span>
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-md px-2.5 py-2">
|
||||
<span
|
||||
className="flex flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500"
|
||||
style={{ width: 20, height: 20 }}
|
||||
>
|
||||
<span className="text-[9px] font-semibold text-white">S</span>
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col text-left">
|
||||
<span className="truncate text-[13px] font-medium text-[#ededed]">Local viewer</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showUserMenu && verified && email && (
|
||||
<div className="absolute bottom-full left-2 right-2 z-50 mb-1 overflow-hidden rounded-lg border border-[#333] bg-black shadow-xl">
|
||||
<div className="border-b border-[#333] px-3 py-2">
|
||||
<p className="truncate text-[13px] font-medium text-white">Linked email</p>
|
||||
<p className="truncate text-[11px] text-[#666]">{email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
onForget();
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-[13px] text-[#888] transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-red-400"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Forget this email
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
className="group absolute right-0 top-0 z-30 h-full w-1 cursor-col-resize"
|
||||
onMouseDown={handleResizeStart}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 top-0 h-full w-px bg-[rgba(255,255,255,0.08)] transition-all duration-100",
|
||||
isResizing ? "w-0.5 bg-[rgba(255,255,255,0.3)]" : "group-hover:bg-[rgba(255,255,255,0.2)]"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Overlay during resize to prevent text selection. */}
|
||||
{isResizing && <div className="fixed inset-0 z-10 cursor-col-resize" />}
|
||||
|
||||
<UpgradeModal
|
||||
open={upgradeFeature !== null}
|
||||
description={upgradeFeature ?? ""}
|
||||
source="sidebar"
|
||||
onClose={() => setUpgradeFeature(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface NavItemProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
function NavItem({ icon, label, active, onClick, count }: NavItemProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"group flex h-9 w-full origin-left flex-row items-center rounded-md transition-colors",
|
||||
active
|
||||
? "bg-[rgba(255,255,255,0.12)] text-white"
|
||||
: "text-[#888] hover:bg-[rgba(255,255,255,0.06)] hover:text-[#ededed]"
|
||||
)}
|
||||
>
|
||||
<div className="grid flex-none place-content-center" style={{ width: 36, height: 36 }}>
|
||||
{icon}
|
||||
</div>
|
||||
<span className="min-w-0 flex-1 truncate text-left text-[14px] font-medium">{label}</span>
|
||||
{count != null && (
|
||||
<span className="mr-2 flex-none rounded-full border border-white/10 px-2 py-0.5 text-[10px] tabular-nums leading-none text-[#777]">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Overview icon: a dashboard grid glyph (16x16 viewBox).
|
||||
function ProjectsIcon() {
|
||||
return (
|
||||
<svg style={{ width: 16, height: 16, color: "currentcolor" }} viewBox="0 0 16 16" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2.5 5.5V2.5H5.5V5.5H2.5ZM1 2C1 1.44772 1.44772 1 2 1H6C6.55228 1 7 1.44772 7 2V6C7 6.55228 6.55228 7 6 7H2C1.44772 7 1 6.55228 1 6V2ZM2.5 13.5V10.5H5.5V13.5H2.5ZM1 10C1 9.44772 1.44772 9 2 9H6C6.55228 9 7 9.44772 7 10V14C7 14.5523 6.55228 15 6 15H2C1.44772 15 1 14.5523 1 14V10ZM10.5 2.5V5.5H13.5V2.5H10.5ZM10 1C9.44772 1 9 1.44772 9 2V6C9 6.55228 9.44772 7 10 7H14C14.5523 7 15 6.55228 15 6V2C15 1.44772 14.5523 1 14 1H10ZM10.5 13.5V10.5H13.5V13.5H10.5ZM9 10C9 9.44772 9.44772 9 10 9H14C14.5523 9 15 9.44772 15 10V14C15 14.5523 14.5523 15 14 15H10C9.44772 15 9 14.5523 9 14V10Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
X,
|
||||
Sparkles,
|
||||
ExternalLink,
|
||||
GitPullRequest,
|
||||
Shield,
|
||||
Zap,
|
||||
CalendarClock,
|
||||
WandSparkles,
|
||||
Plug,
|
||||
} from "lucide-react";
|
||||
import { SIGNUP_URL, PRICING_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
|
||||
/**
|
||||
* Dialog shown when a platform feature is clicked in the sidebar: a short
|
||||
* description of the feature plus what Strix Cloud includes. The local viewer
|
||||
* has no billing, so both CTAs link out to the public sign-up / pricing pages.
|
||||
*/
|
||||
|
||||
const CLOUD_HIGHLIGHTS: { icon: React.ElementType; label: string }[] = [
|
||||
{ icon: GitPullRequest, label: "PR security reviews" },
|
||||
{ icon: Shield, label: "Attack surface monitoring" },
|
||||
{ icon: Zap, label: "Real-time threat intelligence" },
|
||||
{ icon: CalendarClock, label: "Scheduled pentesting" },
|
||||
{ icon: WandSparkles, label: "One-click autofix" },
|
||||
{ icon: Plug, label: "Jira, Linear & Slack integrations" },
|
||||
];
|
||||
|
||||
export function UpgradeModal({
|
||||
open,
|
||||
onClose,
|
||||
description,
|
||||
source = "sidebar",
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** A short sentence describing what the clicked feature does. */
|
||||
description: string;
|
||||
source?: string;
|
||||
}) {
|
||||
// Keep the dialog mounted through its exit animation: `render` controls
|
||||
// presence in the DOM and `state` ("open"/"closed") drives the keyframe. On
|
||||
// close we flip to "closed", let the 200ms animation play, then unmount --
|
||||
// the same lifecycle Radix gives shadcn's Dialog.
|
||||
const [render, setRender] = useState(open);
|
||||
const [state, setState] = useState<"open" | "closed">(open ? "open" : "closed");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRender(true);
|
||||
setState("open");
|
||||
return;
|
||||
}
|
||||
setState("closed");
|
||||
const t = setTimeout(() => setRender(false), 200);
|
||||
return () => clearTimeout(t);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!render) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [render, onClose]);
|
||||
|
||||
if (!render) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-state={state}
|
||||
className="dialog-overlay fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Upgrade your plan"
|
||||
>
|
||||
<div
|
||||
data-state={state}
|
||||
className="dialog-panel relative w-full max-w-md rounded-2xl border border-[#222] bg-black p-6 shadow-lg sm:rounded-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="absolute right-4 top-4 rounded-md p-1 text-[#888] transition-colors hover:bg-[#1a1a1a] hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<h2 className="text-lg text-white">Available in Strix Cloud</h2>
|
||||
{description && (
|
||||
<p className="mt-2 text-base leading-relaxed text-[#e5e5e5]">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-4">
|
||||
<div className="rounded-xl border border-[#333] bg-[#0a0a0a] p-4 sm:rounded-lg">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-sm font-medium text-white">Strix Cloud also includes</span>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm text-[#888]">
|
||||
{CLOUD_HIGHLIGHTS.map((f) => (
|
||||
<li key={f.label} className="flex items-center gap-2">
|
||||
<f.icon className="h-3.5 w-3.5 text-[#555]" />
|
||||
{f.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<a
|
||||
href={ctaUrl(SIGNUP_URL, "upgrade_try_free")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("upgrade_try_free", source)}
|
||||
className="flex h-10 w-full items-center justify-center gap-2 rounded-lg bg-white px-4 text-sm font-semibold text-black transition-colors hover:bg-neutral-200"
|
||||
>
|
||||
Open Strix Cloud
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
<a
|
||||
href={ctaUrl(PRICING_URL, "upgrade_view_plans")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("upgrade_view_plans", source)}
|
||||
className="flex h-9 w-full items-center justify-center gap-1.5 rounded-lg border border-[#333] px-4 text-sm font-medium text-[#888] transition-colors hover:border-[#555] hover:text-white"
|
||||
>
|
||||
Learn more
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UpgradeModal;
|
||||
-121
@@ -1,121 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { TruncatedText } from "./ToolCard";
|
||||
import Markdown from "./Markdown";
|
||||
|
||||
const SEVERITY_COLORS: Record<string, string> = {
|
||||
critical: "text-red-400", high: "text-orange-400", medium: "text-yellow-400",
|
||||
low: "text-blue-400", info: "text-cyan-400", none: "text-[#888]",
|
||||
};
|
||||
|
||||
interface ReportEntry {
|
||||
id?: string;
|
||||
title?: string;
|
||||
severity?: string;
|
||||
cvss?: number;
|
||||
cve?: string;
|
||||
cwe?: string;
|
||||
target?: string;
|
||||
endpoint?: string;
|
||||
method?: string;
|
||||
description_preview?: string;
|
||||
description?: string;
|
||||
agent_name?: string;
|
||||
by_you?: boolean;
|
||||
}
|
||||
|
||||
function authorTag(r: ReportEntry) {
|
||||
if (!r.agent_name && !r.by_you) return null;
|
||||
const label = r.by_you ? "you" : r.agent_name;
|
||||
return <span className="text-[#666] text-xs ml-1.5">({label})</span>;
|
||||
}
|
||||
|
||||
function sevBadge(severity: string | undefined) {
|
||||
const sev = String(severity ?? "").toLowerCase();
|
||||
const color = SEVERITY_COLORS[sev] ?? "text-yellow-400";
|
||||
return <span className={`font-semibold text-[13px] ${color}`}>{sev.toUpperCase() || "—"}</span>;
|
||||
}
|
||||
|
||||
export default function ReportListRenderer({ toolName, result }: ToolRendererProps) {
|
||||
const res = result as Record<string, unknown> | null;
|
||||
const ok = res != null && typeof res === "object" && res.success === true;
|
||||
|
||||
if (toolName === "get_report") {
|
||||
const report = ok ? (res.report as ReportEntry | undefined) : undefined;
|
||||
return (
|
||||
<div>
|
||||
<span className="text-red-400/80 font-semibold text-sm">report</span>
|
||||
{report ? (
|
||||
<div className="mt-1.5 space-y-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{sevBadge(report.severity)}
|
||||
{report.cvss != null && <span className="text-[#888] text-[13px]">CVSS {report.cvss}</span>}
|
||||
{report.id && <span className="text-[#555] font-mono text-[13px]">{report.id}</span>}
|
||||
{report.cve && <span className="text-[#888] font-mono text-[13px]">{report.cve}</span>}
|
||||
{report.cwe && <span className="text-[#888] font-mono text-[13px]">{report.cwe}</span>}
|
||||
{(report.agent_name || report.by_you) && (
|
||||
<span className="text-[#666] text-[13px]">{report.by_you ? "you" : report.agent_name}</span>
|
||||
)}
|
||||
</div>
|
||||
{report.title && <div className="text-[15px] text-white/80 font-semibold">{report.title}</div>}
|
||||
{(report.target || report.endpoint) && (
|
||||
<div className="text-[13px] text-[#888] font-mono">
|
||||
{report.target}{report.endpoint ? ` ${report.method ?? ""} ${report.endpoint}` : ""}
|
||||
</div>
|
||||
)}
|
||||
{report.description && <TruncatedText text={report.description} maxLines={20} />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 text-[#555] text-xs">
|
||||
{(res && typeof res === "object" && (res.error as string)) || "Report not found"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// list_reports
|
||||
const rawReports = ok ? res.reports : null;
|
||||
const reports: ReportEntry[] = Array.isArray(rawReports) ? (rawReports as ReportEntry[]) : [];
|
||||
const total = ok && typeof res.total_count === "number" ? (res.total_count as number) : reports.length;
|
||||
const counts = ok && res.severity_counts && typeof res.severity_counts === "object"
|
||||
? (res.severity_counts as Record<string, number>)
|
||||
: {};
|
||||
const countEntries = Object.entries(counts);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-red-400/80 font-semibold text-sm">reports</span>
|
||||
<span className="text-[#555] text-[13px]">({total})</span>
|
||||
{countEntries.map(([sev, n]) => (
|
||||
<span key={sev} className="text-[13px]">
|
||||
{sevBadge(sev)}<span className="text-[#888] ml-0.5">{n}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{reports.length > 0 ? (
|
||||
<div className="mt-1.5 space-y-1">
|
||||
{reports.map((r, i) => (
|
||||
<div key={r.id ?? i} className="text-[13px]">
|
||||
<span className="text-[#555] mr-1">-</span>
|
||||
{sevBadge(r.severity)}
|
||||
{r.id && <span className="text-[#555] font-mono ml-1.5">{r.id}</span>}
|
||||
<span className="text-[#999] ml-1.5">{r.title ?? "(untitled)"}</span>
|
||||
{authorTag(r)}
|
||||
{(r.target || r.endpoint) && (
|
||||
<div className="ml-3 text-[#666] font-mono text-xs">
|
||||
{r.target}{r.endpoint ? ` ${r.method ?? ""} ${r.endpoint}` : ""}
|
||||
</div>
|
||||
)}
|
||||
{r.description_preview && (
|
||||
<div className="ml-3"><Markdown text={r.description_preview} /></div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <div className="mt-1 text-[#555] text-xs">No reports filed yet</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--font-geist-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif;
|
||||
--font-geist-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
font-family: var(--font-geist-sans);
|
||||
}
|
||||
|
||||
/* Thin sidebar scrollbar */
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Motion vocabulary --------------------- */
|
||||
|
||||
/* Page transition: replayed on every view change via a keyed wrapper. */
|
||||
@keyframes page-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
filter: blur(8px);
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0px);
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
.animate-page-in {
|
||||
animation: page-in 150ms ease-out;
|
||||
}
|
||||
|
||||
/* Plain fade. */
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.animate-fade-in {
|
||||
animation: fade-in 350ms ease-out;
|
||||
}
|
||||
|
||||
/* Staggered card entrance for lists/grids (first four cascade). */
|
||||
@keyframes cardIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
filter: blur(4px);
|
||||
transform: translateY(8px) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0px);
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
.animate-card-in {
|
||||
opacity: 0;
|
||||
animation: cardIn 300ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
.animate-card-in:nth-child(1) {
|
||||
animation-delay: 0ms;
|
||||
}
|
||||
.animate-card-in:nth-child(2) {
|
||||
animation-delay: 50ms;
|
||||
}
|
||||
.animate-card-in:nth-child(3) {
|
||||
animation-delay: 100ms;
|
||||
}
|
||||
.animate-card-in:nth-child(4) {
|
||||
animation-delay: 150ms;
|
||||
}
|
||||
|
||||
/* Shimmer sweep for progress indicators. */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(400%);
|
||||
}
|
||||
}
|
||||
.animate-shimmer {
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
/* Dialog enter/exit — mirrors shadcn's data-[state]:animate-in/animate-out
|
||||
(fade-in-0/zoom-in-95 in, fade-out-0/zoom-out-95 out) driven off a
|
||||
data-state attribute rather than a transition, so the enter always plays. */
|
||||
@keyframes dialog-overlay-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes dialog-overlay-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes dialog-panel-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes dialog-panel-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
.dialog-overlay[data-state="open"] {
|
||||
animation: dialog-overlay-in 200ms ease;
|
||||
}
|
||||
.dialog-overlay[data-state="closed"] {
|
||||
animation: dialog-overlay-out 200ms ease forwards;
|
||||
}
|
||||
.dialog-panel[data-state="open"] {
|
||||
animation: dialog-panel-in 200ms ease;
|
||||
}
|
||||
.dialog-panel[data-state="closed"] {
|
||||
animation: dialog-panel-out 200ms ease forwards;
|
||||
}
|
||||
|
||||
/* Agent detail modal: fade only (no scale) and faster. Its panel holds the full
|
||||
transcript, and animating a transform on that much DOM janks; fading the
|
||||
overlay (the panel inherits its opacity) stays cheap and snappy. */
|
||||
.agent-modal[data-state="open"] {
|
||||
animation: dialog-overlay-in 140ms ease;
|
||||
}
|
||||
.agent-modal[data-state="closed"] {
|
||||
animation: dialog-overlay-out 140ms ease forwards;
|
||||
}
|
||||
|
||||
/* Tab content transition. */
|
||||
@keyframes tab-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
filter: blur(4px);
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0px);
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
.animate-tab-in {
|
||||
animation: tab-in 200ms ease-out;
|
||||
}
|
||||
|
||||
/* Markdown prose styling */
|
||||
.prose-markdown {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: #999;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.prose-markdown p {
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
.prose-markdown p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.prose-markdown strong {
|
||||
color: #ccc;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose-markdown em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.prose-markdown code {
|
||||
background: #0a0a0a;
|
||||
border: 1px solid #111;
|
||||
border-radius: 4px;
|
||||
padding: 0.15em 0.4em;
|
||||
font-size: 0.9em;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
color: #ccc;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
.prose-markdown pre {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
.prose-markdown pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.prose-markdown ul,
|
||||
.prose-markdown ol {
|
||||
padding-left: 1.5em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
.prose-markdown ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.prose-markdown ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.prose-markdown li {
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.prose-markdown li > ul,
|
||||
.prose-markdown li > ol {
|
||||
padding-left: 1.5em;
|
||||
margin-top: 0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.prose-markdown ol + ul {
|
||||
padding-left: 3em;
|
||||
margin-top: -0.5em;
|
||||
}
|
||||
|
||||
.prose-markdown h1,
|
||||
.prose-markdown h2,
|
||||
.prose-markdown h3,
|
||||
.prose-markdown h4,
|
||||
.prose-markdown h5,
|
||||
.prose-markdown h6 {
|
||||
color: #ddd;
|
||||
font-weight: 600;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.prose-markdown a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.prose-markdown blockquote {
|
||||
border-left: 3px solid #333;
|
||||
padding-left: 1em;
|
||||
color: #777;
|
||||
margin: 0.75em 0;
|
||||
}
|
||||
|
||||
.prose-markdown hr {
|
||||
border: none;
|
||||
border-top: 1px solid #222;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.prose-markdown > table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0.75em 0;
|
||||
}
|
||||
|
||||
.prose-markdown > table th,
|
||||
.prose-markdown > table td {
|
||||
border: 1px solid #333;
|
||||
padding: 0.4em 0.75em;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.prose-markdown > table th {
|
||||
background: #1a1a1a;
|
||||
color: #ccc;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
export interface ParsedFencedCode {
|
||||
language?: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
const FENCE_RE = /^```([^\n`]*)\r?\n([\s\S]*?)\r?\n?```$/;
|
||||
|
||||
/**
|
||||
* Agent-generated `poc_script_code` is stored wrapped in a markdown code fence
|
||||
* that carries the language, e.g.
|
||||
*
|
||||
* ```python
|
||||
* import requests
|
||||
* ```
|
||||
*
|
||||
* Renderers that show the value as bare code must not display the fence lines
|
||||
* literally. This extracts the inner code and the fence's language tag. Returns
|
||||
* the input unchanged (no language) when it isn't fenced.
|
||||
*/
|
||||
export function parseFencedCode(raw: string | null | undefined): ParsedFencedCode {
|
||||
if (!raw) return { code: "" };
|
||||
const match = FENCE_RE.exec(raw.trim());
|
||||
if (!match) return { code: raw };
|
||||
const info = match[1].trim();
|
||||
const language = info ? info.split(/\s+/)[0] : undefined;
|
||||
return { language: language || undefined, code: match[2] };
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import hljs from "highlight.js/lib/common";
|
||||
import http from "highlight.js/lib/languages/http";
|
||||
import nginx from "highlight.js/lib/languages/nginx";
|
||||
import apache from "highlight.js/lib/languages/apache";
|
||||
import dockerfile from "highlight.js/lib/languages/dockerfile";
|
||||
import properties from "highlight.js/lib/languages/properties";
|
||||
|
||||
hljs.registerLanguage("http", http);
|
||||
hljs.registerLanguage("nginx", nginx);
|
||||
hljs.registerLanguage("apache", apache);
|
||||
hljs.registerLanguage("dockerfile", dockerfile);
|
||||
hljs.registerLanguage("properties", properties);
|
||||
|
||||
/**
|
||||
* Highlight code, preferring an explicit language when it's recognized,
|
||||
* otherwise auto-detecting. Falls back to Python when auto-detection is
|
||||
* inconclusive, since legacy (unfenced) PoC scripts are Python.
|
||||
*/
|
||||
export function highlightCode(code: string, language?: string | null): string {
|
||||
try {
|
||||
if (language && hljs.getLanguage(language)) {
|
||||
return hljs.highlight(code, { language, ignoreIllegals: true }).value;
|
||||
}
|
||||
const auto = hljs.highlightAuto(code);
|
||||
if (auto.language) return auto.value;
|
||||
return hljs.highlight(code, { language: "python", ignoreIllegals: true }).value;
|
||||
} catch {
|
||||
return hljs.highlight(code, { language: "python", ignoreIllegals: true }).value;
|
||||
}
|
||||
}
|
||||
|
||||
export default hljs;
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
"""LLM-facing context management: model-aware budgets and history compaction."""
|
||||
@@ -1,354 +0,0 @@
|
||||
"""Provider-agnostic conversation compaction.
|
||||
|
||||
When an agent's session grows past the model's usable context window, older
|
||||
turns are summarised into a single checkpoint while the most recent turns are
|
||||
kept verbatim. This runs for every LiteLLM provider (not just OpenAI), keeps a
|
||||
security-focused structured summary, and preserves tool-call/tool-result
|
||||
pairing so the trimmed history is still valid provider input.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import litellm
|
||||
from litellm.exceptions import BadRequestError, ContextWindowExceededError
|
||||
|
||||
from strix.config import load_settings
|
||||
from strix.core.sessions import replace_session_items, session_write_lock
|
||||
from strix.llm.context_budget import context_window, count_tokens, output_limit
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agents.memory import Session
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CHECKPOINT_TAG = "<conversation-checkpoint>"
|
||||
_TOOL_OUTPUT_MAX_CHARS = 2_000
|
||||
_MIN_ITEMS_TO_COMPACT = 6
|
||||
_HEAD_TRUNCATED_MARKER = "\n\n[... older conversation omitted to fit the summary request ...]\n\n"
|
||||
|
||||
|
||||
# Providers that don't type overflow errors (OpenRouter maps every 400 to a
|
||||
# plain BadRequestError) leave only the message to go on, so we match it the way
|
||||
# LiteLLM's own checker does — but with rate-limit exclusions first, so a
|
||||
# throttling 429 is never mistaken for an overflow and sent into compaction.
|
||||
_OVERFLOW_EXCLUSIONS = (
|
||||
"rate limit",
|
||||
"too many requests",
|
||||
"throttling",
|
||||
"service unavailable",
|
||||
"quota",
|
||||
)
|
||||
_OVERFLOW_MARKERS = (
|
||||
"context length",
|
||||
"context window",
|
||||
"context_length_exceeded",
|
||||
"prompt is too long",
|
||||
"input is too long",
|
||||
"input length",
|
||||
"maximum prompt length",
|
||||
"reduce the length of the messages",
|
||||
"too many tokens",
|
||||
"token limit exceeded",
|
||||
"request entity too large",
|
||||
)
|
||||
|
||||
|
||||
def is_context_overflow(exc: BaseException) -> bool:
|
||||
"""Whether ``exc`` is a model context-window-overflow error.
|
||||
|
||||
LiteLLM types most providers' overflow as ContextWindowExceededError, but its
|
||||
OpenRouter branch raises a plain BadRequestError, so for that we fall back to
|
||||
matching the provider message.
|
||||
"""
|
||||
if isinstance(exc, ContextWindowExceededError):
|
||||
return True
|
||||
if isinstance(exc, BadRequestError):
|
||||
msg = str(exc).lower()
|
||||
if any(x in msg for x in _OVERFLOW_EXCLUSIONS):
|
||||
return False
|
||||
return any(x in msg for x in _OVERFLOW_MARKERS)
|
||||
return False
|
||||
|
||||
|
||||
_SUMMARY_INSTRUCTIONS = """\
|
||||
You are compacting the earlier part of an autonomous security-testing agent's \
|
||||
conversation so it fits the model context window. Produce a dense, factual \
|
||||
record that lets the agent continue with no loss of important state.
|
||||
|
||||
This is a security engagement: dropped findings mean lost vulnerabilities. Be \
|
||||
EXHAUSTIVE, not concise. Enumerate every distinct item as its own bullet — \
|
||||
never merge, deduplicate, generalise, or omit distinct findings, credentials, \
|
||||
or dead ends, even if they seem minor or repetitive. If the source mentions \
|
||||
five vulnerabilities, list five. Copy exact values verbatim: URLs, endpoints, \
|
||||
file paths, parameters, payloads, credentials, tokens, keys, hashes, cracked \
|
||||
passwords, software versions, and error messages — never paraphrase or \
|
||||
placeholder them. Do not invent anything and do not describe this compaction \
|
||||
process.
|
||||
|
||||
Return Markdown with exactly these sections:
|
||||
|
||||
## Objective
|
||||
The overall goal and target scope.
|
||||
|
||||
## Vulnerabilities & Findings
|
||||
One bullet per DISTINCT vulnerability or finding (SQLi, XSS, SSRF, auth bypass, \
|
||||
misconfig, etc.). For each: type, exact location (URL/endpoint/param/file), the \
|
||||
verbatim payload or proof, confirmation status, and impact. List them all.
|
||||
|
||||
## Credentials & Secrets
|
||||
One bullet per credential, secret, API key, token, hash, or cracked password, \
|
||||
copied verbatim with where it applies. Write "(none)" only if truly none.
|
||||
|
||||
## System & Recon Details
|
||||
Architecture, tech stack, versions, discovered endpoints/paths/params, and \
|
||||
other weak points worth keeping.
|
||||
|
||||
## Work State
|
||||
- Completed: what has been verified or finished.
|
||||
- Active: what is in progress right now.
|
||||
- Blocked: anything stuck and why.
|
||||
|
||||
## Failed Attempts & Dead Ends
|
||||
One bullet per approach already tried that did not work (including WAF blocks, \
|
||||
filtered inputs, non-exploitable leads) so they are not repeated. Write \
|
||||
"(none)" only if truly none.
|
||||
|
||||
## Next Move
|
||||
The concrete next step(s) the agent intended to take.
|
||||
|
||||
## Relevant Files
|
||||
Files/notes/reports created or modified and their purpose."""
|
||||
|
||||
|
||||
def _content_text(content: Any) -> str:
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
text = block.get("text")
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
elif block.get("type") in {"input_image", "image_url", "output_image"}:
|
||||
parts.append("[image]")
|
||||
return "\n".join(parts)
|
||||
return ""
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int) -> str:
|
||||
return text if len(text) <= limit else f"{text[:limit]}\n[truncated]"
|
||||
|
||||
|
||||
def _serialize_item(item: Any) -> str:
|
||||
if not isinstance(item, dict):
|
||||
return str(item)
|
||||
item_type = item.get("type")
|
||||
role = item.get("role")
|
||||
if item_type == "function_call":
|
||||
args = _truncate(str(item.get("arguments", "")), _TOOL_OUTPUT_MAX_CHARS)
|
||||
return f"[tool_call {item.get('name', '?')}] {args}"
|
||||
if item_type == "function_call_output":
|
||||
output = item.get("output")
|
||||
text = output if isinstance(output, str) else _content_text(output)
|
||||
return f"[tool_result] {_truncate(text, _TOOL_OUTPUT_MAX_CHARS)}"
|
||||
if item_type == "reasoning":
|
||||
return ""
|
||||
if role or item_type == "message":
|
||||
return f"[{role or 'assistant'}] {_content_text(item.get('content'))}".strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _serialize_items(items: list[Any]) -> str:
|
||||
return "\n".join(s for s in (_serialize_item(item) for item in items) if s)
|
||||
|
||||
|
||||
def _is_tool_call(item: Any) -> bool:
|
||||
return isinstance(item, dict) and item.get("type") == "function_call"
|
||||
|
||||
|
||||
def _is_tool_output(item: Any) -> bool:
|
||||
return isinstance(item, dict) and item.get("type") == "function_call_output"
|
||||
|
||||
|
||||
def _open_calls_at(items: list[Any]) -> list[int]:
|
||||
"""Prefix count of tool calls still awaiting their result at each index;
|
||||
a split is only safe where this is zero."""
|
||||
balance = [0] * (len(items) + 1)
|
||||
for i, item in enumerate(items):
|
||||
delta = 1 if _is_tool_call(item) else -1 if _is_tool_output(item) else 0
|
||||
balance[i + 1] = max(0, balance[i] + delta)
|
||||
return balance
|
||||
|
||||
|
||||
def _select_split(model: str, items: list[Any], keep_tokens: int) -> int:
|
||||
"""Index where the kept-verbatim recent tail begins: walk newest→oldest to
|
||||
``keep_tokens``, then snap to a point with no tool call left open."""
|
||||
total = 0
|
||||
split = len(items)
|
||||
for i in range(len(items) - 1, -1, -1):
|
||||
total += count_tokens(model, _serialize_item(items[i]))
|
||||
if total > keep_tokens:
|
||||
break
|
||||
split = i
|
||||
open_calls = _open_calls_at(items)
|
||||
while split > 0 and open_calls[split] != 0:
|
||||
split -= 1
|
||||
return split
|
||||
|
||||
|
||||
def _previous_summary(head: list[Any]) -> str | None:
|
||||
for item in head:
|
||||
if isinstance(item, dict) and item.get("role") == "user":
|
||||
text = _content_text(item.get("content"))
|
||||
if text.startswith(_CHECKPOINT_TAG):
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
def _fit_to_tokens(model: str, text: str, max_tokens: int) -> str:
|
||||
"""Head+tail-truncate ``text`` to ``max_tokens``, keeping start and end."""
|
||||
if count_tokens(model, text) <= max_tokens:
|
||||
return text
|
||||
# Rough char budget (~4x tokens), then tighten by real token count.
|
||||
budget_chars = max_tokens * 4
|
||||
head_chars = budget_chars // 2
|
||||
tail_chars = budget_chars - head_chars
|
||||
candidate = text[:head_chars] + _HEAD_TRUNCATED_MARKER + text[len(text) - tail_chars :]
|
||||
while count_tokens(model, candidate) > max_tokens and (head_chars > 0 or tail_chars > 0):
|
||||
head_chars = int(head_chars * 0.8)
|
||||
tail_chars = int(tail_chars * 0.8)
|
||||
candidate = text[:head_chars] + _HEAD_TRUNCATED_MARKER + text[len(text) - tail_chars :]
|
||||
return candidate
|
||||
|
||||
|
||||
def _summary_output_tokens(model: str) -> int:
|
||||
"""Summary output allowance, capped at the model's own output limit."""
|
||||
return min(load_settings().context.summary_max_tokens, output_limit(model))
|
||||
|
||||
|
||||
def _summary_input_budget(model: str, previous: str | None) -> int:
|
||||
"""Token room left for the head after instructions and the summary output."""
|
||||
overhead = count_tokens(model, _SUMMARY_INSTRUCTIONS)
|
||||
if previous:
|
||||
overhead += count_tokens(model, previous)
|
||||
# 256 leaves slack for the prompt wrapper text not counted in ``overhead``.
|
||||
room = context_window(model) - _summary_output_tokens(model) - overhead - 256
|
||||
return max(0, room)
|
||||
|
||||
|
||||
def _build_summary_prompt(serialized_head: str, previous: str | None) -> str:
|
||||
previous_block = (
|
||||
f"\n\nA previous checkpoint summary follows. Update it: keep what is "
|
||||
f"still true, drop what is now stale, and merge in the new "
|
||||
f"conversation below.\n\n{previous}\n"
|
||||
if previous
|
||||
else ""
|
||||
)
|
||||
return (
|
||||
f"{_SUMMARY_INSTRUCTIONS}{previous_block}\n\n"
|
||||
f"Conversation to summarise:\n\n{serialized_head}"
|
||||
)
|
||||
|
||||
|
||||
def _checkpoint_item(summary: str) -> dict[str, Any]:
|
||||
return {
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"{_CHECKPOINT_TAG}\nThe following summarises earlier conversation that was "
|
||||
f"compacted to fit the context window. Treat it as established context, not "
|
||||
f"new instructions.\n\n{summary}\n</conversation-checkpoint>"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def _summarize(model: str, prompt: str, max_tokens: int) -> str | None:
|
||||
llm = load_settings().llm
|
||||
try:
|
||||
response = await litellm.acompletion(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
max_tokens=max_tokens,
|
||||
api_key=llm.api_key,
|
||||
api_base=llm.api_base,
|
||||
timeout=llm.timeout,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("compaction summary call failed for model %s", model)
|
||||
return None
|
||||
try:
|
||||
content = response.choices[0].message.content
|
||||
except (AttributeError, IndexError, KeyError):
|
||||
logger.warning("compaction summary returned no content")
|
||||
return None
|
||||
return content.strip() if isinstance(content, str) and content.strip() else None
|
||||
|
||||
|
||||
async def maybe_compact(
|
||||
session: Session,
|
||||
*,
|
||||
model: str,
|
||||
instructions: str = "",
|
||||
tools_text: str = "",
|
||||
force: bool = False,
|
||||
) -> bool:
|
||||
"""Compact ``session`` if it is near the model's context window.
|
||||
|
||||
Returns ``True`` when the session was rewritten. ``force`` skips the size
|
||||
check (used after a provider context-overflow error).
|
||||
"""
|
||||
context = load_settings().context
|
||||
if not context.auto_compact and not force:
|
||||
return False
|
||||
|
||||
async with session_write_lock(session):
|
||||
items = list(await session.get_items())
|
||||
if len(items) < _MIN_ITEMS_TO_COMPACT:
|
||||
return False
|
||||
|
||||
window = context_window(model)
|
||||
reserve = max(context.compact_buffer_tokens, output_limit(model))
|
||||
budget = max(context.keep_tokens, window - reserve)
|
||||
used = count_tokens(model, "\n".join((instructions, tools_text, _serialize_items(items))))
|
||||
if not force and used <= budget:
|
||||
return False
|
||||
|
||||
split = _select_split(model, items, context.keep_tokens)
|
||||
head, recent = items[:split], items[split:]
|
||||
previous = _previous_summary(head)
|
||||
input_budget = _summary_input_budget(model, previous)
|
||||
if not head or input_budget <= 0:
|
||||
# Nothing to summarise, or no room for even the summary request itself.
|
||||
if head:
|
||||
logger.warning(
|
||||
"skipping compaction for %s: no room to summarise within its context window", model
|
||||
)
|
||||
return False
|
||||
|
||||
serialized_head = _fit_to_tokens(model, _serialize_items(head), input_budget)
|
||||
summary = await _summarize(
|
||||
model,
|
||||
_build_summary_prompt(serialized_head, previous),
|
||||
_summary_output_tokens(model),
|
||||
)
|
||||
if summary is None:
|
||||
return False
|
||||
|
||||
new_items = [_checkpoint_item(summary), *recent]
|
||||
rewritten = await replace_session_items(session, new_items, expected_len=len(items))
|
||||
if rewritten:
|
||||
logger.info(
|
||||
"compacted %s: %d items (~%d tok) -> %d items (summary + %d recent)",
|
||||
model,
|
||||
len(items),
|
||||
used,
|
||||
len(new_items),
|
||||
len(recent),
|
||||
)
|
||||
return rewritten
|
||||
@@ -1,76 +0,0 @@
|
||||
"""Model-aware token budgets, resolved from LiteLLM model metadata with a
|
||||
large configurable fallback for models LiteLLM doesn't map.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
import litellm
|
||||
|
||||
from strix.config import load_settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# LiteLLM keys models without the routing prefix users type (``openai/``,
|
||||
# ``litellm/``, ``ollama/`` ...). Strip a leading provider segment on lookup.
|
||||
_STRIPPABLE_PREFIXES = ("openai/", "litellm/", "any-llm/", "ollama/", "ollama_chat/")
|
||||
|
||||
_DEFAULT_OUTPUT_TOKENS = 8_192
|
||||
|
||||
|
||||
def _lookup_key(model: str) -> str:
|
||||
for prefix in _STRIPPABLE_PREFIXES:
|
||||
if model.startswith(prefix):
|
||||
return model[len(prefix) :]
|
||||
return model
|
||||
|
||||
|
||||
def _safe_get_model_info(model: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
return dict(litellm.get_model_info(model))
|
||||
except Exception: # noqa: BLE001 - unmapped models raise; caller falls back.
|
||||
return None
|
||||
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def _model_info(model: str) -> dict[str, int]:
|
||||
for candidate in (model, _lookup_key(model)):
|
||||
info = _safe_get_model_info(candidate)
|
||||
if info is not None:
|
||||
return {
|
||||
"max_input_tokens": int(
|
||||
info.get("max_input_tokens") or info.get("max_tokens") or 0
|
||||
),
|
||||
"max_output_tokens": int(info.get("max_output_tokens") or 0),
|
||||
}
|
||||
logger.debug("No LiteLLM model info for %r; using configured fallbacks", model)
|
||||
return {"max_input_tokens": 0, "max_output_tokens": 0}
|
||||
|
||||
|
||||
def context_window(model: str) -> int:
|
||||
"""Input token capacity for ``model`` (configured fallback when unmapped)."""
|
||||
resolved = _model_info(model)["max_input_tokens"]
|
||||
return resolved or load_settings().context.fallback_context_tokens
|
||||
|
||||
|
||||
def output_limit(model: str) -> int:
|
||||
"""Max output tokens for ``model`` (a conservative default when unmapped)."""
|
||||
return _model_info(model)["max_output_tokens"] or _DEFAULT_OUTPUT_TOKENS
|
||||
|
||||
|
||||
def count_tokens(model: str, text: str) -> int:
|
||||
"""Token count for ``text`` under ``model``.
|
||||
|
||||
Falls back to UTF-8 byte length (a guaranteed upper bound) when LiteLLM
|
||||
can't count, so budget checks stay conservative.
|
||||
"""
|
||||
if not text:
|
||||
return 0
|
||||
try:
|
||||
return int(litellm.token_counter(model=_lookup_key(model), text=text))
|
||||
except Exception: # noqa: BLE001 - tokenizer may be unavailable for some models.
|
||||
return len(text.encode("utf-8"))
|
||||
+8
-42
@@ -13,55 +13,20 @@ from openai.types.responses import ResponseOutputMessage
|
||||
|
||||
from strix.config import load_settings
|
||||
from strix.config.models import (
|
||||
DEFAULT_MODEL_RETRY,
|
||||
StrixProvider,
|
||||
configure_sdk_model_defaults,
|
||||
request_timeout_extra_args,
|
||||
)
|
||||
from strix.core.inputs import make_model_settings
|
||||
from strix.report.state import get_global_report_state
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agents.items import ModelResponse
|
||||
|
||||
from strix.config.settings import DedupeSettings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _dedupe_extra_args(dedupe: DedupeSettings) -> dict[str, str]:
|
||||
"""Per-call credential + endpoint for the dedupe model.
|
||||
|
||||
Provider env vars and the global base URL are process-wide, so a
|
||||
shared-provider dedupe key or a distinct dedupe endpoint can't be installed
|
||||
globally without clobbering (or being clobbered by) the main model's
|
||||
config. Passing them per call keeps the two apart. Only applies when a
|
||||
dedicated dedupe model is configured.
|
||||
"""
|
||||
if not dedupe.model:
|
||||
return {}
|
||||
extra: dict[str, str] = {}
|
||||
if dedupe.api_key and dedupe.api_key.strip():
|
||||
extra["api_key"] = dedupe.api_key.strip()
|
||||
if dedupe.api_base and dedupe.api_base.strip():
|
||||
extra["api_base"] = dedupe.api_base.strip()
|
||||
return extra
|
||||
|
||||
|
||||
def _dedupe_model_settings(
|
||||
dedupe: DedupeSettings, model_name: str, request_timeout: float | None
|
||||
) -> ModelSettings:
|
||||
settings = make_model_settings(
|
||||
dedupe.reasoning_effort,
|
||||
model_name=model_name,
|
||||
force_required_tool_choice=False,
|
||||
request_timeout=request_timeout,
|
||||
)
|
||||
extra = _dedupe_extra_args(dedupe)
|
||||
if extra:
|
||||
settings = settings.resolve(ModelSettings(extra_args=extra))
|
||||
return settings
|
||||
|
||||
DEDUPE_SYSTEM_PROMPT = """You are an expert vulnerability report deduplication judge.
|
||||
Your task is to determine if a candidate vulnerability report describes the SAME vulnerability
|
||||
as any existing report.
|
||||
@@ -321,14 +286,13 @@ async def check_duplicate(
|
||||
|
||||
try:
|
||||
settings = load_settings()
|
||||
dedupe = settings.dedupe
|
||||
model_name = (dedupe.model or "").strip() or settings.llm.model
|
||||
model_name = settings.llm.model
|
||||
if not model_name:
|
||||
return {
|
||||
"is_duplicate": False,
|
||||
"duplicate_id": "",
|
||||
"confidence": 0.0,
|
||||
"reason": "No LLM model configured; skipping dedupe check",
|
||||
"reason": "STRIX_LLM not configured; skipping dedupe check",
|
||||
}
|
||||
|
||||
candidate_cleaned = _prepare_report_for_comparison(candidate)
|
||||
@@ -347,8 +311,10 @@ async def check_duplicate(
|
||||
response = await model.get_response(
|
||||
system_instructions=DEDUPE_SYSTEM_PROMPT,
|
||||
input=user_msg,
|
||||
model_settings=_dedupe_model_settings(
|
||||
dedupe, resolved_model, settings.llm.timeout
|
||||
model_settings=ModelSettings(
|
||||
retry=DEFAULT_MODEL_RETRY,
|
||||
include_usage=True,
|
||||
extra_args=request_timeout_extra_args(settings.llm.timeout),
|
||||
),
|
||||
tools=[],
|
||||
output_schema=None,
|
||||
|
||||
@@ -10,8 +10,6 @@ from uuid import uuid4
|
||||
|
||||
from agents.usage import Usage
|
||||
|
||||
from strix.config import codex
|
||||
from strix.config.loader import load_settings
|
||||
from strix.core.paths import run_dir_for
|
||||
from strix.report.sarif import write_sarif
|
||||
from strix.report.usage import LLMUsageLedger
|
||||
@@ -119,15 +117,12 @@ class ReportState:
|
||||
self.scan_results: dict[str, Any] | None = None
|
||||
self.scan_config: dict[str, Any] | None = None
|
||||
self._llm_usage = LLMUsageLedger()
|
||||
auth_mode = codex.auth_mode(load_settings().llm.model)
|
||||
self._llm_usage.zero_cost = auth_mode == "subscription"
|
||||
self.run_record: dict[str, Any] = {
|
||||
"run_id": self.run_id,
|
||||
"run_name": self.run_name,
|
||||
"start_time": self.start_time,
|
||||
"end_time": None,
|
||||
"status": "running",
|
||||
"auth_mode": auth_mode,
|
||||
"targets_info": [],
|
||||
"llm_usage": self._build_llm_usage_record(),
|
||||
}
|
||||
|
||||
@@ -19,9 +19,6 @@ class LLMUsageLedger:
|
||||
self._agent_usage: dict[str, Usage] = {}
|
||||
self._agent_metadata: dict[str, dict[str, str]] = {}
|
||||
self._total_cost = 0.0
|
||||
# When True, tokens are still tracked but cost stays $0 — the run is on a
|
||||
# model subscription, so there is no metered per-token charge to report.
|
||||
self.zero_cost = False
|
||||
|
||||
def record(
|
||||
self,
|
||||
@@ -44,7 +41,7 @@ class LLMUsageLedger:
|
||||
if model:
|
||||
metadata["model"] = model
|
||||
|
||||
if not self.zero_cost and not _is_litellm_routed(model):
|
||||
if not _is_litellm_routed(model):
|
||||
estimated = _estimate_litellm_cost(usage, model)
|
||||
if estimated:
|
||||
self._total_cost += estimated
|
||||
@@ -52,8 +49,6 @@ class LLMUsageLedger:
|
||||
return True
|
||||
|
||||
def record_observed_cost(self, cost: float) -> None:
|
||||
if self.zero_cost:
|
||||
return
|
||||
if isinstance(cost, int | float) and cost > 0:
|
||||
self._total_cost += float(cost)
|
||||
|
||||
|
||||
+6
-65
@@ -10,27 +10,19 @@ import re
|
||||
import tempfile
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from pygments.lexers import PythonLexer, get_lexer_by_name, guess_lexer
|
||||
from pygments.lexers.special import TextLexer
|
||||
from pygments.util import ClassNotFound
|
||||
from typing import Any
|
||||
|
||||
from strix.core.paths import run_record_path
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pygments.lexer import Lexer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
|
||||
|
||||
_FENCE_RE = re.compile(r"^```([^\n`]*)\r?\n(.*?)\r?\n?```$", re.DOTALL)
|
||||
_BACKTICK_RUN = re.compile(r"`+")
|
||||
|
||||
|
||||
def safe_fence(content: str) -> str:
|
||||
def _safe_fence(content: str) -> str:
|
||||
"""Return a backtick fence that ``content`` cannot break out of.
|
||||
|
||||
Per CommonMark a fenced code block is closed only by a run of backticks at
|
||||
@@ -43,56 +35,6 @@ def safe_fence(content: str) -> str:
|
||||
return "`" * max(3, longest + 1)
|
||||
|
||||
|
||||
def parse_fenced_code(raw: str) -> tuple[str | None, str]:
|
||||
"""Split an optionally fenced code string into ``(language, code)``.
|
||||
|
||||
Agent-generated code fields (e.g. ``poc_script_code``) are stored wrapped in
|
||||
a markdown fence carrying the language, like ``` ```python\n...\n``` ```.
|
||||
Return the fence's language tag and the inner code, or ``(None, raw)`` when
|
||||
the value isn't fenced.
|
||||
"""
|
||||
match = _FENCE_RE.match(raw.strip())
|
||||
if not match:
|
||||
return None, raw
|
||||
info = match.group(1).strip()
|
||||
language = info.split()[0] if info else None
|
||||
return (language or None), match.group(2)
|
||||
|
||||
|
||||
def resolve_lexer(language: str | None, code: str) -> Lexer:
|
||||
"""Pick a pygments lexer for ``code``.
|
||||
|
||||
Prefer the explicit fence ``language`` when it names a known lexer, otherwise
|
||||
auto-detect from the source. Fall back to Python when detection is
|
||||
inconclusive, since legacy (unfenced) PoC scripts are Python.
|
||||
"""
|
||||
if language:
|
||||
try:
|
||||
return get_lexer_by_name(language)
|
||||
except ClassNotFound:
|
||||
pass
|
||||
try:
|
||||
lexer = guess_lexer(code)
|
||||
except ClassNotFound:
|
||||
return cast("Lexer", PythonLexer())
|
||||
# ``guess_lexer`` returns the plain-text lexer when it can't detect anything.
|
||||
if isinstance(lexer, TextLexer):
|
||||
return cast("Lexer", PythonLexer())
|
||||
return lexer
|
||||
|
||||
|
||||
def guess_language_name(code: str) -> str:
|
||||
"""Return a markdown fence tag for ``code``, defaulting to ``python`` when
|
||||
auto-detection is inconclusive."""
|
||||
try:
|
||||
lexer = guess_lexer(code)
|
||||
except ClassNotFound:
|
||||
return "python"
|
||||
if isinstance(lexer, TextLexer) or not lexer.aliases:
|
||||
return "python"
|
||||
return str(lexer.aliases[0])
|
||||
|
||||
|
||||
def read_run_record(run_dir: Path) -> dict[str, Any]:
|
||||
path = run_record_path(run_dir)
|
||||
if not path.exists():
|
||||
@@ -245,10 +187,9 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
|
||||
lines.append(str(report["poc_description"]))
|
||||
lines.append("")
|
||||
if report.get("poc_script_code"):
|
||||
language, code = parse_fenced_code(str(report["poc_script_code"]))
|
||||
fence_lang = language or guess_language_name(code)
|
||||
fence = safe_fence(code)
|
||||
lines.append(f"{fence}{fence_lang}")
|
||||
code = str(report["poc_script_code"])
|
||||
fence = _safe_fence(code)
|
||||
lines.append(fence)
|
||||
lines.append(code)
|
||||
lines.append(fence)
|
||||
lines.append("")
|
||||
@@ -268,7 +209,7 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
|
||||
lines.append(f" {loc['label']}")
|
||||
if loc.get("snippet"):
|
||||
snippet = str(loc["snippet"])
|
||||
fence = safe_fence(snippet)
|
||||
fence = _safe_fence(snippet)
|
||||
lines.append(f" {fence}")
|
||||
lines.extend(f" {ln}" for ln in snippet.splitlines())
|
||||
lines.append(f" {fence}")
|
||||
|
||||
@@ -110,7 +110,7 @@ def stage_symlink_safe_dir(src_root: Path) -> tuple[Path, Path | None]:
|
||||
if not tree_has_symlink(root):
|
||||
return root, None
|
||||
|
||||
staged = Path(tempfile.mkdtemp(prefix=_STAGING_PREFIX)).resolve()
|
||||
staged = Path(tempfile.mkdtemp(prefix=_STAGING_PREFIX))
|
||||
try:
|
||||
_stage_dir(root, staged, root, frozenset({root}))
|
||||
except OSError:
|
||||
|
||||
@@ -54,17 +54,18 @@ CT logs record nearly every publicly-trusted certificate. Query by domain (match
|
||||
|
||||
## Recommended Tooling
|
||||
|
||||
These tools are available in the sandbox and are pipeline-friendly with JSON output:
|
||||
Prefer the projectdiscovery suite (already available in the sandbox and pipeline-friendly with JSON output):
|
||||
|
||||
- **`subfinder`** — passive subdomain aggregation across many sources incl. CT: `subfinder -d example.com -all -recursive -silent -oJ -o subs.jsonl`
|
||||
- **`httpx`** — live probing plus cert/SAN grab in one pass: `httpx -l hosts.txt -tls-grab -json` (see methodology).
|
||||
- **`tlsx`** — TLS/cert data at scale; grab SANs and issuer/org to pivot: `tlsx -l hosts.txt -san -cn -tls-version -json -o tls.jsonl`
|
||||
- **`uncover`** — query Shodan/Censys/Fofa/Quake/crt.sh engines from one CLI: `uncover -q 'ssl:"Example Inc"' -e shodan,censys,fofa -json`
|
||||
- **`asnmap`** — org/domain/ASN → CIDR ranges: `asnmap -d example.com -json` / `asnmap -org "Example Inc"`
|
||||
- **`mapcidr`** — expand/aggregate CIDRs into host lists for probing: `mapcidr -cidr 192.0.2.0/24 -o hosts.txt`
|
||||
- **`dnsx`** — fast resolution, PTR, and wildcard filtering: `dnsx -l names.txt -a -aaaa -cname -ptr -resp -json -o dns.jsonl`
|
||||
- **`httpx`** — live probing + cert grab in one pass (see methodology).
|
||||
- **`naabu`** — port sweep for non-HTTP services: `naabu -list hosts.txt -top-ports 100 -verify -silent`
|
||||
- **`curl` + `jq`** — direct **crt.sh** JSON queries for CT (no key needed) and other index APIs.
|
||||
- **`openssl s_client`** — active read of a live host's cert to extract SANs/CN.
|
||||
- **`dig`** / **`nslookup`** — forward/reverse (PTR) resolution and CNAME chains.
|
||||
- **`whois`** — ASN/netblock lookups (e.g. `whois -h whois.cymru.com`).
|
||||
|
||||
Cross-source results — CT + passive DNS + `subfinder` together beat any single source. If you need a tool that is not installed, install it into the sandbox at runtime.
|
||||
Also useful: **`amass`** (`amass intel`/`enum` for ASN, cert, and passive sources), **`cero`** (bulk SAN extraction from IPs/ranges), and direct **crt.sh** JSON queries when no keys are configured. Cross-source results — CT + passive DNS + `subfinder` together beat any single source.
|
||||
|
||||
## Key Techniques
|
||||
|
||||
@@ -74,7 +75,7 @@ Every new name, PTR result, CNAME target, and cert SAN becomes a fresh seed. Loo
|
||||
|
||||
### Cert-Fingerprint Pivoting
|
||||
|
||||
Search Censys/Shodan by a cert's `fingerprint_sha256` to find every other host presenting the same certificate — the strongest cross-asset link for tying acquisitions and shadow infra to the target.
|
||||
Search Censys/Shodan (or `uncover`) by a cert's `fingerprint_sha256` to find every other host presenting the same certificate — the strongest cross-asset link for tying acquisitions and shadow infra to the target.
|
||||
|
||||
### Naming-Convention Inference
|
||||
|
||||
@@ -82,11 +83,11 @@ Wildcard SANs and observed hostnames expose the org's naming scheme; generate ta
|
||||
|
||||
### IP-First Discovery
|
||||
|
||||
For ASN-owned ranges, sweep IPs directly with `naabu`/`httpx` and read served certs (`httpx -tls-grab`, or `openssl s_client`) to find services that have no DNS name at all.
|
||||
For ASN-owned ranges, sweep IPs directly with `naabu`/`httpx` and read served certs (`tlsx`) to find services that have no DNS name at all.
|
||||
|
||||
## Advanced Techniques
|
||||
|
||||
- **Active SAN harvesting** across whole ranges with `httpx -tls-grab` (or `openssl s_client`) recovers internal hostnames never logged to public CT.
|
||||
- **Active SAN harvesting** across whole ranges with `tlsx`/`cero` recovers internal hostnames never logged to public CT.
|
||||
- **Favicon and response hashing** (`httpx -favicon`, hash pivots in Shodan) clusters instances of the same app across unrelated hostnames.
|
||||
- **Vhost differentials**: probe a single IP with multiple `Host:` values to unmask co-located apps behind one address.
|
||||
- **Historical CT/DNS diffing** highlights recently issued certs and newly appearing hosts — high-signal for fresh or misconfigured deployments.
|
||||
@@ -107,11 +108,11 @@ For ASN-owned ranges, sweep IPs directly with `naabu`/`httpx` and read served ce
|
||||
## Testing Methodology
|
||||
|
||||
1. **Seed** - domains, org/legal names, known IPs, email domains, code-host org
|
||||
2. **Certificate transparency** - pull all logged certs per seed domain and org name (crt.sh, Censys/Shodan)
|
||||
3. **SAN/CN extraction** - parse every Subject CN and SAN with `httpx -tls-grab` (or `openssl s_client`); each new name is a new seed
|
||||
4. **Passive DNS** - resolve forward and reverse with `dig`; harvest historical records
|
||||
5. **ASN/IP mapping** - `whois` the netblock/ASN to expand owned ranges, then sweep for live hosts
|
||||
6. **Active TLS pivot** - `httpx -tls-grab` on live IPs/ports to grab SANs missing from public CT
|
||||
2. **Certificate transparency** - pull all logged certs per seed domain and org name (crt.sh, `uncover`)
|
||||
3. **SAN/CN extraction** - parse every Subject CN and SAN with `tlsx`; each new name is a new seed
|
||||
4. **Passive DNS** - resolve forward and reverse with `dnsx`; harvest historical records
|
||||
5. **ASN/IP mapping** - `asnmap` → `mapcidr` to expand owned ranges, then sweep for live hosts
|
||||
6. **Active TLS pivot** - `tlsx`/`cero` on live IPs/ports to grab SANs missing from public CT
|
||||
7. **Consolidate & probe** - dedupe, `httpx` probe, classify, and route to specialists
|
||||
|
||||
## Validation
|
||||
@@ -138,13 +139,13 @@ For ASN-owned ranges, sweep IPs directly with `naabu`/`httpx` and read served ce
|
||||
## Pro Tips
|
||||
|
||||
1. Loop the pipeline — every SAN, PTR, and CNAME target is a new seed until the set converges.
|
||||
2. crt.sh is the cheapest high-yield source (no key); Censys/Shodan add cert-fingerprint and vhost pivoting when keys exist.
|
||||
3. Always cert-grab live hosts with `httpx -tls-grab` (or `openssl s_client`) — active SANs catch internal hostnames never sent to public CT.
|
||||
2. crt.sh is the cheapest high-yield source (no key); Censys/Shodan via `uncover` add cert-fingerprint and vhost pivoting when keys exist.
|
||||
3. Always cert-grab live hosts with `tlsx` — active SANs catch internal hostnames never sent to public CT.
|
||||
4. Internal-looking SANs (`*.internal`, `*.svc.cluster.local`, staging names) are the highest-signal leads.
|
||||
5. Wildcard SANs reveal naming conventions — seed targeted guesses instead of blind brute force.
|
||||
6. Cluster by function, not product name, so the workflow generalizes to any exposed service.
|
||||
7. Keep JSON output throughout so stages chain cleanly (`subfinder` → `dig` → `httpx` → `naabu`).
|
||||
7. Keep JSON output throughout so stages chain cleanly (`subfinder` → `dnsx` → `httpx` → `naabu`).
|
||||
|
||||
## Summary
|
||||
|
||||
Broad passive discovery — CT + TLS SAN pivoting + passive DNS + ASN/IP mapping, looped until convergence — finds the assets brute force misses, especially internal-named and forgotten services leaked through certificates. Build the inventory with `subfinder`, `httpx`, `naabu`, and CT/DNS/cert queries, probe and classify it generically, then route each interesting asset to the specialist skill for its class.
|
||||
Broad passive discovery — CT + TLS SAN pivoting + passive DNS + ASN/IP mapping, looped until convergence — finds the assets brute force misses, especially internal-named and forgotten services leaked through certificates. Build the inventory with the projectdiscovery suite, probe and classify it generically, then route each interesting asset to the specialist skill for its class.
|
||||
|
||||
@@ -213,7 +213,7 @@ pipx install bloodhound-ce # bloodhound-ce-python collector (BloodHound CE
|
||||
pipx install coercer # multi-protocol coercion (MS-EFSR/RPRN/DFSNM/FSRVP)
|
||||
pipx install bloodyAD # DACL / LDAP object edits over LDAP
|
||||
pipx install ldapdomaindump # LDAP dumper (bloodhound.py author)
|
||||
go install github.com/ropnop/kerbrute@latest # kerbrute (Go) — user enum / pre-auth brute
|
||||
# kerbrute (Go, user enum / pre-auth brute) is prebuilt and already on PATH
|
||||
|
||||
# Kali apt packages
|
||||
sudo apt-get install -y smbclient ldap-utils krb5-user enum4linux-ng responder hashcat john
|
||||
|
||||
@@ -98,12 +98,11 @@ The sandbox's Python lives in `/app/.venv`, and it is the active virtualenv
|
||||
`requests`, `httpx`, `beautifulsoup4` (`bs4`), `lxml`, `pyjwt` (`jwt`),
|
||||
`cryptography`.
|
||||
|
||||
To add a one-off dependency for an exploit script, use `uv` (already in the
|
||||
image and much faster than pip):
|
||||
To add a one-off dependency for an exploit script, install it into the active
|
||||
venv with `pip`:
|
||||
|
||||
```bash
|
||||
uv pip install --python /app/.venv/bin/python <package>
|
||||
pip install <package>
|
||||
```
|
||||
|
||||
Plain `pip install <package>` also works because the venv is active. Install
|
||||
before you import, so scripts don't fail with `ModuleNotFoundError`.
|
||||
Install before you import, so scripts don't fail with `ModuleNotFoundError`.
|
||||
|
||||
@@ -152,7 +152,7 @@ TLS clues: certificate CN/SAN referencing provider default host instead of the c
|
||||
|
||||
## Pro Tips
|
||||
|
||||
1. Build a pipeline: enumerate (subfinder) → resolve (dig) → probe (httpx) → fingerprint (nuclei/custom) → verify claims
|
||||
1. Build a pipeline: enumerate (subfinder/amass) → resolve (dnsx) → probe (httpx) → fingerprint (nuclei/custom) → verify claims
|
||||
2. Maintain a current fingerprint corpus; provider messages change frequently
|
||||
3. Prefer minimal PoCs: static "ownership proof" page and, where allowed, DV cert issuance
|
||||
4. Monitor CT for unexpected certs on your subdomains
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: weak-password-detection
|
||||
description: Weak password detection, credential stuffing, and brute-force testing using common passwords, system-generated credentials, and HTTP fuzzing / NSE brute-force tooling
|
||||
description: Weak password detection, credential stuffing, and brute-force testing using common passwords, system-generated credentials, and tooling like Hydra
|
||||
---
|
||||
|
||||
# Weak Password Detection / Credential Brute-Force
|
||||
@@ -98,7 +98,7 @@ Weak or default credentials remain one of the most prevalent and high-impact vul
|
||||
- Season + year patterns: `Summer2025!`, `Winter2026@`
|
||||
- Keyboard walks and leet speak variations
|
||||
- Previously breached passwords for the target domain
|
||||
- Scrape the target site to build a content-derived wordlist (e.g. a small custom Python crawler that harvests unique words)
|
||||
- Cewl: `cewl -d 3 -m 5 -w custom.txt https://target.com` to generate from website content
|
||||
|
||||
### Credential Stuffing Workflows
|
||||
|
||||
@@ -123,25 +123,37 @@ Weak or default credentials remain one of the most prevalent and high-impact vul
|
||||
|
||||
### Service-Level Brute-Force
|
||||
|
||||
- HTTP login endpoints: `ffuf` or custom scripts (see Tooling)
|
||||
- SSH/FTP/SMB/Telnet and other services: `nmap` NSE `*-brute` scripts, e.g. `nmap -p 22 --script ssh-brute --script-args userdb=users.txt,passdb=passwords.txt target.com`
|
||||
- Databases (MySQL, PostgreSQL, MongoDB, Redis): weak/default credentials via the matching NSE brute script (`mysql-brute`, `pgsql-brute`, `mongodb-brute`, `redis-brute`) or a custom client script
|
||||
- Any protocol lacking a ready script: custom Python
|
||||
- SSH: `hydra -l admin -P passwords.txt ssh://target.com`
|
||||
- FTP: `hydra -L users.txt -P passwords.txt ftp://target.com`
|
||||
- RDP: `hydra -l administrator -P passwords.txt rdp://target.com`
|
||||
- SMB: `hydra -L users.txt -P passwords.txt smb://target.com`
|
||||
- Database: MySQL, PostgreSQL, MongoDB, Redis with weak credentials
|
||||
- API endpoints: `ffuf` or custom scripts for HTTP-based brute-force
|
||||
|
||||
## Tooling
|
||||
|
||||
### ffuf (primary for web logins)
|
||||
### Hydra (Primary Tool)
|
||||
|
||||
- HTTP POST form brute-force:
|
||||
`hydra -l admin -P /usr/share/wordlists/rockyou.txt target.com http-post-form "/login:username=^USER^&password=^PASS^:Invalid credentials"`
|
||||
- Basic Auth:
|
||||
`hydra -L users.txt -P passwords.txt target.com http-get -s 8080 /admin`
|
||||
- SSH:
|
||||
`hydra -l root -P passwords.txt -t 4 ssh://target.com`
|
||||
- FTP:
|
||||
`hydra -L users.txt -P passwords.txt ftp://target.com`
|
||||
- Custom headers and cookies:
|
||||
`hydra ... http-post-form "/api/login:json={\"user\":\"^USER^\",\"pass\":\"^PASS^\"}:F=401"`
|
||||
|
||||
### ffuf (HTTP Fuzzing)
|
||||
|
||||
- Login brute-force with multiple users and passwords:
|
||||
`ffuf -w users.txt:USER -w passwords.txt:PASS -u https://target.com/login -X POST -d "username=USER&password=PASS" -fr "Invalid"`
|
||||
- JSON body / custom headers via `-H` and a JSON `-d` payload
|
||||
- Filter by response size, status code, or regex to identify successes
|
||||
|
||||
### nmap NSE (service brute-force)
|
||||
### Patator (Versatile Brute-Force)
|
||||
|
||||
- `*-brute` scripts cover many non-HTTP services:
|
||||
`nmap -p 22 --script ssh-brute --script-args userdb=users.txt,passdb=passwords.txt target.com`
|
||||
- Available scripts include `ssh-brute`, `ftp-brute`, `smb-brute`, `telnet-brute`, `mysql-brute`, `pgsql-brute`, `mongodb-brute`, `redis-brute`, `http-brute`, `http-form-brute`.
|
||||
- `patator http_fuzz url=https://target.com/login method=POST body='username=FILE0&password=FILE1' 0=user.txt 1=pass.txt -x ignore:fgrep='Invalid'`
|
||||
|
||||
### Custom Python Scripts
|
||||
|
||||
@@ -151,10 +163,10 @@ Weak or default credentials remain one of the most prevalent and high-impact vul
|
||||
|
||||
### Wordlists
|
||||
|
||||
No password wordlists ship in the sandbox by default — download what you need into `/home/pentester/tools/wordlists` at runtime:
|
||||
- Common passwords (e.g. `rockyou.txt`) from its upstream source
|
||||
- SecLists `Passwords/` and `Passwords/Default-Credentials/` (vendor defaults) from https://github.com/danielmiessler/SecLists
|
||||
- Custom lists from target-specific scraping
|
||||
- `/usr/share/wordlists/rockyou.txt` (common passwords)
|
||||
- `/usr/share/seclists/Passwords/` (organized by category)
|
||||
- `/usr/share/seclists/Passwords/Default-Credentials/` (vendor defaults)
|
||||
- Custom lists from Cewl, CeWL, or target-specific scraping
|
||||
- Breach compilation subsets filtered by target relevance
|
||||
|
||||
## Validation
|
||||
@@ -192,7 +204,7 @@ No password wordlists ship in the sandbox by default — download what you need
|
||||
6. Check for concurrent session limits; successful logins may kick out legitimate users
|
||||
7. GraphQL batching can test multiple credentials in a single request, bypassing per-request limits
|
||||
8. Document the password policy and recommend minimum standards (length, complexity, breach checking)
|
||||
9. For web logins prefer `ffuf`; for other services use `nmap` NSE `*-brute` scripts or custom scripts with equivalent logic
|
||||
9. When Hydra is unavailable, use ffuf or custom scripts with equivalent logic
|
||||
10. Combine with MFA testing: weak passwords plus missing MFA is a critical finding
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -15,7 +15,7 @@ We collect only very **basic** usage data including:
|
||||
**Session Errors:** Duration and error types (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)\
|
||||
**Model Usage:** Which LLM model is being used (not prompts or responses)\
|
||||
**Feature Usage:** Which built-in skills are loaded\
|
||||
**Aggregate Metrics:** Vulnerability counts by severity and weakness category (CWE)
|
||||
|
||||
|
||||
@@ -58,14 +58,12 @@ def start(
|
||||
is_whitebox: bool,
|
||||
interactive: bool,
|
||||
has_instructions: bool,
|
||||
auth_mode: str | None = None,
|
||||
) -> None:
|
||||
_send(
|
||||
"scan_started",
|
||||
{
|
||||
**base_props(),
|
||||
"model": model or "unknown",
|
||||
"auth_mode": auth_mode or "api_key",
|
||||
"scan_mode": scan_mode or "unknown",
|
||||
"scan_type": "whitebox" if is_whitebox else "blackbox",
|
||||
"interactive": interactive,
|
||||
@@ -135,7 +133,6 @@ def end(report_state: "ReportState", exit_reason: str = "completed") -> None:
|
||||
"scan_ended",
|
||||
{
|
||||
**base_props(),
|
||||
"auth_mode": report_state.run_record.get("auth_mode") or "api_key",
|
||||
"exit_reason": report_state.scan_ended_exit_reason,
|
||||
"duration_seconds": round(duration),
|
||||
"vulnerabilities_total": len(report_state.vulnerability_reports),
|
||||
@@ -183,14 +180,6 @@ 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)
|
||||
|
||||
@@ -59,7 +59,6 @@ def start(
|
||||
is_whitebox: bool,
|
||||
interactive: bool,
|
||||
has_instructions: bool,
|
||||
auth_mode: str | None = None,
|
||||
) -> None:
|
||||
_send(
|
||||
"scan_started",
|
||||
@@ -67,7 +66,6 @@ def start(
|
||||
**base_props(),
|
||||
"session": SESSION_ID,
|
||||
"model": model or "unknown",
|
||||
"auth_mode": auth_mode or "api_key",
|
||||
"scan_mode": scan_mode or "unknown",
|
||||
"scan_type": "whitebox" if is_whitebox else "blackbox",
|
||||
"interactive": interactive,
|
||||
@@ -142,7 +140,6 @@ def end(report_state: ReportState, exit_reason: str = "completed") -> None:
|
||||
{
|
||||
**base_props(),
|
||||
"session": SESSION_ID,
|
||||
"auth_mode": report_state.run_record.get("auth_mode") or "api_key",
|
||||
"exit_reason": report_state.scan_ended_exit_reason,
|
||||
"duration_seconds": round(duration),
|
||||
"vulnerabilities_total": len(report_state.vulnerability_reports),
|
||||
|
||||
@@ -87,7 +87,7 @@ async def view_agent_graph(ctx: RunContextWrapper) -> str:
|
||||
default=str,
|
||||
)
|
||||
|
||||
parent_of, statuses, names, _ = await coordinator.graph_snapshot()
|
||||
parent_of, statuses, names = await coordinator.graph_snapshot()
|
||||
|
||||
lines: list[str] = []
|
||||
|
||||
@@ -635,7 +635,7 @@ async def stop_agent(
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
_, statuses, _, _ = await coordinator.graph_snapshot()
|
||||
_, statuses, _ = await coordinator.graph_snapshot()
|
||||
if target_agent_id not in statuses:
|
||||
return json.dumps(
|
||||
{"success": False, "error": f"Unknown agent_id: {target_agent_id}"},
|
||||
|
||||
@@ -116,16 +116,12 @@ async def finish_scan(
|
||||
/ ``crashed`` / ``stopped`` agents are safe to leave behind.
|
||||
Calling ``finish_scan`` while children are alive orphans their
|
||||
work and produces an incomplete report.
|
||||
2. It's a good idea to call ``list_reports`` before finishing to
|
||||
review every finding filed in this scan (use ``get_report`` for
|
||||
full detail on any of them) so your ``executive_summary`` /
|
||||
``technical_analysis`` are grounded in what was actually reported
|
||||
— don't invent or omit findings. All vulnerabilities you found are
|
||||
filed via ``create_vulnerability_report`` — or, for known-CVE
|
||||
dependency findings, ``create_dependency_report`` (un-reported
|
||||
findings are not tracked and not credited). A dependency CVE
|
||||
already filed via ``create_dependency_report`` counts as reported;
|
||||
it does NOT need re-filing here and does NOT block finishing.
|
||||
2. All vulnerabilities you found are filed via
|
||||
``create_vulnerability_report`` — or, for known-CVE dependency
|
||||
findings, ``create_dependency_report`` (un-reported findings are
|
||||
not tracked and not credited). A dependency CVE already filed via
|
||||
``create_dependency_report`` counts as reported; it does NOT need
|
||||
re-filing here and does NOT block finishing.
|
||||
3. Don't double-report — one report per distinct vulnerability.
|
||||
4. **Attack-chaining gate.** Do NOT finish until you have genuinely
|
||||
considered chaining the confirmed findings into higher-impact,
|
||||
|
||||
@@ -27,21 +27,6 @@ _NOTE_ID_GENERATION_ATTEMPTS = 1024
|
||||
_notes_path: Path | None = None
|
||||
|
||||
|
||||
def _caller_identity(ctx: RunContextWrapper) -> tuple[str | None, str | None]:
|
||||
"""Return the (agent_id, agent_name) of the agent invoking this tool."""
|
||||
inner = ctx.context if isinstance(ctx.context, dict) else {}
|
||||
raw_agent_id = inner.get("agent_id")
|
||||
agent_id = raw_agent_id if isinstance(raw_agent_id, str) else None
|
||||
agent_name: str | None = None
|
||||
coordinator = inner.get("coordinator")
|
||||
if agent_id is not None and coordinator is not None:
|
||||
names = getattr(coordinator, "names", {})
|
||||
if isinstance(names, dict):
|
||||
raw_agent_name = names.get(agent_id)
|
||||
agent_name = raw_agent_name if isinstance(raw_agent_name, str) else None
|
||||
return agent_id, agent_name
|
||||
|
||||
|
||||
def _generate_note_id() -> str | None:
|
||||
for _ in range(_NOTE_ID_GENERATION_ATTEMPTS):
|
||||
note_id = uuid.uuid4().hex[:6]
|
||||
@@ -132,26 +117,10 @@ def _filter_notes(
|
||||
return filtered
|
||||
|
||||
|
||||
def _mark_authorship(
|
||||
entry: dict[str, Any], note: dict[str, Any], caller_agent_id: str | None
|
||||
) -> dict[str, Any]:
|
||||
"""Attach the note's author and flag whether the caller wrote it."""
|
||||
agent_name = note.get("agent_name")
|
||||
if agent_name:
|
||||
entry["agent_name"] = agent_name
|
||||
agent_id = note.get("agent_id")
|
||||
if agent_id:
|
||||
entry["agent_id"] = agent_id
|
||||
if caller_agent_id is not None and agent_id == caller_agent_id:
|
||||
entry["by_you"] = True
|
||||
return entry
|
||||
|
||||
|
||||
def _to_note_listing_entry(
|
||||
note: dict[str, Any],
|
||||
*,
|
||||
include_content: bool = False,
|
||||
caller_agent_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
entry = {
|
||||
"note_id": note.get("note_id"),
|
||||
@@ -169,7 +138,7 @@ def _to_note_listing_entry(
|
||||
entry["content_preview"] = f"{content[:_DEFAULT_CONTENT_PREVIEW_CHARS].rstrip()}..."
|
||||
else:
|
||||
entry["content_preview"] = content
|
||||
return _mark_authorship(entry, note, caller_agent_id)
|
||||
return entry
|
||||
|
||||
|
||||
def _create_note_impl(
|
||||
@@ -177,8 +146,6 @@ def _create_note_impl(
|
||||
content: str,
|
||||
category: str = "general",
|
||||
tags: list[str] | None = None,
|
||||
agent_id: str | None = None,
|
||||
agent_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
with _notes_lock:
|
||||
try:
|
||||
@@ -212,10 +179,6 @@ def _create_note_impl(
|
||||
"created_at": timestamp,
|
||||
"updated_at": timestamp,
|
||||
}
|
||||
if agent_id:
|
||||
note["agent_id"] = agent_id
|
||||
if agent_name:
|
||||
note["agent_name"] = agent_name
|
||||
_notes_storage[note_id] = note
|
||||
except (ValueError, TypeError) as e:
|
||||
return {"success": False, "error": f"Failed to create note: {e}", "note_id": None}
|
||||
@@ -234,17 +197,11 @@ def _list_notes_impl(
|
||||
tags: list[str] | None = None,
|
||||
search: str | None = None,
|
||||
include_content: bool = False,
|
||||
caller_agent_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
with _notes_lock:
|
||||
try:
|
||||
filtered = _filter_notes(category=category, tags=tags, search_query=search)
|
||||
notes = [
|
||||
_to_note_listing_entry(
|
||||
n, include_content=include_content, caller_agent_id=caller_agent_id
|
||||
)
|
||||
for n in filtered
|
||||
]
|
||||
notes = [_to_note_listing_entry(n, include_content=include_content) for n in filtered]
|
||||
except (ValueError, TypeError) as e:
|
||||
return {
|
||||
"success": False,
|
||||
@@ -261,7 +218,7 @@ def _list_notes_impl(
|
||||
}
|
||||
|
||||
|
||||
def _get_note_impl(note_id: str, caller_agent_id: str | None = None) -> dict[str, Any]:
|
||||
def _get_note_impl(note_id: str) -> dict[str, Any]:
|
||||
with _notes_lock:
|
||||
try:
|
||||
if not note_id or not note_id.strip():
|
||||
@@ -275,7 +232,6 @@ def _get_note_impl(note_id: str, caller_agent_id: str | None = None) -> dict[str
|
||||
}
|
||||
note_with_id = note.copy()
|
||||
note_with_id["note_id"] = note_id
|
||||
_mark_authorship(note_with_id, note, caller_agent_id)
|
||||
except (ValueError, TypeError) as e:
|
||||
return {"success": False, "error": f"Failed to get note: {e}", "note": None}
|
||||
else:
|
||||
@@ -348,9 +304,7 @@ async def create_note(
|
||||
|
||||
Notes are visible to every agent in the same scan for the lifetime
|
||||
of the run; they live in-memory only and are cleared when the
|
||||
process exits. Each note records the agent that wrote it, so
|
||||
``list_notes`` / ``get_note`` show the author (``agent_name``) and
|
||||
flag your own notes with ``by_you``.
|
||||
process exits.
|
||||
|
||||
For actionable tasks, use ``todo`` instead — notes are for capturing
|
||||
information, todos are for tracking work.
|
||||
@@ -375,11 +329,8 @@ async def create_note(
|
||||
category: One of the categories above. Default ``"general"``.
|
||||
tags: Optional free-form tags.
|
||||
"""
|
||||
agent_id, agent_name = _caller_identity(ctx)
|
||||
return json.dumps(
|
||||
await asyncio.to_thread(
|
||||
_create_note_impl, title, content, category, tags, agent_id, agent_name
|
||||
),
|
||||
await asyncio.to_thread(_create_note_impl, title, content, category, tags),
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
@@ -404,9 +355,6 @@ async def list_notes(
|
||||
when you need to scan many notes; expensive in tokens for large
|
||||
notes.
|
||||
|
||||
Each entry also carries the author (``agent_name``) and, for notes
|
||||
you wrote yourself, ``by_you: true``.
|
||||
|
||||
Args:
|
||||
category: Filter by category.
|
||||
tags: Filter to notes that have any of these tags.
|
||||
@@ -414,7 +362,6 @@ async def list_notes(
|
||||
include_content: When False (default) entries have a preview;
|
||||
when True the full ``content`` is included.
|
||||
"""
|
||||
caller_agent_id, _ = _caller_identity(ctx)
|
||||
return json.dumps(
|
||||
await asyncio.to_thread(
|
||||
_list_notes_impl,
|
||||
@@ -422,7 +369,6 @@ async def list_notes(
|
||||
tags=tags,
|
||||
search=search,
|
||||
include_content=include_content,
|
||||
caller_agent_id=caller_agent_id,
|
||||
),
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
@@ -436,11 +382,8 @@ async def get_note(ctx: RunContextWrapper, note_id: str) -> str:
|
||||
Args:
|
||||
note_id: Note id from ``create_note`` or a ``list_notes`` entry.
|
||||
"""
|
||||
caller_agent_id, _ = _caller_identity(ctx)
|
||||
return json.dumps(
|
||||
await asyncio.to_thread(_get_note_impl, note_id, caller_agent_id),
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
await asyncio.to_thread(_get_note_impl, note_id), ensure_ascii=False, default=str
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
"""Bound oversized tool results before they enter agent history.
|
||||
|
||||
Oversized results are spilled into the sandbox at
|
||||
``/workspace/.strix/tool-output/<id>.txt``; the agent sees a head + tail slice
|
||||
plus the path and reads the rest back with its own file tools. The spill writer
|
||||
is injected by the runner via :func:`configure_spill_writer`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TRUNCATION_NOTICE = "[... {lines} lines ({bytes} bytes) truncated ...]"
|
||||
_WORKSPACE_SPILL_NOTICE = (
|
||||
"[... {lines} lines ({bytes} bytes) truncated — full output saved to {path} "
|
||||
"in the sandbox; read it with exec_command (e.g. `sed -n`, `grep`, `cat`) ...]"
|
||||
)
|
||||
|
||||
WORKSPACE_SPILL_DIR = "/workspace/.strix/tool-output"
|
||||
|
||||
# Longest possible workspace path, used only to reserve notice bytes.
|
||||
_SAMPLE_WORKSPACE_PATH = f"{WORKSPACE_SPILL_DIR}/{'0' * 32}.txt"
|
||||
|
||||
if TYPE_CHECKING:
|
||||
SpillWriter = Callable[[str, str], Awaitable[str | None]]
|
||||
|
||||
_spill: dict[str, SpillWriter] = {}
|
||||
|
||||
|
||||
def configure_spill_writer(writer: SpillWriter | None) -> None:
|
||||
"""Install (or clear) the sandbox-workspace spill writer."""
|
||||
if writer is None:
|
||||
_spill.pop("writer", None)
|
||||
else:
|
||||
_spill["writer"] = writer
|
||||
|
||||
|
||||
def _byte_len(text: str) -> int:
|
||||
return len(text.encode("utf-8"))
|
||||
|
||||
|
||||
def _take_prefix(text: str, max_bytes: int) -> str:
|
||||
budget = 0
|
||||
out: list[str] = []
|
||||
for char in text:
|
||||
size = len(char.encode("utf-8"))
|
||||
if budget + size > max_bytes:
|
||||
break
|
||||
out.append(char)
|
||||
budget += size
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _take_suffix(text: str, max_bytes: int) -> str:
|
||||
budget = 0
|
||||
out: list[str] = []
|
||||
for char in reversed(text):
|
||||
size = len(char.encode("utf-8"))
|
||||
if budget + size > max_bytes:
|
||||
break
|
||||
out.append(char)
|
||||
budget += size
|
||||
out.reverse()
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _head_tail(
|
||||
text: str,
|
||||
max_lines: int,
|
||||
max_bytes: int,
|
||||
*,
|
||||
notice_templates: tuple[str, ...] = (_TRUNCATION_NOTICE,),
|
||||
) -> tuple[str, str, int, int] | None:
|
||||
"""Head/tail slices plus dropped line/byte counts, or ``None`` if small.
|
||||
|
||||
``max_bytes`` bounds the entire joined result; the largest of
|
||||
``notice_templates`` (plus separators) is reserved before slicing.
|
||||
"""
|
||||
lines = text.split("\n")
|
||||
total_bytes = _byte_len(text)
|
||||
if len(lines) <= max_lines and total_bytes <= max_bytes:
|
||||
return None
|
||||
|
||||
# Reserve using the largest counts/path; ``+ 4`` covers the two "\n\n".
|
||||
notice_overhead = (
|
||||
max(
|
||||
_byte_len(
|
||||
template.format(
|
||||
lines=len(lines),
|
||||
bytes=total_bytes,
|
||||
path=_SAMPLE_WORKSPACE_PATH,
|
||||
)
|
||||
)
|
||||
for template in notice_templates
|
||||
)
|
||||
+ 4
|
||||
)
|
||||
byte_budget = max(2, max_bytes - notice_overhead)
|
||||
|
||||
head_lines = max(1, max_lines // 2)
|
||||
tail_lines = max_lines - head_lines
|
||||
head = "\n".join(lines[:head_lines])
|
||||
tail = "\n".join(lines[len(lines) - tail_lines :]) if tail_lines > 0 else ""
|
||||
|
||||
half_bytes = max(1, byte_budget // 2)
|
||||
if _byte_len(head) > half_bytes:
|
||||
head = _take_prefix(head, half_bytes)
|
||||
if tail and _byte_len(tail) > half_bytes:
|
||||
tail = _take_suffix(tail, half_bytes)
|
||||
|
||||
# Count from the final slices; the byte pass may have dropped whole lines.
|
||||
kept_lines = len(head.split("\n")) + (len(tail.split("\n")) if tail else 0)
|
||||
dropped_lines = max(0, len(lines) - kept_lines)
|
||||
dropped_bytes = max(0, total_bytes - _byte_len(head) - _byte_len(tail))
|
||||
return head, tail, dropped_lines, dropped_bytes
|
||||
|
||||
|
||||
def _join(head: str, tail: str, notice: str) -> str:
|
||||
return f"{head}\n\n{notice}\n\n{tail}" if tail else f"{head}\n\n{notice}"
|
||||
|
||||
|
||||
def bound_text(text: str, *, max_lines: int, max_bytes: int) -> str:
|
||||
"""Return ``text`` unchanged when small, else a head+tail preview.
|
||||
|
||||
Nothing is persisted; use :func:`bound_and_store` to keep the full output.
|
||||
"""
|
||||
parts = _head_tail(text, max_lines, max_bytes)
|
||||
if parts is None:
|
||||
return text
|
||||
head, tail, dropped_lines, dropped_bytes = parts
|
||||
return _join(head, tail, _TRUNCATION_NOTICE.format(lines=dropped_lines, bytes=dropped_bytes))
|
||||
|
||||
|
||||
async def bound_and_store(text: str, *, max_lines: int, max_bytes: int) -> str:
|
||||
"""Like :func:`bound_text`, but spill the full output into the sandbox and
|
||||
point the agent at its path. Degrades to a plain preview if the spill fails.
|
||||
"""
|
||||
parts = _head_tail(
|
||||
text,
|
||||
max_lines,
|
||||
max_bytes,
|
||||
notice_templates=(_WORKSPACE_SPILL_NOTICE, _TRUNCATION_NOTICE),
|
||||
)
|
||||
if parts is None:
|
||||
return text
|
||||
head, tail, dropped_lines, dropped_bytes = parts
|
||||
|
||||
writer = _spill.get("writer")
|
||||
if writer is not None:
|
||||
path = await writer(uuid.uuid4().hex, text)
|
||||
if path is not None:
|
||||
notice = _WORKSPACE_SPILL_NOTICE.format(
|
||||
lines=dropped_lines, bytes=dropped_bytes, path=path
|
||||
)
|
||||
return _join(head, tail, notice)
|
||||
|
||||
return _join(head, tail, _TRUNCATION_NOTICE.format(lines=dropped_lines, bytes=dropped_bytes))
|
||||
+21
-309
@@ -1,13 +1,7 @@
|
||||
"""Reporting tools — file vuln findings (with dedup + CVSS) and read them back.
|
||||
|
||||
``create_vulnerability_report`` / ``create_dependency_report`` file findings;
|
||||
``list_reports`` / ``get_report`` let any agent (notably the root orchestrator)
|
||||
review what's been filed so far across the whole scan.
|
||||
"""
|
||||
"""``create_vulnerability_report`` — file a vuln finding with dedup + CVSS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
@@ -324,21 +318,6 @@ async def _do_create( # noqa: PLR0912
|
||||
}
|
||||
|
||||
|
||||
def _caller_identity(ctx: RunContextWrapper) -> tuple[str | None, str | None]:
|
||||
"""Return the (agent_id, agent_name) of the agent invoking this tool."""
|
||||
inner = ctx.context if isinstance(ctx.context, dict) else {}
|
||||
raw_agent_id = inner.get("agent_id")
|
||||
agent_id = raw_agent_id if isinstance(raw_agent_id, str) else None
|
||||
agent_name: str | None = None
|
||||
coordinator = inner.get("coordinator")
|
||||
if agent_id is not None and coordinator is not None:
|
||||
names = getattr(coordinator, "names", {})
|
||||
if isinstance(names, dict):
|
||||
raw_agent_name = names.get(agent_id)
|
||||
agent_name = raw_agent_name if isinstance(raw_agent_name, str) else None
|
||||
return agent_id, agent_name
|
||||
|
||||
|
||||
@function_tool(timeout=180, strict_mode=False)
|
||||
async def create_vulnerability_report(
|
||||
ctx: RunContextWrapper,
|
||||
@@ -625,7 +604,16 @@ async def create_vulnerability_report(
|
||||
template engine's auto-escaping over string interpolation.
|
||||
fix_effort: "low"
|
||||
"""
|
||||
agent_id, agent_name = _caller_identity(ctx)
|
||||
inner = ctx.context if isinstance(ctx.context, dict) else {}
|
||||
raw_agent_id = inner.get("agent_id")
|
||||
agent_id = raw_agent_id if isinstance(raw_agent_id, str) else None
|
||||
agent_name = None
|
||||
coordinator = inner.get("coordinator")
|
||||
if agent_id is not None and coordinator is not None:
|
||||
names = getattr(coordinator, "names", {})
|
||||
if isinstance(names, dict):
|
||||
raw_agent_name = names.get(agent_id)
|
||||
agent_name = raw_agent_name if isinstance(raw_agent_name, str) else None
|
||||
|
||||
result = await _do_create(
|
||||
title=title,
|
||||
@@ -930,7 +918,16 @@ async def create_dependency_report(
|
||||
fix_effort: One of ``trivial`` / ``low`` / ``medium`` / ``high``
|
||||
(dependency upgrades are usually ``trivial``/``low``).
|
||||
"""
|
||||
agent_id, agent_name = _caller_identity(ctx)
|
||||
inner = ctx.context if isinstance(ctx.context, dict) else {}
|
||||
raw_agent_id = inner.get("agent_id")
|
||||
agent_id = raw_agent_id if isinstance(raw_agent_id, str) else None
|
||||
agent_name = None
|
||||
coordinator = inner.get("coordinator")
|
||||
if agent_id is not None and coordinator is not None:
|
||||
names = getattr(coordinator, "names", {})
|
||||
if isinstance(names, dict):
|
||||
raw_agent_name = names.get(agent_id)
|
||||
agent_name = raw_agent_name if isinstance(raw_agent_name, str) else None
|
||||
|
||||
result = await _do_create_dependency(
|
||||
title=title,
|
||||
@@ -952,288 +949,3 @@ async def create_dependency_report(
|
||||
agent_name=agent_name,
|
||||
)
|
||||
return json.dumps(result, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
_SEVERITY_ORDER = {
|
||||
"critical": 0,
|
||||
"high": 1,
|
||||
"medium": 2,
|
||||
"low": 3,
|
||||
"info": 4,
|
||||
"none": 5,
|
||||
}
|
||||
_VALID_SEVERITIES = frozenset(_SEVERITY_ORDER)
|
||||
_VALID_FINDING_CLASSES = frozenset({"dynamic", "dependency_cve"})
|
||||
_REPORT_DESCRIPTION_PREVIEW_CHARS = 280
|
||||
|
||||
# Compact, listing-safe fields — no full bodies / PoC code / evidence.
|
||||
_REPORT_SUMMARY_FIELDS = (
|
||||
"id",
|
||||
"title",
|
||||
"severity",
|
||||
"cvss",
|
||||
"finding_class",
|
||||
"cve",
|
||||
"cwe",
|
||||
"target",
|
||||
"endpoint",
|
||||
"method",
|
||||
"fix_effort",
|
||||
"agent_name",
|
||||
"timestamp",
|
||||
)
|
||||
|
||||
|
||||
def _report_severity_rank(report: dict[str, Any]) -> int:
|
||||
return _SEVERITY_ORDER.get(str(report.get("severity", "")).lower(), 99)
|
||||
|
||||
|
||||
def _report_matches_filters(
|
||||
report: dict[str, Any],
|
||||
*,
|
||||
severity: str | None,
|
||||
finding_class: str | None,
|
||||
target: str | None,
|
||||
search: str | None,
|
||||
) -> bool:
|
||||
if severity and str(report.get("severity", "")).lower() != severity:
|
||||
return False
|
||||
if finding_class and str(report.get("finding_class", "dynamic")).lower() != finding_class:
|
||||
return False
|
||||
if target:
|
||||
target_lower = target.lower()
|
||||
haystack = f"{report.get('target', '')} {report.get('endpoint', '')}".lower()
|
||||
if target_lower not in haystack:
|
||||
return False
|
||||
if search:
|
||||
search_lower = search.lower()
|
||||
title_match = search_lower in str(report.get("title", "")).lower()
|
||||
desc_match = search_lower in str(report.get("description", "")).lower()
|
||||
if not (title_match or desc_match):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _mark_authorship(
|
||||
entry: dict[str, Any], report: dict[str, Any], caller_agent_id: str | None
|
||||
) -> dict[str, Any]:
|
||||
"""Flag whether ``report`` was filed by the agent making this call."""
|
||||
if caller_agent_id is not None and report.get("agent_id") == caller_agent_id:
|
||||
entry["by_you"] = True
|
||||
return entry
|
||||
|
||||
|
||||
def _to_report_summary_entry(
|
||||
report: dict[str, Any], caller_agent_id: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
entry = {
|
||||
field: report[field] for field in _REPORT_SUMMARY_FIELDS if report.get(field) is not None
|
||||
}
|
||||
description = str(report.get("description", "")).strip()
|
||||
if description:
|
||||
if len(description) > _REPORT_DESCRIPTION_PREVIEW_CHARS:
|
||||
entry["description_preview"] = (
|
||||
f"{description[:_REPORT_DESCRIPTION_PREVIEW_CHARS].rstrip()}..."
|
||||
)
|
||||
else:
|
||||
entry["description_preview"] = description
|
||||
return _mark_authorship(entry, report, caller_agent_id)
|
||||
|
||||
|
||||
def _severity_counts(reports: list[dict[str, Any]]) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for report in reports:
|
||||
sev = str(report.get("severity", "")).lower() or "none"
|
||||
counts[sev] = counts.get(sev, 0) + 1
|
||||
return {sev: counts[sev] for sev in _SEVERITY_ORDER if sev in counts}
|
||||
|
||||
|
||||
async def _run_report_reader(fn: Any, *args: Any, **kwargs: Any) -> dict[str, Any]:
|
||||
try:
|
||||
return await asyncio.to_thread(fn, *args, **kwargs)
|
||||
except (ImportError, AttributeError) as e:
|
||||
logger.exception("report reader failed")
|
||||
return {"success": False, "error": f"Failed to read reports: {e!s}"}
|
||||
|
||||
|
||||
def _do_list_reports(
|
||||
*,
|
||||
severity: str | None,
|
||||
finding_class: str | None,
|
||||
target: str | None,
|
||||
search: str | None,
|
||||
include_details: bool,
|
||||
caller_agent_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
errors: list[str] = []
|
||||
severity = (severity or "").strip().lower() or None
|
||||
if severity and severity not in _VALID_SEVERITIES:
|
||||
errors.append(
|
||||
f"Invalid severity: {severity!r}. Must be one of: {sorted(_VALID_SEVERITIES)}"
|
||||
)
|
||||
finding_class = (finding_class or "").strip().lower() or None
|
||||
if finding_class and finding_class not in _VALID_FINDING_CLASSES:
|
||||
errors.append(
|
||||
f"Invalid finding_class: {finding_class!r}. "
|
||||
f"Must be one of: {sorted(_VALID_FINDING_CLASSES)}"
|
||||
)
|
||||
if errors:
|
||||
return {"success": False, "error": "Validation failed", "errors": errors}
|
||||
|
||||
from strix.report.state import get_global_report_state
|
||||
|
||||
report_state = get_global_report_state()
|
||||
if report_state is None:
|
||||
return {
|
||||
"success": True,
|
||||
"reports": [],
|
||||
"filtered_count": 0,
|
||||
"total_count": 0,
|
||||
"severity_counts": {},
|
||||
"warning": "Report state unavailable - no reports have been filed yet",
|
||||
}
|
||||
|
||||
all_reports = report_state.get_existing_vulnerabilities()
|
||||
matched = [
|
||||
r
|
||||
for r in all_reports
|
||||
if _report_matches_filters(
|
||||
r,
|
||||
severity=severity,
|
||||
finding_class=finding_class,
|
||||
target=(target or "").strip() or None,
|
||||
search=(search or "").strip() or None,
|
||||
)
|
||||
]
|
||||
matched.sort(key=lambda r: (_report_severity_rank(r), str(r.get("id", ""))))
|
||||
|
||||
reports = [
|
||||
_mark_authorship(dict(r), r, caller_agent_id)
|
||||
if include_details
|
||||
else _to_report_summary_entry(r, caller_agent_id)
|
||||
for r in matched
|
||||
]
|
||||
return {
|
||||
"success": True,
|
||||
"reports": reports,
|
||||
"filtered_count": len(reports),
|
||||
"total_count": len(all_reports),
|
||||
"severity_counts": _severity_counts(all_reports),
|
||||
}
|
||||
|
||||
|
||||
def _do_get_report(report_id: str, caller_agent_id: str | None = None) -> dict[str, Any]:
|
||||
report_id = (report_id or "").strip()
|
||||
if not report_id:
|
||||
return {"success": False, "error": "report_id cannot be empty", "report": None}
|
||||
|
||||
from strix.report.state import get_global_report_state
|
||||
|
||||
report_state = get_global_report_state()
|
||||
if report_state is None:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Report state unavailable - no reports have been filed yet",
|
||||
"report": None,
|
||||
}
|
||||
|
||||
for report in report_state.get_existing_vulnerabilities():
|
||||
if report.get("id") == report_id:
|
||||
return {
|
||||
"success": True,
|
||||
"report": _mark_authorship(dict(report), report, caller_agent_id),
|
||||
}
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Report with id '{report_id}' not found",
|
||||
"report": None,
|
||||
}
|
||||
|
||||
|
||||
@function_tool(timeout=30)
|
||||
async def list_reports(
|
||||
ctx: RunContextWrapper,
|
||||
severity: str | None = None,
|
||||
finding_class: str | None = None,
|
||||
target: str | None = None,
|
||||
search: str | None = None,
|
||||
include_details: bool = False,
|
||||
) -> str:
|
||||
"""List vulnerability reports filed so far in this scan — metadata-first.
|
||||
|
||||
**For the orchestrator / root agent.** This is an orchestration tool
|
||||
for tracking scan-wide coverage and assembling the final report — leaf
|
||||
/ specialist agents do their own testing and file findings; they should
|
||||
NOT call this. If you are a subagent, ignore it and focus on your task.
|
||||
|
||||
Reports are shared across **every** agent in the scan, so this returns
|
||||
findings filed by any agent (root or child), not just your own. As the
|
||||
root agent, use it to track progress, avoid dispatching work on
|
||||
already-covered ground, reason about attack-chaining across confirmed
|
||||
findings, and build the ``finish_scan`` executive summary.
|
||||
|
||||
By default each entry is compact: ``id``, ``title``, ``severity``,
|
||||
``cvss``, ``finding_class``, ``cve`` / ``cwe``, ``target`` /
|
||||
``endpoint``, ``fix_effort``, ``agent_name`` (who filed it), ``timestamp``,
|
||||
plus a 280-char ``description_preview``. Entries you filed yourself are
|
||||
flagged ``by_you: true``. The response also carries
|
||||
``total_count`` and ``severity_counts`` (counts per severity across all
|
||||
reports, ignoring filters). Set ``include_details=True`` for full report
|
||||
bodies (PoC, evidence, remediation, code_locations) — token-expensive;
|
||||
prefer ``get_report`` to drill into a single finding.
|
||||
|
||||
Filters compose (all must match): ``severity`` and ``finding_class``
|
||||
match exactly, ``target`` is a substring match against target/endpoint,
|
||||
and ``search`` is a substring match against title/description. Results
|
||||
are ordered by severity (critical -> info), then report id.
|
||||
|
||||
This is read-only — it never files or dedupes anything.
|
||||
|
||||
Args:
|
||||
severity: Filter to one of ``critical`` / ``high`` / ``medium`` /
|
||||
``low`` / ``info`` / ``none``.
|
||||
finding_class: Filter to ``dynamic`` (PoC-backed) or
|
||||
``dependency_cve`` (known-CVE supply-chain).
|
||||
target: Substring match against a report's target / endpoint.
|
||||
search: Substring match against title and description.
|
||||
include_details: When False (default) entries are compact; when
|
||||
True full report bodies are returned.
|
||||
"""
|
||||
caller_agent_id, _ = _caller_identity(ctx)
|
||||
return json.dumps(
|
||||
await _run_report_reader(
|
||||
_do_list_reports,
|
||||
severity=severity,
|
||||
finding_class=finding_class,
|
||||
target=target,
|
||||
search=search,
|
||||
include_details=include_details,
|
||||
caller_agent_id=caller_agent_id,
|
||||
),
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
|
||||
@function_tool(timeout=30)
|
||||
async def get_report(ctx: RunContextWrapper, report_id: str) -> str:
|
||||
"""Fetch one vulnerability report by its id (e.g. ``vuln-0001``).
|
||||
|
||||
Returns the full report body — description, impact, technical analysis,
|
||||
PoC, evidence, remediation, CVSS breakdown, and any ``code_locations``.
|
||||
Use ``list_reports`` first to find ids; this is the cheap way to read a
|
||||
single finding in full without pulling every body.
|
||||
|
||||
Read-only.
|
||||
|
||||
Args:
|
||||
report_id: Report id from ``list_reports`` or a
|
||||
``create_vulnerability_report`` / ``create_dependency_report``
|
||||
response (format ``vuln-NNNN``).
|
||||
"""
|
||||
caller_agent_id, _ = _caller_identity(ctx)
|
||||
return json.dumps(
|
||||
await _run_report_reader(_do_get_report, report_id, caller_agent_id),
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ directly from the run's on-disk files. No cloud dependency, no file picker.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from strix.interface.viewer.server import serve
|
||||
from strix.viewer.server import serve
|
||||
|
||||
|
||||
__all__ = ["serve"]
|
||||
@@ -155,7 +155,7 @@ def _post_json(path: str, payload: dict[str, Any], *, timeout: int) -> tuple[int
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 # nosec B310
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
|
||||
return response.status, _parse_body(response.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, _parse_body(exc.read())
|
||||
@@ -207,26 +207,6 @@ 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,
|
||||
@@ -261,7 +241,6 @@ def report_send(
|
||||
__all__ = [
|
||||
"AUTH_PATH",
|
||||
"RelayError",
|
||||
"feedback_submit",
|
||||
"forget",
|
||||
"is_verified",
|
||||
"otp_start",
|
||||
@@ -16,8 +16,8 @@ from strix.core.paths import (
|
||||
run_record_path,
|
||||
runs_base_dir,
|
||||
)
|
||||
from strix.interface.viewer.server import authorized_url, bundle_is_built, serve
|
||||
from strix.interface.viewer.transcript import read_run_summary
|
||||
from strix.viewer.server import authorized_url, bundle_is_built, serve
|
||||
from strix.viewer.transcript import read_run_summary
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -58,7 +58,7 @@ def run_view(argv: list[str]) -> None:
|
||||
if not bundle_is_built():
|
||||
console.print(
|
||||
"[bold red]Viewer UI is not built.[/]\n"
|
||||
"Build it with: [cyan]cd strix/interface/viewer/frontend && npm ci && npm run build[/]"
|
||||
"Build it with: [cyan]cd strix/viewer/frontend && npm ci && npm run build[/]"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
Generated
+63
-10
@@ -16,7 +16,6 @@
|
||||
"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"
|
||||
@@ -920,6 +919,9 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -934,6 +936,9 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -948,6 +953,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -962,6 +970,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -976,6 +987,9 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -990,6 +1004,9 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1004,6 +1021,9 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1018,6 +1038,9 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1032,6 +1055,9 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1046,6 +1072,9 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1060,6 +1089,9 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1074,6 +1106,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1088,6 +1123,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1311,6 +1349,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1328,6 +1369,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1345,6 +1389,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1362,6 +1409,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2493,6 +2543,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2514,6 +2567,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2535,6 +2591,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2556,6 +2615,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3633,15 +3695,6 @@
|
||||
"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",
|
||||
@@ -17,7 +17,6 @@
|
||||
"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"
|
||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -2,10 +2,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
AlertCircle,
|
||||
Bot,
|
||||
Waypoints,
|
||||
Mail,
|
||||
ChevronDown,
|
||||
Wrench,
|
||||
FileCheck2,
|
||||
CalendarClock,
|
||||
Radar,
|
||||
GitPullRequest,
|
||||
Rocket,
|
||||
ArrowUpRight,
|
||||
History,
|
||||
@@ -41,10 +45,11 @@ import PastRunsView from "@/components/PastRunsView";
|
||||
import EmailReportView from "@/components/EmailReportView";
|
||||
import { RunDetails } from "@/components/RunDetails";
|
||||
import { TrustToast } from "@/components/TrustToast";
|
||||
import FeedbackView from "@/components/FeedbackView";
|
||||
import { ProInlineCta } from "@/components/ProCta";
|
||||
import FeatureDetail from "@/components/FeatureDetail";
|
||||
import { ProTile, ProInlineCta, type ProItem } from "@/components/ProCta";
|
||||
import { FEATURES } from "@/lib/pro-features";
|
||||
|
||||
export type View = "overview" | "issues" | "agents" | "history" | "email" | "feedback";
|
||||
export type View = "overview" | "issues" | "agents" | "history" | "feature" | "email";
|
||||
|
||||
const TRUST_BANNER =
|
||||
"Your findings stay on your machine. They're rendered here locally in your browser and never uploaded or stored by Strix.";
|
||||
@@ -52,12 +57,25 @@ 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<string | null>(null);
|
||||
const [run, setRun] = useState<LoadedRun | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [view, setView] = useState<View>("overview");
|
||||
const [activeFeature, setActiveFeature] = useState<string | null>(null);
|
||||
const [auth, setAuth] = useState<AuthStatus | null>(null);
|
||||
const [runs, setRuns] = useState<RunsPayload | null>(null);
|
||||
const [emailPurpose, setEmailPurpose] = useState<"report" | "verify">("report");
|
||||
@@ -231,6 +249,12 @@ 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();
|
||||
@@ -242,13 +266,11 @@ export default function App() {
|
||||
<Sidebar
|
||||
view={view}
|
||||
onSelectView={(v) => {
|
||||
// Clicking a sidebar view always lands on that section's top level,
|
||||
// so leaving a specific issue's detail view and clicking "Issues"
|
||||
// returns to the full findings list.
|
||||
setSelectedId(null);
|
||||
if (v === "history") openHistory();
|
||||
else userSetView(v);
|
||||
}}
|
||||
activeFeature={activeFeature}
|
||||
onSelectFeature={selectFeature}
|
||||
issuesCount={run?.vulnerabilities.length ?? 0}
|
||||
agentCount={agentCount}
|
||||
runCount={runs?.count ?? 0}
|
||||
@@ -263,7 +285,7 @@ export default function App() {
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Top bar */}
|
||||
<div className="border-b border-[#222]">
|
||||
<div className="max-w-[88rem] mx-auto px-3 sm:px-6 py-4 flex items-center gap-1.5">
|
||||
<div className="max-w-[72rem] mx-auto px-6 py-4 flex items-center gap-1.5">
|
||||
<a
|
||||
href={ctaUrl("https://app.strix.ai", "logo")}
|
||||
target="_blank"
|
||||
@@ -275,6 +297,7 @@ export default function App() {
|
||||
<img src="./logo.png" alt="Strix" className="w-10 h-8 object-cover" />
|
||||
<div className="text-base text-white font-medium tracking-tight">Strix</div>
|
||||
</a>
|
||||
<span className="text-xs text-[#666]">Local results</span>
|
||||
{run && <LiveIndicator finished={run.finished} />}
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{verified && runs && !runs.locked && runs.runs.length > 0 && (
|
||||
@@ -299,20 +322,14 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-[88rem] mx-auto px-3 sm:px-6 py-8 sm:py-12 space-y-6">
|
||||
{error && !run && view !== "history" && view !== "email" && (
|
||||
<div className="max-w-[72rem] mx-auto px-6 py-8 space-y-6">
|
||||
{error && !run && view !== "history" && view !== "email" && view !== "feature" && (
|
||||
<div className="rounded-lg px-4 py-3 flex gap-3 items-start border border-red-500/30 bg-red-500/5">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0 mt-0.5 text-red-400" aria-hidden="true" />
|
||||
<p className="text-sm text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyed wrapper: re-mounts on every view / finding / run change so the
|
||||
page-in transition replays. */}
|
||||
<div
|
||||
key={`${activeRun ?? "launched"}:${view}:${selectedId ?? ""}`}
|
||||
className="animate-page-in space-y-6"
|
||||
>
|
||||
{view === "email" ? (
|
||||
<EmailReportView
|
||||
activeRun={activeRun}
|
||||
@@ -325,11 +342,8 @@ export default function App() {
|
||||
}}
|
||||
onExit={(dest) => setView(dest === "history" ? "history" : "overview")}
|
||||
/>
|
||||
) : view === "feedback" ? (
|
||||
<FeedbackView
|
||||
defaultEmail={auth?.email ?? null}
|
||||
onExit={(dest) => setView(dest)}
|
||||
/>
|
||||
) : view === "feature" && activeFeature && FEATURES[activeFeature] ? (
|
||||
<FeatureDetail feature={FEATURES[activeFeature]} />
|
||||
) : view === "history" ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -355,7 +369,7 @@ export default function App() {
|
||||
{/* Tab strip: shown on small screens where the sidebar is hidden. */}
|
||||
<div className="flex gap-5 border-b border-[#2a2a2a] lg:hidden">
|
||||
<TabButton active={view === "overview"} onClick={() => userSetView("overview")}>
|
||||
Pentest Overview
|
||||
Overview
|
||||
</TabButton>
|
||||
<TabButton active={view === "issues"} onClick={() => userSetView("issues")}>
|
||||
Issues{run.vulnerabilities.length > 0 ? ` (${run.vulnerabilities.length})` : ""}
|
||||
@@ -398,7 +412,6 @@ export default function App() {
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TrustToast message={TRUST_BANNER} />
|
||||
@@ -425,37 +438,33 @@ function RunSwitcher({
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
aria-label="Switch pentest"
|
||||
className="flex items-center gap-2 rounded-lg border border-[#3a3a3a] bg-[rgba(255,255,255,0.05)] px-3 py-2 text-sm text-white transition-colors hover:border-[#555] hover:bg-[rgba(255,255,255,0.09)]"
|
||||
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs text-[#aaa] transition-colors hover:text-white"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
>
|
||||
<History className="h-4 w-4 flex-shrink-0 text-[#888]" aria-hidden="true" />
|
||||
<span className="flex-shrink-0 text-[#888]">Pentest</span>
|
||||
<span className="max-w-[260px] truncate font-medium">{current}</span>
|
||||
<ChevronDown className="h-4 w-4 flex-shrink-0 text-[#aaa]" aria-hidden="true" />
|
||||
<History className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
<span className="max-w-[160px] truncate">{current}</span>
|
||||
<ChevronDown className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 z-50 mt-2 max-h-96 w-96 overflow-y-auto rounded-xl py-1.5 shadow-2xl"
|
||||
style={{ border: "1px solid #3a3a3a", background: "#0a0a0a" }}
|
||||
className="absolute right-0 z-50 mt-1.5 max-h-80 w-64 overflow-y-auto rounded-lg py-1 shadow-xl"
|
||||
style={{ border: "1px solid #2a2a2a", background: "#0a0a0a" }}
|
||||
>
|
||||
<div className="border-b border-[#222] px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-[#666]">
|
||||
Switch pentest
|
||||
</div>
|
||||
{runs.runs.map((r) => {
|
||||
const active = r.name === activeRun;
|
||||
return (
|
||||
<button
|
||||
key={r.name}
|
||||
onMouseDown={() => onSelect(r.name)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors hover:bg-[rgba(255,255,255,0.06)] ${
|
||||
active ? "bg-[rgba(255,255,255,0.04)] text-white" : "text-[#aaa]"
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-[rgba(255,255,255,0.06)] ${
|
||||
active ? "text-white" : "text-[#aaa]"
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">{runTitle(r.target, r.name)}</span>
|
||||
{r.target && <span className="block truncate font-mono text-xs text-[#666]">{r.target}</span>}
|
||||
<span className="block truncate">{runTitle(r.target, r.name)}</span>
|
||||
{r.target && <span className="block truncate font-mono text-[#666]">{r.target}</span>}
|
||||
</span>
|
||||
{active && <span className="h-2 w-2 flex-shrink-0 rounded-full bg-emerald-400" />}
|
||||
{active && <span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-emerald-400" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -499,7 +508,7 @@ function SummaryHeader({ summary }: { summary: ParsedRunSummary }) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">
|
||||
{runTitle(summary.targets[0] ?? null, summary.runName ?? summary.runId ?? "Pentest results")}
|
||||
{runTitle(summary.targets[0] ?? null, summary.runName ?? summary.runId ?? "Scan results")}
|
||||
</h1>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-[#888]">
|
||||
{summary.targets.length > 0 && (
|
||||
@@ -538,7 +547,7 @@ function FindingsList({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center text-sm text-[#888]">
|
||||
{finished ? "No findings in this run." : "No findings yet. The pentest is still running…"}
|
||||
{finished ? "No findings in this run." : "No findings yet. The scan is still running…"}
|
||||
</div>
|
||||
{finished && (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
@@ -564,7 +573,7 @@ function FindingsList({
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => onSelect(v.id)}
|
||||
className="animate-card-in cursor-pointer w-full text-left rounded-lg border border-[#222] hover:border-[#444] bg-[rgba(255,255,255,0.02)] px-4 py-3 transition-colors flex items-center gap-3"
|
||||
className="cursor-pointer w-full text-left rounded-lg border border-[#222] hover:border-[#444] bg-[rgba(255,255,255,0.02)] px-4 py-3 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${getSeverityDot(v.severity)}`} aria-hidden="true" />
|
||||
<span className="flex-1 min-w-0">
|
||||
@@ -627,7 +636,7 @@ function EmailReportCta({ onOpenEmail }: { onOpenEmail: () => void }) {
|
||||
</p>
|
||||
</div>
|
||||
<span className="flex-shrink-0 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity group-hover:opacity-90">
|
||||
Export report to PDF
|
||||
Email report
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
@@ -664,32 +673,26 @@ function OverviewTab({
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="animate-card-in">
|
||||
<RunDetails raw={raw} durationSeconds={summary.durationSeconds} />
|
||||
</div>
|
||||
<RunDetails raw={raw} durationSeconds={summary.durationSeconds} />
|
||||
|
||||
{total > 0 && (
|
||||
<div className="animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<IssueSeveritySummary findings={{ total, ...counts }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Primary CTA: the one primary on Overview. Hidden until the run is
|
||||
finished, since a live scan would only email a partial report. */}
|
||||
{finished && (
|
||||
<div className="animate-card-in">
|
||||
<EmailReportCta onOpenEmail={onOpenEmail} />
|
||||
</div>
|
||||
)}
|
||||
{finished && <EmailReportCta onOpenEmail={onOpenEmail} />}
|
||||
|
||||
{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">
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5 space-y-8">
|
||||
{sections.map((s) => (
|
||||
<ContentSection key={s.title} title={s.title} content={s.content} />
|
||||
))}
|
||||
</div>
|
||||
) : reportMarkdown ? (
|
||||
<div className="animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<ContentSection content={dedupeHeadings(reportMarkdown)} />
|
||||
</div>
|
||||
) : (
|
||||
@@ -698,6 +701,22 @@ function OverviewTab({
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Near Recommendations: act on the fixes. */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{RECOMMENDATION_CTAS.map((item) => (
|
||||
<ProTile key={item.slug} item={item} surface="overview" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Continuous coverage for your org (restyled upsell tiles). */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-white">Continuous coverage for your org</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{COVERAGE_CTAS.map((item) => (
|
||||
<ProTile key={item.slug} item={item} surface="overview" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -727,7 +746,8 @@ 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; no node selected means no modal.
|
||||
// Clicking a graph node opens the agent's transcript in a modal (matching the
|
||||
// cloud app); no node selected means no modal.
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const selectedAgent = selectedId ? (agents.find((a) => a.id === selectedId) ?? null) : null;
|
||||
|
||||
@@ -738,7 +758,7 @@ function AgentsTab({ run, canSteer }: { run: LoadedRun; canSteer: boolean }) {
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="w-4 h-4 text-[#888]" aria-hidden="true" />
|
||||
<Waypoints className="w-4 h-4 text-[#888]" aria-hidden="true" />
|
||||
<h2 className="text-sm font-semibold text-white">Agent graph</h2>
|
||||
<span className="text-xs text-[#666]">
|
||||
{agents.length} agent{agents.length === 1 ? "" : "s"}
|
||||
@@ -764,12 +784,12 @@ 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="text-sm font-semibold text-white">Run this scan with more depth</p>
|
||||
<p className="mt-0.5 text-xs text-[#666]">Re-run this scan on managed infra in the cloud.</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2.5">
|
||||
<ProInlineCta
|
||||
label="Re-run in Strix Pro with more depth"
|
||||
desc="Run this pentest on managed infra with more depth."
|
||||
label="Re-run in Strix Cloud with more depth"
|
||||
desc="Run this scan on managed infra with more depth."
|
||||
slug="live_scan"
|
||||
surface="agents"
|
||||
icon={Rocket}
|
||||
@@ -777,13 +797,14 @@ function AgentsTab({ run, canSteer }: { run: LoadedRun; canSteer: boolean }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AgentDetailModal
|
||||
open={selectedAgent !== null}
|
||||
agent={selectedAgent}
|
||||
events={events}
|
||||
steerable={steerable}
|
||||
onClose={() => setSelectedId(null)}
|
||||
/>
|
||||
{selectedAgent && (
|
||||
<AgentDetailModal
|
||||
agent={selectedAgent}
|
||||
events={events}
|
||||
steerable={steerable}
|
||||
onClose={() => setSelectedId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+9
-5
@@ -180,7 +180,7 @@ export default function EmailReportView({
|
||||
const confirmationEmail = sentTo || auth?.email || email.trim();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl space-y-4">
|
||||
<div className="mx-auto max-w-md space-y-4">
|
||||
<button
|
||||
onClick={() => onExit(verifyOnly ? "history" : "overview")}
|
||||
className="cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] transition-colors hover:text-white"
|
||||
@@ -192,7 +192,7 @@ export default function EmailReportView({
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5 text-[#888]" aria-hidden="true" />
|
||||
<h1 className="text-2xl font-semibold text-white">
|
||||
{verifyOnly ? "Verify your email" : "Export report to PDF"}
|
||||
{verifyOnly ? "Verify your email" : "Email report"}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -225,13 +225,16 @@ export default function EmailReportView({
|
||||
<div className="flex items-start gap-2.5">
|
||||
<ShieldCheck className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400" aria-hidden="true" />
|
||||
<p className="text-xs leading-relaxed text-[#aaa]">
|
||||
We email an <span className="text-white">encrypted PDF</span>. Nothing else leaves your machine.
|
||||
Viewing stays local and nothing is uploaded. Emailing is an explicit
|
||||
opt-in: we send an <span className="text-white">encrypted PDF</span>.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Lock className="mt-0.5 h-4 w-4 flex-shrink-0 text-[#888]" aria-hidden="true" />
|
||||
<p className="text-xs leading-relaxed text-[#aaa]">
|
||||
Only you hold the password; Strix can't read it.
|
||||
The report is encrypted with a password that only you hold. Strix
|
||||
cannot read it and never stores it. We collect only your email so we
|
||||
can send it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -239,7 +242,7 @@ export default function EmailReportView({
|
||||
onClick={startFlow}
|
||||
className="w-full cursor-pointer rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
Export report
|
||||
{verified ? "Email me the encrypted PDF" : "Continue with your email"}
|
||||
</button>
|
||||
{verified && auth?.email && (
|
||||
<p className="text-center text-xs text-[#666]">Sending to {auth.email}</p>
|
||||
@@ -266,6 +269,7 @@ export default function EmailReportView({
|
||||
className="w-full rounded-lg bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-[#444]"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
/>
|
||||
<span className="mt-1.5 block text-[11px] text-[#666]">Use your work email.</span>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
CalendarClock,
|
||||
WandSparkles,
|
||||
Puzzle,
|
||||
Users,
|
||||
ArrowUpRight,
|
||||
} from "lucide-react";
|
||||
import { SIGNUP_URL, PRICING_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
import type { ProFeature } from "@/lib/pro-features";
|
||||
import { ProTag } from "@/components/ProCta";
|
||||
|
||||
/**
|
||||
* In-app upsell page for a single platform feature. Modeled on the cloud app's
|
||||
* Networks upsell: a centered bordered card with an icon medallion, tier pill,
|
||||
* headline, one-line description, a shared "Included in Strix Pro" bullet list,
|
||||
* then a primary sign-up CTA and a secondary link to all plans.
|
||||
*/
|
||||
|
||||
const INCLUDED = [
|
||||
{
|
||||
icon: CalendarClock,
|
||||
text: "Continuous coverage: scheduled pentests and attack surface monitoring",
|
||||
},
|
||||
{ icon: WandSparkles, text: "One-click autofix that opens a retested pull request" },
|
||||
{ icon: Puzzle, text: "Two-way sync to Jira, Linear, and Slack" },
|
||||
{ icon: Users, text: "Your whole team, with roles and shared history" },
|
||||
];
|
||||
|
||||
export default function FeatureDetail({ feature }: { feature: ProFeature }) {
|
||||
const Icon = feature.icon;
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-lg">
|
||||
<div className="rounded-2xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center">
|
||||
<div
|
||||
className="mx-auto flex h-12 w-12 items-center justify-center rounded-xl"
|
||||
style={{ border: "1px solid #2a2a2a", background: "rgba(255,255,255,0.04)" }}
|
||||
>
|
||||
<Icon className="h-5 w-5 text-[#888]" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-center">
|
||||
<ProTag label={feature.tier} />
|
||||
</div>
|
||||
|
||||
<h2 className="mt-3 text-2xl font-semibold text-white">{feature.headline}</h2>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-[#888]">{feature.description}</p>
|
||||
|
||||
<div
|
||||
className="mt-6 rounded-xl p-4 text-left"
|
||||
style={{ border: "1px solid #222", background: "rgba(255,255,255,0.02)" }}
|
||||
>
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-[#666]">
|
||||
Included in Strix Pro
|
||||
</p>
|
||||
<ul className="space-y-2.5">
|
||||
{INCLUDED.map((item) => {
|
||||
const BulletIcon = item.icon;
|
||||
return (
|
||||
<li key={item.text} className="flex items-start gap-2.5">
|
||||
<BulletIcon
|
||||
className="mt-0.5 h-4 w-4 flex-shrink-0 text-[#888]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-sm text-[#aaa]">{item.text}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col items-center gap-3">
|
||||
<a
|
||||
href={ctaUrl(SIGNUP_URL, feature.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta(feature.slug, "feature_page")}
|
||||
className="inline-flex w-full items-center justify-center gap-1.5 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
Start free
|
||||
<ArrowUpRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</a>
|
||||
<a
|
||||
href={ctaUrl(PRICING_URL, feature.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta(feature.slug, "feature_page_plans")}
|
||||
className="inline-flex items-center gap-1 text-xs text-[#888] transition-colors hover:text-white"
|
||||
>
|
||||
View all plans
|
||||
<ArrowUpRight className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -143,7 +143,7 @@ export default function PastRunsView({
|
||||
<button
|
||||
key={run.name}
|
||||
onClick={() => onSelectRun(run.name)}
|
||||
className={`animate-card-in group flex w-full cursor-pointer items-center gap-4 rounded-lg border px-4 py-3 text-left transition-colors ${
|
||||
className={`group flex w-full cursor-pointer items-center gap-4 rounded-lg border px-4 py-3 text-left transition-colors ${
|
||||
active
|
||||
? "border-[#444] bg-[rgba(255,255,255,0.04)]"
|
||||
: "border-[#222] bg-[rgba(255,255,255,0.02)] hover:border-[#444]"
|
||||
@@ -0,0 +1,184 @@
|
||||
import React, { useState } from "react";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
import type { ProFeature } from "@/lib/pro-features";
|
||||
|
||||
/**
|
||||
* Shared Pro CTA primitives. Every Pro item is a direct link-out to the cloud
|
||||
* sign-up in a new tab with a hover tooltip one-liner (no modal, no lock icon).
|
||||
* Built once here and reused by the sidebar Platform section, the top upsell
|
||||
* row, and the inline CTAs in the tabs.
|
||||
*/
|
||||
|
||||
/** Small tier pill ("Pro" or "Enterprise"). Deliberately not a padlock. */
|
||||
export function ProTag({ label = "Pro", className = "" }: { label?: string; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-[#aaa] ${className}`}
|
||||
style={{ border: "1px solid #2a2a2a", background: "rgba(255,255,255,0.04)" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight hover tooltip. Wraps a trigger and reveals `text` above it on
|
||||
* hover/focus. Plain Tailwind + local state (no radix vendored).
|
||||
*/
|
||||
export function Tooltip({
|
||||
text,
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
text: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<span
|
||||
className={`relative inline-flex ${className}`}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
>
|
||||
{children}
|
||||
{open && (
|
||||
<span
|
||||
role="tooltip"
|
||||
className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 w-max max-w-[240px] -translate-x-1/2 rounded-md px-2.5 py-1.5 text-xs text-[#ddd] shadow-lg"
|
||||
style={{ border: "1px solid #2a2a2a", background: "#0a0a0a" }}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ProItem {
|
||||
title: string;
|
||||
desc: string;
|
||||
slug: string;
|
||||
icon: React.ElementType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Card-style Pro feature tile: icon + name + one-liner + Pro tag + arrow.
|
||||
* Used in the top upsell row and inline CTA grids.
|
||||
*/
|
||||
export function ProTile({ item, surface }: { item: ProItem; surface?: string }) {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<a
|
||||
href={ctaUrl(SIGNUP_URL, item.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta(item.slug, surface)}
|
||||
title={item.desc}
|
||||
className="group block cursor-pointer rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-4 text-left transition-colors hover:border-[#444]"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Icon className="h-4 w-4 text-[#888] transition-colors group-hover:text-white" aria-hidden="true" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ProTag />
|
||||
<ArrowUpRight className="h-3.5 w-3.5 text-[#555] transition-colors group-hover:text-[#aaa]" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-white">{item.title}</p>
|
||||
<p className="mt-0.5 text-xs text-[#666]">{item.desc}</p>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar-row Pro item: a two-line row (icon + label + short one-liner
|
||||
* underneath) with a small right-aligned tier tag. Opens the in-app
|
||||
* FeatureDetail view via onClick (no link-out) so it sits uniformly beside the
|
||||
* run/local rows in the themed nav list.
|
||||
*/
|
||||
export function ProNavItem({
|
||||
feature,
|
||||
active,
|
||||
onClick,
|
||||
collapsed = false,
|
||||
}: {
|
||||
feature: ProFeature;
|
||||
active?: boolean;
|
||||
onClick: () => void;
|
||||
collapsed?: boolean;
|
||||
}) {
|
||||
const Icon = feature.icon;
|
||||
if (collapsed) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={`${feature.title} (${feature.tier})`}
|
||||
className={`group flex w-full cursor-pointer items-center justify-center rounded-md px-2.5 py-2 transition-colors ${
|
||||
active
|
||||
? "text-white"
|
||||
: "text-[#888] hover:bg-[rgba(255,255,255,0.06)] hover:text-white"
|
||||
}`}
|
||||
style={active ? { background: "rgba(255,255,255,0.12)" } : undefined}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`group flex w-full cursor-pointer items-start gap-2.5 rounded-md px-2.5 py-1.5 text-left transition-colors ${
|
||||
active
|
||||
? "text-white"
|
||||
: "text-[#888] hover:bg-[rgba(255,255,255,0.06)] hover:text-white"
|
||||
}`}
|
||||
style={active ? { background: "rgba(255,255,255,0.12)" } : undefined}
|
||||
>
|
||||
<Icon className="mt-0.5 h-4 w-4 flex-shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="flex-1 truncate text-sm">{feature.title}</span>
|
||||
<ProTag label={feature.tier} />
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[11px] leading-snug text-[#666]">{feature.navDesc}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline Pro CTA button (compact). Used in the finding detail and per-surface
|
||||
* rows where a full card is too heavy.
|
||||
*/
|
||||
export function ProInlineCta({
|
||||
label,
|
||||
desc,
|
||||
slug,
|
||||
icon: Icon,
|
||||
surface,
|
||||
}: {
|
||||
label: string;
|
||||
desc: string;
|
||||
slug: string;
|
||||
icon: React.ElementType;
|
||||
surface?: string;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip text={desc}>
|
||||
<a
|
||||
href={ctaUrl(SIGNUP_URL, slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta(slug, surface)}
|
||||
className="group inline-flex items-center gap-2 rounded-lg border border-[#222] bg-[rgba(255,255,255,0.02)] px-3 py-2 text-sm text-[#aaa] transition-colors hover:border-[#444] hover:text-white"
|
||||
>
|
||||
<Icon className="h-4 w-4 text-[#888] transition-colors group-hover:text-white" aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
<ProTag className="ml-0.5" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
+3
-26
@@ -72,13 +72,7 @@ export function RunDetails({
|
||||
const diffMode = str(diff.mode);
|
||||
const diffBase = str(raw.diff_base);
|
||||
const nonInteractive = raw.non_interactive === true;
|
||||
const localSources = arr(raw.local_sources)
|
||||
.map((x) => {
|
||||
if (typeof x === "string") return x;
|
||||
const o = rec(x);
|
||||
return str(o.source_path) ?? str(o.target_path) ?? "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
const localSources = arr(raw.local_sources).map((x) => String(x)).filter(Boolean);
|
||||
const status = cap(str(raw.status));
|
||||
|
||||
let scope = scopeMode ?? "auto";
|
||||
@@ -100,7 +94,6 @@ export function RunDetails({
|
||||
const reasoning = num(rec(arr(usage.output_tokens_details)[0]).reasoning_tokens);
|
||||
const totalTokens = num(usage.total_tokens);
|
||||
const cost = num(usage.cost);
|
||||
const subscription = str(raw.auth_mode) === "subscription";
|
||||
|
||||
const sub = (n: number, word: string) => (
|
||||
<span className="text-[#666]"> ({formatNumber(n)} {word})</span>
|
||||
@@ -153,7 +146,7 @@ export function RunDetails({
|
||||
<span className="text-[#666]">None</span>
|
||||
)}
|
||||
</Field>
|
||||
{scanMode && <Field label="Pentest mode">{scanMode}</Field>}
|
||||
{scanMode && <Field label="Scan mode">{scanMode}</Field>}
|
||||
<Field label="Scope">{scope}</Field>
|
||||
<Field label="Mode">{nonInteractive ? "Non-interactive" : "Interactive"}</Field>
|
||||
{localSources.length > 0 && (
|
||||
@@ -176,15 +169,6 @@ export function RunDetails({
|
||||
{hasUsage ? (
|
||||
<dl className="space-y-2.5 tabular-nums">
|
||||
<Field label="Model">{models.length ? models.join(", ") : "n/a"}</Field>
|
||||
{subscription && (
|
||||
<Field label="Provider">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="rounded-full border border-[#22c55e]/40 bg-[#22c55e]/10 px-2 py-0.5 text-[11px] text-[#22c55e]">
|
||||
ChatGPT subscription
|
||||
</span>
|
||||
</span>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Run time">{fmtDuration(durationSeconds)}</Field>
|
||||
{requests != null && <Field label="Requests">{formatNumber(requests)}</Field>}
|
||||
{inputTokens != null && (
|
||||
@@ -200,14 +184,7 @@ export function RunDetails({
|
||||
</Field>
|
||||
)}
|
||||
{totalTokens != null && <Field label="Total tokens">{formatNumber(totalTokens)}</Field>}
|
||||
{subscription ? (
|
||||
<Field label="Cost">
|
||||
<span className="text-[#22c55e]">$0.00</span>
|
||||
<span className="text-[#666]"> (subscription)</span>
|
||||
</Field>
|
||||
) : (
|
||||
cost != null && <Field label="Cost">${cost.toFixed(2)}</Field>
|
||||
)}
|
||||
{cost != null && <Field label="Cost">${cost.toFixed(2)}</Field>}
|
||||
{agents.length > 0 && <Field label="Agents">{formatNumber(agents.length)}</Field>}
|
||||
</dl>
|
||||
) : (
|
||||
@@ -0,0 +1,289 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
FileText,
|
||||
Bug,
|
||||
Waypoints,
|
||||
History,
|
||||
Mail,
|
||||
ArrowUpRight,
|
||||
LogOut,
|
||||
ShieldCheck,
|
||||
PanelLeftClose,
|
||||
PanelLeft,
|
||||
} from "lucide-react";
|
||||
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
import { ProNavItem } from "@/components/ProCta";
|
||||
import { FEATURES, PLATFORM_ORDER } from "@/lib/pro-features";
|
||||
import type { View } from "@/App";
|
||||
|
||||
/**
|
||||
* Persistent left rail. A single, ungrouped, ordered list of uniform two-line
|
||||
* rows (icon + label + short one-liner): the current run's views, the local
|
||||
* run-history + email-report actions, then the platform features. No section
|
||||
* headers. Tier is shown only by the inline Pro/Enterprise tag on platform
|
||||
* rows. Matches App.tsx's dark palette.
|
||||
*
|
||||
* Can collapse to a narrow icon-only rail; the collapsed state persists in
|
||||
* localStorage and each icon row keeps a `title` tooltip so the labels stay
|
||||
* discoverable.
|
||||
*/
|
||||
|
||||
const COLLAPSE_KEY = "strix_viewer_sidebar_collapsed";
|
||||
|
||||
interface SidebarProps {
|
||||
view: View;
|
||||
onSelectView: (view: View) => void;
|
||||
activeFeature: string | null;
|
||||
onSelectFeature: (slug: string) => void;
|
||||
issuesCount: number;
|
||||
agentCount: number;
|
||||
runCount: number;
|
||||
finished: boolean;
|
||||
verified: boolean;
|
||||
email: string | null;
|
||||
onOpenEmail: () => void;
|
||||
onOpenHistory: () => void;
|
||||
onForget: () => void;
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
view,
|
||||
onSelectView,
|
||||
activeFeature,
|
||||
onSelectFeature,
|
||||
issuesCount,
|
||||
agentCount,
|
||||
runCount,
|
||||
finished,
|
||||
verified,
|
||||
email,
|
||||
onOpenEmail,
|
||||
onOpenHistory,
|
||||
onForget,
|
||||
}: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
setCollapsed(localStorage.getItem(COLLAPSE_KEY) === "1");
|
||||
} catch {
|
||||
/* localStorage may be unavailable; default to expanded */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
setCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
try {
|
||||
localStorage.setItem(COLLAPSE_KEY, next ? "1" : "0");
|
||||
} catch {
|
||||
/* best-effort persistence */
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`hidden flex-shrink-0 border-r border-[#222] lg:block ${collapsed ? "w-14" : "w-72"}`}
|
||||
>
|
||||
<div className="sticky top-0 flex h-screen flex-col overflow-y-auto px-3 py-4">
|
||||
{/* Header: wordmark + Explore full platform + signed-in chip */}
|
||||
<div className="px-1.5">
|
||||
<div className={`flex items-center ${collapsed ? "flex-col gap-2" : "justify-between"}`}>
|
||||
<a
|
||||
href={ctaUrl("https://app.strix.ai", "logo")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("logo", "sidebar")}
|
||||
className="flex items-center gap-1.5 opacity-90 transition-opacity hover:opacity-100"
|
||||
title="Open Strix Cloud"
|
||||
>
|
||||
<img src="./logo.png" alt="Strix" className="h-8 w-10 object-cover" />
|
||||
{!collapsed && (
|
||||
<span className="text-base font-medium tracking-tight text-white">Strix</span>
|
||||
)}
|
||||
</a>
|
||||
<button
|
||||
onClick={toggleCollapsed}
|
||||
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
className="flex-shrink-0 cursor-pointer rounded-md p-1.5 text-[#666] transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-white"
|
||||
>
|
||||
{collapsed ? (
|
||||
<PanelLeft className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<PanelLeftClose className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<a
|
||||
href={ctaUrl(SIGNUP_URL, "sidebar_start_free")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("sidebar_start_free", "sidebar")}
|
||||
title="Explore full platform"
|
||||
className={`mt-3 flex w-full cursor-pointer items-center justify-center gap-1.5 rounded-lg bg-white font-semibold text-black transition-opacity hover:opacity-90 ${
|
||||
collapsed ? "px-0 py-2" : "px-3 py-2 text-sm"
|
||||
}`}
|
||||
>
|
||||
{!collapsed && "Explore full platform"}
|
||||
<ArrowUpRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</a>
|
||||
{verified && email && (
|
||||
collapsed ? (
|
||||
<div
|
||||
className="mt-2.5 flex items-center justify-center rounded-lg py-2"
|
||||
style={{ border: "1px solid #222", background: "rgba(255,255,255,0.02)" }}
|
||||
title={`Linked email: ${email}`}
|
||||
>
|
||||
<ShieldCheck className="h-3.5 w-3.5 flex-shrink-0 text-emerald-400" aria-hidden="true" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="mt-2.5 flex items-center gap-2 rounded-lg px-2.5 py-2"
|
||||
style={{ border: "1px solid #222", background: "rgba(255,255,255,0.02)" }}
|
||||
>
|
||||
<ShieldCheck className="h-3.5 w-3.5 flex-shrink-0 text-emerald-400" aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[11px] text-[#666]">Linked email</p>
|
||||
<p className="truncate text-xs text-[#aaa]" title={email}>{email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onForget}
|
||||
title="Forget this email on this machine"
|
||||
className="flex-shrink-0 cursor-pointer text-[#666] transition-colors hover:text-white"
|
||||
aria-label="Forget"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* One single ordered list, no section headers. */}
|
||||
<div className="mt-6 space-y-0.5">
|
||||
<NavItem
|
||||
icon={FileText}
|
||||
label="Overview"
|
||||
desc="This run's executive report"
|
||||
active={view === "overview"}
|
||||
onClick={() => onSelectView("overview")}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
<NavItem
|
||||
icon={Bug}
|
||||
label="Issues"
|
||||
desc="Findings from this run"
|
||||
count={issuesCount > 0 ? issuesCount : undefined}
|
||||
active={view === "issues"}
|
||||
onClick={() => onSelectView("issues")}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
{agentCount > 0 && (
|
||||
<NavItem
|
||||
icon={Waypoints}
|
||||
label="Agents"
|
||||
desc="What each agent did"
|
||||
count={agentCount}
|
||||
active={view === "agents"}
|
||||
onClick={() => onSelectView("agents")}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
)}
|
||||
<NavItem
|
||||
icon={History}
|
||||
label="Past runs"
|
||||
desc="Every run on this machine"
|
||||
count={runCount > 0 ? runCount : undefined}
|
||||
active={view === "history"}
|
||||
onClick={onOpenHistory}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
{/* Emailing a report only makes sense once the run is complete; a
|
||||
live scan would send a partial report, so hide it until finished. */}
|
||||
{finished && (
|
||||
<NavItem
|
||||
icon={Mail}
|
||||
label="Email report"
|
||||
desc="Get an encrypted PDF by email"
|
||||
active={view === "email"}
|
||||
onClick={onOpenEmail}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
)}
|
||||
|
||||
{PLATFORM_ORDER.map((slug) => {
|
||||
const feature = FEATURES[slug];
|
||||
if (!feature) return null;
|
||||
return (
|
||||
<ProNavItem
|
||||
key={slug}
|
||||
feature={feature}
|
||||
active={view === "feature" && activeFeature === slug}
|
||||
onClick={() => onSelectFeature(slug)}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function NavItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
desc,
|
||||
count,
|
||||
active,
|
||||
onClick,
|
||||
collapsed = false,
|
||||
}: {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
desc: string;
|
||||
count?: number;
|
||||
active?: boolean;
|
||||
onClick: () => void;
|
||||
collapsed?: boolean;
|
||||
}) {
|
||||
if (collapsed) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={count != null ? `${label} (${count})` : label}
|
||||
className={`flex w-full cursor-pointer items-center justify-center rounded-md px-2.5 py-2 transition-colors ${
|
||||
active
|
||||
? "text-white"
|
||||
: "text-[#888] hover:bg-[rgba(255,255,255,0.06)] hover:text-white"
|
||||
}`}
|
||||
style={active ? { background: "rgba(255,255,255,0.12)" } : undefined}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex w-full cursor-pointer items-start gap-2.5 rounded-md px-2.5 py-1.5 text-left transition-colors ${
|
||||
active
|
||||
? "text-white"
|
||||
: "text-[#888] hover:bg-[rgba(255,255,255,0.06)] hover:text-white"
|
||||
}`}
|
||||
style={active ? { background: "rgba(255,255,255,0.12)" } : undefined}
|
||||
>
|
||||
<Icon className="mt-0.5 h-4 w-4 flex-shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="flex-1 truncate text-sm">{label}</span>
|
||||
{count != null && <span className="text-xs text-[#666] tabular-nums">{count}</span>}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[11px] leading-snug text-[#666]">{desc}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
+17
-67
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { AgentTranscript } from "./AgentTranscript";
|
||||
import { ScanPromptComposer } from "./ScanPromptComposer";
|
||||
@@ -18,27 +18,19 @@ const STATUS_DOT: Record<string, string> = {
|
||||
const NEAR_BOTTOM_PX = 80;
|
||||
|
||||
/**
|
||||
* Overlay modal showing a single agent's full transcript. A centered
|
||||
* ``max-w-6xl`` / ``60vh`` panel that animates in and out via the shared
|
||||
* ``agent-modal`` data-state keyframes (fade), with a pinned header
|
||||
* (status dot + agent name),
|
||||
* the transcript scrolling beneath it, and a footer. Auto-scrolls to follow new
|
||||
* activity while the user is near the bottom. Closes on backdrop click, the X
|
||||
* button, or Escape.
|
||||
*
|
||||
* Driven by an ``open`` prop (rather than conditional mounting) so the exit
|
||||
* animation can play before unmount; the last agent is retained through the
|
||||
* close so content doesn't blank out mid-animation.
|
||||
* Overlay modal showing a single agent's full transcript. Matches the cloud
|
||||
* app: a fixed-size panel with a pinned header (status dot + agent name), the
|
||||
* transcript scrolling beneath it, and a footer. Auto-scrolls to follow new
|
||||
* activity while the user is near the bottom (so a live run trails). Closes on
|
||||
* backdrop click, the X button, or Escape.
|
||||
*/
|
||||
export function AgentDetailModal({
|
||||
open,
|
||||
agent,
|
||||
events,
|
||||
steerable,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
agent: TranscriptAgent | null;
|
||||
agent: TranscriptAgent;
|
||||
events: TranscriptEvent[];
|
||||
steerable: boolean;
|
||||
onClose: () => void;
|
||||
@@ -46,42 +38,6 @@ export function AgentDetailModal({
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const nearBottom = useRef(false);
|
||||
|
||||
// Keep the modal mounted through its exit animation (see UpgradeModal).
|
||||
const [render, setRender] = useState(open);
|
||||
const [state, setState] = useState<"open" | "closed">(open ? "open" : "closed");
|
||||
// Defer the (heavy) transcript one frame so the shell + fade paint instantly
|
||||
// instead of waiting on the full event list to render.
|
||||
const [contentReady, setContentReady] = useState(false);
|
||||
|
||||
// Retain the last non-null agent so the panel keeps rendering its content
|
||||
// during the close animation, after the parent has cleared the selection.
|
||||
const lastAgentRef = useRef<TranscriptAgent | null>(agent);
|
||||
useEffect(() => {
|
||||
if (agent) lastAgentRef.current = agent;
|
||||
}, [agent]);
|
||||
const shownAgent = agent ?? lastAgentRef.current;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRender(true);
|
||||
setState("open");
|
||||
return;
|
||||
}
|
||||
setState("closed");
|
||||
const t = setTimeout(() => setRender(false), 140);
|
||||
return () => clearTimeout(t);
|
||||
}, [open]);
|
||||
|
||||
// Mount the transcript a frame after the shell is on screen.
|
||||
useEffect(() => {
|
||||
if (!render) {
|
||||
setContentReady(false);
|
||||
return;
|
||||
}
|
||||
const id = requestAnimationFrame(() => setContentReady(true));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [render]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
@@ -98,7 +54,6 @@ export function AgentDetailModal({
|
||||
}, [events]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!render) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
@@ -109,30 +64,27 @@ export function AgentDetailModal({
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [render, onClose]);
|
||||
|
||||
if (!render || !shownAgent) return null;
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-state={state}
|
||||
className="agent-modal fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4 sm:p-8"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 sm:p-8"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Agent ${shownAgent.name}`}
|
||||
aria-label={`Agent ${agent.name}`}
|
||||
>
|
||||
<div
|
||||
className="relative flex h-[60vh] w-[calc(100vw-4rem)] max-w-6xl flex-col overflow-hidden rounded-xl border border-[#222] bg-[#0a0a0a] shadow-2xl"
|
||||
className="relative flex h-[80vh] w-full max-w-5xl flex-col rounded-xl border border-[#222] bg-[#0a0a0a] shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[#222] px-5 py-3.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={`h-2 w-2 flex-shrink-0 rounded-full ${STATUS_DOT[shownAgent.status] ?? "bg-[#888]"}`}
|
||||
className={`h-2 w-2 flex-shrink-0 rounded-full ${STATUS_DOT[agent.status] ?? "bg-[#888]"}`}
|
||||
/>
|
||||
<span className="truncate text-sm font-semibold text-white">{shownAgent.name}</span>
|
||||
<span className="flex-shrink-0 font-mono text-xs text-[#555]">{shownAgent.id}</span>
|
||||
<span className="truncate text-sm font-semibold text-white">{agent.name}</span>
|
||||
<span className="flex-shrink-0 font-mono text-xs text-[#555]">{agent.id}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -145,16 +97,14 @@ export function AgentDetailModal({
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto p-5">
|
||||
{contentReady && (
|
||||
<AgentTranscript agent={shownAgent} events={events} showHeader={false} />
|
||||
)}
|
||||
<AgentTranscript agent={agent} events={events} showHeader={false} />
|
||||
</div>
|
||||
|
||||
{steerable && (
|
||||
<div className="border-t border-[#222] px-5 py-3">
|
||||
<ScanPromptComposer
|
||||
agents={[shownAgent]}
|
||||
fixedAgentId={shownAgent.id}
|
||||
agents={[agent]}
|
||||
fixedAgentId={agent.id}
|
||||
className="mt-0"
|
||||
/>
|
||||
</div>
|
||||
+1
-1
@@ -248,7 +248,7 @@ export function ScanPromptComposer({
|
||||
void handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder="Send a live prompt to the running pentest…"
|
||||
placeholder="Send a live prompt to the running scan…"
|
||||
maxLength={4000}
|
||||
disabled={sending}
|
||||
className="block w-full resize-none border-0 bg-transparent p-0 text-[15px] leading-6 text-white placeholder:text-[#444] focus:outline-none disabled:opacity-60 max-h-[160px] overflow-y-auto"
|
||||
+4
-5
@@ -3,11 +3,10 @@
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
|
||||
export default function LoadSkillRenderer({ args }: ToolRendererProps) {
|
||||
// `skills` may arrive as an array of names or a comma-separated string
|
||||
// depending on the tool call, so normalize both to a clean list.
|
||||
const raw = args.skills;
|
||||
const requestedSkills = (Array.isArray(raw) ? raw : String(raw ?? "").split(","))
|
||||
.map((skill) => String(skill).trim())
|
||||
const requestedRaw = (args.skills as string) ?? "";
|
||||
const requestedSkills = requestedRaw
|
||||
.split(",")
|
||||
.map((skill) => skill.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
-6
@@ -49,9 +49,6 @@ export default function NotesRenderer({ toolName, args, result }: ToolRendererPr
|
||||
<div className="mt-1.5 text-[#999] text-[13px]">
|
||||
{note.title ?? "(untitled)"}
|
||||
<span className="text-[#555] ml-1">({note.category ?? "general"})</span>
|
||||
{(note.by_you || note.agent_name) && (
|
||||
<span className="text-[#666] ml-1 text-xs">by {note.by_you ? "you" : note.agent_name}</span>
|
||||
)}
|
||||
</div>
|
||||
{note.content && <div className="mt-1"><Markdown text={note.content} /></div>}
|
||||
</>
|
||||
@@ -77,9 +74,6 @@ export default function NotesRenderer({ toolName, args, result }: ToolRendererPr
|
||||
<span className="text-[#555] mr-1">-</span>
|
||||
<span className="text-[#999]">{n.title ?? "(untitled)"}</span>
|
||||
<span className="text-[#555] ml-1">({n.category ?? "general"})</span>
|
||||
{(n.by_you || n.agent_name) && (
|
||||
<span className="text-[#666] ml-1 text-xs">by {n.by_you ? "you" : n.agent_name}</span>
|
||||
)}
|
||||
{n.content && <div className="ml-3"><Markdown text={n.content} /></div>}
|
||||
</div>
|
||||
))}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user