Compare commits

..
182 changed files with 2425 additions and 12674 deletions
+6 -27
View File
@@ -6,9 +6,6 @@ on:
- 'v*'
workflow_dispatch:
permissions:
contents: read
jobs:
build:
strategy:
@@ -21,23 +18,19 @@ jobs:
target: macos-x86_64
- os: ubuntu-22.04
target: linux-x86_64
- os: ubuntu-22.04-arm
target: linux-arm64
- os: windows-latest
target: windows-x86_64
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
@@ -45,20 +38,6 @@ jobs:
uv sync --frozen
uv run pyinstaller strix.spec --noconfirm
if [[ "${{ runner.os }}" == "Windows" ]]; then
dist/strix.exe --version
else
dist/strix --version
fi
if [[ "${{ matrix.target }}" == "linux-arm64" ]]; then
file dist/strix
file dist/strix | grep -q "ARM aarch64" || {
echo "::error::linux-arm64 artifact is not an ARM aarch64 binary"
exit 1
}
fi
VERSION=$(grep '^version' pyproject.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
mkdir -p dist/release
@@ -71,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: |
@@ -86,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
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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!"
-14
View File
@@ -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`
+15 -47
View File
@@ -1,26 +1,3 @@
# ---------------------------------------------------------------------------
# Builder stage: compile the Go tools here so the Go toolchain (~225MB) and the
# module/build caches never reach the runtime image. The resulting binaries are
# statically linked and copied into the final stage.
# ---------------------------------------------------------------------------
FROM kalilinux/kali-rolling:latest AS gobuilder
RUN apt-get update && \
apt-get install -y kali-archive-keyring && \
apt-get update && \
apt-get install -y --no-install-recommends golang-go git ca-certificates
ENV GOBIN=/out/bin
RUN mkdir -p /out/bin && \
go install -v github.com/projectdiscovery/httpx/cmd/httpx@latest && \
go install -v github.com/projectdiscovery/katana/cmd/katana@latest && \
go install -v github.com/projectdiscovery/cvemap/cmd/vulnx@latest && \
go install -v github.com/jaeles-project/gospider@latest && \
go install -v github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest
# ---------------------------------------------------------------------------
# Runtime stage
# ---------------------------------------------------------------------------
FROM kalilinux/kali-rolling:latest
LABEL description="AI Agent Penetration Testing Environment with Comprehensive Automated Tools"
@@ -42,13 +19,14 @@ RUN apt-get update && \
apt-get install -y --no-install-recommends \
wget curl git vim nano unzip tar \
apt-transport-https ca-certificates gnupg lsb-release \
software-properties-common \
gcc libc6-dev \
python3 python3-pip python3-venv python3-setuptools \
build-essential software-properties-common \
gcc libc6-dev pkg-config libpcap-dev libssl-dev \
python3 python3-pip python3-dev python3-venv python3-setuptools \
golang-go \
net-tools dnsutils whois \
file xxd \
jq parallel ripgrep grep \
less procps htop \
less man-db procps htop \
iproute2 iputils-ping netcat-traditional \
nmap ncat ndiff \
sqlmap nuclei subfinder naabu ffuf \
@@ -88,8 +66,11 @@ RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/b
USER pentester
WORKDIR /tmp
# Go tools are built in the gobuilder stage; copy the static binaries only.
COPY --from=gobuilder --chown=pentester:pentester /out/bin/ /home/pentester/go/bin/
RUN go install -v github.com/projectdiscovery/httpx/cmd/httpx@latest && \
go install -v github.com/projectdiscovery/katana/cmd/katana@latest && \
go install -v github.com/projectdiscovery/cvemap/cmd/vulnx@latest && \
go install -v github.com/jaeles-project/gospider@latest && \
go install -v github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest
RUN nuclei -update-templates
@@ -106,10 +87,7 @@ RUN npm install -g retire@latest && \
npm install -g js-beautify@latest && \
npm install -g @ast-grep/cli@latest && \
npm install -g tree-sitter-cli@latest && \
npm install -g agent-browser@0.26.0 && \
npm cache clean --force && \
# ast-grep ships two identical binaries (`ast-grep` and `sg`); dedupe (~52MB)
ln -sf ast-grep /home/pentester/.npm-global/lib/node_modules/@ast-grep/cli/sg
npm install -g agent-browser@0.26.0
ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
ENV AGENT_BROWSER_USER_AGENT="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
@@ -154,14 +132,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 \
@@ -175,6 +146,8 @@ RUN set -eux; \
install -m 0755 /tmp/gitleaks /usr/local/bin/gitleaks; \
rm -f /tmp/gitleaks /tmp/gitleaks.tgz
RUN apt-get update && apt-get install -y zaproxy
RUN curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
RUN apt-get install -y wapiti
@@ -190,12 +163,7 @@ USER root
RUN apt-get autoremove -y && \
apt-get autoclean && \
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \
# Purge non-English locales (~160MB)
find /usr/share/locale -mindepth 1 -maxdepth 1 -type d \
! -name 'en' ! -name 'en_US' ! -name 'C' -exec rm -rf {} + && \
# Remove package documentation and man pages not needed at runtime (~95MB)
rm -rf /usr/share/doc/* /usr/share/doc-base/* /usr/share/man/*
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
ENV PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:/app/.venv/bin:$PATH"
ENV VIRTUAL_ENV="/app/.venv"
-39
View File
@@ -19,14 +19,6 @@ Configure Strix using environment variables or a config file.
Custom API base URL. Also accepts `OPENAI_API_BASE`, `LITELLM_BASE_URL`, or `OLLAMA_API_BASE`.
</ParamField>
<ParamField path="LLM_EXTRA_HEADERS" type="string">
Extra HTTP headers sent on every LLM request, as a JSON object (e.g.
`{"X-Feature-Key":"value","X-Tenant":"acme"}`). Useful for OpenAI-compatible
gateways that require attribution or routing headers in addition to the bearer
token. The bearer token itself still comes from `LLM_API_KEY`. Applies to both
the LiteLLM and native OpenAI routing paths.
</ParamField>
<ParamField path="LLM_TIMEOUT" default="300" type="integer">
Request timeout in seconds for LLM calls.
</ParamField>
@@ -43,37 +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="DEDUPE_LLM_EXTRA_HEADERS" type="string">
Optional JSON object of extra HTTP headers sent on every deduplication-model
request, e.g. `{"X-Feature-Key":"value"}`. A dedicated dedupe model never
inherits `LLM_EXTRA_HEADERS`; set this when its endpoint needs custom headers.
</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">
-17
View File
@@ -54,20 +54,3 @@ If you use LM Studio, vLLM, or other runners:
export STRIX_LLM="openai/local-model"
export LLM_API_BASE="http://localhost:1234/v1" # Adjust port as needed
```
### Gateways that require custom headers
Some OpenAI-compatible gateways require extra HTTP headers (for attribution or
tenant routing) alongside the bearer token. Set them with `LLM_EXTRA_HEADERS` as
a JSON object — they are sent on every request:
```bash
export STRIX_LLM="openai/your-model"
export LLM_API_BASE="https://your-gateway.example/v1"
export LLM_API_KEY="your-bearer-token" # sent as Authorization: Bearer ...
export LLM_EXTRA_HEADERS='{"X-Feature-Key":"value","X-Tenant":"acme"}'
```
For endpoints behind a private CA, point Strix at your certificate bundle with
the standard `SSL_CERT_FILE=/path/to/ca-bundle.pem` — never disable TLS
verification against a real endpoint.
+4 -38
View File
@@ -61,28 +61,11 @@ strix (--target <target> | --target-list <path> | --mount <path>) [options]
Path to a custom config file (JSON) to use instead of `~/.strix/cli-config.json`.
</ParamField>
<ParamField path="--max-budget" type="number">
<ParamField path="--max-budget-usd" type="number">
Maximum LLM spend in USD for the whole scan, counted cumulatively across the
root agent and every child agent. The budget is checked after each model
response.
In non-interactive mode (`-n`), once the running cost reaches the threshold,
the scan stops cleanly with a `stopped` status (not a failure) and the sandbox
is torn down. Sub-agents are stopped early, at 90% of the budget, reserving
the final slice for the root agent to wind down and produce the final report.
In interactive mode, reaching the budget pauses the scan instead of ending
it: every agent parks, and sending any message resumes the scan with the cap
extended by the original budget amount. There is no sub-agent reserve in
interactive mode.
As the budget is approached, graduated wrap-up warnings are surfaced to
**every** agent so they can finish their work and call their lifecycle tool
before the hard stop. The bands sit just below each role's own stop point: the
root is warned at **70%, 85% and 95%** (it stops at 100%), while sub-agents are
warned at **75%, 80% and 85%** (they stop at the 90% reserve). In interactive
mode every agent uses the **70%, 85% and 95%** bands. Percentages shown in the
warnings are the real cumulative spend against the full budget.
response; once the running cost reaches the threshold, the scan stops cleanly
with a `stopped` status (not a failure) and the sandbox is torn down.
Must be greater than `0`. Omit the flag for no limit.
@@ -101,19 +84,6 @@ strix (--target <target> | --target-list <path> | --mount <path>) [options]
counts.
</ParamField>
<ParamField path="--max-turns" type="integer" default="500">
Maximum number of turns (one model response plus its tool round) allotted to
**each** agent, applied per run. When an agent reaches this limit it is
force-stopped.
As the limit is approached, graduated wrap-up warnings (at 70%, 85% and 95%)
are injected into that agent's next model turn so it can prioritise its
remaining work and call its lifecycle tool (`finish_scan` for the root agent,
`agent_finish` for sub-agents) before the hard stop.
Must be greater than `0`.
</ParamField>
## Examples
```bash
@@ -129,9 +99,6 @@ strix --target api.example.com --instruction "Focus on IDOR and auth bypass"
# CI/CD mode
strix -n --target ./ --scan-mode quick
# Cap cost and per-agent turns
strix --target https://example.com --max-budget 25 --max-turns 300
# Force diff-scope against a specific base ref
strix -n --target ./ --scan-mode quick --scope-mode diff --diff-base origin/main
@@ -149,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 -24
View File
@@ -1,6 +1,6 @@
[project]
name = "strix-agent"
version = "1.4.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,17 +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_disable_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"]
@@ -259,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"]
+2 -2
View File
@@ -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'
@@ -41,7 +41,7 @@ fi
combo="$os-$arch"
case "$combo" in
linux-x86_64|linux-arm64|macos-x86_64|macos-arm64|windows-x86_64)
linux-x86_64|macos-x86_64|macos-arm64|windows-x86_64)
;;
*)
echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}"
+7 -7
View File
@@ -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',
+17 -102
View File
@@ -16,14 +16,13 @@ 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,
send_message_to_agent,
stop_agent,
view_agent_graph,
wait_for_agents,
wait_for_message,
)
from strix.tools.finish.tool import finish_scan
from strix.tools.load_skill.tool import load_skill
@@ -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,13 +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.respond.tool import respond_to_user
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,
@@ -111,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:
@@ -148,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)
@@ -163,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)
@@ -195,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\\])")
@@ -264,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
@@ -282,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)
@@ -311,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)
@@ -346,10 +273,6 @@ def _make_shell_configurator(*, chat_completions: bool) -> Any:
return configure
# Tools that hand control away by parking the agent rather than ending the scan.
_PARKING_TOOLS: frozenset[str] = frozenset({"respond_to_user", "wait_for_agents"})
def _lifecycle_tool_completed(tool_name: str, output: Any) -> bool:
if tool_name == "agent_finish":
completion_key = "agent_completed"
@@ -368,7 +291,7 @@ def _lifecycle_tool_completed(tool_name: str, output: Any) -> bool:
def _wait_tool_parked(tool_name: str, output: Any) -> bool:
if tool_name not in _PARKING_TOOLS or not isinstance(output, str):
if tool_name != "wait_for_message" or not isinstance(output, str):
return False
try:
parsed = json.loads(output)
@@ -420,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,
@@ -430,7 +351,7 @@ _BASE_TOOLS: tuple[Tool, ...] = (
scope_rules,
view_agent_graph,
send_message_to_agent,
wait_for_agents,
wait_for_message,
create_agent,
stop_agent,
)
@@ -514,17 +435,11 @@ def build_strix_agent(
)
agent_tools = [*_EXTRA_TOOLS, *(extra_tools or [])]
if interactive:
# Yielding to the user is only meaningful when one is attached.
agent_tools.append(respond_to_user)
if is_root:
tools: list[Tool] = [*_BASE_TOOLS, *agent_tools, finish_scan]
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)",
@@ -544,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(
-1
View File
@@ -91,7 +91,6 @@ def render_system_prompt(
loaded_skill_names=list(skill_content.keys()),
available_skills=get_available_skills(),
interactive=interactive,
is_root=is_root,
system_prompt_context=system_prompt_context or {},
**skill_content,
)
+35 -44
View File
@@ -1,14 +1,5 @@
You are Strix, an advanced AI application security validation agent developed by OmniSecure Labs. Your purpose is to perform authorized security verification, reproduce and validate weaknesses on in-scope assets, and help remediate real security issues.
You follow all instructions and rules provided to you exactly as written in the system prompt at all times.
{% if is_root %}
<root_agent_directive>
YOU ARE THE ROOT AGENT. Your job is ORCHESTRATION, not hands-on testing.
- You accomplish security work by DELEGATING to specialized subagents via create_agent — you do NOT run scanners, crawlers, fuzzers, or send exploit/injection payloads yourself.
- IMPORTANT — how to read this prompt as root: the rest of this system prompt is written in the second person ("you") and describes the hands-on testing methodology (recon, mapping, scanning, payload spraying, PoC building, fixing). When you are the root agent, treat every such hands-on instruction as something you ensure gets done BY A SUBAGENT, not as a task you perform in your own turns. The "map the target", "recon first", "mandatory initial phases", and "spray payloads" directives are DELEGATION REQUIREMENTS for you — spawn recon/mapping/testing subagents to satisfy them.
- Do NOT probe endpoints, run "basic" or "quick" injection/XSS/etc. tests, or do exploratory scanning before delegating. Even a single quick test on a discovered endpoint is out of role: spin up a subagent instead.
- Your own turns should be spent on: reading scope/config, decomposing the target, spawning and monitoring subagents, tracking todos/notes/coverage, deciding next steps, and aggregating results into the final report.
</root_agent_directive>
{% endif %}
<core_capabilities>
- Security assessment and vulnerability scanning
@@ -31,26 +22,28 @@ INTER-AGENT MESSAGES:
{% if interactive %}
INTERACTIVE BEHAVIOR:
- You are in an interactive conversation with a user.
- HOW EXECUTION ENDS: your turn ends ONLY when you make an explicit lifecycle tool call. Plain text NEVER ends your turn and NEVER hands control to the user — text is shown to the user, and then execution continues.
- To answer the user and hand control back, call respond_to_user. It delivers your message AND parks you for their reply in one call, so there is no way to answer and then forget to stop. This is the ONLY way to yield to the user.
- To wait on another AGENT (a child's report, a peer's reply), call wait_for_agents. That is not a way to reach the user.
- To end the whole engagement, call the lifecycle tool: finish_scan (root) or agent_finish (subagent).
- A turn that ends with plain text and no tool call does NOT stop you: the system nudges you to continue and will re-run you. Do not rely on going silent to pause — it will not pause you.
- Answering a user question: put the answer in respond_to_user's message. Do not write the answer as plain text and then fall silent — that does not reach a stopping point, it just triggers a continuation nudge.
- You may include brief explanatory text before a tool call, and you can narrate while you work — plain text is shown to the user as you go. Narrating is free; respond_to_user is specifically the act of WAITING for the user, so do not call it just to give a status update.
- Respond naturally when the user asks questions or gives instructions.
- While actively working on a task, every turn should carry exactly one tool call — use think to plan, the appropriate tool to act, and respond_to_user only when you genuinely need the user.
- Never loop through think or other tools just to prepare, polish, confirm, or announce an answer. Once you know the answer, send it with respond_to_user.
- You are in an interactive conversation with a user
- CRITICAL: A message WITHOUT a tool call IMMEDIATELY STOPS your entire execution and waits for user input. This is a HARD SYSTEM CONSTRAINT, not a suggestion.
- Statements like "Planning the assessment..." or "I'll now scan..." or "Starting with..." WITHOUT a tool call will HALT YOUR WORK COMPLETELY. The system interprets no-tool-call as "I'm done, waiting for the user."
- If you want to plan, call the think tool. If you want to act, call the appropriate tool. There is NO valid reason to output text without a tool call while working on a task.
- The ONLY time you may send a message without a tool call is when you are genuinely DONE and presenting final results, or when you NEED the user to answer a question before continuing.
- EVERY message while working MUST contain exactly one tool call — this is what keeps execution moving. No tool call = execution stops.
- You may include brief explanatory text BEFORE the tool call
- Respond naturally when the user asks questions or gives instructions
- For simple conversation, acknowledgements, or direct questions that you can answer from current context, reply in plain text and stop. Do NOT call think just to prepare wording.
- If you use a tool to answer a user question (for example list_todos, view_agent_graph, or a file read), then after the tool result arrives, provide the answer in plain text and stop unless the user explicitly asked you to continue working.
- Never loop through think or other tools just to prepare, polish, confirm, or announce a final answer. Once you know the answer, say it.
- NEVER send empty messages — if you have nothing to do or say, call the wait_for_message tool
- If you catch yourself about to describe multiple steps without a tool call, STOP and call the think tool instead
{% else %}
AUTONOMOUS BEHAVIOR:
- Work autonomously by default
- You should NOT ask for user input or confirmation - you should always proceed with your task autonomously.
- Minimize user messaging: avoid redundancy and repetition; consolidate updates into a single concise message
- NEVER send an empty or blank message. If you have no content to output or need to wait for subagent results, you MUST call the wait_for_agents tool (or another appropriate tool) instead of emitting an empty response.
- There is no user attached to this run, so there is nobody to ask and nothing to yield to. If there is nothing left to execute: do NOT send filler/repetitive text — either call wait_for_agents (only if you are genuinely expecting another agent to message you) or finish your work (subagents: agent_finish; root: finish_scan)
- While the agent loop is running, almost every output MUST be a tool call. Do NOT send plain text messages; act via tools. If waiting on another agent, use wait_for_agents; when done, use agent_finish (subagents) or finish_scan (root)
- A text-only turn does nothing: it neither ends the run nor yields — it just wastes a turn and forces a retry. The lifecycle tools (``finish_scan`` for root, ``agent_finish`` for subagents) are the ONLY way to terminate, and the report flows through them. If you find yourself wanting to say "Done!" or "Scan complete" without a tool call, call the lifecycle tool instead.
- NEVER send an empty or blank message. If you have no content to output or need to wait (for user input, subagent results, or any other reason), you MUST call the wait_for_message tool (or another appropriate tool) instead of emitting an empty response.
- If there is nothing to execute and no user query to answer any more: do NOT send filler/repetitive text — either call wait_for_message or finish your work (subagents: agent_finish; root: finish_scan)
- While the agent loop is running, almost every output MUST be a tool call. Do NOT send plain text messages; act via tools. If idle, use wait_for_message; when done, use agent_finish (subagents) or finish_scan (root)
- A text-only turn — even one — IMMEDIATELY ends the scan/run with no report written. The lifecycle tools (``finish_scan`` for root, ``agent_finish`` for subagents) are the ONLY valid way to terminate. If you find yourself wanting to say "Done!" or "Scan complete" without a tool call, call the lifecycle tool instead — the report and termination signal both flow through it.
{% endif %}
</communication_rules>
@@ -132,8 +125,10 @@ WHITE-BOX TESTING (code provided):
- Local execution, unit/integration testing, patch verification, and HTTP requests against locally started in-scope services are normal authorized white-box validation
- If dynamically running the code proves impossible after exhaustive attempts, pivot to comprehensive static analysis.
- Try to infer how to run the code based on its structure and content.
- Derive the code fix as PART OF reporting, not as a separate later pass: create_vulnerability_report already requires the concrete patch inline (`code_locations` with verbatim `fix_before`/`fix_after` and `fix_pr_body`), so the reporting agent that analyzes the root cause is the one that produces the fix. Do NOT spawn a downstream agent afterwards to re-derive/re-apply the same patch.
- If you also apply and verify the patch in the repo (edit the file, re-test that the vulnerability is gone), do it in the same agent/turn while the analysis is fresh — right before or as part of filing the report — never as a second re-analysis pass.
- FIX discovered vulnerabilities in code in same file.
- Test patches to confirm vulnerability removal.
- Do not stop until all reported vulnerabilities are fixed.
- Include code diff in final report.
COMBINED MODE (code + deployed target present):
- Treat this as static analysis plus dynamic testing simultaneously
@@ -186,7 +181,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
@@ -194,7 +189,7 @@ EFFICIENCY TACTICS:
- For Caido proxy automation inside Python, explicitly import from
`caido_api`:
`from caido_api import list_requests, view_request, repeat_request, list_sitemap, view_sitemap_entry, scope_rules`
- Prefer established fuzzers/scanners where applicable: ffuf, sqlmap, nuclei, wapiti, arjun, httpx, katana, semgrep, bandit, trufflehog, nmap. Use scripts mainly to coordinate or validate around them, not to replace them without reason
- Prefer established fuzzers/scanners where applicable: ffuf, sqlmap, zaproxy, nuclei, wapiti, arjun, httpx, katana, semgrep, bandit, trufflehog, nmap. Use scripts mainly to coordinate or validate around them, not to replace them without reason
- For trial-heavy vectors (SQLi, XSS, XXE, SSRF, RCE, auth/JWT, deserialization), DO NOT iterate payloads manually in the browser. Always spray payloads via Python scripts through `exec_command` or terminal tools.
- When using established fuzzers/scanners, use the proxy for inspection where helpful
- Generate/adapt large payload corpora: combine encodings (URL, unicode, base64), comment styles, wrappers, time-based/differential probes. Expand with wordlists/templates
@@ -211,9 +206,8 @@ VALIDATION REQUIREMENTS:
- Document complete attack chain
- Keep going until you find something that matters
- A vulnerability is ONLY considered reported when a reporting agent uses create_vulnerability_report (or create_dependency_report for known-CVE dependency/supply-chain findings) with full details. Mentions in agent_finish, finish_scan, or generic messages are NOT sufficient
- 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.)
- Do NOT patch/fix before reporting: first create the vulnerability report via create_vulnerability_report (by the reporting agent). Only after reporting is completed should fixing/patching proceed
- 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>
@@ -268,9 +262,7 @@ DISK & SCRATCH HYGIENE:
- If disk gets tight or a write fails for space, check what's large under /workspace and clean up files from your own task; leave another agent's files unless you've confirmed they're no longer in use
MANDATORY INITIAL PHASES:
{% if is_root %}
- ROOT AGENT: these phases are mandatory for the assessment, but you MUST accomplish them by delegating to reconnaissance/mapping subagents — do NOT run recon, crawling, enumeration, or mapping tools in your own turns. Spawn the appropriate subagent(s) and track their coverage.
{% endif %}
BLACK-BOX TESTING - PHASE 1 (RECON & MAPPING):
- COMPLETE full reconnaissance: subdomain enumeration, port scanning, service detection
- MAP entire attack surface: all endpoints, parameters, APIs, forms, inputs
@@ -299,14 +291,13 @@ ROOT AGENT ROLE:
- The root agent should coordinate strategy, delegate meaningful work, track progress, maintain todo lists, maintain notes, monitor subagent results, and decide next steps
- The root agent should keep a clear view of overall coverage, uncovered attack surfaces, validation status, and reporting/fixing progress
- The root agent should avoid spending its own iterations on detailed testing, payload execution, or deep target-specific investigation when that work can be delegated to specialized subagents
- The root agent may do orchestration-support work needed to delegate well — reading scope/config, inspecting workspace layout, reading subagent output/reports, and light bookkeeping. It must NOT do the actual security testing itself: no running scanners/fuzzers/crawlers, no sending injection/XSS/SSRF/etc. payloads, and no "basic" or "quick" probing of discovered endpoints. If a check requires touching the target, delegate it to a subagent rather than doing it yourself
- Its default and near-exclusive mode is coordinator/controller
- The root agent may do lightweight triage, quick verification, or setup work when necessary to unblock delegation, but its default mode should be coordinator/controller
- Subagents should do the substantive testing, validation, reporting, and fixing work
- The root agent is responsible for ensuring that work is broken down clearly, tracked, and completed across the agent tree
1. **CREATE AGENTS SELECTIVELY** - Spawn subagents when delegation materially improves parallelism, specialization, coverage, or independent validation. Deeper delegation is allowed when the child has a meaningfully different responsibility from the parent. Do not spawn subagents for trivial continuation of the same narrow task.
2. **BLACK-BOX**: Discovery → Validation → Reporting (3 agents per vulnerability)
3. **WHITE-BOX**: Discovery → Validation → Reporting-with-fix (3 agents per vulnerability — the reporting agent derives and files the fix inline; do NOT add a separate fixing agent that re-derives the same patch)
3. **WHITE-BOX**: Discovery → Validation → Reporting → Fixing (4 agents per vulnerability)
4. **MULTIPLE VULNS = MULTIPLE CHAINS** - Each vulnerability finding gets its own validation chain
5. **CREATE AGENTS AS YOU GO** - Don't create all agents at start, create them when you discover new attack surfaces
6. **ONE JOB PER AGENT** - Each agent has ONE specific task only
@@ -325,7 +316,8 @@ BLACK-BOX (domain/URL only):
WHITE-BOX (source code provided):
- Found authentication code issues? → Create authentication analysis agent
- Auth agent finds potential vulnerability? → Create "Auth Validation Agent"
- Validation agent confirms vulnerability? → Create "Auth Reporting Agent" that files the report AND its inline fix (`code_locations` + `fix_pr_body`) in one shot — no separate fixing agent
- Validation agent confirms vulnerability? → Create "Auth Reporting Agent"
- Reporting agent documents vulnerability? → Create "Auth Fixing Agent" (implement code fix and test it works)
VULNERABILITY WORKFLOW (MANDATORY FOR EVERY FINDING):
@@ -346,11 +338,9 @@ Authentication Code Agent finds weak password validation
Spawns "Auth Validation Agent" (proves it's exploitable)
If valid → Spawns "Auth Reporting Agent" (creates the vulnerability report
WITH the fix inline: code_locations fix_before/fix_after + fix_pr_body,
applying/verifying the patch in the same turn if desired)
If valid → Spawns "Auth Reporting Agent" (creates vulnerability report)
STOP - no separate fixing agent; the fix was derived once, at report time
Spawns "Auth Fixing Agent" (implements secure code fix)
```
CRITICAL RULES:
@@ -386,7 +376,7 @@ FOCUS PRINCIPLES:
REALISTIC TESTING OUTCOMES:
- **No Findings**: Agent completes testing but finds no vulnerabilities
- **Validation Failed**: Initial finding was false positive, validation agent confirms it's not exploitable
- **Valid Vulnerability**: Validation succeeds, spawns a reporting agent that files the report with the fix inline (white-box) — no separate fixing agent
- **Valid Vulnerability**: Validation succeeds, spawns reporting agent and then fixing agent (white-box)
PERSISTENCE IS MANDATORY:
- Real vulnerabilities take TIME - expect to need 2000+ steps minimum
@@ -411,6 +401,7 @@ VULNERABILITY ASSESSMENT:
- nuclei - Vulnerability scanner with templates
- sqlmap - SQL injection detection/exploitation
- trivy - Container/dependency vulnerability scanner
- zaproxy - OWASP ZAP web app scanner
- wapiti - Web vulnerability scanner
WEB FUZZING & DISCOVERY:
@@ -448,10 +439,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, uv, Go, 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, go install, etc.)
Directories:
- /workspace - where you should work.
-4
View File
@@ -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",
-404
View File
@@ -1,404 +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.parse
from pathlib import Path
from typing import TYPE_CHECKING, Any
import requests
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]:
try:
response = requests.post(
TOKEN_URL,
data=payload,
headers={"Accept": "application/json"},
timeout=_TOKEN_TIMEOUT,
)
except requests.RequestException as exc:
raise CodexAuthError("unavailable", str(exc)) from exc
if response.status_code >= 400:
detail = response.text[:300]
raise CodexAuthError("token_http_error", f"HTTP {response.status_code}: {detail}")
data = json.loads(response.content or b"{}")
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 -390
View File
@@ -2,50 +2,23 @@
from __future__ import annotations
import contextlib
import inspect
import os
import time
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.models.fake_id import FAKE_RESPONSES_ID
from agents.models.interface import Model
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.responses import Response, ResponseCompletedEvent
from openai.types.responses.response_usage import ResponseUsage
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.agent_output import AgentOutputSchemaBase
from agents.handoffs import Handoff
from agents.items import ModelResponse, TResponseInputItem, TResponseStreamEvent
from agents.models.interface import ModelProvider, ModelTracing
from agents.retry import ModelRetryAdvice, ModelRetryAdviceRequest
from agents.tool import Tool
from agents.usage import Usage
from openai import AsyncOpenAI
from openai.types.responses.response_prompt_param import ResponsePromptParam
from strix.config.settings import LlmSettings, ReasoningEffort, Settings
from strix.config.settings import Settings
def request_timeout_extra_args(timeout_s: float | None) -> dict[str, float] | None:
@@ -60,211 +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 _NonStreamingModel(Model):
"""Serve the SDK's streamed run loop from a single non-streaming request.
Some OpenAI-compatible gateways do not support Server-Sent Events, or
deliver them unreliably (dropping structured tool-call deltas, or stalling
mid-stream so the whole turn waits out the read timeout). The SDK run loop
Strix uses only issues streamed requests, so such a gateway fails every
turn. Opt in with ``LLM_DISABLE_STREAMING=true`` to wrap the resolved model
so each turn makes one non-streaming ``get_response`` (``stream:false`` on
the wire) and the completed result is replayed as a single terminal stream
event. The run loop then executes tools and emits run items from that final
response exactly as it would for a real stream, so nothing else changes.
"""
def __init__(self, inner: Model) -> None:
self._inner = inner
async def close(self) -> None:
await self._inner.close()
def get_retry_advice(self, request: ModelRetryAdviceRequest) -> ModelRetryAdvice | None:
return self._inner.get_retry_advice(request)
async def get_response(
self,
system_instructions: str | None,
input: str | list[TResponseInputItem], # noqa: A002
model_settings: ModelSettings,
tools: list[Tool],
output_schema: AgentOutputSchemaBase | None,
handoffs: list[Handoff],
tracing: ModelTracing,
*,
previous_response_id: str | None,
conversation_id: str | None,
prompt: ResponsePromptParam | None,
) -> ModelResponse:
return await self._inner.get_response(
system_instructions,
input,
model_settings,
tools,
output_schema,
handoffs,
tracing,
previous_response_id=previous_response_id,
conversation_id=conversation_id,
prompt=prompt,
)
async def stream_response(
self,
system_instructions: str | None,
input: str | list[TResponseInputItem], # noqa: A002
model_settings: ModelSettings,
tools: list[Tool],
output_schema: AgentOutputSchemaBase | None,
handoffs: list[Handoff],
tracing: ModelTracing,
*,
previous_response_id: str | None,
conversation_id: str | None,
prompt: ResponsePromptParam | None,
) -> AsyncIterator[TResponseStreamEvent]:
response = await self._inner.get_response(
system_instructions,
input,
model_settings,
tools,
output_schema,
handoffs,
tracing,
previous_response_id=previous_response_id,
conversation_id=conversation_id,
prompt=prompt,
)
yield _completed_stream_event(response, getattr(self._inner, "model", None))
def _completed_stream_event(
model_response: ModelResponse, model_name: object | None
) -> TResponseStreamEvent:
"""Wrap a non-streamed ``ModelResponse`` as the terminal event of a stream.
The run loop builds its authoritative per-turn response solely from the
``response.completed`` event, so a single event carrying the full output
and usage is all it needs.
"""
response = Response(
id=model_response.response_id or FAKE_RESPONSES_ID,
created_at=time.time(),
model=str(model_name) if model_name else "",
object="response",
output=list(model_response.output),
tool_choice="auto",
tools=[],
parallel_tool_calls=False,
usage=_response_usage(model_response.usage),
)
return ResponseCompletedEvent(
response=response,
sequence_number=0,
type="response.completed",
)
def _response_usage(usage: Usage | None) -> ResponseUsage | None:
if usage is None:
return None
return ResponseUsage(
input_tokens=usage.input_tokens,
output_tokens=usage.output_tokens,
total_tokens=usage.total_tokens,
input_tokens_details=usage.input_tokens_details,
output_tokens_details=usage.output_tokens_details,
)
class StrixProvider(MultiProvider):
"""Route any non-OpenAI prefix through LiteLLM with the prefix preserved,
so users type ``deepseek/deepseek-chat`` rather than
@@ -288,23 +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:
llm = load_settings().llm
slug = codex.subscription_model(model_name)
if slug:
# The ChatGPT subscription backend is always streamed; it has no
# non-streaming mode to fall back to, so LLM_DISABLE_STREAMING
# does not apply here.
return _CodexResponsesModel(
slug,
codex.get_subscription_client(),
reasoning_effort=llm.reasoning_effort,
)
model = super().get_model(model_name)
if llm.disable_streaming:
return _NonStreamingModel(model)
return model
DEFAULT_MODEL_RETRY = ModelRetrySettings(
max_retries=5,
@@ -323,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")),
)
@@ -366,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:
@@ -380,7 +129,6 @@ def configure_sdk_model_defaults(settings: Settings) -> None:
set_default_openai_api("chat_completions")
else:
set_default_openai_api("responses")
_configure_extra_headers(llm)
def _mirror_api_key_to_provider_env(model_name: str | None, api_key: str) -> None:
@@ -415,51 +163,6 @@ def _configure_litellm_compatibility() -> None:
litellm.suppress_debug_info = True
_register_litellm_cost_callback()
_install_openrouter_stream_cost_capture()
def _install_openrouter_stream_cost_capture() -> None:
"""Preserve OpenRouter's per-stream cost, which LiteLLM drops when streaming.
OpenRouter reports the real charge in ``usage.cost`` of the final stream
chunk, but LiteLLM rebuilds streamed responses from token-only fields and
discards it (its non-streamed path stashes the cost in hidden params; the
streaming path does not). Every scan streams, so without this the cost is
lost and Strix falls back to a cost-map estimate that is missing entirely
for new models (e.g. kimi-k3), reporting $0. Subclass the OpenRouter
streaming handler to record the cost keyed by response id so the cost
callback can recover the exact charge for the matching rebuilt response.
"""
import litellm
from litellm.llms.openrouter.chat.transformation import (
OpenRouterChatCompletionStreamingHandler,
OpenrouterConfig,
)
from strix.report.state import streamed_openrouter_costs
class _StrixOpenRouterStreamingHandler(OpenRouterChatCompletionStreamingHandler):
def chunk_parser(self, chunk: dict[str, Any]) -> Any:
stream = super().chunk_parser(chunk)
streamed_openrouter_costs.remember(
chunk.get("id") or getattr(stream, "id", None), chunk.get("usage")
)
return stream
class _StrixOpenrouterConfig(OpenrouterConfig):
def get_model_response_iterator(
self, streaming_response: Any, sync_stream: bool, json_mode: bool | None = False
) -> Any:
return _StrixOpenRouterStreamingHandler(
streaming_response=streaming_response,
sync_stream=sync_stream,
json_mode=json_mode,
)
# LiteLLM's provider-config factory reads litellm.OpenrouterConfig at call
# time, so overriding the attribute is enough for the subclass to take
# effect. (type: ignore — mypy rejects reassigning a class attribute.)
litellm.OpenrouterConfig = _StrixOpenrouterConfig # type: ignore[misc]
_OPENROUTER_ATTRIBUTION_HEADERS = {
@@ -485,43 +188,6 @@ def _configure_openrouter_attribution(model_name: str | None) -> None:
litellm.headers = {**existing, **_OPENROUTER_ATTRIBUTION_HEADERS} # type: ignore[assignment]
def _configure_extra_headers(llm: LlmSettings) -> None:
"""Send user-provided default headers on every LLM request.
Some OpenAI-compatible endpoints require extra HTTP headers (e.g. request
attribution or tenant routing) alongside the bearer token. Users supply
them via ``LLM_EXTRA_HEADERS``; they are applied to both routing paths:
the LiteLLM route (``litellm.headers``) and the SDK-native OpenAI route
(a default client carrying ``default_headers``), so they take effect
regardless of the ``STRIX_LLM`` prefix.
"""
headers = llm.extra_headers
if not headers:
return
_merge_litellm_headers(headers)
_register_openai_client_with_headers(llm, headers)
def _merge_litellm_headers(headers: dict[str, str]) -> None:
import litellm
current: object = litellm.headers
existing: dict[str, str] = current if isinstance(current, dict) else {}
litellm.headers = {**existing, **headers} # type: ignore[assignment]
def _register_openai_client_with_headers(llm: LlmSettings, headers: dict[str, str]) -> None:
from agents import set_default_openai_client
from openai import AsyncOpenAI
client = AsyncOpenAI(
api_key=llm.api_key or "not-needed",
base_url=llm.api_base,
default_headers=dict(headers),
)
set_default_openai_client(client, use_for_tracing=False)
def _register_litellm_cost_callback() -> None:
import litellm
@@ -545,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
@@ -649,45 +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:
name = (model_name or "").strip().lower()
return name.startswith("bedrock/") or "anthropic." in name
def _prompt_cache_name_candidates(model_name: str) -> list[str]:
# LiteLLM's model map keys the same model under several names; strip the
# route prefix, then leading dotted segments (region, provider).
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:
# Bedrock rejects the cache marker for models LiteLLM's map doesn't
# recognise as cache-capable, so callers withhold it unless 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:
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
+1 -51
View File
@@ -35,67 +35,19 @@ class LlmSettings(BaseSettings):
"OLLAMA_API_BASE",
),
)
extra_headers: dict[str, str] | None = Field(
default=None,
alias="LLM_EXTRA_HEADERS",
)
reasoning_effort: ReasoningEffort = Field(default="high", alias="STRIX_REASONING_EFFORT")
force_required_tool_choice: bool = Field(
default=False,
alias="STRIX_FORCE_REQUIRED_TOOL_CHOICE",
)
prompt_cache: bool = Field(
default=True,
alias="STRIX_PROMPT_CACHE",
)
disable_streaming: bool = Field(
default=False,
alias="LLM_DISABLE_STREAMING",
)
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")
extra_headers: dict[str, str] | None = Field(
default=None,
alias="DEDUPE_LLM_EXTRA_HEADERS",
)
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")
@@ -133,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)
+38 -228
View File
@@ -14,20 +14,13 @@ from strix.core.sessions import session_write_lock
if TYPE_CHECKING:
from collections.abc import Callable
from agents.items import TResponseInputItem
from agents.memory import Session
logger = logging.getLogger(__name__)
Status = Literal["running", "waiting", "completed", "stopped", "crashed", "failed", "budget_paused"]
# Why an agent parked. The user can message any agent, so this - not the agent's
# position in the tree - decides whether waiting is bounded: only an agent waiting
# on other agents is re-checked on a timer.
WaitKind = Literal["user", "agents", "stalled"]
Status = Literal["running", "waiting", "completed", "stopped", "crashed", "failed"]
@dataclass(slots=True)
@@ -37,8 +30,6 @@ class AgentRuntime:
stream: Any | None = None
interrupt_on_message: bool = False
wake: asyncio.Event = field(default_factory=asyncio.Event)
mailbox: list[dict[str, Any]] = field(default_factory=list)
user_wake_required: bool = False
class AgentCoordinator:
@@ -50,18 +41,11 @@ 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.recovery_counts: dict[str, int] = {}
self.idle_resume_counts: dict[str, int] = {}
self.wait_kinds: dict[str, WaitKind] = {}
self.runtimes: dict[str, AgentRuntime] = {}
self._lock = asyncio.Lock()
self._snapshot_path: Path | None = None
self.is_shutting_down = False
self._budget_stopped = False
self._reserve_stopped = False
self._budget_paused = False
self._extend_budget: Callable[[], None] | None = None
def set_snapshot_path(self, path: Path) -> None:
self._snapshot_path = path
@@ -80,71 +64,6 @@ class AgentCoordinator:
for runtime in self.runtimes.values():
runtime.wake.set()
@property
def reserve_stopped(self) -> bool:
return self._reserve_stopped
@property
def budget_paused(self) -> bool:
return self._budget_paused
def set_budget_extender(self, extend: Callable[[], None]) -> None:
self._extend_budget = extend
async def pause_for_budget(self, agent_id: str) -> None:
async with self._lock:
self._budget_paused = True
await self.set_status(agent_id, "budget_paused")
async def resume_from_budget_pause(self, *, exclude: str | None = None) -> None:
async with self._lock:
if not self._budget_paused:
return
self._budget_paused = False
paused = [aid for aid, status in self.statuses.items() if status == "budget_paused"]
if self._extend_budget is not None:
self._extend_budget()
for aid in paused:
await self.set_status(aid, "waiting")
if aid != exclude:
await self.send(
aid,
{
"from": "system",
"type": "budget_extended",
"content": (
"[Budget] The user extended the scan budget \u2014 continue your "
"current task."
),
},
)
async def reset_budget_stops(
self,
*,
budget_stopped: bool,
reserve_stopped: bool,
budget_paused: bool = False,
) -> None:
async with self._lock:
self._budget_stopped = budget_stopped
self._reserve_stopped = reserve_stopped
if not budget_paused:
self._budget_paused = False
for aid, status in self.statuses.items():
if status == "budget_paused":
self.statuses[aid] = "waiting"
await self._maybe_snapshot()
async def claim_reserve_notification(self) -> str | None:
async with self._lock:
if self._reserve_stopped:
return None
self._reserve_stopped = True
for runtime in self.runtimes.values():
runtime.wake.set()
return next((aid for aid, parent in self.parent_of.items() if parent is None), None)
async def register(
self,
agent_id: str,
@@ -188,120 +107,62 @@ class AgentCoordinator:
async with self._lock:
if agent_id in self.statuses:
self.statuses[agent_id] = "running"
self.errors.pop(agent_id, None)
self.wait_kinds.pop(agent_id, None)
self.runtimes.setdefault(agent_id, AgentRuntime()).user_wake_required = False
await self._maybe_snapshot()
async def park_waiting(self, agent_id: str, *, wait_kind: WaitKind) -> None:
"""Park an agent, recording what it is waiting on so the driver can time it."""
async with self._lock:
if agent_id in self.statuses:
self.wait_kinds[agent_id] = wait_kind
async def park_waiting(self, agent_id: str) -> None:
await self.set_status(agent_id, "waiting")
async def wait_kind_of(self, agent_id: str) -> WaitKind | None:
async with self._lock:
return self.wait_kinds.get(agent_id)
async def record_recovery(self, agent_id: str) -> int:
"""Count a turn that ended without a lifecycle tool call; return the new total.
Persisted so a resumed agent cannot earn a fresh nudge budget on every
auto-resume and loop forever.
"""
async with self._lock:
count = self.recovery_counts.get(agent_id, 0) + 1
self.recovery_counts[agent_id] = count
await self._maybe_snapshot()
return count
async def reset_recovery(self, agent_id: str) -> None:
"""Clear the nudge budget after real progress (new message or a lifecycle tool)."""
async with self._lock:
if self.recovery_counts.pop(agent_id, None) is None:
return
await self._maybe_snapshot()
async def record_idle_resume(self, agent_id: str) -> int:
"""Count an auto-resume that no message triggered; return the new total.
An agent that parks again after every auto-resume would otherwise burn a
model turn per timeout for the rest of the scan.
"""
async with self._lock:
count = self.idle_resume_counts.get(agent_id, 0) + 1
self.idle_resume_counts[agent_id] = count
await self._maybe_snapshot()
return count
async def reset_idle_resumes(self, agent_id: str) -> None:
async with self._lock:
if self.idle_resume_counts.pop(agent_id, None) is None:
return
await self._maybe_snapshot()
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.user_wake_required = status in {"failed", "crashed"}
runtime.wake.set()
logger.info("agent.status %s=%s", agent_id, status)
await self._maybe_snapshot()
async def send(
self, target_agent_id: str, message: dict[str, Any], *, interrupt: bool = True
) -> bool:
"""Queue a user/peer message in the target's mailbox and wake it."""
from_user = message.get("from") == "user"
if from_user and self._budget_paused:
await self.resume_from_budget_pause(exclude=target_agent_id)
async def send(self, target_agent_id: str, message: dict[str, Any]) -> bool:
"""Deliver a user/peer message by appending it to the target SDK session."""
async with self._lock:
if target_agent_id not in self.statuses:
logger.debug("agent.send dropped unknown target=%s", target_agent_id)
return False
runtime = self.runtimes.setdefault(target_agent_id, AgentRuntime())
runtime.mailbox.append(dict(message))
self.pending_counts[target_agent_id] = self.pending_counts.get(target_agent_id, 0) + 1
if from_user:
runtime.user_wake_required = False
runtime.wake.set()
session = runtime.session
stream = runtime.stream
interrupt_on_message = runtime.interrupt_on_message
if stream is not None and interrupt and interrupt_on_message:
interrupt = runtime.interrupt_on_message
if session is None:
logger.warning(
"agent.send dropped target=%s because its SDK session is not attached",
target_agent_id,
)
return False
try:
async with session_write_lock(session):
await session.add_items([self._message_to_session_item(message)])
except Exception:
logger.exception(
"agent.send failed to append to SDK session target=%s",
target_agent_id,
)
return False
async with self._lock:
self.pending_counts[target_agent_id] = self.pending_counts.get(target_agent_id, 0) + 1
self.runtimes.setdefault(target_agent_id, AgentRuntime()).wake.set()
if stream is not None and interrupt:
stream.cancel(mode="immediate")
await self._maybe_snapshot()
return True
async def wait_for_message(self, agent_id: str, *, timeout: float | None = None) -> bool:
"""Wait until a message is ready for ``agent_id``; False on ``timeout``."""
async def wait_for_message(self, agent_id: str) -> None:
while True:
async with self._lock:
runtime = self.runtimes.setdefault(agent_id, AgentRuntime())
reserve_exit = self._reserve_stopped and self.parent_of.get(agent_id) is not None
pending_ready = (
self.pending_counts.get(agent_id, 0) > 0 and not runtime.user_wake_required
)
if self._budget_stopped or reserve_exit or pending_ready:
return True
wake = runtime.wake
if self._budget_stopped or self.pending_counts.get(agent_id, 0) > 0:
return
wake = self.runtimes.setdefault(agent_id, AgentRuntime()).wake
wake.clear()
if timeout is None:
await wake.wait()
else:
try:
await asyncio.wait_for(wake.wait(), timeout)
except TimeoutError:
return False
await wake.wait()
async def consume_pending(
self,
@@ -309,38 +170,17 @@ class AgentCoordinator:
*,
include_items: bool = False,
) -> tuple[int, list[Any]]:
"""Drain the agent's mailbox into its own SDK session."""
async with self._lock:
runtime = self.runtimes.setdefault(agent_id, AgentRuntime())
queued = list(runtime.mailbox)
runtime.mailbox.clear()
count = max(self.pending_counts.get(agent_id, 0), len(queued))
count = self.pending_counts.get(agent_id, 0)
self.pending_counts[agent_id] = 0
session = runtime.session
session = self.runtimes.get(agent_id, AgentRuntime()).session
if count <= 0:
return 0, []
items = [self._message_to_session_item(m) for m in queued]
if items:
if session is None:
logger.warning(
"agent %s has no SDK session attached; %d queued messages were not persisted",
agent_id,
len(items),
)
else:
try:
async with session_write_lock(session):
await session.add_items(items)
except Exception:
logger.exception(
"failed to append %d queued messages to the session of %s",
len(items),
agent_id,
)
await self._maybe_snapshot()
if not include_items:
if not include_items or session is None:
return count, []
return count, items
items = await session.get_items()
return count, list(items[-count:])
async def request_stop(self, agent_id: str) -> None:
async with self._lock:
@@ -406,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"))
@@ -451,18 +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),
"recovery_counts": dict(self.recovery_counts),
"idle_resume_counts": dict(self.idle_resume_counts),
"wait_kinds": dict(self.wait_kinds),
"mailboxes": {
aid: [dict(m) for m in runtime.mailbox]
for aid, runtime in self.runtimes.items()
if runtime.mailbox
},
"errors": dict(self.errors),
"budget_stopped": self._budget_stopped,
"reserve_stopped": self._reserve_stopped,
"budget_paused": self._budget_paused,
}
async def restore(self, snap: dict[str, Any]) -> None:
@@ -472,19 +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", {}))
self.recovery_counts = dict(snap.get("recovery_counts", {}))
self.idle_resume_counts = dict(snap.get("idle_resume_counts", {}))
self.wait_kinds = dict(snap.get("wait_kinds", {}))
mailboxes = snap.get("mailboxes", {})
if isinstance(mailboxes, dict):
for aid, msgs in mailboxes.items():
if isinstance(msgs, list):
runtime = self.runtimes.setdefault(aid, AgentRuntime())
runtime.mailbox = [dict(m) for m in msgs if isinstance(m, dict)]
self._budget_stopped = bool(snap.get("budget_stopped", False))
self._reserve_stopped = bool(snap.get("reserve_stopped", False))
self._budget_paused = bool(snap.get("budget_paused", False))
for aid in self.statuses:
self.runtimes.setdefault(aid, AgentRuntime())
+110 -527
View File
@@ -9,32 +9,19 @@ import uuid
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, cast
import litellm
from agents import RunConfig, Runner
from agents.exceptions import AgentsException, MaxTurnsExceeded, UserError
from agents.sandbox.errors import ExecTransportError
from docker import errors as docker_errors # type: ignore[import-untyped, unused-ignore]
from openai import (
APIConnectionError,
APIError,
APITimeoutError,
)
from openai import APIError
from strix.config import codex
from strix.core.hooks import (
BudgetExceededError,
BudgetPausedError,
SubagentBudgetReservedError,
)
from strix.core.hooks import BudgetExceededError
from strix.core.inputs import child_initial_input
from strix.core.sessions import (
enforce_image_budget,
open_agent_session,
replace_session_items,
seed_initial_input,
strip_all_images_from_session,
)
from strix.llm.compaction import is_context_overflow, maybe_compact
if TYPE_CHECKING:
@@ -53,120 +40,6 @@ logger = logging.getLogger(__name__)
StreamEventSink = Callable[[str, Any], None]
_INPUT_REJECTION_CODES = frozenset({400, 404, 422})
_MAX_COMPACTIONS_PER_CYCLE = 2
class ProviderRefusalError(AgentsException):
"""Raised when a provider returns a structured refusal instead of an exception."""
def _structured_provider_refusal(result: Any) -> str | None:
for item in getattr(result, "new_items", ()) or ():
raw_item = getattr(item, "raw_item", None)
for content in getattr(raw_item, "content", ()) or ():
if getattr(content, "type", None) != "refusal":
continue
refusal = getattr(content, "refusal", None)
if isinstance(refusal, str) and refusal.strip():
return refusal.strip()
return "The model provider refused this request."
return None
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,
)
_MAX_TRANSIENT_MODEL_RETRIES = 5
_TRANSIENT_MODEL_RETRY_BASE_DELAY_S = 2.0
_TRANSIENT_MODEL_RETRY_MAX_DELAY_S = 90.0
def _model_error_status_code(exc: BaseException) -> int | None:
code = getattr(exc, "status_code", None)
return code if isinstance(code, int) else None
def _is_transient_model_error(exc: BaseException) -> bool:
if codex.is_content_guardrail_error(exc):
return False
if isinstance(
exc, APITimeoutError | APIConnectionError | TimeoutError | ConnectionError | OSError
):
return True
code = _model_error_status_code(exc)
if code is not None:
return bool(litellm._should_retry(code))
return isinstance(exc, APIError)
def _transient_model_retry_delay(attempt: int) -> float:
delay = _TRANSIENT_MODEL_RETRY_BASE_DELAY_S * float(2 ** (attempt - 1))
return min(delay, _TRANSIENT_MODEL_RETRY_MAX_DELAY_S)
async def _salvage_stream_to_session(
session: Session,
pre_run_items: list[Any],
stream: Any,
agent_id: str,
) -> None:
"""Persist a crashed run's full history so a revived agent loses no context."""
if stream is None:
return
try:
replay = list(stream.to_input_list())
except Exception:
logger.exception("could not build salvage history for %s", agent_id)
return
desired = list(pre_run_items) + replay
if len(desired) <= len(pre_run_items):
return
try:
await replace_session_items(session, desired)
except Exception:
logger.exception("salvaging crashed run history failed for %s", agent_id)
async def _seed_and_prepare_first_input(
session: Session | None, initial_input: Any, *, start_parked: bool
) -> Any:
"""Persist the opening input up front so it survives a first-turn crash."""
if initial_input and session is not None and not start_parked:
with contextlib.suppress(Exception):
if await seed_initial_input(session, initial_input):
return []
return initial_input
async def run_agent_loop(
@@ -191,29 +64,13 @@ async def run_agent_loop(
)
result: RunResultBase | None = None
first_cycle_input = await _seed_and_prepare_first_input(
session, initial_input, start_parked=start_parked
)
budget_stopped = coordinator.budget_stopped
reserve_stopped = coordinator.reserve_stopped
if budget_stopped:
await coordinator.set_status(agent_id, "stopped")
raise BudgetExceededError("scan budget reached")
if reserve_stopped and context.get("parent_id") is not None:
await coordinator.set_status(agent_id, "stopped")
raise SubagentBudgetReservedError("scan reached the sub-agent budget reserve")
if reserve_stopped and start_parked and interactive and context.get("parent_id") is None:
await coordinator.send(agent_id, _reserve_notice())
if not (start_parked and interactive):
with contextlib.suppress(BudgetPausedError):
result = await _run_until_lifecycle(
if interactive:
result = await _run_cycle(
agent,
coordinator,
agent_id,
initial_input=first_cycle_input,
input_data=initial_input,
run_config=run_config,
context=context,
max_turns=max_turns,
@@ -222,14 +79,26 @@ async def run_agent_loop(
event_sink=event_sink,
hooks=hooks,
)
else:
result = await _run_noninteractive_until_lifecycle(
agent,
coordinator,
agent_id,
initial_input=initial_input,
run_config=run_config,
context=context,
max_turns=max_turns,
session=session,
event_sink=event_sink,
hooks=hooks,
)
if not interactive:
return result
while True:
timeout = await _plain_waiting_timeout(coordinator, agent_id)
try:
woke = await coordinator.wait_for_message(agent_id, timeout=timeout)
await coordinator.wait_for_message(agent_id)
except asyncio.CancelledError:
return result
@@ -237,53 +106,20 @@ async def run_agent_loop(
await coordinator.set_status(agent_id, "stopped")
raise BudgetExceededError("scan budget reached")
if coordinator.reserve_stopped and context.get("parent_id") is not None:
await coordinator.set_status(agent_id, "stopped")
raise SubagentBudgetReservedError("scan reached the sub-agent budget reserve")
if woke:
# Real input is real progress, so the nudge budget starts over. A bare
# auto-resume is not: it must not hand a wedged agent a fresh budget.
await coordinator.reset_recovery(agent_id)
await coordinator.reset_idle_resumes(agent_id)
else:
idle_resumes = await coordinator.record_idle_resume(agent_id)
if idle_resumes >= _MAX_IDLE_AUTO_RESUMES:
logger.warning(
"agent %s auto-resumed %d times without hearing from anyone; "
"leaving it parked until a real message arrives",
agent_id,
idle_resumes,
)
await coordinator.park_waiting(agent_id, wait_kind="stalled")
await _notify_parent_on_stall(coordinator, agent_id)
continue
logger.info("agent %s reached its waiting timeout; auto-resuming", agent_id)
await coordinator.send(
agent_id,
{
"from": "system",
"type": "auto_resume",
"content": "Waiting timeout reached. Resuming execution.",
},
interrupt=False,
)
await coordinator.consume_pending(agent_id)
with contextlib.suppress(BudgetPausedError):
result = await _run_until_lifecycle(
agent,
coordinator,
agent_id,
initial_input=[],
run_config=run_config,
context=context,
max_turns=max_turns,
session=session,
interactive=True,
event_sink=event_sink,
hooks=hooks,
)
result = await _run_cycle(
agent,
coordinator,
agent_id,
input_data=[],
run_config=run_config,
context=context,
max_turns=max_turns,
session=session,
interactive=interactive,
event_sink=event_sink,
hooks=hooks,
)
async def spawn_child_agent(
@@ -431,10 +267,7 @@ async def respawn_subagents(
await coordinator.set_status(child_id, "crashed")
_INTERACTIVE_TOOL_RECOVERY_LIMIT = 3
async def _run_until_lifecycle(
async def _run_noninteractive_until_lifecycle(
agent: Any,
coordinator: AgentCoordinator,
agent_id: str,
@@ -444,167 +277,21 @@ async def _run_until_lifecycle(
context: dict[str, Any],
max_turns: int,
session: Session | None,
interactive: bool,
event_sink: StreamEventSink | None,
hooks: RunHooks[dict[str, Any]] | None,
) -> RunResultBase | None:
"""Drive an agent until an explicit lifecycle tool settles its status.
A turn that ends without ``finish_scan``, ``agent_finish``,
``respond_to_user``, or ``wait_for_agents`` leaves the agent ``running``:
plain text never terminates a run and never yields to the user. Such a turn
is nudged back into a tool call, bounded by a recovery limit.
"""
"""Non-chat mode keeps running until finish_scan / agent_finish settles status."""
result: RunResultBase | None = None
input_data: Any = initial_input
recovery_limit = _INTERACTIVE_TOOL_RECOVERY_LIMIT if interactive else max(1, max_turns)
invalid_final_outputs = 0
invalid_final_output_limit = max(1, max_turns)
while True:
if coordinator.budget_stopped:
await coordinator.set_status(agent_id, "stopped")
raise BudgetExceededError("scan budget reached")
if coordinator.reserve_stopped and context.get("parent_id") is not None:
await coordinator.set_status(agent_id, "stopped")
raise SubagentBudgetReservedError("scan reached the sub-agent budget reserve")
if interactive:
result = await _run_cycle_parked(
agent,
coordinator,
agent_id,
input_data=input_data,
run_config=run_config,
context=context,
max_turns=max_turns,
session=session,
event_sink=event_sink,
hooks=hooks,
)
else:
result = await _run_cycle(
agent,
coordinator,
agent_id,
input_data=input_data,
run_config=run_config,
context=context,
max_turns=max_turns,
session=session,
interactive=False,
event_sink=event_sink,
hooks=hooks,
)
status = await _agent_status(coordinator, agent_id)
if status != "running":
await coordinator.reset_recovery(agent_id)
return result
recoveries = await coordinator.record_recovery(agent_id)
logger.warning(
"agent %s ended a turn without a lifecycle tool call (interactive=%s); "
"forcing tool continuation (%d/%d): %s",
agent_id,
interactive,
recoveries,
recovery_limit,
_final_output_preview(result),
)
if recoveries >= recovery_limit:
return await _exhausted_recovery(coordinator, agent_id, result, interactive=interactive)
input_data = await _append_tool_required_message(
session=session,
context=context,
attempt=recoveries,
limit=recovery_limit,
interactive=interactive,
)
async def _exhausted_recovery(
coordinator: AgentCoordinator,
agent_id: str,
result: RunResultBase | None,
*,
interactive: bool,
) -> RunResultBase | None:
"""Settle an agent that never recovered into a tool call.
Interactive runs park instead of dying: a human is attached and can message
any agent, so the scan stays resumable. Autonomous runs have nobody to
resume them, so they fail loudly.
"""
if not interactive:
await coordinator.set_status(agent_id, "crashed")
await _notify_parent_on_terminal(coordinator, agent_id, "crashed")
raise MaxTurnsExceeded(
"Agent exhausted recovery attempts without calling finish_scan or agent_finish."
)
logger.warning(
"agent %s exhausted tool-call recovery attempts; parking until a message arrives",
agent_id,
)
await coordinator.park_waiting(agent_id, wait_kind="stalled")
# A parked child owes its parent a completion report it can no longer send. The
# parent is an agent, not a watching human, so nothing else tells it to stop
# waiting and it burns its full timeout on a message that is never coming.
await _notify_parent_on_stall(coordinator, agent_id)
return result
_WAITING_AUTO_RESUME_TIMEOUT_S = 300.0
# An agent that parks again after every auto-resume makes no progress, so stop
# spending a model turn per timeout and leave it parked for a real message.
_MAX_IDLE_AUTO_RESUMES = 3
async def _plain_waiting_timeout(
coordinator: AgentCoordinator,
agent_id: str,
) -> float | None:
"""Auto-resume timeout for a parked agent; None waits until a message arrives.
Driven by what the agent is waiting on, not by where it sits in the graph:
the user can message any agent, so an agent awaiting a human parks
indefinitely whether or not it is the root. Only an agent awaiting other
agents is re-checked on a timer, and only until it has spent its idle
budget re-parking without hearing anything.
"""
async with coordinator._lock:
status = coordinator.statuses.get(agent_id)
has_error = agent_id in coordinator.errors
runtime = coordinator.runtimes.get(agent_id)
gated = runtime.user_wake_required if runtime is not None else False
wait_kind = coordinator.wait_kinds.get(agent_id)
idle_resumes = coordinator.idle_resume_counts.get(agent_id, 0)
if status != "waiting" or has_error or gated:
return None
if wait_kind != "agents" or idle_resumes >= _MAX_IDLE_AUTO_RESUMES:
return None
return _WAITING_AUTO_RESUME_TIMEOUT_S
async def _run_cycle_parked(
agent: Any,
coordinator: AgentCoordinator,
agent_id: str,
*,
input_data: Any,
run_config: RunConfig,
context: dict[str, Any],
max_turns: int,
session: Session | None,
event_sink: StreamEventSink | None,
hooks: RunHooks[dict[str, Any]] | None,
) -> RunResultBase | None:
"""Interactive run cycle that parks on any error instead of killing the runner."""
try:
return await _run_cycle(
result = await _run_cycle(
agent,
coordinator,
agent_id,
@@ -613,17 +300,39 @@ async def _run_cycle_parked(
context=context,
max_turns=max_turns,
session=session,
interactive=True,
interactive=False,
event_sink=event_sink,
hooks=hooks,
)
except (BudgetExceededError, BudgetPausedError, SubagentBudgetReservedError):
raise
except Exception as exc:
logger.exception("error escaped the run cycle for %s; parking as failed", agent_id)
await coordinator.set_status(agent_id, "failed", error=str(exc) or type(exc).__name__)
await _notify_parent_on_terminal(coordinator, agent_id, "failed")
return None
status = await _agent_status(coordinator, agent_id)
if status != "running":
return result
invalid_final_outputs += 1
logger.warning(
"agent %s produced non-lifecycle final output in non-interactive mode; "
"forcing tool continuation (%d/%d): %s",
agent_id,
invalid_final_outputs,
invalid_final_output_limit,
_final_output_preview(result),
)
if invalid_final_outputs >= invalid_final_output_limit:
await coordinator.set_status(agent_id, "crashed")
await _notify_parent_on_crash(coordinator, agent_id, "crashed")
raise MaxTurnsExceeded(
"Agent exhausted non-interactive recovery attempts without calling "
"finish_scan or agent_finish."
)
input_data = await _append_noninteractive_tool_required_message(
session=session,
context=context,
attempt=invalid_final_outputs,
limit=invalid_final_output_limit,
)
async def _run_cycle( # noqa: PLR0912, PLR0915
@@ -641,11 +350,7 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
hooks: RunHooks[dict[str, Any]] | None,
) -> RunResultBase | None:
image_strips = 0
compactions = 0
model_retries = 0
while True:
stream: Any = None
pre_run_items: list[Any] = []
try:
await coordinator.mark_running(agent_id)
if session is not None:
@@ -655,12 +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)
with contextlib.suppress(Exception):
pre_run_items = list(await session.get_items())
stream = Runner.run_streamed(
agent,
input=input_data,
@@ -681,9 +380,9 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
logger.exception("stream event sink failed for %s", agent_id)
if stream.run_loop_exception is not None:
raise stream.run_loop_exception
if refusal := _structured_provider_refusal(stream):
raise ProviderRefusalError(refusal)
except (BudgetExceededError, BudgetPausedError, SubagentBudgetReservedError):
except BudgetExceededError:
# A RuntimeError subclass: re-raise explicitly so it is never
# mistaken for the LiteLLM "after shutdown" race below.
raise
except RuntimeError as stream_exc:
if "after shutdown" not in str(stream_exc):
@@ -702,15 +401,6 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
)
finally:
await coordinator.detach_stream(agent_id, stream)
except BudgetPausedError as exc:
logger.info("agent %s paused at the scan budget limit: %s", agent_id, exc)
await coordinator.pause_for_budget(agent_id)
raise
except SubagentBudgetReservedError as exc:
logger.info("sub-agent %s stopped at the budget reserve: %s", agent_id, exc)
await coordinator.set_status(agent_id, "stopped")
await _notify_root_on_budget_reserve(coordinator)
raise
except BudgetExceededError as exc:
logger.info(
"agent %s reached the scan budget limit; stopping the scan: %s", agent_id, exc
@@ -738,48 +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 model_retries < _MAX_TRANSIENT_MODEL_RETRIES and _is_transient_model_error(exc):
model_retries += 1
delay = _transient_model_retry_delay(model_retries)
logger.warning(
"transient model/provider error for %s; replaying turn "
"(attempt %d/%d, backoff %.1fs): %r",
agent_id,
model_retries,
_MAX_TRANSIENT_MODEL_RETRIES,
delay,
exc,
)
await asyncio.sleep(delay)
if session is not None:
input_data = []
continue
if session is not None:
await _salvage_stream_to_session(session, pre_run_items, stream, agent_id)
if isinstance(exc, ProviderRefusalError):
logger.warning("agent %s refused by the model provider: %s", agent_id, exc)
await coordinator.set_status(agent_id, "failed", error=str(exc))
await _notify_parent_on_terminal(coordinator, agent_id, "failed")
return None
if not interactive:
raise
if isinstance(exc, MaxTurnsExceeded):
@@ -789,11 +437,31 @@ 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 _notify_parent_on_terminal(coordinator, agent_id, status)
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:
return cast("RunResultBase | None", stream)
await _settle_run_result(coordinator, agent_id, interactive)
return stream
async def _settle_run_result(
coordinator: AgentCoordinator,
agent_id: str,
interactive: bool,
) -> None:
async with coordinator._lock:
current_status = coordinator.statuses.get(agent_id)
if current_status != "running":
return
if not interactive:
return
await coordinator.set_status(agent_id, "waiting")
async def _agent_status(coordinator: AgentCoordinator, agent_id: str) -> Status | None:
@@ -811,37 +479,23 @@ def _final_output_preview(result: RunResultBase | None) -> str:
return text[:300]
async def _append_tool_required_message(
async def _append_noninteractive_tool_required_message(
*,
session: Session | None,
context: dict[str, Any],
attempt: int,
limit: int,
interactive: bool,
) -> list[dict[str, str]]:
finish_tool = "finish_scan" if context.get("parent_id") is None else "agent_finish"
if interactive:
message = (
"Your previous message ended a turn without a tool call. Plain text never ends "
"execution and never hands control to the user: it is shown to the user, and the "
"run continues. Continue immediately and call exactly one tool. "
"If you have something to tell the user and nothing to do until they reply, "
"call respond_to_user. "
"If you are blocked waiting for another agent, call wait_for_agents. "
f"If the whole engagement is complete, call {finish_tool}. "
"Otherwise use the appropriate execution or planning tool. "
f"This is recovery attempt {attempt}/{limit}."
)
else:
message = (
"Your previous response ended the autonomous Strix run without a lifecycle tool "
"call. That is invalid in non-interactive mode; plain text final answers are "
"ignored. Continue immediately and call exactly one tool. "
f"If your work is complete, call {finish_tool}. "
"If you are blocked waiting for another agent, call wait_for_agents. "
"Otherwise use the appropriate execution or planning tool. "
f"This is recovery attempt {attempt}/{limit}."
)
message = (
"Your previous response ended the autonomous Strix run without a lifecycle tool call. "
"That is invalid in non-interactive mode; plain text final answers are ignored. "
"Continue immediately and call exactly one tool. "
f"If your work is complete, call {finish_tool}. "
"If you are blocked waiting for another agent, call wait_for_message. "
"Otherwise use the appropriate execution or planning tool. "
f"This is recovery attempt {attempt}/{limit}."
)
item = {"role": "user", "content": message}
if session is None:
return [item]
@@ -850,61 +504,12 @@ async def _append_tool_required_message(
return []
_TERMINAL_NOTICE = {
"crashed": (
"[Agent crash] {name} ({agent_id}) terminated unexpectedly. "
"Stop waiting on this child unless you want to message it again."
),
"failed": (
"[Agent failed] {name} ({agent_id}) stopped with an error and will not "
"send a completion report. Stop waiting on this child unless you want to "
"message it again."
),
"stopped": (
"[Agent capped] {name} ({agent_id}) hit its turn limit and was stopped "
"before finishing. It will not send a completion report, so stop waiting "
"on this child; account for its capped subtask and continue."
),
}
_STALL_NOTICE = (
"[Agent stalled] {name} ({agent_id}) kept ending turns without a tool call and is "
"parked until it receives a message. It will not send a completion report on its "
"own: either message it with a concrete next step to unblock it, or stop waiting on "
"it and account for its unfinished subtask."
)
async def _notify_parent_on_stall(
coordinator: AgentCoordinator,
agent_id: str,
) -> None:
"""Tell the parent that a child parked mid-task, so it stops waiting blindly."""
async with coordinator._lock:
parent = coordinator.parent_of.get(agent_id)
name = coordinator.names.get(agent_id, agent_id)
if parent is None:
return
await coordinator.send(
parent,
{
"from": agent_id,
"type": "stalled",
"priority": "high",
"content": _STALL_NOTICE.format(name=name, agent_id=agent_id),
},
interrupt=False,
)
async def _notify_parent_on_terminal(
async def _notify_parent_on_crash(
coordinator: AgentCoordinator,
agent_id: str,
status: str,
) -> None:
template = _TERMINAL_NOTICE.get(status)
if template is None:
if status != "crashed":
return
async with coordinator._lock:
parent = coordinator.parent_of.get(agent_id)
@@ -915,36 +520,16 @@ async def _notify_parent_on_terminal(
parent,
{
"from": agent_id,
"type": status,
"type": "crash",
"priority": "high",
"content": template.format(name=name, agent_id=agent_id),
"content": (
f"[Agent crash] {name} ({agent_id}) terminated unexpectedly. "
"Stop waiting on this child unless you want to message it again."
),
},
interrupt=False,
)
def _reserve_notice() -> dict[str, Any]:
return {
"from": "system",
"type": "budget_reserve_stop",
"priority": "high",
"content": (
"[Budget reserve] The scan has reached the sub-agent budget reserve: every "
"sub-agent is being force-stopped as soon as its in-flight turn completes, and "
"none will send a completion report. Their confirmed vulnerabilities are "
"already filed as they were found. Do not wait on any sub-agents and do not "
"spawn new ones — wrap up now and call finish_scan."
),
}
async def _notify_root_on_budget_reserve(coordinator: AgentCoordinator) -> None:
root = await coordinator.claim_reserve_notification()
if root is None:
return
await coordinator.send(root, _reserve_notice())
async def _start_child_runner(
*,
parent_ctx: dict[str, Any],
@@ -996,8 +581,6 @@ async def _start_child_runner(
)
except BudgetExceededError:
logger.info("child %s stopped after reaching the scan budget limit", child_id)
except SubagentBudgetReservedError:
logger.info("child %s stopped at the sub-agent budget reserve", child_id)
task_handle = asyncio.create_task(_child_loop(), name=f"agent-{name}-{child_id}")
await coordinator.attach_runtime(child_id, task=task_handle)
+3 -202
View File
@@ -14,210 +14,26 @@ from strix.report.state import get_global_report_state
if TYPE_CHECKING:
from agents import RunContextWrapper
from agents.agent import Agent
from agents.items import ModelResponse, TResponseInputItem
from agents.items import ModelResponse
logger = logging.getLogger(__name__)
_STAGE_LABELS: tuple[str, ...] = ("NOTICE", "URGENT", "CRITICAL")
_TURN_WARN_BANDS: tuple[float, ...] = (0.70, 0.85, 0.95)
_ROOT_BUDGET_WARN_BANDS: tuple[float, ...] = (0.70, 0.85, 0.95)
_SUBAGENT_BUDGET_WARN_BANDS: tuple[float, ...] = (0.75, 0.80, 0.85)
_SUBAGENT_BUDGET_RESERVE = 0.90
class BudgetExceededError(RuntimeError):
"""Raised when the accumulated LLM cost reaches the configured budget."""
class SubagentBudgetReservedError(RuntimeError):
"""Raised to stop a single sub-agent once the reserve threshold is crossed."""
class BudgetPausedError(RuntimeError):
"""Raised to park one agent when an interactive scan reaches its budget."""
def recomputed_budget_flags(
cost: float,
max_budget_usd: float | None,
*,
interactive: bool,
) -> tuple[bool, bool]:
"""Return the (budget_stopped, reserve_stopped) flags a resumed scan should carry."""
if max_budget_usd is None:
return False, False
if interactive:
return False, False
budget_stopped = cost >= max_budget_usd
reserve_stopped = cost >= max_budget_usd * _SUBAGENT_BUDGET_RESERVE
return budget_stopped, reserve_stopped
def _crossed_stage(fraction: float, bands: tuple[float, ...]) -> int | None:
crossed: int | None = None
for index, band in enumerate(bands):
if fraction >= band:
crossed = index
return crossed
_ROOT_DIRECTIVES: tuple[str, ...] = (
(
"As the root agent, begin planning your wind-down of the whole scan: avoid "
"starting large new lines of investigation, and keep your required objectives on "
"track so you can call finish_scan comfortably before the limit."
),
(
"As the root agent, prioritize wrapping up the whole scan now: stop opening new "
"lines of investigation, close out only what is essential, and move toward calling "
"finish_scan to compile and deliver the final report."
),
(
"As the root agent, STOP all other work on the whole scan and finish immediately: "
"secure your findings and call finish_scan now — anything left unfinished when the "
"limit is hit is discarded."
),
)
_SUBAGENT_DIRECTIVES: tuple[str, ...] = (
(
"As a sub-agent, begin planning your wind-down: avoid starting large new subtasks, "
"and if you are close to a confirmed, validated vulnerability, drive it to a result "
"you can report."
),
(
"As a sub-agent, prioritize wrapping up your task now: report any confirmed, "
"validated vulnerability, finish work that is nearly done rather than starting "
"anything new, and prepare to call agent_finish."
),
(
"As a sub-agent, STOP all other work and finish immediately: report any confirmed "
"vulnerability right now and call agent_finish to hand your results back to your "
"parent before you are cut off."
),
)
def _wrapup_directive(context: RunContextWrapper[dict[str, Any]], stage: int) -> str:
is_root = context.context.get("parent_id") is None
directives = _ROOT_DIRECTIVES if is_root else _SUBAGENT_DIRECTIVES
return directives[stage]
def _urgency(stage: int) -> str:
return _STAGE_LABELS[stage]
class ReportUsageHooks(RunHooks[dict[str, Any]]):
"""Persist SDK-native usage and warn/stop as turn and cost budgets are consumed."""
"""Persist SDK-native usage after every model response."""
def __init__(
self,
*,
model: str,
max_budget_usd: float | None = None,
max_turns: int | None = None,
interactive: bool = False,
) -> None:
def __init__(self, *, model: str, max_budget_usd: float | None = None) -> None:
if max_budget_usd is not None and (
not math.isfinite(max_budget_usd) or max_budget_usd <= 0
):
raise ValueError("max_budget_usd must be a finite number greater than 0")
if max_turns is not None and max_turns <= 0:
raise ValueError("max_turns must be a positive integer")
self._model = model
self._max_budget_usd = max_budget_usd
self._budget_increment = max_budget_usd
self._max_turns = max_turns
self._interactive = interactive
def extend_budget(self) -> None:
if self._max_budget_usd is None or self._budget_increment is None:
return
self._max_budget_usd += self._budget_increment
async def on_llm_start(
self,
context: RunContextWrapper[dict[str, Any]],
agent: Agent[dict[str, Any]], # noqa: ARG002
system_prompt: str | None, # noqa: ARG002
input_items: list[TResponseInputItem],
) -> None:
try:
self._maybe_warn_turns(context, input_items)
self._maybe_warn_budget(context, input_items)
except Exception:
logger.exception("budget/turn warning injection failed")
def _maybe_warn_turns(
self,
context: RunContextWrapper[dict[str, Any]],
input_items: list[TResponseInputItem],
) -> None:
if not self._max_turns:
return
usage = getattr(context, "usage", None)
requests = getattr(usage, "requests", None)
if not isinstance(requests, int):
return
turns_used = requests + 1
stage = _crossed_stage(turns_used / self._max_turns, _TURN_WARN_BANDS)
if stage is None:
return
remaining = max(self._max_turns - turns_used, 0)
pct = round(100 * turns_used / self._max_turns)
content = (
f"[{_urgency(stage)}] Turn budget: {turns_used}/{self._max_turns} used ({pct}%). "
f"About {remaining} turn(s) remain before this agent is force-stopped and any "
f"in-progress work is discarded. {_wrapup_directive(context, stage)}"
)
input_items.append({"role": "user", "content": content})
def _maybe_warn_budget(
self,
context: RunContextWrapper[dict[str, Any]],
input_items: list[TResponseInputItem],
) -> None:
if self._max_budget_usd is None:
return
report_state = get_global_report_state()
if report_state is None:
return
cost = report_state.get_total_llm_cost()
is_root = context.context.get("parent_id") is None
if self._interactive:
bands = _ROOT_BUDGET_WARN_BANDS
else:
bands = _ROOT_BUDGET_WARN_BANDS if is_root else _SUBAGENT_BUDGET_WARN_BANDS
stage = _crossed_stage(cost / self._max_budget_usd, bands)
if stage is None:
return
pct = round(100 * cost / self._max_budget_usd)
reserve_pct = round(_SUBAGENT_BUDGET_RESERVE * 100)
if self._interactive:
content = (
f"[{_urgency(stage)}] Scan cost budget: ${cost:.2f}/${self._max_budget_usd:.2f} "
f"spent ({pct}%). This budget is shared across every agent in the scan; when it "
"is reached all agents are paused until the user chooses to continue. "
f"{_wrapup_directive(context, stage)}"
)
elif is_root:
content = (
f"[{_urgency(stage)}] Scan cost budget: ${cost:.2f}/${self._max_budget_usd:.2f} "
f"spent ({pct}%). This budget is shared across every agent in the scan; when it "
"is reached the whole scan is stopped immediately, and sub-agents are stopped at "
f"{reserve_pct}% to reserve the remainder for your final report. "
f"{_wrapup_directive(context, stage)}"
)
else:
content = (
f"[{_urgency(stage)}] Scan cost budget: ${cost:.2f}/${self._max_budget_usd:.2f} "
f"spent ({pct}%). This budget is shared across every agent in the scan; "
f"sub-agents are stopped at {reserve_pct}% to leave the remainder for the root "
f"agent's final report. {_wrapup_directive(context, stage)}"
)
input_items.append({"role": "user", "content": content})
async def on_llm_end(
self,
@@ -250,21 +66,6 @@ class ReportUsageHooks(RunHooks[dict[str, Any]]):
if self._max_budget_usd is not None:
cost = report_state.get_total_llm_cost()
if cost >= self._max_budget_usd:
if self._interactive:
raise BudgetPausedError(
f"Scan budget of ${self._max_budget_usd:.2f} reached "
f"(spent ${cost:.4f}); pausing until the user continues"
)
raise BudgetExceededError(
f"Token budget of ${self._max_budget_usd:.2f} exceeded (spent ${cost:.4f})"
)
is_root = ctx.get("parent_id") is None
if not self._interactive and not is_root:
reserve_limit = self._max_budget_usd * _SUBAGENT_BUDGET_RESERVE
if cost >= reserve_limit:
raise SubagentBudgetReservedError(
f"Sub-agent budget reserve reached: spent ${cost:.4f} of "
f"${self._max_budget_usd:.2f} "
f"(>= {round(_SUBAGENT_BUDGET_RESERVE * 100)}% reserve); stopping this "
"sub-agent so the root agent can finish the scan."
)
-35
View File
@@ -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,15 +128,12 @@ def make_model_settings(
model_name: str,
force_required_tool_choice: bool = False,
request_timeout: float | None = None,
prompt_cache: bool = True,
extra_headers: dict[str, str] | None = None,
) -> ModelSettings:
model_settings = ModelSettings(
parallel_tool_calls=False,
retry=DEFAULT_MODEL_RETRY,
include_usage=True,
extra_args=request_timeout_extra_args(request_timeout),
extra_headers=dict(extra_headers) if extra_headers else None,
)
if (
reasoning_effort is not None
@@ -151,38 +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:
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 ``cache_control_injection_points`` for Claude prompt caching.
System prompt + rolling last-message breakpoint everywhere; ``tool_config``
only on Bedrock Converse (the only route whose LiteLLM transform consumes
it — elsewhere it leaks onto the wire and native Anthropic 400s). Unmapped
Bedrock models get no points at all: Bedrock rejects the passed-through
field outright.
"""
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: 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 {"cache_control_injection_points": points}
def child_initial_input(
*,
name: str,
+4 -47
View File
@@ -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
@@ -31,7 +29,7 @@ from strix.core.execution import (
from strix.core.execution import (
spawn_child_agent as start_child_agent,
)
from strix.core.hooks import BudgetExceededError, ReportUsageHooks, recomputed_budget_flags
from strix.core.hooks import BudgetExceededError, ReportUsageHooks
from strix.core.inputs import (
DEFAULT_MAX_TURNS,
build_root_task,
@@ -40,13 +38,8 @@ from strix.core.inputs import (
)
from strix.core.paths import run_dir_for, runtime_state_dir
from strix.core.sessions import open_agent_session
from strix.report.state import get_global_report_state
from strix.runtime import session_manager
from strix.telemetry.logging import set_scan_id, setup_scan_logging
from strix.tools.output_store import (
WORKSPACE_SPILL_DIR,
configure_spill_writer,
)
if TYPE_CHECKING:
@@ -186,18 +179,6 @@ async def run_strix_scan(
f"Cannot resume scan {scan_id}: missing SDK session database at {agents_db}",
)
await coordinator.restore(snap)
report_state = get_global_report_state()
if report_state is not None:
budget_stopped, reserve_stopped = recomputed_budget_flags(
report_state.get_total_llm_cost(),
max_budget_usd,
interactive=interactive,
)
await coordinator.reset_budget_stops(
budget_stopped=budget_stopped,
reserve_stopped=reserve_stopped,
budget_paused=interactive and coordinator.budget_paused,
)
for aid, parent in coordinator.parent_of.items():
if parent is None:
root_id = aid
@@ -222,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:
@@ -249,8 +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,
extra_headers=settings.llm.extra_headers,
)
run_config = RunConfig(
model=resolved_model,
@@ -259,14 +224,7 @@ async def run_strix_scan(
sandbox=SandboxRunConfig(client=bundle["client"], session=bundle["session"]),
trace_include_sensitive_data=False,
)
hooks = ReportUsageHooks(
model=resolved_model,
max_budget_usd=max_budget_usd,
max_turns=max_turns,
interactive=interactive,
)
if interactive:
coordinator.set_budget_extender(hooks.extend_budget)
hooks = ReportUsageHooks(model=resolved_model, max_budget_usd=max_budget_usd)
scope_context = build_scope_context(scan_config)
root_context = _merge_root_prompt_context(scope_context, extra_system_prompt_context)
@@ -280,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,
@@ -294,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,
@@ -441,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()
-46
View File
@@ -7,7 +7,6 @@ import logging
from typing import TYPE_CHECKING, Any, cast
from weakref import WeakKeyDictionary
from agents.items import ItemHelpers
from agents.memory import SQLiteSession
@@ -27,18 +26,6 @@ def open_agent_session(agent_id: str, path: Path) -> SQLiteSession:
return SQLiteSession(session_id=agent_id, db_path=path)
async def seed_initial_input(session: Session, initial_input: Any) -> bool:
"""Commit an agent's opening identity/task input before its first run cycle."""
items = ItemHelpers.input_to_new_input_list(initial_input)
if not items:
return False
async with session_write_lock(session):
if await session.get_items():
return False
await session.add_items(items)
return True
_IMAGE_REJECTED_TEXT = "[image rejected by the model]"
_IMAGE_ELIDED_TEXT = "[older screenshot elided to bound context memory]"
_INHERITED_IMAGE_TEXT = "[screenshot omitted from inherited context]"
@@ -105,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)."""
-419
View File
@@ -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"]
-2
View File
@@ -13,7 +13,6 @@ from rich.panel import Panel
from rich.text import Text
from strix.config import load_settings
from strix.core.inputs import DEFAULT_MAX_TURNS
from strix.core.runner import run_strix_scan
from strix.report.state import ReportState, set_global_report_state
from strix.runtime import session_manager
@@ -185,7 +184,6 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
local_sources=getattr(args, "local_sources", None) or [],
interactive=bool(getattr(args, "interactive", False)),
max_budget_usd=getattr(args, "max_budget_usd", None),
max_turns=getattr(args, "max_turns", DEFAULT_MAX_TURNS),
)
finally:
stop_updates.set()
+77 -182
View File
@@ -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,
)
@@ -31,17 +30,9 @@ from strix.config.models import (
is_known_openai_bare_model,
is_recommended_or_frontier_model,
)
from strix.core.inputs import DEFAULT_MAX_TURNS, make_model_settings
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,
@@ -94,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")
@@ -211,7 +192,7 @@ def validate_environment() -> None:
padding=(1, 2),
)
logger.debug("Missing required env vars: %s", missing_required_vars)
logger.error("Missing required env vars: %s", missing_required_vars)
console.print("\n")
console.print(panel)
console.print()
@@ -224,7 +205,7 @@ def validate_environment() -> None:
def check_docker_installed() -> None:
if shutil.which("docker") is None:
logger.debug("Docker CLI not found in PATH")
logger.error("Docker CLI not found in PATH")
console = Console()
error_text = Text()
error_text.append("DOCKER NOT INSTALLED", style="bold red")
@@ -286,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")
@@ -318,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
@@ -382,13 +340,7 @@ async def warm_up_llm(show_model_warning: bool = True) -> None:
model.get_response(
system_instructions="You are a helpful assistant.",
input="Reply with just 'OK'.",
model_settings=make_model_settings(
None,
model_name=raw_model,
request_timeout=llm.timeout,
prompt_cache=False,
extra_headers=llm.extra_headers,
),
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
@@ -401,75 +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)
# A dedicated dedupe model may route to another provider, which must
# never receive the main endpoint's headers; it has its own
# DEDUPE_LLM_EXTRA_HEADERS.
deduper_settings = make_model_settings(
None,
model_name=dedupe_model,
request_timeout=llm.timeout,
prompt_cache=False,
extra_headers=settings.dedupe.extra_headers,
)
if deduper_extra:
merged = {**(deduper_settings.extra_args or {}), **deduper_extra}
deduper_settings = deduper_settings.resolve(ModelSettings(extra_args=merged))
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.debug("LLM warm-up failed", exc_info=True)
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),
)
@@ -500,16 +400,6 @@ def _positive_budget(value: str) -> float:
return budget
def _positive_int(value: str) -> int:
try:
parsed = int(value)
except ValueError as exc:
raise argparse.ArgumentTypeError(f"invalid int value: {value!r}") from exc
if parsed <= 0:
raise argparse.ArgumentTypeError("must be an integer greater than 0")
return parsed
def parse_arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Strix Multi-Agent Cybersecurity Penetration Testing Tool",
@@ -558,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",
@@ -665,27 +547,10 @@ Examples:
)
parser.add_argument(
"--max-budget",
dest="max_budget_usd",
metavar="USD",
"--max-budget-usd",
type=_positive_budget,
default=None,
help=(
"Maximum LLM cost in USD (> 0). The scan stops cleanly when this limit is reached. "
"Graduated wrap-up warnings are sent to all agents as it is approached."
),
)
parser.add_argument(
"--max-turns",
dest="max_turns",
metavar="N",
type=_positive_int,
default=DEFAULT_MAX_TURNS,
help=(
"Maximum turns per agent (> 0, default %(default)s). Each agent is force-stopped "
"when it reaches this limit, with graduated wrap-up warnings as it is approached."
),
help="Maximum LLM cost in USD (> 0). The scan stops cleanly when this limit is reached.",
)
parser.add_argument(
@@ -701,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."
@@ -802,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,
@@ -860,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()
@@ -899,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()
@@ -935,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:
@@ -964,7 +841,7 @@ def pull_docker_image() -> None:
last_update = process_pull_line(line, layers_info, status, last_update)
except DockerException as e:
logger.debug("Failed to pull docker image %s", image, exc_info=True)
logger.exception("Failed to pull docker image %s", image)
console.print()
error_text = Text()
error_text.append("FAILED TO PULL IMAGE", style="bold red")
@@ -998,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()
@@ -1077,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,
@@ -1112,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()
+25 -81
View File
@@ -15,7 +15,6 @@ from typing import TYPE_CHECKING, Any, ClassVar
if TYPE_CHECKING:
from pygments.token import _TokenType
from textual.timer import Timer
from rich.align import Align
@@ -35,7 +34,6 @@ from textual.widgets.tree import TreeNode
from strix.config import load_settings
from strix.config.models import is_recommended_or_frontier_model
from strix.core.hooks import BudgetExceededError
from strix.core.inputs import DEFAULT_MAX_TURNS
from strix.core.runner import run_strix_scan
from strix.interface.tui.live_view import TuiLiveView
from strix.interface.tui.messages import send_user_message_to_agent
@@ -44,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
@@ -338,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"]
@@ -353,7 +346,7 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
if not token_value:
continue
color = None
tt: _TokenType | None = token_type
tt = token_type
while tt:
if tt in colors:
color = colors[tt]
@@ -508,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:
@@ -609,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", ""])
@@ -630,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")
@@ -815,8 +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._budget_pause_notified = False
self._spinner_frame_index: int = 0
self._sweep_num_squares: int = 6
@@ -1030,50 +1015,26 @@ 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 or "",
)
if 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)
self._notify_budget_pause(statuses)
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)
def _notify_budget_pause(self, statuses: dict[str, Any]) -> None:
paused = any(status == "budget_paused" for status in statuses.values())
if paused and not self._budget_pause_notified:
self._budget_pause_notified = True
self.notify(
"Budget limit reached \u2014 agents paused. Send a message to continue "
"(this extends the budget), or ctrl-q to quit.",
severity="warning",
timeout=15,
)
elif not paused:
self._budget_pause_notified = False
def _update_agent_node(self, agent_id: str, agent_data: dict[str, Any]) -> bool:
if agent_id not in self.agent_nodes:
return False
@@ -1086,10 +1047,8 @@ class StrixTUIApp(App): # type: ignore[misc]
status_indicators = {
"running": "",
"waiting": "",
"budget_paused": "",
"completed": "🟢",
"failed": "🔴",
"crashed": "🔴",
"stopped": "",
}
@@ -1275,30 +1234,20 @@ 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)
if status in {"waiting", "budget_paused"}:
if status == "waiting":
text = Text()
keymap = Text()
if status == "budget_paused":
text.append("Budget limit reached", style="yellow")
text.append(" \u00b7 ", style="dim")
text.append("Send a message to continue", style="dim")
keymap = keymap_styled([("ctrl-q", "quit")])
else:
error_msg = agent_data.get("error_message") or ""
if error_msg:
text.append(error_msg, style="red")
text.append(" \u00b7 ", style="dim")
text.append("Send message to resume", style="dim")
return (text, keymap, False)
text.append("Send message to resume", style="dim")
return (text, Text(), False)
if status == "running":
if self._agent_has_real_activity(agent_id):
@@ -1523,7 +1472,6 @@ class StrixTUIApp(App): # type: ignore[misc]
coordinator=self.coordinator,
interactive=True,
max_budget_usd=getattr(self.args, "max_budget_usd", None),
max_turns=getattr(self.args, "max_turns", DEFAULT_MAX_TURNS),
event_sink=self._capture_sdk_event,
),
)
@@ -1531,7 +1479,10 @@ class StrixTUIApp(App): # type: ignore[misc]
except (KeyboardInterrupt, asyncio.CancelledError):
logger.info("Scan interrupted by user")
except BudgetExceededError:
logger.info("Scan stopped: --max-budget limit reached")
# Defensive: the runner stops the scan cleanly on budget and
# returns, so this normally never propagates. Treat it as a
# graceful stop, not a scan error, if it ever does.
logger.info("Scan stopped: --max-budget-usd limit reached")
except (ConnectionError, TimeoutError) as e:
logging.exception("Network error during scan")
self._scan_error = e
@@ -1586,10 +1537,8 @@ class StrixTUIApp(App): # type: ignore[misc]
status_indicators = {
"running": "",
"waiting": "",
"budget_paused": "",
"completed": "🟢",
"failed": "🔴",
"crashed": "🔴",
"stopped": "",
}
@@ -1633,10 +1582,8 @@ class StrixTUIApp(App): # type: ignore[misc]
status_indicators = {
"running": "",
"waiting": "",
"budget_paused": "",
"completed": "🟢",
"failed": "🔴",
"crashed": "🔴",
"stopped": "",
}
@@ -1758,10 +1705,7 @@ class StrixTUIApp(App): # type: ignore[misc]
message=message,
)
if not submitted:
if self._scan_completed.is_set():
self.notify("The scan has ended; message was not sent", severity="warning")
else:
self.notify("Scan loop is not ready; message was not sent", severity="warning")
self.notify("Scan loop is not ready; message was not sent", severity="warning")
return
self._displayed_events.clear()
@@ -1894,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[/]")
+6 -19
View File
@@ -20,7 +20,7 @@ class TuiLiveView:
self.events: list[dict[str, Any]] = []
self._next_event_id = 1
self._open_assistant_event_by_agent: dict[str, dict[str, Any]] = {}
self._tool_event_by_agent_and_call_id: dict[tuple[str, str], dict[str, Any]] = {}
self._tool_event_by_call_id: dict[str, dict[str, Any]] = {}
def hydrate_from_run_dir(self, run_dir: Path) -> None:
state_dir = runtime_state_dir(run_dir)
@@ -82,21 +82,10 @@ class TuiLiveView:
current["parent_id"] = parent_id
if status is not None:
current["status"] = status
if error_message is not None:
if error_message:
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,
@@ -223,8 +212,7 @@ class TuiLiveView:
timestamp: str | None = None,
) -> None:
call_id = call["call_id"]
event_key = (agent_id, call_id)
existing = self._tool_event_by_agent_and_call_id.get(event_key)
existing = self._tool_event_by_call_id.get(call_id)
tool_data = {
"tool_name": call["tool_name"],
"args": call["args"],
@@ -234,7 +222,7 @@ class TuiLiveView:
}
if existing is None:
event = self._append_event(agent_id, "tool", tool_data, timestamp=timestamp)
self._tool_event_by_agent_and_call_id[event_key] = event
self._tool_event_by_call_id[call_id] = event
else:
existing["data"].update(tool_data)
self._bump_event(existing, timestamp=timestamp)
@@ -250,8 +238,7 @@ class TuiLiveView:
timestamp: str | None = None,
) -> None:
call_id = output["call_id"]
event_key = (agent_id, call_id)
event = self._tool_event_by_agent_and_call_id.get(event_key)
event = self._tool_event_by_call_id.get(call_id)
if event is None:
event = self._append_event(
agent_id,
@@ -265,7 +252,7 @@ class TuiLiveView:
},
timestamp=timestamp,
)
self._tool_event_by_agent_and_call_id[event_key] = event
self._tool_event_by_call_id[call_id] = event
result = _parse_json_value(output["output"])
event["data"]["result"] = result
@@ -6,7 +6,6 @@ from . import (
notes_renderer,
proxy_renderer,
reporting_renderer,
respond_renderer,
shell_renderer,
thinking_renderer,
todo_renderer,
@@ -24,7 +23,6 @@ __all__ = [
"proxy_renderer",
"render_tool_widget",
"reporting_renderer",
"respond_renderer",
"shell_renderer",
"thinking_renderer",
"todo_renderer",
@@ -117,8 +117,8 @@ class AgentFinishRenderer(BaseToolRenderer):
@register_tool_renderer
class WaitForAgentsRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "wait_for_agents"
class WaitForMessageRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "wait_for_message"
css_classes: ClassVar[list[str]] = ["tool-call", "agents-graph-tool"]
@classmethod
@@ -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,35 +0,0 @@
from typing import Any, ClassVar
from rich.text import Text
from textual.widgets import Static
from .agent_message_renderer import AgentMessageRenderer
from .base_renderer import BaseToolRenderer
from .registry import register_tool_renderer
@register_tool_renderer
class RespondToUserRenderer(BaseToolRenderer):
"""Render a reply as the agent's own prose, not as a tool call.
``respond_to_user`` carries the message the user is meant to read, so it
gets the same markdown treatment as a plain assistant turn.
"""
tool_name: ClassVar[str] = "respond_to_user"
css_classes: ClassVar[list[str]] = ["tool-call", "respond-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
args = tool_data.get("args", {})
message = args.get("message", "")
text = Text()
if message:
text.append_text(AgentMessageRenderer.render_simple(message))
text.append("\n\n")
text.append("", style="#6b7280")
text.append("waiting for your reply", style="dim")
css_classes = cls.get_css_classes(tool_data.get("status", "unknown"))
return Static(text, classes=css_classes)
-395
View File
@@ -1,395 +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",
"linux-arm64",
"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
+14 -44
View File
@@ -11,10 +11,11 @@ import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen
import docker
import requests
from docker.errors import DockerException, ImageNotFound
from rich.console import Console
from rich.panel import Panel
@@ -252,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))
@@ -296,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")
@@ -330,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")
@@ -360,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)
@@ -405,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:
@@ -418,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")
@@ -1087,12 +1054,13 @@ def resolve_diff_scope_context(
def _is_http_git_repo(url: str) -> bool:
check_url = f"{url.rstrip('/')}/info/refs?service=git-upload-pack"
try:
resp = requests.get(check_url, headers={"User-Agent": "git/strix"}, timeout=10)
except (requests.RequestException, ValueError):
req = Request(check_url, headers={"User-Agent": "git/strix"}) # noqa: S310
with urlopen(req, timeout=10) as resp: # noqa: S310 # nosec B310
return "x-git-upload-pack-advertisement" in resp.headers.get("Content-Type", "")
except HTTPError as e:
return e.code == 401
except (URLError, OSError, ValueError):
return False
if resp.status_code >= 400:
return resp.status_code == 401
return "x-git-upload-pack-advertisement" in resp.headers.get("Content-Type", "")
def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR0911
@@ -1179,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 &amp; 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&apos;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&apos;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;
@@ -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,20 +0,0 @@
"use client";
import type { ToolRendererProps } from "@/types/events";
import Markdown from "./Markdown";
/**
* `respond_to_user` carries the message the user is meant to read, so it renders
* as the agent's own prose rather than as a tool call.
*/
export default function RespondRenderer({ args }: ToolRendererProps) {
const message = (args.message as string) ?? "";
if (!message) return null;
return (
<div>
<Markdown text={message} />
<div className="mt-1.5 text-[#888] text-[13px]">waiting for your reply</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
View File
@@ -1 +0,0 @@
"""LLM-facing context management: model-aware budgets and history compaction."""
-386
View File
@@ -1,386 +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
from agents.model_settings import ModelSettings
from agents.models.interface import ModelTracing
from litellm.exceptions import BadRequestError, ContextWindowExceededError
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
from strix.config import load_settings
from strix.config.models import StrixProvider
from strix.core.inputs import make_model_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.items import ModelResponse
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>"
),
}
def _extract_text(response: ModelResponse) -> str:
parts: list[str] = []
for item in response.output:
if not isinstance(item, ResponseOutputMessage):
continue
parts.extend(
chunk.text
for chunk in item.content
if isinstance(chunk, ResponseOutputText) and chunk.text
)
return "".join(parts)
async def _summarize(model: str, prompt: str, max_tokens: int) -> str | None:
llm = load_settings().llm
model_settings = make_model_settings(
None,
model_name=model,
request_timeout=llm.timeout,
prompt_cache=False,
extra_headers=llm.extra_headers,
).resolve(ModelSettings(max_tokens=max_tokens))
try:
response = (
await StrixProvider()
.get_model(model)
.get_response(
system_instructions=None,
input=prompt,
model_settings=model_settings,
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
)
except Exception:
logger.exception("compaction summary call failed for model %s", model)
return None
content = _extract_text(response).strip()
if not content:
logger.warning("compaction summary returned no content")
return None
return content
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
-87
View File
@@ -1,87 +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/",
"chatgpt/",
"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]:
lookup_key = _lookup_key(model)
# Provider-qualified ChatGPT lookups may start a synchronous device-login
# poll. LiteLLM keys the metadata by the underlying model slug.
candidates = (lookup_key,) if model.startswith("chatgpt/") else (model, lookup_key)
for candidate in candidates:
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"))
+9 -48
View File
@@ -13,62 +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:
llm = load_settings().llm
settings = make_model_settings(
dedupe.reasoning_effort,
model_name=model_name,
force_required_tool_choice=False,
request_timeout=request_timeout,
# The main model's headers apply only when dedupe falls back to the main
# model; a dedicated dedupe model may route to another provider, which
# must never receive the main endpoint's credentials. A dedicated model
# gets its own DEDUPE_LLM_EXTRA_HEADERS instead.
extra_headers=dedupe.extra_headers if dedupe.model else llm.extra_headers,
)
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.
@@ -328,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)
@@ -354,7 +311,11 @@ 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,
handoffs=[],
-79
View File
@@ -1,7 +1,6 @@
import json
import logging
import subprocess
import threading
from collections.abc import Callable
from datetime import UTC, datetime
from importlib.metadata import PackageNotFoundError, version
@@ -11,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
@@ -96,8 +93,6 @@ def get_global_report_state() -> Optional["ReportState"]:
def set_global_report_state(report_state: "ReportState") -> None:
global _global_report_state # noqa: PLW0603
_global_report_state = report_state
# New run: drop any streamed-cost entries a prior run left unconsumed.
streamed_openrouter_costs.clear()
class ReportState:
@@ -122,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(),
}
@@ -510,72 +502,6 @@ class ReportState:
self._sync_llm_usage_record()
def openrouter_stream_cost(usage: Any) -> float | None:
"""Total OpenRouter-reported cost from a raw stream ``usage`` block, or None.
Non-BYOK responses bill everything to ``usage.cost``. BYOK responses put the
OpenRouter fee in ``usage.cost`` (often 0) and the provider charge in
``usage.cost_details.upstream_inference_cost``, so BYOK totals sum the two.
"""
if not isinstance(usage, dict):
return None
total = 0.0
cost = usage.get("cost")
if isinstance(cost, int | float) and cost > 0:
total += float(cost)
if bool(usage.get("is_byok")):
details = usage.get("cost_details")
upstream = details.get("upstream_inference_cost") if isinstance(details, dict) else None
if isinstance(upstream, int | float) and upstream > 0:
total += float(upstream)
return total if total > 0 else None
def _response_id(completion_response: Any) -> str | None:
response_id = getattr(completion_response, "id", None)
if response_id is None and isinstance(completion_response, dict):
response_id = cast("dict[str, Any]", completion_response).get("id")
return response_id if isinstance(response_id, str) and response_id else None
class StreamedOpenRouterCosts:
"""Correlates OpenRouter's per-stream cost from the parser to the cost callback.
LiteLLM rebuilds streamed responses from token-only chunks and drops the
``usage.cost`` OpenRouter reports in its final stream chunk (its non-streamed
path preserves it; streaming snapshots hidden params at stream start). Every
scan streams, so the OpenRouter streaming handler (see strix.config.models)
records the cost here keyed by response id, and the callback takes it back out
for the matching rebuilt response. Entries are removed on read; ``clear()``
runs per scan so nothing accumulates across runs.
"""
def __init__(self) -> None:
self._costs: dict[str, float] = {}
self._lock = threading.Lock()
def remember(self, response_id: Any, usage: Any) -> None:
cost = openrouter_stream_cost(usage)
if cost is None or not (isinstance(response_id, str) and response_id):
return
with self._lock:
self._costs[response_id] = cost
def take(self, completion_response: Any) -> float | None:
response_id = _response_id(completion_response)
if response_id is None:
return None
with self._lock:
return self._costs.pop(response_id, None)
def clear(self) -> None:
with self._lock:
self._costs.clear()
streamed_openrouter_costs = StreamedOpenRouterCosts()
def litellm_cost_callback(
kwargs: Any,
completion_response: Any,
@@ -610,11 +536,6 @@ def litellm_cost_callback(
if cost is None:
cost = _usage_reported_cost(completion_response)
# Recover the exact OpenRouter cost the streaming handler stashed for this
# response — LiteLLM drops it from streamed usage, so nothing above sees it.
if cost is None:
cost = streamed_openrouter_costs.take(completion_response)
if cost is None:
cost = _estimate_response_cost(kwargs, completion_response)
+1 -6
View File
@@ -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
View File
@@ -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}")
-14
View File
@@ -110,19 +110,6 @@ def _apply_log_limits(create_kwargs: dict[str, Any]) -> None:
)
def _apply_run_labels(create_kwargs: dict[str, Any]) -> None:
run_id = os.getenv("STRIX_RUN_ID")
if not run_id:
return
labels = create_kwargs.setdefault("labels", {})
if not isinstance(labels, dict):
return
labels["strix-run-id"] = run_id
run_type = os.getenv("STRIX_RUN_TYPE")
if run_type:
labels["strix-run-type"] = run_type
class StrixDockerSandboxSession(DockerSandboxSession):
sandbox_network: str = ""
@@ -235,7 +222,6 @@ class StrixDockerSandboxClient(DockerSandboxClient):
_apply_sandbox_network(create_kwargs)
_apply_resource_limits(create_kwargs)
_apply_log_limits(create_kwargs)
_apply_run_labels(create_kwargs)
# Strix injection: host bind mounts (e.g. large repos passed via --mount)
# that bypass the SDK's file-by-file LocalDir copy.
+1 -1
View File
@@ -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:
+4 -3
View File
@@ -5,7 +5,7 @@ description: Orchestration layer that coordinates specialized subagents for secu
# Root Agent
Orchestration layer for security assessments. This agent coordinates specialized subagents but does not perform testing directly. You never run scanners, crawlers, or fuzzers and never send exploit/injection payloads yourself — not even a quick "basic" test on a discovered endpoint. Any work that touches the target is delegated to a subagent.
Orchestration layer for security assessments. This agent coordinates specialized subagents but does not perform testing directly.
You can create agents throughout the testing process—not just at the beginning. Spawn agents dynamically based on findings and evolving scope.
@@ -18,7 +18,7 @@ You can create agents throughout the testing process—not just at the beginning
## Scope Decomposition
Before spawning agents, analyze the target from the scan config/scope and any provided context (and, once recon subagents report, from their results) — not by running recon tools yourself:
Before spawning agents, analyze the target:
1. **Identify attack surfaces** - web apps, APIs, infrastructure, etc.
2. **Define boundaries** - in-scope domains, IP ranges, excluded assets
@@ -72,7 +72,8 @@ Before creating agents:
Complex findings warrant specialized subagents:
- Discovery agent finds potential vulnerability
- Validation agent confirms exploitability
- Reporting agent documents with reproduction steps AND supplies the fix inline (the report tool carries the patch via `code_locations`/`fix_pr_body`) — do not add a separate fix agent that re-derives the same patch
- Reporting agent documents with reproduction steps
- Fix agent provides remediation (if needed)
**Resource Efficiency**
+20 -19
View File
@@ -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.
@@ -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
+1 -1
View File
@@ -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)
+9 -14
View File
@@ -1,9 +1,9 @@
import json
import logging
import urllib.request
from datetime import datetime
from typing import TYPE_CHECKING, Any
import requests
from strix.config import load_settings
from strix.telemetry._common import (
SESSION_ID,
@@ -37,7 +37,13 @@ def _send(event: str, properties: dict[str, Any]) -> bool:
"distinct_id": SESSION_ID,
"properties": properties,
}
requests.post(f"{_POSTHOG_HOST}/capture/", json=payload, timeout=10)
req = urllib.request.Request( # noqa: S310
f"{_POSTHOG_HOST}/capture/",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=10): # noqa: S310 # nosec B310
pass
except Exception: # noqa: BLE001
logger.debug("posthog send failed for event %s", event, exc_info=True)
return False
@@ -52,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,
@@ -129,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),
@@ -177,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)
+4 -6
View File
@@ -2,11 +2,10 @@ from __future__ import annotations
import logging
import urllib.parse
import urllib.request
from datetime import datetime
from typing import TYPE_CHECKING, Any
import requests
from strix.config import load_settings
from strix.telemetry._common import (
SESSION_ID,
@@ -43,7 +42,9 @@ def _send(event: str, properties: dict[str, Any]) -> bool:
url = f"{_SCARF_ENDPOINT}{path}"
if query:
url = f"{url}?{query}"
requests.post(url, timeout=10)
req = urllib.request.Request(url, method="POST") # noqa: S310
with urllib.request.urlopen(req, timeout=10): # noqa: S310 # nosec B310
pass
except Exception: # noqa: BLE001
logger.debug("scarf send failed for event %s", event, exc_info=True)
return False
@@ -58,7 +59,6 @@ def start(
is_whitebox: bool,
interactive: bool,
has_instructions: bool,
auth_mode: str | None = None,
) -> None:
_send(
"scan_started",
@@ -66,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,
@@ -141,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),
+21 -38
View File
@@ -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] = []
@@ -218,43 +218,25 @@ def _session_items_payload(items: list[Any]) -> list[dict[str, Any]]:
return payload
_WAIT_DEFAULT_TIMEOUT_S = 300
# Enforced by the SDK around the whole tool call, so it caps an oversized
# ``timeout_seconds`` the model asks for. One second of headroom lets the
# tool's own timeout fire first and return a clean result.
_WAIT_HARD_CEILING_S = _WAIT_DEFAULT_TIMEOUT_S + 1
@function_tool(timeout=_WAIT_HARD_CEILING_S)
async def wait_for_agents( # noqa: PLR0911
@function_tool(timeout=601)
async def wait_for_message( # noqa: PLR0911
ctx: RunContextWrapper,
reason: str = "Waiting for messages from other agents",
timeout_seconds: int = _WAIT_DEFAULT_TIMEOUT_S,
timeout_seconds: int = 600,
) -> str:
"""Pause until another AGENT messages you (or the timeout elapses).
"""Pause this agent until a message lands in its inbox (or timeout).
Use when you have nothing useful to do until a child or peer
responds typically after spawning subagents and you want their
completion reports. You resume the instant any message arrives, so
size ``timeout_seconds`` to the work you're awaiting.
**This tool is only for waiting on other agents.** Two things it is
NOT for:
- **Talking to the user.** Use ``respond_to_user``, which delivers
your message and hands control back in one call.
- **Waiting for a long-running command.** This tool does not watch
processes at all it sleeps until a *message* arrives, so it
burns the full timeout even if your command finished a second
later. Poll the process instead: ``exec_command`` returns a
session/process id, and ``write_stdin`` with ``chars=""`` returns
as soon as there is new output or the process exits.
Use when you have nothing useful to do until a child/peer responds
typically after spawning subagents and you want to wait for
their completion reports. The agent automatically resumes when any
message arrives, so pick a ``timeout_seconds`` proportional to the
work you're awaiting.
**Critical caveats:**
- **Never** call this if you have no agents left to hear from
that just strands you until the timeout. Call ``finish_scan``
(root) or ``agent_finish`` (subagent) instead.
- **Never** call this if you finished your own task and have **no**
child agents running that's a permanent stall. Call
``finish_scan`` (root) or ``agent_finish`` (subagent) instead.
- If you're waiting on an agent that **isn't your child**, message
it first asking it to ping you when done otherwise it has no
reason to send to your inbox and you'll wait the full timeout.
@@ -265,8 +247,7 @@ async def wait_for_agents( # noqa: PLR0911
reason: One-line note shown in graph snapshots while you're
waiting (helps a human or sibling agent debug who's stuck
on what).
timeout_seconds: Max seconds to wait (default 300, and values above
that are cut short by a hard ceiling). This is only
timeout_seconds: Max seconds to wait (default 600). This is only
a cap the tool returns the INSTANT a message arrives, so a
larger value never makes you wait longer when the reply does
come. Right-size it to what you're waiting on: a short wait
@@ -276,7 +257,9 @@ async def wait_for_agents( # noqa: PLR0911
bites when the expected message never arrives so an oversized
timeout on a trivial wait just strands you idle until it
elapses. On timeout the tool returns and you decide whether to
keep working or wait again.
keep working or wait again. (Applies to autonomous multi-agent
runs; in interactive/chat sessions the agent instead parks until
a message arrives and this cap is not enforced.)
"""
inner = _ctx(ctx)
coordinator = coordinator_from_context(inner)
@@ -319,7 +302,7 @@ async def wait_for_agents( # noqa: PLR0911
)
if interactive:
await coordinator.park_waiting(me, wait_kind="agents")
await coordinator.park_waiting(me)
return json.dumps(
{
"success": True,
@@ -331,7 +314,7 @@ async def wait_for_agents( # noqa: PLR0911
default=str,
)
await coordinator.park_waiting(me, wait_kind="agents")
await coordinator.park_waiting(me)
try:
await asyncio.wait_for(coordinator.wait_for_message(me), timeout_seconds)
except TimeoutError:
@@ -390,7 +373,7 @@ async def create_agent(
Decompose complex pentests by handing focused subtasks to dedicated
children. The child runs asynchronously the parent continues
immediately and can ``wait_for_agents`` later (or just keep
immediately and can ``wait_for_message`` later (or just keep
working in parallel). When the child calls ``agent_finish``, its
completion report lands in the parent's inbox.
@@ -652,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}"},
+8 -14
View File
@@ -101,7 +101,7 @@ async def finish_scan(
execution stops. There is no draft mode and no second chance: never
submit placeholder, provisional, or "checking if done" text in any
field, and never call ``finish_scan`` to poll whether subagents are
done (use ``view_agent_graph`` / ``wait_for_agents`` for that).
done (use ``view_agent_graph`` / ``wait_for_message`` for that).
Call it exactly ONCE, only when every field holds genuine, finished
assessment prose.
@@ -111,21 +111,17 @@ async def finish_scan(
summary. If ANY agent is in ``running`` / ``waiting`` state,
you MUST NOT call ``finish_scan`` yet
wrap them up first via ``send_message_to_agent`` (ask them to
finish), ``wait_for_agents`` (block until their report
finish), ``wait_for_message`` (block until their report
arrives), or ``stop_agent`` (graceful cancel). Only ``completed``
/ ``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,
@@ -253,8 +249,6 @@ async def finish_scan(
parent_id = inner.get("parent_id")
if coordinator is not None and parent_id is None and me is not None:
active_agents = await coordinator.active_agents_except(me)
if active_agents and coordinator.reserve_stopped:
active_agents = []
else:
active_agents = []
+6 -63
View File
@@ -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
)
-166
View File
@@ -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
View File
@@ -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
View File
@@ -1,6 +0,0 @@
"""User-facing reply tool for interactive sessions."""
from strix.tools.respond.tool import respond_to_user
__all__ = ["respond_to_user"]
-110
View File
@@ -1,110 +0,0 @@
"""``respond_to_user`` — deliver a reply and hand control back to the user."""
from __future__ import annotations
import json
from typing import Any
from agents import RunContextWrapper, function_tool
from strix.core.agents import coordinator_from_context
def _ctx(ctx: RunContextWrapper) -> dict[str, Any]:
return ctx.context if isinstance(ctx.context, dict) else {}
@function_tool
async def respond_to_user(ctx: RunContextWrapper, message: str) -> str:
"""Answer the user and hand control back to them.
This is the ONLY way to yield to the user. Delivering the message and
yielding are the same call on purpose: there is no way to answer and
then forget to stop, and no way to stop without having answered.
Call it when you have something for the user and nothing to do until
they reply you answered their question, you need a decision or a
credential only they can give, or you finished a chunk of work and
want direction. You resume exactly where you left off when they
reply, with everything you have done so far intact.
Do NOT call it to narrate progress or to think out loud. Plain text
is still shown to the user as you work, so say whatever you like
mid-task without stopping; ``respond_to_user`` is specifically the
act of *waiting* for them. Every call costs the user their attention.
Not for these:
- **Waiting on another agent** (a child's report, a peer's reply)
use ``wait_for_agents``.
- **Ending the engagement** use ``finish_scan`` (root) or
``agent_finish`` (subagent). Those are terminal; this is a pause.
Args:
message: What to say to the user. Self-contained: they may not
have followed the tool calls that led here. Lead with the
answer or the decision you need, and if you are blocked, say
exactly what you need from them.
"""
inner = _ctx(ctx)
coordinator = coordinator_from_context(inner)
me = inner.get("agent_id")
interactive = bool(inner.get("interactive", False))
if coordinator is None or me is None:
return json.dumps(
{"success": False, "error": "Agent coordinator or agent_id missing in context"},
ensure_ascii=False,
default=str,
)
if not interactive:
return json.dumps(
{
"success": False,
"error": (
"No user is attached to an autonomous run. Keep working, and call "
"finish_scan (root) or agent_finish (subagent) when the task is done."
),
},
ensure_ascii=False,
default=str,
)
async with coordinator._lock:
stopped = coordinator.statuses.get(me) == "stopped"
if stopped:
return json.dumps(
{"success": True, "wait_outcome": "stopped", "message": message},
ensure_ascii=False,
default=str,
)
# A message that arrived while this turn was running is the user already
# talking: take it now instead of parking for one they have sent.
pending, _ = await coordinator.consume_pending(me)
if pending > 0:
await coordinator.mark_running(me)
return json.dumps(
{
"success": True,
"wait_outcome": "message_arrived",
"pending_messages": pending,
"message": message,
"note": "Your reply was delivered; the user had already sent a new message.",
},
ensure_ascii=False,
default=str,
)
await coordinator.park_waiting(me, wait_kind="user")
return json.dumps(
{
"success": True,
"wait_outcome": "waiting",
"message": message,
"note": "Reply delivered; parked until the user responds.",
},
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"]
@@ -15,12 +15,12 @@ import base64
import contextlib
import json
import logging
import urllib.error
import urllib.request
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import requests
from strix.config.loader import load_settings
@@ -147,17 +147,21 @@ def _post_json(path: str, payload: dict[str, Any], *, timeout: int) -> tuple[int
map, not raised.
"""
url = f"{_app_url()}{path}"
body = json.dumps(payload).encode("utf-8")
request = urllib.request.Request( # noqa: S310 - fixed https relay URL
url,
data=body,
headers={"Content-Type": "application/json", "Accept": "application/json"},
method="POST",
)
try:
response = requests.post(
url,
json=payload,
headers={"Accept": "application/json"},
timeout=timeout,
)
except requests.RequestException as exc:
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
return response.status, _parse_body(response.read())
except urllib.error.HTTPError as exc:
return exc.code, _parse_body(exc.read())
except (urllib.error.URLError, TimeoutError, OSError) as exc:
logger.warning("relay request to %s failed: %s", path, exc)
raise RelayError("unavailable") from exc
return response.status_code, _parse_body(response.content)
def _parse_body(raw: bytes) -> dict[str, Any]:
@@ -203,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,
@@ -257,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)
@@ -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>
);
}
@@ -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&apos;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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
@@ -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"
@@ -54,7 +54,7 @@ export default function AgentCommsRenderer({ toolName, args }: ToolRendererProps
);
}
if (toolName === "wait_for_agents") {
if (toolName === "wait_for_message") {
const reason = (args.reason as string) ?? "";
return (
<div className="flex items-center gap-2">

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