Compare commits

...
Author SHA1 Message Date
Ahmed Allam 10376b412b refactor(context): trim verbose comments 2026-07-26 19:26:51 +00:00
Ahmed Allam 95046a6cea fix(context): reject tool-output byte ceilings below the notice size
A configured tool_output_max_bytes smaller than the truncation notice
itself can't fit a bounded preview, so a persisted result could exceed the
ceiling. Enforce a config floor (ge=1024) so nonsensical values are
rejected at load time instead of being worked around at runtime.
2026-07-25 23:46:11 +00:00
Ahmed Allam aac59de1e5 fix(context): reserve notice budget so bounded output honors max_bytes
The head+tail slices could each take half of max_bytes, then the
truncation notice and its separators were appended on top, so the value
persisted to history could exceed the configured maximum. Reserve an
upper bound for the notice (and separators) out of the byte budget before
slicing so the whole joined result stays within max_bytes.
2026-07-25 23:06:40 +00:00
Ahmed Allam ce358aa879 fix(context): bound native filesystem tool output in Responses mode
Chat-completions mode converts filesystem CustomTools to FunctionTools
(which bounds their result), but the Responses-API path kept them native
and unbounded, so a large read_file could still exhaust the context
window. Always configure the Filesystem capability to head+tail bound
tool output in both modes.
2026-07-25 22:50:50 +00:00
Ahmed Allam dab93bcc12 fix(context): clamp shell output cap and count byte-trimmed dropped lines
Treat tool_output_max_tokens as a ceiling so an explicit model-supplied
cap can't exceed it, and derive the truncation notice's dropped-line
count from the lines actually kept after the byte-trim pass. Also cast
the pygments fallback lexer so it satisfies the resolve_lexer return
type under the pre-commit mypy hook.
2026-07-25 22:32:56 +00:00
Ahmed AllamandDevin AI 3b8980c47b feat(context): bound per-tool output before it enters agent history
Cap the size of every tool result so a single verbose command (recursive
find, noisy scanner, full page dump) can't pin the conversation near the
model's context window for the rest of a scan.

- New ContextSettings config group with env-tunable caps.
- Default the SDK shell tools' max_output_tokens so exec_command /
  write_stdin truncate head+tail instead of returning unbounded output.
- Bound Strix's own FunctionTool/CustomTool results (line + UTF-8 byte
  head+tail preview with a truncation notice) and cap error strings.
2026-07-25 22:31:31 +00:00
Ahmed AllamandAhmed Allam 8157ccba27 refactor(reports): fold fence helpers into report writer 2026-07-25 13:09:57 -07:00
Ahmed AllamandAhmed Allam 95d2e5fba9 fix(reports): safe-fence code-location snippets in markdown copy 2026-07-25 13:09:57 -07:00
Ahmed AllamandAhmed Allam 21243486e2 fix(reports): safe-fence markdown PoC export and share fence helpers 2026-07-25 13:09:57 -07:00
Ahmed AllamandAhmed Allam 97ed7e79a1 fix(reports): auto-detect PoC language with Python fallback 2026-07-25 13:09:57 -07:00
Ahmed AllamandAhmed Allam 31c18f8f75 fix(reports): strip markdown code fence from poc_script_code before rendering 2026-07-25 13:09:57 -07:00
Ahmed AllamandAhmed Allam f23fadfbff Rename root agent to Strix 2026-07-25 11:40:05 -07:00
08126eb518 feat(dedupe): add dedicated deduplication model (#823)
Co-authored-by: oyasumi <oyasumi@kantilabs.xyz>
Co-authored-by: Ahmed Allam <ahmed39652003@gmail.com>
2026-07-25 06:29:39 -07:00
Utku TugrulandGitHub d4f4697533 runtime: resolve staged local-dir path to avoid symlink rejection on macOS (#857) 2026-07-25 05:45:31 -07:00
dvir aradandAhmed Allam 57304b0084 docs(cli): document exit code 1 and clarify exit 0 semantics
The Exit Codes table in the CLI reference only listed 0 and 2, but the
CLI also exits with 1 on fatal errors (missing environment variables,
Docker unavailable, invalid config file, diff-scope resolution failure,
or an unhandled exception). It also implied exit 0 means no
vulnerabilities were found, which is only true in headless mode -
interactive runs always exit 0 regardless of findings.

Document exit code 1 and clarify the two cases for exit 0.
2026-07-25 05:29:13 -07:00
cd8270c98b Sign in with a ChatGPT subscription for inference (#854)
Co-authored-by: Jonathan Singer <jonathansinger@Jonathans-MacBook-Pro.local>
Co-authored-by: Jonathan Singer <jonathansinger@Mac-3004.lan>
Co-authored-by: Ahmed Allam <ahmed39652003@gmail.com>
2026-07-24 15:41:19 -07:00
Ahmed AllamandAhmed Allam 93af2b94a2 chore(deps): bump setuptools to 83.0.0 (GHSA-h35f-9h28-mq5c) 2026-07-24 07:08:50 -07:00
960caf86aa Pin release-workflow actions to commit SHAs + least-privilege token (#862)
Every `uses:` in build-release.yml was a mutable tag; the `release` job has
`contents: write` and publishes the binaries users install, so a compromised
action could tamper the release. Action tag-hijacking keeps recurring
(aquasecurity/trivy-action, 75 tags, Mar 2026 TeamPCP; tj-actions, 2025;
codfish/semantic-release-action, Jun 2026) and SHA-pinned workflows were immune
each time. Pin all six actions to the commit each @major resolves to today
(concrete version in a trailing comment; setup-uv's annotated tag dereferenced
to its commit, not the tag object, so Dependabot tracks it). Also add a
top-level `permissions: contents: read` (the release job keeps its explicit
write) and `persist-credentials: false` on the build checkout.

actionlint passes. Pairs with #860 (Dependabot github-actions keeps the pins
current).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 15:22:28 -04:00
yoniandAhmed Allam 7e02b8d8da Quit after scan instead of hosting local viewer
Remove the post-scan local viewer hosting step so the run exits directly
instead of blocking with 'Hosting the local viewer. Press Ctrl-C to stop.'
Drop the 'View in web' link (and 'Reopen' variant) from the completion
panel, which now always shows 'View  strix view <run_name>'.
2026-07-22 14:29:11 -07:00
alex sandGitHub 137a42c3e3 fix(deps): cap cryptography <49 to keep Intel macOS universal2 wheel (#859)
cryptography 49.x ships arm64-only macOS wheels (no universal2), forcing the
Intel macOS (macos-x86_64) release runner to build from sdist under
`uv sync --frozen`. Pin to 48.0.1, which still clears GHSA-537c-gmf6-5ccf
(fixed in 48.0.1) and provides a macosx_10_9_universal2 wheel.
2026-07-22 16:56:15 -04:00
alex sandGitHub 473b3c4af1 chore(deps): bump cryptography to 49.0.0 and pyasn1 to 0.6.4 (#856)
Resolves GHSA-537c-gmf6-5ccf (vulnerable OpenSSL in cryptography wheels,
fixed in 48.0.1) and CVE-2026-59885 / CVE-2026-59886 (pyasn1 DoS via
OBJECT IDENTIFIER / REAL decoding, fixed in 0.6.4).
2026-07-22 16:33:06 -04:00
alex sandGitHub d1a73a24f8 feat(cli): update notifications + self-update (strix --update) (#807)
* feat(cli): update notifications + self-update (strix --update)

* fix(update): verify release checksum, clean up staged binary, roll back Windows rename on failure

* feat(update): 3-way pre-scan prompt (update now / not now / skip this version) + package-manager upgrade

* fix(update): never show update prompt/notice in non-interactive runs
2026-07-22 16:29:03 -04:00
Ahmed AllamandAhmed Allam a2f5e3acb6 chore: release v1.3.1 2026-07-22 11:47:02 -07:00
Ahmed AllamandAhmed Allam e8c2564595 sandbox: bump install.sh pre-pull tag to 1.1.0 (match runtime default) 2026-07-22 11:40:23 -07:00
Ahmed AllamandAhmed Allam 78594e1645 sandbox: bump default inner-sandbox image to 1.1.0 2026-07-22 11:40:23 -07:00
Ahmed AllamandGitHub 8ec54d9e2b chore: release v1.3.0 (#849) 2026-07-22 09:21:24 -07:00
Ahmed AllamandGitHub 1f7373714b Local viewer: prominent scan switcher + rename to "pentest" terminology (#848) 2026-07-22 09:17:52 -07:00
Ahmed AllamandGitHub ef07bad945 Local viewer: UI polish and a Feedback & support tab (#847) 2026-07-22 08:34:52 -07:00
seanturner83andGitHub 89a707ff51 sandbox: shrink image 7.2GB → 3.8GB (cache cleanup, multi-stage Go build, drop ZAP) (#474) 2026-07-22 07:25:38 -07:00
Ahmed AllamandAhmed Allam 59f49a1fa2 fix(prompt): make root agent orchestrate-only and fold fixing into reporting 2026-07-22 02:56:49 -07:00
alex sandGitHub 2bb730c366 fix(container): keep /app/.venv/bin on the login-shell PATH so python3 finds preinstalled libs (#839) 2026-07-21 23:49:44 -04:00
84 changed files with 5133 additions and 1665 deletions
+11 -6
View File
@@ -6,6 +6,9 @@ on:
- 'v*'
workflow_dispatch:
permissions:
contents: read
jobs:
build:
strategy:
@@ -24,13 +27,15 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false
- uses: actions/setup-python@v5
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: '3.12'
- uses: astral-sh/setup-uv@v5
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
- name: Build
shell: bash
@@ -50,7 +55,7 @@ jobs:
tar -C dist/release -czvf "dist/release/strix-${VERSION}-${{ matrix.target }}.tar.gz" "strix-${VERSION}-${{ matrix.target }}"
fi
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: strix-${{ matrix.target }}
path: |
@@ -65,13 +70,13 @@ jobs:
contents: write
steps:
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
path: release
merge-multiple: true
- name: Create Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
with:
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') }}
generate_release_notes: true
+14
View File
@@ -267,6 +267,20 @@ 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`
+54 -17
View File
@@ -1,3 +1,26 @@
# ---------------------------------------------------------------------------
# Builder stage: compile the Go tools here so the Go toolchain (~225MB) and the
# module/build caches never reach the runtime image. The resulting binaries are
# statically linked and copied into the final stage.
# ---------------------------------------------------------------------------
FROM kalilinux/kali-rolling:latest AS gobuilder
RUN apt-get update && \
apt-get install -y kali-archive-keyring && \
apt-get update && \
apt-get install -y --no-install-recommends golang-go git ca-certificates
ENV GOBIN=/out/bin
RUN mkdir -p /out/bin && \
go install -v github.com/projectdiscovery/httpx/cmd/httpx@latest && \
go install -v github.com/projectdiscovery/katana/cmd/katana@latest && \
go install -v github.com/projectdiscovery/cvemap/cmd/vulnx@latest && \
go install -v github.com/jaeles-project/gospider@latest && \
go install -v github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest
# ---------------------------------------------------------------------------
# Runtime stage
# ---------------------------------------------------------------------------
FROM kalilinux/kali-rolling:latest
LABEL description="AI Agent Penetration Testing Environment with Comprehensive Automated Tools"
@@ -19,14 +42,13 @@ RUN apt-get update && \
apt-get install -y --no-install-recommends \
wget curl git vim nano unzip tar \
apt-transport-https ca-certificates gnupg lsb-release \
build-essential software-properties-common \
gcc libc6-dev pkg-config libpcap-dev libssl-dev \
python3 python3-pip python3-dev python3-venv python3-setuptools \
golang-go \
software-properties-common \
gcc libc6-dev \
python3 python3-pip python3-venv python3-setuptools \
net-tools dnsutils whois \
file xxd \
jq parallel ripgrep grep \
less man-db procps htop \
less procps htop \
iproute2 iputils-ping netcat-traditional \
nmap ncat ndiff \
sqlmap nuclei subfinder naabu ffuf \
@@ -66,11 +88,8 @@ RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/b
USER pentester
WORKDIR /tmp
RUN go install -v github.com/projectdiscovery/httpx/cmd/httpx@latest && \
go install -v github.com/projectdiscovery/katana/cmd/katana@latest && \
go install -v github.com/projectdiscovery/cvemap/cmd/vulnx@latest && \
go install -v github.com/jaeles-project/gospider@latest && \
go install -v github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest
# Go tools are built in the gobuilder stage; copy the static binaries only.
COPY --from=gobuilder --chown=pentester:pentester /out/bin/ /home/pentester/go/bin/
RUN nuclei -update-templates
@@ -87,7 +106,10 @@ RUN npm install -g retire@latest && \
npm install -g js-beautify@latest && \
npm install -g @ast-grep/cli@latest && \
npm install -g tree-sitter-cli@latest && \
npm install -g agent-browser@0.26.0
npm install -g agent-browser@0.26.0 && \
npm cache clean --force && \
# ast-grep ships two identical binaries (`ast-grep` and `sg`); dedupe (~52MB)
ln -sf ast-grep /home/pentester/.npm-global/lib/node_modules/@ast-grep/cli/sg
ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
ENV AGENT_BROWSER_USER_AGENT="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
@@ -132,7 +154,14 @@ RUN git clone https://github.com/aravind0x7/JS-Snooper.git && \
USER root
RUN curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh -s -- -b /usr/local/bin
# Install trufflehog into a pentester-owned dir on PATH so its runtime self-update
# (which replaces the binary in place) succeeds: as non-root `pentester` it cannot
# overwrite a root-owned binary under /usr/local/bin, which otherwise fails with
# "cannot move binary" and aborts the scan. Pin the initial version for
# reproducible builds; self-update then pulls fresh detectors at runtime.
ARG TRUFFLEHOG_VERSION=3.95.9
RUN curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh -s -- -b /home/pentester/.local/bin "v${TRUFFLEHOG_VERSION}" && \
chown -R pentester:pentester /home/pentester/.local
RUN set -eux; \
ARCH="$(uname -m)"; \
case "$ARCH" in \
@@ -146,8 +175,6 @@ RUN set -eux; \
install -m 0755 /tmp/gitleaks /usr/local/bin/gitleaks; \
rm -f /tmp/gitleaks /tmp/gitleaks.tgz
RUN apt-get update && apt-get install -y zaproxy
RUN curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
RUN apt-get install -y wapiti
@@ -163,7 +190,12 @@ USER root
RUN apt-get autoremove -y && \
apt-get autoclean && \
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \
# Purge non-English locales (~160MB)
find /usr/share/locale -mindepth 1 -maxdepth 1 -type d \
! -name 'en' ! -name 'en_US' ! -name 'C' -exec rm -rf {} + && \
# Remove package documentation and man pages not needed at runtime (~95MB)
rm -rf /usr/share/doc/* /usr/share/doc-base/* /usr/share/man/*
ENV PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:/app/.venv/bin:$PATH"
ENV VIRTUAL_ENV="/app/.venv"
@@ -205,8 +237,13 @@ RUN python3 -m venv /app/.venv && \
COPY --chown=pentester:pentester strix/tools/proxy/caido_api.py /opt/strix-python/caido_api.py
ENV PYTHONPATH=/opt/strix-python
RUN echo 'export PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:$PATH"' >> /home/pentester/.bashrc && \
echo 'export PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:$PATH"' >> /home/pentester/.profile
# Login shells (e.g. `bash -lc`) source /etc/profile, which on Debian/Kali
# hard-resets PATH and drops the image's ENV PATH entries. Re-add the same
# directories here — including /app/.venv/bin — so `python3`/`pip` resolve to
# the venv (which ships requests, httpx, bs4, lxml, pyjwt, cryptography, and the
# Caido SDK) instead of the externally-managed system interpreter.
RUN echo 'export PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:/app/.venv/bin:$PATH"' >> /home/pentester/.bashrc && \
echo 'export PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:/app/.venv/bin:$PATH"' >> /home/pentester/.profile
USER root
COPY containers/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
+25
View File
@@ -35,6 +35,31 @@ Configure Strix using environment variables or a config file.
Timeout in seconds for memory compression operations (context summarization).
</ParamField>
### Dedicated deduplication model
Finding deduplication is a cheap, structured classification task. By default it
runs on the main model, but you can route it to a smaller/cheaper model without
affecting the agents that do the actual testing.
<ParamField path="STRIX_DEDUPE_MODEL" type="string">
Model used to judge whether a candidate finding duplicates an existing report.
Falls back to `STRIX_LLM` when unset.
</ParamField>
<ParamField path="DEDUPE_LLM_API_KEY" type="string">
Optional provider key for the deduplication model.
</ParamField>
<ParamField path="DEDUPE_LLM_API_BASE" type="string">
Optional custom API base URL for the deduplication model. Use when the dedupe
model runs on a different endpoint than the main model.
</ParamField>
<ParamField path="STRIX_DEDUPE_REASONING_EFFORT" type="string">
Reasoning effort for the deduplication model. Defaults to the model's own
baseline when unset.
</ParamField>
## Optional Features
<ParamField path="PERPLEXITY_API_KEY" type="string">
+2 -1
View File
@@ -116,5 +116,6 @@ strix --mount ./huge-monorepo
| Code | Meaning |
|------|---------|
| 0 | Scan completed, no vulnerabilities found |
| 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) |
| 2 | Vulnerabilities found (headless mode only) |
+17 -3
View File
@@ -1,6 +1,6 @@
[project]
name = "strix-agent"
version = "1.2.0"
version = "1.3.1"
description = "Open-source AI Hackers for your apps"
readme = "README.md"
license = "Apache-2.0"
@@ -46,7 +46,9 @@ dependencies = [
"caido-sdk-client>=0.2.0",
"reportlab>=4.0",
"pypdf>=5.0",
"cryptography>=42",
# Cap <49: 49.x drops the universal2 macOS wheel (arm64-only), which breaks
# the Intel macOS (macos-x86_64) release build's `uv sync --frozen`.
"cryptography>=48.0.1,<49",
]
[project.optional-dependencies]
@@ -120,6 +122,7 @@ module = [
"pydantic_settings.*",
"reportlab.*",
"pypdf.*",
"pygments.*",
]
ignore_missing_imports = true
disable_error_code = ["import-untyped"]
@@ -213,6 +216,10 @@ ignore = [
# Test doubles use fixture tokens/passwords and match a callee signature whose
# args they intentionally ignore.
"tests/test_viewer_auth.py" = ["S105", "S106", "ARG001"]
"tests/test_codex_auth.py" = ["S105", "S106", "SLF001"]
# Stdlib HTTP handler overrides (do_GET/do_POST).
"strix/interface/auth_cli.py" = ["N802"]
"tests/test_codex_streaming.py" = ["N802"]
"tests/test_report_pdf.py" = ["S105", "S106"]
# Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a
# circular dependency with strix.telemetry / strix.viewer.report_pdf.
@@ -251,9 +258,16 @@ 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"]
"strix/report/state.py" = ["TC003", "PLR0912", "PLR0915", "E501", "PERF401", "PLC0415"]
"strix/report/usage.py" = ["PLC0415"]
# Lazy import of strix.config.models avoids a circular dependency between the
# report pipeline and the config layer.
"strix/report/dedupe.py" = ["PLC0415"]
"strix/telemetry/logging.py" = ["PLC0415"]
"strix/config/models.py" = ["PLC0415"]
# Heavy inference deps (httpx, openai) imported lazily so auth-status checks
# don't pull them in.
"strix/config/codex.py" = ["PLC0415"]
# Interface utility branches per scope-mode / target-type combination;
# splitting would obscure the decision tree without simplifying it.
"strix/interface/utils.py" = ["PLR0912", "BLE001", "PLC0415"]
+1 -1
View File
@@ -4,7 +4,7 @@ set -euo pipefail
APP=strix
REPO="usestrix/strix"
STRIX_IMAGE="ghcr.io/usestrix/strix-sandbox:1.0.0"
STRIX_IMAGE="ghcr.io/usestrix/strix-sandbox:1.1.0"
MUTED='\033[0;2m'
RED='\033[0;31m'
+83 -13
View File
@@ -16,6 +16,7 @@ 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,
@@ -33,6 +34,7 @@ from strix.tools.notes.tools import (
list_notes,
update_note,
)
from strix.tools.output_store import bound_text
from strix.tools.proxy.tools import (
list_requests,
list_sitemap,
@@ -103,8 +105,36 @@ 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
def _bound_result(result: Any) -> Any:
if not isinstance(result, str):
return result
max_lines, max_bytes = _tool_output_limits()
return bound_text(result, max_lines=max_lines, max_bytes=max_bytes)
def _format_tool_error(exc: Exception) -> str:
return str(exc) or exc.__class__.__name__
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 _bound_result(await invoke_tool(ctx, raw_input))
tool.on_invoke_tool = invoke
tool._strix_bounded = True # type: ignore[attr-defined]
return tool
def _function_tool_with_error_result(tool: FunctionTool) -> FunctionTool:
@@ -112,7 +142,7 @@ def _function_tool_with_error_result(tool: FunctionTool) -> FunctionTool:
async def invoke(ctx: Any, raw_input: str) -> Any:
try:
return await invoke_tool(ctx, raw_input)
return _bound_result(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)
@@ -127,7 +157,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 tool.on_invoke_tool(ctx, custom_input)
return _bound_result(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)
@@ -159,12 +189,35 @@ def _custom_tool_as_function_tool(tool: CustomTool) -> FunctionTool:
)
def _configure_chat_completions_filesystem_tools(toolset: Any) -> None:
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 _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:
for name, tool in vars(toolset).items():
if isinstance(tool, CustomTool):
setattr(toolset, name, _custom_tool_as_function_tool(tool))
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))
elif isinstance(tool, FunctionTool):
setattr(toolset, name, _function_tool_with_error_result(tool))
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
_CHARS_ESCAPE_RE = re.compile(r"\\(?:u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[0abtnvfr\\])")
@@ -205,6 +258,16 @@ 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
@@ -213,8 +276,10 @@ def _wrap_exec_command(tool: FunctionTool) -> FunctionTool:
parsed = json.loads(raw_input)
except (json.JSONDecodeError, TypeError):
parsed = None
if isinstance(parsed, dict) and "shell" not in parsed:
parsed["shell"] = "bash"
if isinstance(parsed, dict):
if "shell" not in parsed:
parsed["shell"] = "bash"
_apply_shell_output_cap(parsed)
raw_input = json.dumps(parsed)
try:
return await invoke_tool(ctx, raw_input)
@@ -240,8 +305,10 @@ def _wrap_write_stdin(tool: FunctionTool) -> FunctionTool:
parsed = json.loads(raw_input)
except json.JSONDecodeError:
parsed = None
if isinstance(parsed, dict) and isinstance(parsed.get("chars"), str):
parsed["chars"] = _decode_chars_escape(parsed["chars"])
if isinstance(parsed, dict):
if isinstance(parsed.get("chars"), str):
parsed["chars"] = _decode_chars_escape(parsed["chars"])
_apply_shell_output_cap(parsed)
raw_input = json.dumps(parsed)
try:
return await invoke_tool(ctx, raw_input)
@@ -440,6 +507,9 @@ def build_strix_agent(
else:
tools = [*_BASE_TOOLS, *agent_tools, agent_finish]
_ensure_unique_tool_names(tools)
tools = [
_with_bounded_result(tool) if isinstance(tool, FunctionTool) else tool for tool in tools
]
logger.info(
"Built %s agent '%s' (skills=%d, tools=%d, scan_mode=%s, whitebox=%s)",
@@ -459,8 +529,8 @@ def build_strix_agent(
model=None,
capabilities=[
Filesystem(
configure_tools=(
_configure_chat_completions_filesystem_tools if chat_completions_tools else None
configure_tools=_make_filesystem_configurator(
chat_completions=chat_completions_tools,
),
),
Shell(
+1
View File
@@ -91,6 +91,7 @@ def render_system_prompt(
loaded_skill_names=list(skill_content.keys()),
available_skills=get_available_skills(),
interactive=interactive,
is_root=is_root,
system_prompt_context=system_prompt_context or {},
**skill_content,
)
+27 -17
View File
@@ -1,5 +1,14 @@
You are Strix, an advanced AI application security validation agent developed by OmniSecure Labs. Your purpose is to perform authorized security verification, reproduce and validate weaknesses on in-scope assets, and help remediate real security issues.
You follow all instructions and rules provided to you exactly as written in the system prompt at all times.
{% if is_root %}
<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
@@ -125,10 +134,8 @@ WHITE-BOX TESTING (code provided):
- Local execution, unit/integration testing, patch verification, and HTTP requests against locally started in-scope services are normal authorized white-box validation
- If dynamically running the code proves impossible after exhaustive attempts, pivot to comprehensive static analysis.
- Try to infer how to run the code based on its structure and content.
- FIX discovered vulnerabilities in code in same file.
- Test patches to confirm vulnerability removal.
- Do not stop until all reported vulnerabilities are fixed.
- Include code diff in final report.
- Derive the code fix as PART OF reporting, not as a separate later pass: create_vulnerability_report already requires the concrete patch inline (`code_locations` with verbatim `fix_before`/`fix_after` and `fix_pr_body`), so the reporting agent that analyzes the root cause is the one that produces the fix. Do NOT spawn a downstream agent afterwards to re-derive/re-apply the same patch.
- If you also apply and verify the patch in the repo (edit the file, re-test that the vulnerability is gone), do it in the same agent/turn while the analysis is fresh — right before or as part of filing the report — never as a second re-analysis pass.
COMBINED MODE (code + deployed target present):
- Treat this as static analysis plus dynamic testing simultaneously
@@ -189,7 +196,7 @@ EFFICIENCY TACTICS:
- For Caido proxy automation inside Python, explicitly import from
`caido_api`:
`from caido_api import list_requests, view_request, repeat_request, list_sitemap, view_sitemap_entry, scope_rules`
- Prefer established fuzzers/scanners where applicable: ffuf, sqlmap, zaproxy, nuclei, wapiti, arjun, httpx, katana, semgrep, bandit, trufflehog, nmap. Use scripts mainly to coordinate or validate around them, not to replace them without reason
- Prefer established fuzzers/scanners where applicable: ffuf, sqlmap, nuclei, wapiti, arjun, httpx, katana, semgrep, bandit, trufflehog, nmap. Use scripts mainly to coordinate or validate around them, not to replace them without reason
- For trial-heavy vectors (SQLi, XSS, XXE, SSRF, RCE, auth/JWT, deserialization), DO NOT iterate payloads manually in the browser. Always spray payloads via Python scripts through `exec_command` or terminal tools.
- When using established fuzzers/scanners, use the proxy for inspection where helpful
- Generate/adapt large payload corpora: combine encodings (URL, unicode, base64), comment styles, wrappers, time-based/differential probes. Expand with wordlists/templates
@@ -206,7 +213,7 @@ VALIDATION REQUIREMENTS:
- Document complete attack chain
- Keep going until you find something that matters
- A vulnerability is ONLY considered reported when a reporting agent uses create_vulnerability_report (or create_dependency_report for known-CVE dependency/supply-chain findings) with full details. Mentions in agent_finish, finish_scan, or generic messages are NOT sufficient
- Do NOT patch/fix before reporting: first create the vulnerability report via create_vulnerability_report (by the reporting agent). Only after reporting is completed should fixing/patching proceed
- Reporting and fixing are ONE step, not two: when source is available, the reporting agent derives the concrete fix and files it INLINE via create_vulnerability_report (`code_locations` with `fix_before`/`fix_after` + `fix_pr_body`) — the report is not complete without it. Do NOT report first and then spawn a separate downstream agent to re-derive and re-apply the same patch; that just re-does the analysis and wastes tokens. (Do not silently patch a finding WITHOUT filing a report — the report, with its embedded fix, is the deliverable.)
- DEDUPLICATION: The create_vulnerability_report tool uses LLM-based deduplication. If it rejects your report as a duplicate, DO NOT attempt to re-submit the same vulnerability. Accept the rejection and move on to testing other areas. The vulnerability has already been reported by another agent
</execution_guidelines>
@@ -262,7 +269,9 @@ DISK & SCRATCH HYGIENE:
- If disk gets tight or a write fails for space, check what's large under /workspace and clean up files from your own task; leave another agent's files unless you've confirmed they're no longer in use
MANDATORY INITIAL PHASES:
{% if is_root %}
- ROOT AGENT: these phases are mandatory for the assessment, but you MUST accomplish them by delegating to reconnaissance/mapping subagents — do NOT run recon, crawling, enumeration, or mapping tools in your own turns. Spawn the appropriate subagent(s) and track their coverage.
{% endif %}
BLACK-BOX TESTING - PHASE 1 (RECON & MAPPING):
- COMPLETE full reconnaissance: subdomain enumeration, port scanning, service detection
- MAP entire attack surface: all endpoints, parameters, APIs, forms, inputs
@@ -291,13 +300,14 @@ ROOT AGENT ROLE:
- The root agent should coordinate strategy, delegate meaningful work, track progress, maintain todo lists, maintain notes, monitor subagent results, and decide next steps
- The root agent should keep a clear view of overall coverage, uncovered attack surfaces, validation status, and reporting/fixing progress
- The root agent should avoid spending its own iterations on detailed testing, payload execution, or deep target-specific investigation when that work can be delegated to specialized subagents
- The root agent may do lightweight triage, quick verification, or setup work when necessary to unblock delegation, but its default mode should be coordinator/controller
- The root agent may do orchestration-support work needed to delegate well — reading scope/config, inspecting workspace layout, reading subagent output/reports, and light bookkeeping. It must NOT do the actual security testing itself: no running scanners/fuzzers/crawlers, no sending injection/XSS/SSRF/etc. payloads, and no "basic" or "quick" probing of discovered endpoints. If a check requires touching the target, delegate it to a subagent rather than doing it yourself
- Its default and near-exclusive mode is coordinator/controller
- Subagents should do the substantive testing, validation, reporting, and fixing work
- The root agent is responsible for ensuring that work is broken down clearly, tracked, and completed across the agent tree
1. **CREATE AGENTS SELECTIVELY** - Spawn subagents when delegation materially improves parallelism, specialization, coverage, or independent validation. Deeper delegation is allowed when the child has a meaningfully different responsibility from the parent. Do not spawn subagents for trivial continuation of the same narrow task.
2. **BLACK-BOX**: Discovery → Validation → Reporting (3 agents per vulnerability)
3. **WHITE-BOX**: Discovery → Validation → Reporting → Fixing (4 agents per vulnerability)
3. **WHITE-BOX**: Discovery → Validation → Reporting-with-fix (3 agents per vulnerability — the reporting agent derives and files the fix inline; do NOT add a separate fixing agent that re-derives the same patch)
4. **MULTIPLE VULNS = MULTIPLE CHAINS** - Each vulnerability finding gets its own validation chain
5. **CREATE AGENTS AS YOU GO** - Don't create all agents at start, create them when you discover new attack surfaces
6. **ONE JOB PER AGENT** - Each agent has ONE specific task only
@@ -316,8 +326,7 @@ BLACK-BOX (domain/URL only):
WHITE-BOX (source code provided):
- Found authentication code issues? → Create authentication analysis agent
- Auth agent finds potential vulnerability? → Create "Auth Validation Agent"
- Validation agent confirms vulnerability? → Create "Auth Reporting Agent"
- Reporting agent documents vulnerability? → Create "Auth Fixing Agent" (implement code fix and test it works)
- Validation agent confirms vulnerability? → Create "Auth Reporting Agent" that files the report AND its inline fix (`code_locations` + `fix_pr_body`) in one shot — no separate fixing agent
VULNERABILITY WORKFLOW (MANDATORY FOR EVERY FINDING):
@@ -338,9 +347,11 @@ Authentication Code Agent finds weak password validation
Spawns "Auth Validation Agent" (proves it's exploitable)
If valid → Spawns "Auth Reporting Agent" (creates vulnerability report)
If valid → Spawns "Auth Reporting Agent" (creates the vulnerability report
WITH the fix inline: code_locations fix_before/fix_after + fix_pr_body,
applying/verifying the patch in the same turn if desired)
Spawns "Auth Fixing Agent" (implements secure code fix)
STOP - no separate fixing agent; the fix was derived once, at report time
```
CRITICAL RULES:
@@ -376,7 +387,7 @@ FOCUS PRINCIPLES:
REALISTIC TESTING OUTCOMES:
- **No Findings**: Agent completes testing but finds no vulnerabilities
- **Validation Failed**: Initial finding was false positive, validation agent confirms it's not exploitable
- **Valid Vulnerability**: Validation succeeds, spawns reporting agent and then fixing agent (white-box)
- **Valid Vulnerability**: Validation succeeds, spawns a reporting agent that files the report with the fix inline (white-box) — no separate fixing agent
PERSISTENCE IS MANDATORY:
- Real vulnerabilities take TIME - expect to need 2000+ steps minimum
@@ -401,7 +412,6 @@ VULNERABILITY ASSESSMENT:
- nuclei - Vulnerability scanner with templates
- sqlmap - SQL injection detection/exploitation
- trivy - Container/dependency vulnerability scanner
- zaproxy - OWASP ZAP web app scanner
- wapiti - Web vulnerability scanner
WEB FUZZING & DISCOVERY:
@@ -439,10 +449,10 @@ PROXY & INTERCEPTION:
- Ignore Caido proxy-generated 50x HTML error pages; these are proxy issues (might happen when requesting a wrong host or SSL/TLS issues, etc).
PROGRAMMING:
- Python 3, uv, Go, Node.js/npm
- Python 3, uv, Node.js/npm
- Full development environment
- Docker is NOT available inside the sandbox. Do not run docker; rely on provided tools to run locally.
- You can install any additional tools/packages needed based on the task/context using package managers (apt, pip, npm, go install, etc.)
- You can install any additional tools/packages needed based on the task/context using package managers (apt, pip, npm, etc.)
Directories:
- /workspace - where you should work.
+4
View File
@@ -17,6 +17,8 @@ from strix.config.loader import (
persist_current,
)
from strix.config.settings import (
ContextSettings,
DedupeSettings,
IntegrationSettings,
LlmSettings,
RuntimeSettings,
@@ -26,6 +28,8 @@ from strix.config.settings import (
__all__ = [
"ContextSettings",
"DedupeSettings",
"IntegrationSettings",
"LlmSettings",
"RuntimeSettings",
+411
View File
@@ -0,0 +1,411 @@
"""ChatGPT (Codex) subscription auth: OAuth login, token refresh, and the OpenAI
client that routes inference through the ChatGPT backend.
Mirrors OpenAI's Codex CLI: OAuth 2.0 + PKCE against ``auth.openai.com``, with the
access token sent as a ``Bearer`` token to ``chatgpt.com/backend-api/codex``. Using
a ChatGPT subscription outside OpenAI's own products is not officially supported by
OpenAI; the user chooses this path knowingly. The OAuth constants are OpenAI's own
Codex CLI values (the backend only accepts that client).
"""
from __future__ import annotations
import base64
import contextlib
import hashlib
import json
import logging
import secrets
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Iterator
from openai import AsyncOpenAI
logger = logging.getLogger(__name__)
PROVIDER = "codex"
CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"
TOKEN_URL = "https://auth.openai.com/oauth/token" # noqa: S105 # nosec B105 - URL, not a secret
CALLBACK_HOST = "localhost"
CALLBACK_PORT = 1455
CALLBACK_PATH = "/auth/callback"
REDIRECT_URI = f"http://{CALLBACK_HOST}:{CALLBACK_PORT}{CALLBACK_PATH}"
SCOPE = "openid profile email offline_access"
CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
ORIGINATOR = "codex_cli_rs"
_ACCOUNT_CLAIM = "https://api.openai.com/auth"
_TOKEN_TIMEOUT = 30
_EXPIRY_SKEW_S = 300
_refresh_lock = threading.Lock()
# Kept separate from cli-config.json so OAuth tokens never land in the env-var config.
AUTH_PATH = Path.home() / ".strix" / "subscription-auth.json"
def _read_store() -> dict[str, Any]:
try:
data = json.loads(AUTH_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return data if isinstance(data, dict) else {}
def _write_store(data: dict[str, Any]) -> None:
AUTH_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = AUTH_PATH.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
with contextlib.suppress(OSError):
tmp.chmod(0o600)
tmp.replace(AUTH_PATH)
with contextlib.suppress(OSError):
AUTH_PATH.chmod(0o600)
def read_record() -> dict[str, Any] | None:
record = _read_store().get(PROVIDER)
if not isinstance(record, dict) or record.get("type") != "oauth":
return None
if not (record.get("access") and record.get("refresh") and record.get("account_id")):
return None
return record
def is_authenticated() -> bool:
return read_record() is not None
def save_record(record: dict[str, Any]) -> None:
data = _read_store()
data[PROVIDER] = record
_write_store(data)
def logout() -> None:
data = _read_store()
if PROVIDER not in data:
return
del data[PROVIDER]
if data:
_write_store(data)
return
with contextlib.suppress(OSError):
AUTH_PATH.unlink()
@contextlib.contextmanager
def _refresh_guard() -> Iterator[None]:
"""Serialize token refresh within (lock) and across (flock) Strix processes,
so concurrent runs can't both spend the single-use refresh token."""
with _refresh_lock:
try:
import fcntl
lock_path = AUTH_PATH.with_suffix(".lock")
lock_path.parent.mkdir(parents=True, exist_ok=True)
handle = lock_path.open("w")
except (ImportError, OSError):
yield
return
try:
with contextlib.suppress(OSError):
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
yield
finally:
with contextlib.suppress(OSError):
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
handle.close()
class CodexAuthError(Exception):
def __init__(self, code: str, message: str | None = None) -> None:
self.code = code
super().__init__(message or code)
class CodexContentGuardrailError(Exception):
"""The ChatGPT backend refused a request via its content guardrail.
Terminal — retrying identical content never clears the block."""
def __init__(self, model: str, original: BaseException | None = None) -> None:
self.model = model
self.original = original
super().__init__(
f"'{model}' was blocked by ChatGPT's content guardrails "
f"(flagged as a possible cybersecurity risk). "
f"Set STRIX_LLM to a model that isn't blocked and re-run."
)
_GUARDRAIL_MARKERS = (
"flagged for possible cybersecurity risk",
"trusted access for cyber",
)
def is_content_guardrail_error(exc: BaseException) -> bool:
if isinstance(exc, CodexContentGuardrailError):
return True
text = str(exc).lower()
return any(marker in text for marker in _GUARDRAIL_MARKERS)
def _b64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def generate_pkce() -> tuple[str, str]:
verifier = _b64url(secrets.token_bytes(64))
challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
return verifier, challenge
def create_state() -> str:
return secrets.token_hex(16)
def build_authorize_url(challenge: str, state: str) -> str:
params = {
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"scope": SCOPE,
"code_challenge": challenge,
"code_challenge_method": "S256",
"state": state,
"id_token_add_organizations": "true",
"codex_cli_simplified_flow": "true",
"originator": ORIGINATOR,
}
return f"{AUTHORIZE_URL}?{urllib.parse.urlencode(params)}"
def parse_redirect_input(value: str) -> tuple[str | None, str | None]:
"""Extract ``(code, state)`` from a pasted redirect URL, ``code#state``,
query string, or bare code."""
value = (value or "").strip()
if not value:
return None, None
with contextlib.suppress(ValueError):
parsed = urllib.parse.urlparse(value)
if parsed.scheme and parsed.query:
query = urllib.parse.parse_qs(parsed.query)
return _first(query, "code"), _first(query, "state")
if "#" in value:
code, _, state = value.partition("#")
return code or None, state or None
if "code=" in value:
query = urllib.parse.parse_qs(value)
return _first(query, "code"), _first(query, "state")
return value, None
def _first(query: dict[str, list[str]], key: str) -> str | None:
values = query.get(key)
return values[0] if values else None
def _post_form(payload: dict[str, str]) -> dict[str, Any]:
body = urllib.parse.urlencode(payload).encode("ascii")
request = urllib.request.Request( # noqa: S310 - fixed https OAuth endpoint
TOKEN_URL,
data=body,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen( # noqa: S310 # nosec B310 - fixed https endpoint
request, timeout=_TOKEN_TIMEOUT
) as response:
data = json.loads(response.read() or b"{}")
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")[:300]
raise CodexAuthError("token_http_error", f"HTTP {exc.code}: {detail}") from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise CodexAuthError("unavailable", str(exc)) from exc
if not isinstance(data, dict):
raise CodexAuthError("bad_response", "token endpoint returned non-object")
return data
def _record_from_token_response(
data: dict[str, Any], refresh_fallback: str | None = None
) -> dict[str, Any]:
access = data.get("access_token")
# A refresh response may omit refresh_token when it isn't rotated; keep the old one.
refresh = data.get("refresh_token") or refresh_fallback
expires_in = data.get("expires_in")
if not isinstance(access, str) or not access:
raise CodexAuthError("bad_response", "token response missing access_token")
if not isinstance(refresh, str) or not refresh:
raise CodexAuthError("bad_response", "token response missing refresh_token")
account_id = _account_id_from_jwt(access) or _account_id_from_jwt(
data.get("id_token") if isinstance(data.get("id_token"), str) else ""
)
if not account_id:
raise CodexAuthError("no_account_id", "could not read chatgpt_account_id from token")
ttl = expires_in if isinstance(expires_in, int | float) else 3600
return {
"type": "oauth",
"provider": PROVIDER,
"access": access,
"refresh": refresh,
"account_id": account_id,
"expires_at": time.time() + ttl,
}
def exchange_code(code: str, verifier: str) -> dict[str, Any]:
data = _post_form(
{
"grant_type": "authorization_code",
"client_id": CLIENT_ID,
"code": code,
"code_verifier": verifier,
"redirect_uri": REDIRECT_URI,
}
)
return _record_from_token_response(data)
def refresh_tokens(refresh_token: str) -> dict[str, Any]:
data = _post_form(
{
"grant_type": "refresh_token",
"client_id": CLIENT_ID,
"refresh_token": refresh_token,
}
)
return _record_from_token_response(data, refresh_fallback=refresh_token)
def _account_id_from_jwt(token: str | None) -> str | None:
"""Read the account id claim without verifying the JWT (the server enforces
authenticity on use); it feeds the ``chatgpt-account-id`` header."""
if not token or token.count(".") != 2:
return None
payload_b64 = token.split(".")[1]
padding = "=" * (-len(payload_b64) % 4)
try:
payload = json.loads(base64.urlsafe_b64decode(payload_b64 + padding))
except (ValueError, json.JSONDecodeError):
return None
if not isinstance(payload, dict):
return None
auth = payload.get(_ACCOUNT_CLAIM)
if isinstance(auth, dict):
account_id = auth.get("chatgpt_account_id")
if isinstance(account_id, str) and account_id:
return account_id
organizations = payload.get("organizations")
if isinstance(organizations, list) and organizations and isinstance(organizations[0], dict):
org_id = organizations[0].get("id")
if isinstance(org_id, str) and org_id:
return org_id
return None
def _near_expiry(record: dict[str, Any]) -> bool:
expires_at = record.get("expires_at")
if not isinstance(expires_at, int | float):
return True
return expires_at - _EXPIRY_SKEW_S <= time.time()
def get_valid_token() -> tuple[str, str]:
"""Return ``(access_token, account_id)``, refreshing under the cross-process
guard if near expiry."""
record = read_record()
if record is None:
raise CodexAuthError("not_authenticated", "not signed in; run: strix auth login")
if not _near_expiry(record):
return record["access"], record["account_id"]
with _refresh_guard():
record = read_record()
if record is None:
raise CodexAuthError("not_authenticated", "not signed in; run: strix auth login")
if not _near_expiry(record):
return record["access"], record["account_id"]
try:
refreshed = refresh_tokens(record["refresh"])
except CodexAuthError:
# A peer process may have already spent this single-use refresh token.
latest = read_record()
if latest and latest["refresh"] != record["refresh"] and not _near_expiry(latest):
return latest["access"], latest["account_id"]
raise
save_record(refreshed)
return refreshed["access"], refreshed["account_id"]
def build_openai_client() -> AsyncOpenAI:
"""An ``AsyncOpenAI`` for the ChatGPT backend. A per-request hook re-stamps a
fresh bearer token so long scans survive token expiry."""
import asyncio
import httpx
from openai import AsyncOpenAI
get_valid_token() # fail fast at configure time if the sign-in is dead
async def _auth_hook(request: httpx.Request) -> None:
access, account_id = await asyncio.to_thread(get_valid_token)
request.headers["Authorization"] = f"Bearer {access}"
request.headers["chatgpt-account-id"] = account_id
http_client = httpx.AsyncClient(
timeout=httpx.Timeout(600.0, connect=30.0),
event_hooks={"request": [_auth_hook]},
)
return AsyncOpenAI(
api_key="strix-codex-oauth", # placeholder; the hook overwrites Authorization
base_url=CODEX_BASE_URL,
http_client=http_client,
default_headers={
"OpenAI-Beta": "responses=experimental",
"originator": ORIGINATOR,
},
)
_subscription_client: AsyncOpenAI | None = None
def get_subscription_client() -> AsyncOpenAI:
global _subscription_client # noqa: PLW0603
if _subscription_client is None:
_subscription_client = build_openai_client()
return _subscription_client
SUBSCRIPTION_PREFIX = "chatgpt/"
def subscription_model(model_name: str | None) -> str | None:
"""The model slug behind a ``chatgpt/<model>`` STRIX_LLM, or None."""
name = (model_name or "").strip()
if not name.lower().startswith(SUBSCRIPTION_PREFIX):
return None
return name[len(SUBSCRIPTION_PREFIX) :] or None
def auth_mode(model_name: str | None) -> str:
return "subscription" if subscription_model(model_name) else "api_key"
+128 -12
View File
@@ -2,23 +2,38 @@
from __future__ import annotations
import contextlib
import inspect
import os
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from agents import set_default_openai_api, set_default_openai_key, set_tracing_disabled
from agents import (
set_default_openai_api,
set_default_openai_key,
set_tracing_disabled,
)
from agents.model_settings import ModelSettings
from agents.models.multi_provider import MultiProvider
from agents.models.openai_responses import OpenAIResponsesModel
from agents.retry import (
ModelRetryBackoffSettings,
ModelRetrySettings,
RetryPolicyContext,
retry_policies,
)
from openai.types.shared import Reasoning
from strix.config import codex
from strix.config.loader import load_settings
if TYPE_CHECKING:
from agents.models.interface import ModelProvider
from collections.abc import AsyncIterator
from strix.config.settings import Settings
from agents.models.interface import Model, ModelProvider
from openai import AsyncOpenAI
from strix.config.settings import ReasoningEffort, Settings
def request_timeout_extra_args(timeout_s: float | None) -> dict[str, float] | None:
@@ -33,9 +48,93 @@ def _retry_statusless_provider_errors(context: RetryPolicyContext) -> bool:
normalized = context.normalized
if normalized.is_abort:
return False
if codex.is_content_guardrail_error(context.error):
return False
return normalized.status_code is None
class _CodexResponsesModel(OpenAIResponsesModel):
"""Responses model for the ChatGPT subscription backend (always streamed, stateless)."""
def __init__(
self,
model: str,
openai_client: AsyncOpenAI,
*,
reasoning_effort: ReasoningEffort | None = None,
) -> None:
super().__init__(model, openai_client)
self._reasoning_effort = reasoning_effort
def _codex_settings(self, model_settings: ModelSettings) -> ModelSettings:
overrides = ModelSettings(store=False, response_include=["reasoning.encrypted_content"])
effort = self._reasoning_effort
if effort and effort != "none":
# Clamp to efforts the backend accepts.
if effort == "minimal":
effort = "low"
elif effort == "xhigh":
effort = "high"
overrides = overrides.resolve(ModelSettings(reasoning=Reasoning(effort=effort)))
return model_settings.resolve(overrides)
async def _fetch_response(self, *args: Any, stream: bool = False, **kwargs: Any) -> Any:
if len(args) >= 3: # model_settings is positional arg 2
args = (*args[:2], self._codex_settings(args[2]), *args[3:])
try:
events = await super()._fetch_response(*args, stream=True, **kwargs) # type: ignore[call-overload]
except Exception as exc:
guardrail = self._as_guardrail(exc)
if guardrail is not None:
raise guardrail from exc
raise
guarded = self._guarded(events)
if stream:
return guarded
final_response = None
async for event in guarded:
if getattr(event, "type", None) == "response.completed":
final_response = event.response
if final_response is None:
msg = "ChatGPT backend stream ended without a completed response"
raise RuntimeError(msg)
return final_response
def _as_guardrail(self, exc: BaseException) -> codex.CodexContentGuardrailError | None:
if isinstance(exc, codex.CodexContentGuardrailError):
return exc
if codex.is_content_guardrail_error(exc):
return codex.CodexContentGuardrailError(self.model, exc)
return None
async def _guarded(self, events: Any) -> AsyncIterator[Any]:
"""Convert mid-stream guardrail rejections and close the stream on exit."""
try:
async for event in events:
yield event
except Exception as exc:
guardrail = self._as_guardrail(exc)
if guardrail is not None:
raise guardrail from exc
raise
finally:
await self._aclose(events)
@staticmethod
async def _aclose(events: Any) -> None:
aclose = getattr(events, "aclose", None)
if callable(aclose):
with contextlib.suppress(Exception):
await aclose()
return
close = getattr(events, "close", None)
if callable(close):
with contextlib.suppress(Exception):
result = close()
if inspect.isawaitable(result):
await result
class StrixProvider(MultiProvider):
"""Route any non-OpenAI prefix through LiteLLM with the prefix preserved,
so users type ``deepseek/deepseek-chat`` rather than
@@ -59,6 +158,16 @@ class StrixProvider(MultiProvider):
return self._get_fallback_provider("litellm"), f"ollama_chat/{stripped_model_name}"
return self._get_fallback_provider("litellm"), original_model_name
def get_model(self, model_name: str | None) -> Model:
slug = codex.subscription_model(model_name)
if slug:
return _CodexResponsesModel(
slug,
codex.get_subscription_client(),
reasoning_effort=load_settings().llm.reasoning_effort,
)
return super().get_model(model_name)
DEFAULT_MODEL_RETRY = ModelRetrySettings(
max_retries=5,
@@ -77,39 +186,42 @@ DEFAULT_MODEL_RETRY = ModelRetrySettings(
)
RECOMMENDED_MODEL_NAMES = (
"openai/gpt-5.6",
"openai/gpt-5.6-sol",
"openai/gpt-5.6-terra",
"openai/gpt-5.5",
"openai/gpt-5.6-luna",
"openai/gpt-5.6",
"openai/gpt-5.5-pro",
"openai/gpt-5.5",
"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", "openai"), ("gpt-5",)),
(("azure", "azure_ai", "bedrock_mantle", "chatgpt", "openai"), ("gpt-5",)),
(
("anthropic", "azure_ai", "bedrock", "claude", "databricks", "snowflake", "vertex_ai"),
("claude-fable-5", "claude-opus-4", "claude-sonnet-5", "claude-sonnet-4"),
("claude-fable-5", "claude-opus-5", "claude-opus-4", "claude-sonnet-5", "claude-sonnet-4"),
),
(("google", "gemini", "vertex_ai"), ("gemini-3",)),
(("deepseek",), ("deepseek-v4", "deepseek-r1", "deepseek-reasoner")),
(("alibaba", "dashscope", "qwen"), ("qwen3.7", "qwen3.5", "qwen3-max")),
(("moonshot", "moonshotai", "kimi"), ("kimi-k2.7", "kimi-k2.6", "kimi-k2.5")),
(("alibaba", "dashscope", "qwen"), ("qwen3.8", "qwen3.7", "qwen3-max")),
(("moonshot", "moonshotai", "kimi"), ("kimi-k3", "kimi-k2.7", "kimi-k2.6")),
)
@@ -117,6 +229,8 @@ 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:
@@ -211,6 +325,8 @@ 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
+35 -1
View File
@@ -43,11 +43,43 @@ class LlmSettings(BaseSettings):
timeout: int = Field(default=300, alias="LLM_TIMEOUT")
class DedupeSettings(BaseSettings):
model_config = _BASE_CONFIG
model: str | None = Field(default=None, alias="STRIX_DEDUPE_MODEL")
reasoning_effort: ReasoningEffort | None = Field(
default=None,
alias="STRIX_DEDUPE_REASONING_EFFORT",
)
api_key: str | None = Field(default=None, alias="DEDUPE_LLM_API_KEY")
api_base: str | None = Field(default=None, alias="DEDUPE_LLM_API_BASE")
class ContextSettings(BaseSettings):
"""Context-window management: per-tool-output caps and history compaction."""
model_config = _BASE_CONFIG
auto_compact: bool = Field(default=True, alias="STRIX_CONTEXT_AUTO_COMPACT")
compact_buffer_tokens: int = Field(default=20_000, gt=0, alias="STRIX_CONTEXT_BUFFER_TOKENS")
keep_tokens: int = Field(default=8_000, gt=0, alias="STRIX_CONTEXT_KEEP_TOKENS")
fallback_context_tokens: int = Field(
default=200_000, gt=0, alias="STRIX_CONTEXT_FALLBACK_TOKENS"
)
summary_max_tokens: int = Field(default=4_096, gt=0, alias="STRIX_CONTEXT_SUMMARY_TOKENS")
tool_output_max_tokens: int = Field(default=8_000, gt=0, alias="STRIX_TOOL_OUTPUT_MAX_TOKENS")
tool_output_max_lines: int = Field(default=2_000, gt=0, alias="STRIX_TOOL_OUTPUT_MAX_LINES")
# Floor above the truncation-notice size so a preview always fits.
tool_output_max_bytes: int = Field(
default=50 * 1024, ge=1024, alias="STRIX_TOOL_OUTPUT_MAX_BYTES"
)
class RuntimeSettings(BaseSettings):
model_config = _BASE_CONFIG
image: str = Field(
default="ghcr.io/usestrix/strix-sandbox:1.0.0",
default="ghcr.io/usestrix/strix-sandbox:1.1.0",
alias="STRIX_IMAGE",
)
backend: str = Field(default="docker", alias="STRIX_RUNTIME_BACKEND")
@@ -85,7 +117,9 @@ 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)
+18 -3
View File
@@ -41,6 +41,7 @@ class AgentCoordinator:
self.names: dict[str, str] = {}
self.metadata: dict[str, dict[str, Any]] = {}
self.pending_counts: dict[str, int] = {}
self.errors: dict[str, str] = {}
self.runtimes: dict[str, AgentRuntime] = {}
self._lock = asyncio.Lock()
self._snapshot_path: Path | None = None
@@ -107,16 +108,23 @@ class AgentCoordinator:
async with self._lock:
if agent_id in self.statuses:
self.statuses[agent_id] = "running"
self.errors.pop(agent_id, None)
await self._maybe_snapshot()
async def park_waiting(self, agent_id: str) -> None:
await self.set_status(agent_id, "waiting")
async def set_status(self, agent_id: str, status: Status | str) -> None:
async def set_status(
self, agent_id: str, status: Status | str, *, error: str | None = None
) -> None:
async with self._lock:
if agent_id not in self.statuses:
return
self.statuses[agent_id] = status # type: ignore[assignment]
if error is not None:
self.errors[agent_id] = error
elif status == "running":
self.errors.pop(agent_id, None)
runtime = self.runtimes.setdefault(agent_id, AgentRuntime())
runtime.wake.set()
logger.info("agent.status %s=%s", agent_id, status)
@@ -246,9 +254,14 @@ class AgentCoordinator:
async def graph_snapshot(
self,
) -> tuple[dict[str, str | None], dict[str, Status], dict[str, str]]:
) -> tuple[dict[str, str | None], dict[str, Status], dict[str, str], dict[str, str]]:
async with self._lock:
return dict(self.parent_of), dict(self.statuses), dict(self.names)
return (
dict(self.parent_of),
dict(self.statuses),
dict(self.names),
dict(self.errors),
)
def _message_to_session_item(self, message: dict[str, Any]) -> TResponseInputItem:
sender = str(message.get("from", "unknown"))
@@ -286,6 +299,7 @@ class AgentCoordinator:
"names": dict(self.names),
"metadata": {aid: dict(md) for aid, md in self.metadata.items()},
"pending_counts": dict(self.pending_counts),
"errors": dict(self.errors),
}
async def restore(self, snap: dict[str, Any]) -> None:
@@ -295,6 +309,7 @@ class AgentCoordinator:
self.names = dict(snap.get("names", {}))
self.metadata = {aid: dict(md) for aid, md in snap.get("metadata", {}).items()}
self.pending_counts = dict(snap.get("pending_counts", {}))
self.errors = dict(snap.get("errors", {}))
for aid in self.statuses:
self.runtimes.setdefault(aid, AgentRuntime())
+1 -3
View File
@@ -437,10 +437,8 @@ 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)
await coordinator.set_status(agent_id, status, error=str(exc) or type(exc).__name__)
await _notify_parent_on_crash(coordinator, agent_id, status)
if context.get("parent_id") is None and status in {"failed", "crashed"}:
raise
return None
else:
await _settle_run_result(coordinator, agent_id, interactive)
+2 -2
View File
@@ -238,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,
@@ -252,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,
+419
View File
@@ -0,0 +1,419 @@
"""`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"]
+128 -69
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,6 +20,7 @@ from rich.text import Text
from strix.config import (
apply_config_override,
codex,
load_settings,
persist_current,
)
@@ -33,6 +34,13 @@ from strix.config.models import (
from strix.core.paths import run_dir_for, runtime_state_dir
from strix.interface.cli import run_cli
from strix.interface.tui import run_tui
from strix.interface.update_check import (
is_binary_install,
notify_update,
prompt_update_if_available,
self_update,
start_background_check,
)
from strix.interface.utils import (
assign_workspace_subdirs,
build_final_stats_text,
@@ -85,6 +93,16 @@ 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")
@@ -267,6 +285,29 @@ 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")
@@ -276,8 +317,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()
if (
raw_model
and "/" not in raw_model
@@ -353,23 +394,63 @@ async def warm_up_llm(show_model_warning: bool = True) -> None:
)
logger.info("LLM warm-up succeeded for model %s", (llm.model or "").strip())
if settings.dedupe.model:
from strix.report.dedupe import _dedupe_extra_args
dedupe_model = settings.dedupe.model.strip()
raw_model = dedupe_model
deduper = StrixProvider().get_model(dedupe_model)
# Match the runtime path: send the dedupe key/endpoint per call so a
# separate-provider dedupe model authenticates during warm-up too.
deduper_extra = _dedupe_extra_args(settings.dedupe)
deduper_settings = ModelSettings(extra_args=deduper_extra or None)
await asyncio.wait_for(
deduper.get_response(
system_instructions="You are a helpful assistant.",
input="Reply with just 'OK'.",
model_settings=deduper_settings,
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
),
timeout=llm.timeout,
)
logger.info("LLM warm-up succeeded for dedupe model %s", dedupe_model)
except Exception as e:
logger.exception("LLM warm-up failed")
error_text = Text()
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")
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")
panel = Panel(
error_text,
title="[bold white]STRIX",
title_align="left",
border_style="red",
border_style=border_style,
padding=(1, 2),
)
@@ -448,6 +529,14 @@ Examples:
version=f"strix {get_version()}",
)
parser.add_argument(
"--update",
action="store_true",
help="Update strix to the latest version and exit. Self-updates the "
"standalone binary install; for pip/pipx/uv installs, prints the "
"matching upgrade command instead.",
)
parser.add_argument(
"-t",
"--target",
@@ -566,6 +655,9 @@ Examples:
args = parser.parse_args()
if args.update:
sys.exit(0 if self_update() else 1)
if args.instruction and args.instruction_file:
parser.error(
"Cannot specify both --instruction and --instruction-file. Use one or the other."
@@ -664,6 +756,7 @@ 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,
@@ -721,9 +814,7 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser
args.scan_mode = persisted_scan_mode
def display_completion_message(
args: argparse.Namespace, results_path: Path, web_url: str | None = None
) -> None:
def display_completion_message(args: argparse.Namespace, results_path: Path) -> None:
console = Console()
report_state = get_global_report_state()
@@ -762,28 +853,12 @@ def display_completion_message(
results_text.append(str(results_path), style="#60a5fa")
panel_parts.extend(["\n", results_text])
if web_url:
web_text = Text()
web_text.append("\n")
web_text.append("View in web", style="dim")
web_text.append(" ")
# OSC-8 hyperlink: clickable in modern terminals, falls back to the URL.
web_text.append(web_url, style=f"#60a5fa link {web_url}")
panel_parts.extend(["\n", web_text])
reopen_text = Text()
reopen_text.append("\n")
reopen_text.append("Reopen", style="dim")
reopen_text.append(" ")
reopen_text.append(f"strix view {args.run_name}", style="#22c55e")
panel_parts.extend(["\n", reopen_text])
else:
view_text = Text()
view_text.append("\n")
view_text.append("View", style="dim")
view_text.append(" ")
view_text.append(f"strix view {args.run_name}", style="#22c55e")
panel_parts.extend(["\n", view_text])
view_text = Text()
view_text.append("\n")
view_text.append("View", style="dim")
view_text.append(" ")
view_text.append(f"strix view {args.run_name}", style="#22c55e")
panel_parts.extend(["\n", view_text])
if not scan_completed:
resume_text = Text()
@@ -814,6 +889,8 @@ def display_completion_message(
"[#60a5fa]discord.gg/strix-ai[/]"
)
console.print()
if not args.non_interactive:
notify_update(console)
def pull_docker_image() -> None:
@@ -880,11 +957,24 @@ def main() -> None:
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()
@@ -941,6 +1031,7 @@ 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,
@@ -975,39 +1066,7 @@ def main() -> None:
results_path = run_dir_for(args.run_name)
# For an interactive run, host the local viewer so the completion panel can
# show a clickable "View in web" link. Skipped in non-interactive/CI runs
# (no TTY to serve and it would block the process).
viewer_httpd = None
web_url = None
if not args.non_interactive and sys.stdout.isatty():
from strix.viewer.server import authorized_url, bundle_is_built, serve
if bundle_is_built():
try:
viewer_httpd, base_url, token = serve(results_path, open_browser=False)
# The completion panel's "View in web" link must authorize the
# browser, so hand it the tokened URL rather than the bare host.
web_url = authorized_url(base_url, token)
posthog.viewer_opened(source="post_scan", live=False)
except Exception:
logger.debug("could not start local viewer", exc_info=True)
viewer_httpd, web_url = None, None
display_completion_message(args, results_path, web_url=web_url)
if viewer_httpd is not None:
console = Console()
console.print("[dim]Hosting the local viewer. Press Ctrl-C to stop.[/]")
console.print()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
console.print("\n[dim]Viewer stopped.[/]")
finally:
viewer_httpd.shutdown()
viewer_httpd.server_close()
display_completion_message(args, results_path)
if args.non_interactive:
report_state = get_global_report_state()
+39 -15
View File
@@ -42,6 +42,12 @@ 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
@@ -330,12 +336,11 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
return "#65a30d"
return "#6b7280"
def _highlight_python(self, code: str) -> Text:
def _highlight_python(self, code: str, language: str | None = None) -> Text:
try:
from pygments.lexers import PythonLexer
from pygments.styles import get_style_by_name
lexer = PythonLexer()
lexer = resolve_lexer(language, code)
style = get_style_by_name("native")
colors = {
token: f"#{style_def['color']}" for token, style_def in style if style_def["color"]
@@ -501,10 +506,11 @@ 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_script_code))
text.append_text(self._highlight_python(poc_code, poc_language))
remediation_steps = vuln.get("remediation_steps", "")
if remediation_steps:
@@ -601,9 +607,12 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
lines.append(vuln["poc_description"])
lines.append("")
if vuln.get("poc_script_code"):
lines.append("```python")
lines.append(vuln["poc_script_code"])
lines.append("```")
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)
if vuln.get("code_locations"):
lines.extend(["", "## Code Analysis", ""])
@@ -619,7 +628,9 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
if loc.get("label"):
lines.append(f" {loc['label']}")
if loc.get("snippet"):
lines.append(f"```\n{loc['snippet']}\n```")
snippet = str(loc["snippet"])
snippet_fence = safe_fence(snippet)
lines.append(f"{snippet_fence}\n{snippet}\n{snippet_fence}")
if loc.get("fix_before") or loc.get("fix_after"):
lines.append("**Suggested Fix:**")
lines.append("```diff")
@@ -802,6 +813,7 @@ class StrixTUIApp(App): # type: ignore[misc]
self._scan_stop_event = threading.Event()
self._scan_completed = threading.Event()
self._scan_error: BaseException | None = None
self._error_noted_agents: set[str] = set()
self._spinner_frame_index: int = 0
self._sweep_num_squares: int = 6
@@ -1015,22 +1027,32 @@ class StrixTUIApp(App): # type: ignore[misc]
else:
self._agent_graph_sync_future = None
try:
parent_of, statuses, names = future.result()
parent_of, statuses, names, errors = future.result()
except Exception:
logger.exception("TUI agent graph sync failed")
else:
for agent_id, status in statuses.items():
error = errors.get(agent_id)
self.live_view.upsert_agent(
agent_id,
name=names.get(agent_id, agent_id),
parent_id=parent_of.get(agent_id),
status=status,
error_message=error,
)
if status in {"failed", "crashed"} and error:
if agent_id not in self._error_noted_agents:
self._error_noted_agents.add(agent_id)
self.live_view.record_agent_error(agent_id, error)
else:
self._error_noted_agents.discard(agent_id)
if self._scan_loop is None or self._scan_loop.is_closed():
return
async def collect() -> tuple[dict[str, str | None], dict[str, Any], dict[str, str]]:
async def collect() -> tuple[
dict[str, str | None], dict[str, Any], dict[str, str], dict[str, str]
]:
return await self.coordinator.graph_snapshot()
self._agent_graph_sync_future = asyncio.run_coroutine_threadsafe(collect(), self._scan_loop)
@@ -1049,6 +1071,7 @@ class StrixTUIApp(App): # type: ignore[misc]
"waiting": "",
"completed": "🟢",
"failed": "🔴",
"crashed": "🔴",
"stopped": "",
}
@@ -1234,13 +1257,12 @@ class StrixTUIApp(App): # type: ignore[misc]
text.append(msg)
return (text, Text(), False)
if status == "failed":
if status in {"failed", "crashed"}:
error_msg = agent_data.get("error_message", "")
text = Text()
if error_msg:
text.append(error_msg, style="red")
else:
text.append("Scan failed", style="red")
text.append(error_msg or "Agent failed", style="red")
text.append(" · ", style="dim")
text.append("Send message to resume", style="dim")
self._stop_dot_animation()
return (text, Text(), False)
@@ -1539,6 +1561,7 @@ class StrixTUIApp(App): # type: ignore[misc]
"waiting": "",
"completed": "🟢",
"failed": "🔴",
"crashed": "🔴",
"stopped": "",
}
@@ -1584,6 +1607,7 @@ class StrixTUIApp(App): # type: ignore[misc]
"waiting": "",
"completed": "🟢",
"failed": "🔴",
"crashed": "🔴",
"stopped": "",
}
+11
View File
@@ -86,6 +86,17 @@ class TuiLiveView:
current["error_message"] = error_message
current["updated_at"] = now
def record_agent_error(self, agent_id: str, error: str) -> None:
self._append_event(
agent_id,
"chat",
{
"role": "assistant",
"content": (f"An error occurred: {error}\nI'm now waiting for new instructions."),
"metadata": {"source": "agent_error"},
},
)
def record_user_message(self, agent_id: str, content: str) -> None:
self._append_event(
agent_id,
@@ -1,11 +1,12 @@
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
@@ -61,8 +62,8 @@ class CreateVulnerabilityReportRenderer(BaseToolRenderer):
return None
@classmethod
def _highlight_python(cls, code: str) -> Text:
lexer = PythonLexer()
def _highlight_code(cls, code: str, language: str | None) -> Text:
lexer = resolve_lexer(language, code)
text = Text()
for token_type, token_value in lexer.get_tokens(code):
@@ -234,10 +235,11 @@ 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_python(poc_script_code))
text.append_text(cls._highlight_code(poc_code, poc_language))
if remediation_steps:
text.append("\n\n")
+389
View File
@@ -0,0 +1,389 @@
"""Update notifications and self-update for the strix CLI.
Follows the pattern used by tools like gh, uv, and pip: a background,
rate-limited (once per 24h) check against the release source, a cached
result in ``~/.strix``, a non-intrusive notice with the upgrade command
for the detected install method, and a ``strix --update`` self-update
path for the standalone binary install.
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import platform
import shutil
import stat
import subprocess
import sys
import tarfile
import tempfile
import threading
import time
import zipfile
from pathlib import Path
from typing import cast
import requests
from rich.console import Console
from rich.prompt import Prompt
from strix.telemetry._common import get_version
logger = logging.getLogger(__name__)
GITHUB_REPO = "usestrix/strix"
PYPI_PACKAGE = "strix-agent"
CHECK_INTERVAL_SECONDS = 24 * 60 * 60
REQUEST_TIMEOUT_SECONDS = 5
_CACHE_PATH = Path.home() / ".strix" / "update-check.json"
_background_thread: threading.Thread | None = None
def _is_disabled() -> bool:
return bool(os.environ.get("STRIX_NO_UPDATE_CHECK")) or any(
os.environ.get(key)
for key in ("CI", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS_URL", "BUILDKITE", "CIRCLECI")
)
def is_binary_install() -> bool:
return bool(getattr(sys, "frozen", False))
def get_install_method() -> str:
if is_binary_install():
return "binary"
prefix = str(Path(sys.prefix)).replace("\\", "/")
if "/pipx/" in prefix or prefix.endswith("/pipx"):
return "pipx"
if "/uv/tools/" in prefix:
return "uv"
return "pip"
def get_upgrade_command(method: str | None = None) -> str:
method = method or get_install_method()
commands = {
"binary": "strix --update",
"pipx": "pipx upgrade strix-agent",
"uv": "uv tool upgrade strix-agent",
"pip": "pip install --upgrade strix-agent",
}
return commands[method]
def _parse_version(value: str) -> tuple[int, ...] | None:
parts = value.strip().lstrip("v").split(".")
try:
return tuple(int(part) for part in parts)
except ValueError:
return None
def _is_newer(latest: str, current: str) -> bool:
latest_parts = _parse_version(latest)
current_parts = _parse_version(current)
if latest_parts is None or current_parts is None:
return False
return latest_parts > current_parts
def _fetch_latest_version() -> str | None:
try:
if is_binary_install():
response = requests.get(
f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest",
timeout=REQUEST_TIMEOUT_SECONDS,
)
response.raise_for_status()
tag = response.json().get("tag_name", "")
return tag.lstrip("v") or None
response = requests.get(
f"https://pypi.org/pypi/{PYPI_PACKAGE}/json",
timeout=REQUEST_TIMEOUT_SECONDS,
)
response.raise_for_status()
version = response.json().get("info", {}).get("version")
return str(version) if version else None
except Exception: # noqa: BLE001
logger.debug("update check failed", exc_info=True)
return None
def _fetch_asset_digest(version: str, filename: str) -> str | None:
"""Return the expected sha256 (hex) for a release asset, if the API provides one."""
try:
response = requests.get(
f"https://api.github.com/repos/{GITHUB_REPO}/releases/tags/v{version}",
timeout=REQUEST_TIMEOUT_SECONDS,
)
response.raise_for_status()
for asset in response.json().get("assets", []):
if asset.get("name") == filename:
digest = asset.get("digest") or ""
if digest.startswith("sha256:"):
return digest.removeprefix("sha256:")
except Exception: # noqa: BLE001
logger.debug("release asset digest lookup failed", exc_info=True)
return None
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
def _read_cache() -> dict[str, object]:
try:
with _CACHE_PATH.open(encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
return cast("dict[str, object]", data)
except Exception: # noqa: BLE001, S110
pass # nosec B110
return {}
def _write_cache(**fields: object) -> None:
try:
cache = _read_cache()
cache.update(fields)
_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
_CACHE_PATH.write_text(json.dumps(cache), encoding="utf-8")
except Exception: # noqa: BLE001, S110
pass # nosec B110
def skip_version(version: str) -> None:
"""Remember not to prompt again for this version (newer releases still notify)."""
_write_cache(skipped_version=version)
def _refresh_cache() -> None:
latest = _fetch_latest_version()
if latest:
_write_cache(latest_version=latest, checked_at=time.time())
def start_background_check() -> None:
"""Refresh the cached latest-version info in a daemon thread (at most once per 24h)."""
global _background_thread # noqa: PLW0603
if _is_disabled():
return
cache = _read_cache()
checked_at = cache.get("checked_at")
if isinstance(checked_at, int | float) and time.time() - checked_at < CHECK_INTERVAL_SECONDS:
return
_background_thread = threading.Thread(target=_refresh_cache, daemon=True)
_background_thread.start()
def get_available_update(*, respect_skip: bool = True) -> str | None:
"""Return the newer version from the cache, or None if up to date / unknown."""
if _is_disabled():
return None
if _background_thread is not None:
_background_thread.join(timeout=0.2)
cache = _read_cache()
latest = cache.get("latest_version")
current = get_version()
if not isinstance(latest, str) or current == "unknown" or not _is_newer(latest, current):
return None
if respect_skip and cache.get("skipped_version") == latest:
return None
return latest
def notify_update(console: Console) -> None:
latest = get_available_update()
if not latest:
return
console.print(
f"[#eab308]A new version of strix is available:[/] "
f"[dim]{get_version()}[/] [dim]→[/] [bold #22c55e]{latest}[/]"
f" [dim]·[/] [#60a5fa]{get_upgrade_command()}[/]"
)
console.print()
def run_package_upgrade(console: Console, method: str) -> bool:
"""Upgrade a package-manager install by running its upgrade command."""
command = get_upgrade_command(method).split()
console.print(f"[dim]Running[/] [#60a5fa]{' '.join(command)}[/]")
try:
result = subprocess.run(command, check=False) # noqa: S603
except OSError as e:
console.print(f"[bold red]Update failed:[/] {e}")
return False
if result.returncode != 0:
console.print(
f"[bold red]Update failed[/] [dim](exit code {result.returncode}).[/] "
f"Run it manually: [#60a5fa]{get_upgrade_command(method)}[/]"
)
return False
console.print("[#22c55e]✓ strix updated — restart the scan to use the new version[/]")
return True
def prompt_update_if_available(console: Console) -> bool:
"""Offer an interactive update before a scan starts.
Returns True if strix was updated (caller should re-exec / exit).
"""
latest = get_available_update()
if not latest or not sys.stdin.isatty() or not sys.stdout.isatty():
return False
console.print()
console.print(
f"[#eab308]A new version of strix is available:[/] "
f"[dim]{get_version()}[/] [dim]→[/] [bold #22c55e]{latest}[/]"
)
console.print(
"[dim] y — update now n — not now (ask again next run) s — skip this version[/]"
)
choice = Prompt.ask("Update strix?", choices=["y", "n", "s"], default="n")
console.print()
if choice == "s":
skip_version(latest)
return False
if choice != "y":
return False
method = get_install_method()
if method == "binary":
return self_update(console, version=latest)
return run_package_upgrade(console, method)
def _release_target() -> str | None:
raw_os = platform.system().lower()
os_name = {"darwin": "macos", "linux": "linux", "windows": "windows"}.get(raw_os)
arch = platform.machine().lower()
arch = {"aarch64": "arm64", "amd64": "x86_64"}.get(arch, arch)
if os_name is None:
return None
target = f"{os_name}-{arch}"
supported = {"linux-x86_64", "macos-x86_64", "macos-arm64", "windows-x86_64"}
return target if target in supported else None
def _download_and_replace(version: str, target: str, console: Console) -> bool:
is_windows = target.startswith("windows")
archive_ext = ".zip" if is_windows else ".tar.gz"
filename = f"strix-{version}-{target}{archive_ext}"
url = f"https://github.com/{GITHUB_REPO}/releases/download/v{version}/{filename}"
binary_name = f"strix-{version}-{target}" + (".exe" if is_windows else "")
current_exe = Path(sys.executable).resolve()
with tempfile.TemporaryDirectory() as tmp:
tmp_dir = Path(tmp)
archive_path = tmp_dir / filename
console.print(f"[dim]Downloading[/] {url}")
with requests.get( # nosec B113
url,
stream=True,
timeout=REQUEST_TIMEOUT_SECONDS * 12,
) as response:
response.raise_for_status()
with archive_path.open("wb") as f:
for chunk in response.iter_content(chunk_size=1 << 20):
f.write(chunk)
expected_digest = _fetch_asset_digest(version, filename)
if expected_digest:
actual_digest = _sha256_file(archive_path)
if actual_digest != expected_digest:
raise RuntimeError(
f"checksum mismatch for {filename}: "
f"expected sha256 {expected_digest}, got {actual_digest}"
)
else:
console.print("[dim yellow]No published checksum available; skipping verification[/]")
if is_windows:
with zipfile.ZipFile(archive_path) as zf:
zf.extract(binary_name, tmp_dir)
else:
with tarfile.open(archive_path, "r:gz") as tf:
tf.extract(binary_name, tmp_dir, filter="data")
new_binary = tmp_dir / binary_name
new_binary.chmod(new_binary.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
staged = current_exe.with_name(current_exe.name + ".new")
try:
shutil.copy2(new_binary, staged)
if is_windows:
# Windows can't replace a running executable in place; move it aside first.
old = current_exe.with_name(current_exe.name + ".old")
old.unlink(missing_ok=True)
current_exe.rename(old)
try:
staged.replace(current_exe)
except Exception:
old.rename(current_exe)
raise
else:
staged.replace(current_exe)
except Exception:
staged.unlink(missing_ok=True)
raise
return True
def self_update(console: Console | None = None, version: str | None = None) -> bool:
"""Replace the running standalone binary with the latest release.
Returns True on success. For package-manager installs this only
prints the right upgrade command and returns False.
"""
console = console or Console()
if not is_binary_install():
method = get_install_method()
console.print(
f"[#eab308]This strix was installed via {method};[/] "
f"upgrade it with: [#60a5fa]{get_upgrade_command(method)}[/]"
)
return False
latest = version or _fetch_latest_version()
if not latest:
console.print("[bold red]Could not determine the latest strix version.[/]")
return False
current = get_version()
if current != "unknown" and not _is_newer(latest, current):
console.print(f"[#22c55e]strix {current} is already the latest version.[/]")
return True
target = _release_target()
if not target:
console.print(
f"[bold red]No prebuilt binary for this platform "
f"({platform.system()}/{platform.machine()}).[/]"
)
return False
try:
_download_and_replace(latest, target, console)
except Exception as e: # noqa: BLE001
logger.debug("self-update failed", exc_info=True)
console.print(f"[bold red]Update failed:[/] {e}")
console.print(
"[dim]You can reinstall manually with:[/] "
"[#60a5fa]curl -sSL https://strix.ai/install | bash[/]"
)
return False
_write_cache(latest_version=latest, checked_at=time.time())
console.print(f"[#22c55e]✓ Updated strix to {latest}[/]")
return True
+38 -6
View File
@@ -253,6 +253,20 @@ 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))
@@ -283,11 +297,16 @@ 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")
stats_text.append("$0.0000 ", style="#fbbf24")
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("· ", style="dim white")
stats_text.append("Tokens ", style="dim")
stats_text.append("0", style="white")
@@ -312,7 +331,12 @@ def _build_llm_usage_stats(
stats_text.append("Output Tokens ", style="dim")
stats_text.append(format_token_count(output_tokens), style="white")
if live or cost > 0:
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:
stats_text.append(" · ", style="dim white")
stats_text.append("Cost ", style="dim")
stats_text.append(f"${cost:.4f}", style="#fbbf24")
@@ -337,6 +361,9 @@ 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)
@@ -379,6 +406,10 @@ 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:
@@ -388,7 +419,10 @@ def build_tui_stats_text(report_state: Any) -> Text:
style="white",
)
cost = _float_stat(usage, "cost")
if cost > 0:
if subscription:
stats_text.append(" · ", style="white")
stats_text.append("$0.00", style="white")
elif cost > 0:
stats_text.append(" · ", style="white")
stats_text.append(f"${cost:.2f}", style="white")
@@ -1147,9 +1181,7 @@ 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
+42 -8
View File
@@ -13,20 +13,55 @@ from openai.types.responses import ResponseOutputMessage
from strix.config import load_settings
from strix.config.models import (
DEFAULT_MODEL_RETRY,
StrixProvider,
configure_sdk_model_defaults,
request_timeout_extra_args,
)
from strix.core.inputs import make_model_settings
from strix.report.state import get_global_report_state
if TYPE_CHECKING:
from agents.items import ModelResponse
from strix.config.settings import DedupeSettings
logger = logging.getLogger(__name__)
def _dedupe_extra_args(dedupe: DedupeSettings) -> dict[str, str]:
"""Per-call credential + endpoint for the dedupe model.
Provider env vars and the global base URL are process-wide, so a
shared-provider dedupe key or a distinct dedupe endpoint can't be installed
globally without clobbering (or being clobbered by) the main model's
config. Passing them per call keeps the two apart. Only applies when a
dedicated dedupe model is configured.
"""
if not dedupe.model:
return {}
extra: dict[str, str] = {}
if dedupe.api_key and dedupe.api_key.strip():
extra["api_key"] = dedupe.api_key.strip()
if dedupe.api_base and dedupe.api_base.strip():
extra["api_base"] = dedupe.api_base.strip()
return extra
def _dedupe_model_settings(
dedupe: DedupeSettings, model_name: str, request_timeout: float | None
) -> ModelSettings:
settings = make_model_settings(
dedupe.reasoning_effort,
model_name=model_name,
force_required_tool_choice=False,
request_timeout=request_timeout,
)
extra = _dedupe_extra_args(dedupe)
if extra:
settings = settings.resolve(ModelSettings(extra_args=extra))
return settings
DEDUPE_SYSTEM_PROMPT = """You are an expert vulnerability report deduplication judge.
Your task is to determine if a candidate vulnerability report describes the SAME vulnerability
as any existing report.
@@ -286,13 +321,14 @@ async def check_duplicate(
try:
settings = load_settings()
model_name = settings.llm.model
dedupe = settings.dedupe
model_name = (dedupe.model or "").strip() or settings.llm.model
if not model_name:
return {
"is_duplicate": False,
"duplicate_id": "",
"confidence": 0.0,
"reason": "STRIX_LLM not configured; skipping dedupe check",
"reason": "No LLM model configured; skipping dedupe check",
}
candidate_cleaned = _prepare_report_for_comparison(candidate)
@@ -311,10 +347,8 @@ async def check_duplicate(
response = await model.get_response(
system_instructions=DEDUPE_SYSTEM_PROMPT,
input=user_msg,
model_settings=ModelSettings(
retry=DEFAULT_MODEL_RETRY,
include_usage=True,
extra_args=request_timeout_extra_args(settings.llm.timeout),
model_settings=_dedupe_model_settings(
dedupe, resolved_model, settings.llm.timeout
),
tools=[],
output_schema=None,
+5
View File
@@ -10,6 +10,8 @@ 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
@@ -117,12 +119,15 @@ 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(),
}
+6 -1
View File
@@ -19,6 +19,9 @@ 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,
@@ -41,7 +44,7 @@ class LLMUsageLedger:
if model:
metadata["model"] = model
if not _is_litellm_routed(model):
if not self.zero_cost and not _is_litellm_routed(model):
estimated = _estimate_litellm_cost(usage, model)
if estimated:
self._total_cost += estimated
@@ -49,6 +52,8 @@ 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)
+65 -6
View File
@@ -10,19 +10,27 @@ import re
import tempfile
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
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 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
@@ -35,6 +43,56 @@ 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():
@@ -187,9 +245,10 @@ 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"):
code = str(report["poc_script_code"])
fence = _safe_fence(code)
lines.append(fence)
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}")
lines.append(code)
lines.append(fence)
lines.append("")
@@ -209,7 +268,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}")
+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))
staged = Path(tempfile.mkdtemp(prefix=_STAGING_PREFIX)).resolve()
try:
_stage_dir(root, staged, root, frozenset({root}))
except OSError:
+3 -4
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.
Orchestration layer for security assessments. This agent coordinates specialized subagents but does not perform testing directly. You never run scanners, crawlers, or fuzzers and never send exploit/injection payloads yourself — not even a quick "basic" test on a discovered endpoint. Any work that touches the target is delegated to a subagent.
You can create agents throughout the testing process—not just at the beginning. Spawn agents dynamically based on findings and evolving scope.
@@ -18,7 +18,7 @@ You can create agents throughout the testing process—not just at the beginning
## Scope Decomposition
Before spawning agents, analyze the target:
Before spawning agents, analyze the target from the scan config/scope and any provided context (and, once recon subagents report, from their results) — not by running recon tools yourself:
1. **Identify attack surfaces** - web apps, APIs, infrastructure, etc.
2. **Define boundaries** - in-scope domains, IP ranges, excluded assets
@@ -72,8 +72,7 @@ Before creating agents:
Complex findings warrant specialized subagents:
- Discovery agent finds potential vulnerability
- Validation agent confirms exploitability
- Reporting agent documents with reproduction steps
- Fix agent provides remediation (if needed)
- Reporting agent documents with reproduction steps AND supplies the fix inline (the report tool carries the patch via `code_locations`/`fix_pr_body`) — do not add a separate fix agent that re-derives the same patch
**Resource Efficiency**
+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 (not prompts or responses)\
**Model Usage:** Which LLM model is being used and whether it runs via an API key or a model subscription (not prompts or responses)\
**Feature Usage:** Which built-in skills are loaded\
**Aggregate Metrics:** Vulnerability counts by severity and weakness category (CWE)
+11
View File
@@ -58,12 +58,14 @@ 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,
@@ -133,6 +135,7 @@ 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),
@@ -180,6 +183,14 @@ def viewer_email_event(step: str, purpose: str | None = None) -> None:
)
def viewer_feedback_submitted() -> None:
_send("viewer_feedback_submitted", {**base_props()})
def viewer_agent_steered() -> None:
_send("viewer_agent_steered", {**base_props()})
def error(error_type: str) -> None:
props = {**base_props(), "error_type": error_type}
_send("error", props)
+3
View File
@@ -59,6 +59,7 @@ def start(
is_whitebox: bool,
interactive: bool,
has_instructions: bool,
auth_mode: str | None = None,
) -> None:
_send(
"scan_started",
@@ -66,6 +67,7 @@ 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,
@@ -140,6 +142,7 @@ 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),
+2 -2
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] = []
@@ -635,7 +635,7 @@ async def stop_agent(
ensure_ascii=False,
default=str,
)
_, statuses, _ = await coordinator.graph_snapshot()
_, statuses, _, _ = await coordinator.graph_snapshot()
if target_agent_id not in statuses:
return json.dumps(
{"success": False, "error": f"Unknown agent_id: {target_agent_id}"},
+74
View File
@@ -0,0 +1,74 @@
"""Bound oversized tool results before they enter agent history.
Keeps a head + tail slice and drops the middle, replacing it with a notice of
how much was removed.
"""
from __future__ import annotations
_TRUNCATION_NOTICE = "[... {lines} lines ({bytes} bytes) truncated ...]"
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 bound_text(text: str, *, max_lines: int, max_bytes: int) -> str:
"""Return ``text`` unchanged when small, else a head+tail preview.
Truncates on whichever limit is hit first (line count or UTF-8 byte size).
``max_bytes`` bounds the entire joined result, notice and separators
included.
"""
lines = text.split("\n")
total_bytes = _byte_len(text)
if len(lines) <= max_lines and total_bytes <= max_bytes:
return text
# Reserve notice + separator bytes up front; ``+ 4`` covers the two "\n\n".
notice_overhead = _byte_len(_TRUNCATION_NOTICE.format(lines=len(lines), bytes=total_bytes)) + 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))
notice = _TRUNCATION_NOTICE.format(lines=dropped_lines, bytes=dropped_bytes)
return f"{head}\n\n{notice}\n\n{tail}" if tail else f"{head}\n\n{notice}"
+21
View File
@@ -207,6 +207,26 @@ def otp_verify(email: str, code: str) -> dict[str, Any]:
raise RelayError("unavailable")
def feedback_submit(email: str, message: str) -> None:
"""Relay a feedback message + email to Strix. No verification is required;
the email is taken as given. Raises RelayError on failure."""
status, data = _post_json(
"/api/oss/feedback",
{"email": email, "message": message},
timeout=_OTP_TIMEOUT,
)
if status == 200:
return
if status == 429:
raise RelayError("rate_limited")
if status == 400:
code = data.get("error")
if code in ("invalid_email", "invalid_message"):
raise RelayError(str(code))
raise RelayError("invalid_message")
raise RelayError("unavailable")
def report_send(
token: str,
pdf_bytes: bytes,
@@ -241,6 +261,7 @@ def report_send(
__all__ = [
"AUTH_PATH",
"RelayError",
"feedback_submit",
"forget",
"is_verified",
"otp_start",
+10 -63
View File
@@ -16,6 +16,7 @@
"lucide-react": "^0.563.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-icons": "^5.7.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0"
@@ -919,9 +920,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -936,9 +934,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -953,9 +948,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -970,9 +962,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -987,9 +976,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1004,9 +990,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1021,9 +1004,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1038,9 +1018,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1055,9 +1032,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1072,9 +1046,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1089,9 +1060,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1106,9 +1074,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1123,9 +1088,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1349,9 +1311,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1369,9 +1328,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1389,9 +1345,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1409,9 +1362,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2543,9 +2493,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2567,9 +2514,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2591,9 +2535,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2615,9 +2556,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3695,6 +3633,15 @@
"react": "^19.2.7"
}
},
"node_modules/react-icons": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz",
"integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
+1
View File
@@ -17,6 +17,7 @@
"lucide-react": "^0.563.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-icons": "^5.7.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0"
+68 -89
View File
@@ -2,14 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ArrowLeft,
AlertCircle,
Waypoints,
Bot,
Mail,
ChevronDown,
Wrench,
FileCheck2,
CalendarClock,
Radar,
GitPullRequest,
Rocket,
ArrowUpRight,
History,
@@ -45,11 +41,10 @@ import PastRunsView from "@/components/PastRunsView";
import EmailReportView from "@/components/EmailReportView";
import { RunDetails } from "@/components/RunDetails";
import { TrustToast } from "@/components/TrustToast";
import FeatureDetail from "@/components/FeatureDetail";
import { ProTile, ProInlineCta, type ProItem } from "@/components/ProCta";
import { FEATURES } from "@/lib/pro-features";
import FeedbackView from "@/components/FeedbackView";
import { ProInlineCta } from "@/components/ProCta";
export type View = "overview" | "issues" | "agents" | "history" | "feature" | "email";
export type View = "overview" | "issues" | "agents" | "history" | "email" | "feedback";
const TRUST_BANNER =
"Your findings stay on your machine. They're rendered here locally in your browser and never uploaded or stored by Strix.";
@@ -57,25 +52,12 @@ const TRUST_BANNER =
const SEVERITY_ORDER: VulnerabilitySeverity[] = ["critical", "high", "medium", "low"];
const POLL_MS = 500;
// Curated inline CTAs. Continuous-coverage row on Overview (the restyled upsell
// tiles), plus the recommendations pairing.
const RECOMMENDATION_CTAS: ProItem[] = [
{ title: "One-click autofix + open a fix PR", desc: "Fix it for you and open a PR, retested.", slug: "autofix", icon: Wrench },
{ title: "Export SOC 2 / ISO 27001 report", desc: "Share an auditor-ready report with your team.", slug: "compliance", icon: FileCheck2 },
];
const COVERAGE_CTAS: ProItem[] = [
{ title: "Scheduled pentesting", desc: "Continuous coverage for your whole org.", slug: "scheduled", icon: CalendarClock },
{ title: "Attack surface monitoring", desc: "Continuous coverage for your whole org.", slug: "asm", icon: Radar },
{ title: "PR reviews", desc: "Pentest every pull request your team opens.", slug: "pr_reviews", icon: GitPullRequest },
];
export default function App() {
const [activeRun, setActiveRun] = useState<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");
@@ -249,12 +231,6 @@ export default function App() {
await refreshRuns();
}, [refreshAuth, refreshRuns]);
const selectFeature = useCallback((slug: string) => {
trackCta(slug, "sidebar_nav");
setActiveFeature(slug);
userSetView("feature");
}, [userSetView]);
const onForget = useCallback(async () => {
await forgetAuth();
await refreshAuth();
@@ -266,11 +242,13 @@ export default function App() {
<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}
@@ -285,7 +263,7 @@ export default function App() {
<div className="flex-1 min-w-0">
{/* Top bar */}
<div className="border-b border-[#222]">
<div className="max-w-[72rem] mx-auto px-6 py-4 flex items-center gap-1.5">
<div className="max-w-[88rem] mx-auto px-3 sm:px-6 py-4 flex items-center gap-1.5">
<a
href={ctaUrl("https://app.strix.ai", "logo")}
target="_blank"
@@ -297,7 +275,6 @@ 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 && (
@@ -322,14 +299,20 @@ export default function App() {
</div>
</div>
<div className="max-w-[72rem] mx-auto px-6 py-8 space-y-6">
{error && !run && view !== "history" && view !== "email" && view !== "feature" && (
<div className="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="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}
@@ -342,8 +325,11 @@ export default function App() {
}}
onExit={(dest) => setView(dest === "history" ? "history" : "overview")}
/>
) : view === "feature" && activeFeature && FEATURES[activeFeature] ? (
<FeatureDetail feature={FEATURES[activeFeature]} />
) : view === "feedback" ? (
<FeedbackView
defaultEmail={auth?.email ?? null}
onExit={(dest) => setView(dest)}
/>
) : view === "history" ? (
<div className="space-y-4">
<div className="flex items-center gap-2">
@@ -369,7 +355,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")}>
Overview
Pentest Overview
</TabButton>
<TabButton active={view === "issues"} onClick={() => userSetView("issues")}>
Issues{run.vulnerabilities.length > 0 ? ` (${run.vulnerabilities.length})` : ""}
@@ -412,6 +398,7 @@ export default function App() {
)}
</>
) : null}
</div>
</div>
</div>
<TrustToast message={TRUST_BANNER} />
@@ -438,33 +425,37 @@ function RunSwitcher({
<button
onClick={() => setOpen((o) => !o)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs text-[#aaa] transition-colors hover:text-white"
style={{ border: "1px solid #2a2a2a" }}
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)]"
>
<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" />
<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" />
</button>
{open && (
<div
className="absolute right-0 z-50 mt-1.5 max-h-80 w-64 overflow-y-auto rounded-lg py-1 shadow-xl"
style={{ border: "1px solid #2a2a2a", background: "#0a0a0a" }}
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" }}
>
<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 text-left text-xs transition-colors hover:bg-[rgba(255,255,255,0.06)] ${
active ? "text-white" : "text-[#aaa]"
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]"
}`}
>
<span className="min-w-0 flex-1">
<span className="block truncate">{runTitle(r.target, r.name)}</span>
{r.target && <span className="block truncate font-mono text-[#666]">{r.target}</span>}
<span 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>
{active && <span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-emerald-400" />}
{active && <span className="h-2 w-2 flex-shrink-0 rounded-full bg-emerald-400" />}
</button>
);
})}
@@ -508,7 +499,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 ?? "Scan results")}
{runTitle(summary.targets[0] ?? null, summary.runName ?? summary.runId ?? "Pentest 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 && (
@@ -547,7 +538,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 scan is still running…"}
{finished ? "No findings in this run." : "No findings yet. The pentest is still running…"}
</div>
{finished && (
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
@@ -573,7 +564,7 @@ function FindingsList({
<button
key={v.id}
onClick={() => onSelect(v.id)}
className="cursor-pointer w-full text-left rounded-lg border border-[#222] hover:border-[#444] bg-[rgba(255,255,255,0.02)] px-4 py-3 transition-colors flex items-center gap-3"
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"
>
<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">
@@ -636,7 +627,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">
Email report
Export report to PDF
</span>
</div>
</button>
@@ -673,26 +664,32 @@ function OverviewTab({
return (
<div className="space-y-6">
<RunDetails raw={raw} durationSeconds={summary.durationSeconds} />
<div className="animate-card-in">
<RunDetails raw={raw} durationSeconds={summary.durationSeconds} />
</div>
{total > 0 && (
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
<div className="animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
<IssueSeveritySummary findings={{ total, ...counts }} />
</div>
)}
{/* Primary CTA: the one primary on Overview. Hidden until the run is
finished, since a live scan would only email a partial report. */}
{finished && <EmailReportCta onOpenEmail={onOpenEmail} />}
{finished && (
<div className="animate-card-in">
<EmailReportCta onOpenEmail={onOpenEmail} />
</div>
)}
{sections.length > 0 ? (
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5 space-y-8">
<div className="animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5 space-y-8">
{sections.map((s) => (
<ContentSection key={s.title} title={s.title} content={s.content} />
))}
</div>
) : reportMarkdown ? (
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
<div className="animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
<ContentSection content={dedupeHeadings(reportMarkdown)} />
</div>
) : (
@@ -701,22 +698,6 @@ 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>
);
}
@@ -746,8 +727,7 @@ function TabButton({
function AgentsTab({ run, canSteer }: { run: LoadedRun; canSteer: boolean }) {
const { agents, events } = run.transcript;
const graphAgents = useMemo(() => buildGraphAgents(agents, events), [agents, events]);
// Clicking a graph node opens the agent's transcript in a modal (matching the
// cloud app); no node selected means no modal.
// Clicking a graph node opens the agent's transcript in a modal; no node selected means no modal.
const [selectedId, setSelectedId] = useState<string | null>(null);
const selectedAgent = selectedId ? (agents.find((a) => a.id === selectedId) ?? null) : null;
@@ -758,7 +738,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">
<Waypoints className="w-4 h-4 text-[#888]" aria-hidden="true" />
<Bot 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"}
@@ -784,12 +764,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 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>
<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>
<div className="mt-3 flex flex-wrap gap-2.5">
<ProInlineCta
label="Re-run in Strix Cloud with more depth"
desc="Run this scan on managed infra with more depth."
label="Re-run in Strix Pro with more depth"
desc="Run this pentest on managed infra with more depth."
slug="live_scan"
surface="agents"
icon={Rocket}
@@ -797,14 +777,13 @@ function AgentsTab({ run, canSteer }: { run: LoadedRun; canSteer: boolean }) {
</div>
</div>
{selectedAgent && (
<AgentDetailModal
agent={selectedAgent}
events={events}
steerable={steerable}
onClose={() => setSelectedId(null)}
/>
)}
<AgentDetailModal
open={selectedAgent !== null}
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-md space-y-4">
<div className="mx-auto max-w-xl 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" : "Email report"}
{verifyOnly ? "Verify your email" : "Export report to PDF"}
</h1>
</div>
@@ -225,16 +225,13 @@ 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]">
Viewing stays local and nothing is uploaded. Emailing is an explicit
opt-in: we send an <span className="text-white">encrypted PDF</span>.
We email an <span className="text-white">encrypted PDF</span>. Nothing else leaves your machine.
</p>
</div>
<div className="flex items-start gap-2.5">
<Lock className="mt-0.5 h-4 w-4 flex-shrink-0 text-[#888]" aria-hidden="true" />
<p className="text-xs leading-relaxed text-[#aaa]">
The report is encrypted with a password that only you hold. Strix
cannot read it and never stores it. We collect only your email so we
can send it.
Only you hold the password; Strix can&apos;t read it.
</p>
</div>
</div>
@@ -242,7 +239,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"
>
{verified ? "Email me the encrypted PDF" : "Continue with your email"}
Export report
</button>
{verified && auth?.email && (
<p className="text-center text-xs text-[#666]">Sending to {auth.email}</p>
@@ -269,7 +266,6 @@ 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"
@@ -1,96 +0,0 @@
import {
CalendarClock,
WandSparkles,
Puzzle,
Users,
ArrowUpRight,
} from "lucide-react";
import { SIGNUP_URL, PRICING_URL, ctaUrl, trackCta } from "@/lib/cta";
import type { ProFeature } from "@/lib/pro-features";
import { ProTag } from "@/components/ProCta";
/**
* In-app upsell page for a single platform feature. Modeled on the cloud app's
* Networks upsell: a centered bordered card with an icon medallion, tier pill,
* headline, one-line description, a shared "Included in Strix Pro" bullet list,
* then a primary sign-up CTA and a secondary link to all plans.
*/
const INCLUDED = [
{
icon: CalendarClock,
text: "Continuous coverage: scheduled pentests and attack surface monitoring",
},
{ icon: WandSparkles, text: "One-click autofix that opens a retested pull request" },
{ icon: Puzzle, text: "Two-way sync to Jira, Linear, and Slack" },
{ icon: Users, text: "Your whole team, with roles and shared history" },
];
export default function FeatureDetail({ feature }: { feature: ProFeature }) {
const Icon = feature.icon;
return (
<div className="mx-auto w-full max-w-lg">
<div className="rounded-2xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center">
<div
className="mx-auto flex h-12 w-12 items-center justify-center rounded-xl"
style={{ border: "1px solid #2a2a2a", background: "rgba(255,255,255,0.04)" }}
>
<Icon className="h-5 w-5 text-[#888]" aria-hidden="true" />
</div>
<div className="mt-4 flex justify-center">
<ProTag label={feature.tier} />
</div>
<h2 className="mt-3 text-2xl font-semibold text-white">{feature.headline}</h2>
<p className="mx-auto mt-2 max-w-md text-sm text-[#888]">{feature.description}</p>
<div
className="mt-6 rounded-xl p-4 text-left"
style={{ border: "1px solid #222", background: "rgba(255,255,255,0.02)" }}
>
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-[#666]">
Included in Strix Pro
</p>
<ul className="space-y-2.5">
{INCLUDED.map((item) => {
const BulletIcon = item.icon;
return (
<li key={item.text} className="flex items-start gap-2.5">
<BulletIcon
className="mt-0.5 h-4 w-4 flex-shrink-0 text-[#888]"
aria-hidden="true"
/>
<span className="text-sm text-[#aaa]">{item.text}</span>
</li>
);
})}
</ul>
</div>
<div className="mt-6 flex flex-col items-center gap-3">
<a
href={ctaUrl(SIGNUP_URL, feature.slug)}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackCta(feature.slug, "feature_page")}
className="inline-flex w-full items-center justify-center gap-1.5 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90"
>
Start free
<ArrowUpRight className="h-3.5 w-3.5" aria-hidden="true" />
</a>
<a
href={ctaUrl(PRICING_URL, feature.slug)}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackCta(feature.slug, "feature_page_plans")}
className="inline-flex items-center gap-1 text-xs text-[#888] transition-colors hover:text-white"
>
View all plans
<ArrowUpRight className="h-3 w-3" aria-hidden="true" />
</a>
</div>
</div>
</div>
);
}
@@ -0,0 +1,134 @@
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>
);
}
@@ -143,7 +143,7 @@ export default function PastRunsView({
<button
key={run.name}
onClick={() => onSelectRun(run.name)}
className={`group flex w-full cursor-pointer items-center gap-4 rounded-lg border px-4 py-3 text-left transition-colors ${
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 ${
active
? "border-[#444] bg-[rgba(255,255,255,0.04)]"
: "border-[#222] bg-[rgba(255,255,255,0.02)] hover:border-[#444]"
+3 -115
View File
@@ -1,26 +1,5 @@
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
@@ -58,100 +37,10 @@ export function Tooltip({
);
}
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.
* 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,
@@ -177,7 +66,6 @@ export function ProInlineCta({
>
<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,7 +72,13 @@ 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) => String(x)).filter(Boolean);
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 status = cap(str(raw.status));
let scope = scopeMode ?? "auto";
@@ -94,6 +100,7 @@ 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>
@@ -146,7 +153,7 @@ export function RunDetails({
<span className="text-[#666]">None</span>
)}
</Field>
{scanMode && <Field label="Scan mode">{scanMode}</Field>}
{scanMode && <Field label="Pentest mode">{scanMode}</Field>}
<Field label="Scope">{scope}</Field>
<Field label="Mode">{nonInteractive ? "Non-interactive" : "Interactive"}</Field>
{localSources.length > 0 && (
@@ -169,6 +176,15 @@ 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 && (
@@ -184,7 +200,14 @@ export function RunDetails({
</Field>
)}
{totalTokens != null && <Field label="Total tokens">{formatNumber(totalTokens)}</Field>}
{cost != null && <Field label="Cost">${cost.toFixed(2)}</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>
)}
{agents.length > 0 && <Field label="Agents">{formatNumber(agents.length)}</Field>}
</dl>
) : (
+363 -217
View File
@@ -1,40 +1,40 @@
import React, { useEffect, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
FileText,
Bug,
Waypoints,
AlertTriangle,
Bot,
Users,
History,
Mail,
ArrowUpRight,
LogOut,
ShieldCheck,
PanelLeftClose,
PanelLeft,
ChevronsUpDown,
} 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 { 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 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.
* 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;
activeFeature: string | null;
onSelectFeature: (slug: string) => void;
issuesCount: number;
agentCount: number;
runCount: number;
@@ -46,11 +46,19 @@ interface SidebarProps {
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,
activeFeature,
onSelectFeature,
issuesCount,
agentCount,
runCount,
@@ -61,229 +69,367 @@ export default function Sidebar({
onOpenHistory,
onForget,
}: SidebarProps) {
const [collapsed, setCollapsed] = useState(false);
useEffect(() => {
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 {
setCollapsed(localStorage.getItem(COLLAPSE_KEY) === "1");
return localStorage.getItem(COLLAPSE_KEY) === "1";
} catch {
/* localStorage may be unavailable; default to expanded */
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 toggleCollapsed = () => {
setCollapsed((prev) => {
const next = !prev;
try {
localStorage.setItem(COLLAPSE_KEY, next ? "1" : "0");
} 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);
}
return next;
});
};
};
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 (
<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>
<>
{/* 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>
<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}`}
</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)]"
>
<ShieldCheck className="h-3.5 w-3.5 flex-shrink-0 text-emerald-400" aria-hidden="true" />
</div>
<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="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 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={onForget}
title="Forget this email on this machine"
className="flex-shrink-0 cursor-pointer text-[#666] transition-colors hover:text-white"
aria-label="Forget"
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-3.5 w-3.5" />
<LogOut className="h-4 w-4" />
Forget this email
</button>
</div>
)
)}
</div>
)}
</div>
</section>
{/* 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}
{/* 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)]"
)}
/>
<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>
</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)}
/>
</>
);
}
function NavItem({
icon: Icon,
label,
desc,
count,
active,
onClick,
collapsed = false,
}: {
icon: React.ElementType;
interface NavItemProps {
icon: React.ReactNode;
label: string;
desc: string;
count?: number;
active?: boolean;
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>
);
}
count?: number;
}
function NavItem({ icon, label, active, onClick, count }: NavItemProps) {
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 ${
className={cn(
"group flex h-9 w-full origin-left flex-row items-center rounded-md 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}
? "bg-[rgba(255,255,255,0.12)] text-white"
: "text-[#888] hover:bg-[rgba(255,255,255,0.06)] hover:text-[#ededed]"
)}
>
<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>}
<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>
<span className="mt-0.5 block text-[11px] leading-snug text-[#666]">{desc}</span>
</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>
);
}
@@ -0,0 +1,150 @@
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,4 +1,4 @@
import { useCallback, useEffect, useRef } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { X } from "lucide-react";
import { AgentTranscript } from "./AgentTranscript";
import { ScanPromptComposer } from "./ScanPromptComposer";
@@ -18,19 +18,27 @@ const STATUS_DOT: Record<string, string> = {
const NEAR_BOTTOM_PX = 80;
/**
* Overlay modal showing a single agent's full transcript. Matches the cloud
* app: a fixed-size panel with a pinned header (status dot + agent name), the
* transcript scrolling beneath it, and a footer. Auto-scrolls to follow new
* activity while the user is near the bottom (so a live run trails). Closes on
* backdrop click, the X button, or Escape.
* 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.
*/
export function AgentDetailModal({
open,
agent,
events,
steerable,
onClose,
}: {
agent: TranscriptAgent;
open: boolean;
agent: TranscriptAgent | null;
events: TranscriptEvent[];
steerable: boolean;
onClose: () => void;
@@ -38,6 +46,42 @@ 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;
@@ -54,6 +98,7 @@ export function AgentDetailModal({
}, [events]);
useEffect(() => {
if (!render) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
@@ -64,27 +109,30 @@ export function AgentDetailModal({
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [onClose]);
}, [render, onClose]);
if (!render || !shownAgent) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 sm:p-8"
data-state={state}
className="agent-modal fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4 sm:p-8"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label={`Agent ${agent.name}`}
aria-label={`Agent ${shownAgent.name}`}
>
<div
className="relative flex h-[80vh] w-full max-w-5xl flex-col rounded-xl border border-[#222] bg-[#0a0a0a] shadow-2xl"
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"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-[#222] px-5 py-3.5">
<div className="flex min-w-0 items-center gap-2">
<span
className={`h-2 w-2 flex-shrink-0 rounded-full ${STATUS_DOT[agent.status] ?? "bg-[#888]"}`}
className={`h-2 w-2 flex-shrink-0 rounded-full ${STATUS_DOT[shownAgent.status] ?? "bg-[#888]"}`}
/>
<span className="truncate text-sm font-semibold text-white">{agent.name}</span>
<span className="flex-shrink-0 font-mono text-xs text-[#555]">{agent.id}</span>
<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>
</div>
<button
type="button"
@@ -97,14 +145,16 @@ export function AgentDetailModal({
</div>
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto p-5">
<AgentTranscript agent={agent} events={events} showHeader={false} />
{contentReady && (
<AgentTranscript agent={shownAgent} events={events} showHeader={false} />
)}
</div>
{steerable && (
<div className="border-t border-[#222] px-5 py-3">
<ScanPromptComposer
agents={[agent]}
fixedAgentId={agent.id}
agents={[shownAgent]}
fixedAgentId={shownAgent.id}
className="mt-0"
/>
</div>
@@ -248,7 +248,7 @@ export function ScanPromptComposer({
void handleSend();
}
}}
placeholder="Send a live prompt to the running scan…"
placeholder="Send a live prompt to the running pentest…"
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"
@@ -3,10 +3,11 @@
import type { ToolRendererProps } from "@/types/events";
export default function LoadSkillRenderer({ args }: ToolRendererProps) {
const requestedRaw = (args.skills as string) ?? "";
const requestedSkills = requestedRaw
.split(",")
.map((skill) => skill.trim())
// `skills` may arrive as an array of names or a comma-separated string
// depending on the tool call, so normalize both to a clean list.
const raw = args.skills;
const requestedSkills = (Array.isArray(raw) ? raw : String(raw ?? "").split(","))
.map((skill) => String(skill).trim())
.filter(Boolean);
return (
@@ -3,6 +3,7 @@
import type { ToolRendererProps } from "@/types/events";
import { TruncatedText } from "./ToolCard";
import { MdCodeBlock } from "@/components/vulnerability/MdCodeBlock";
import { parseFencedCode } from "@/lib/fenced-code";
import Markdown from "./Markdown";
const SEVERITY_COLORS: Record<string, string> = {
@@ -19,7 +20,7 @@ export default function VulnReportRenderer({ args, result }: ToolRendererProps)
const method = (args.method as string) ?? "";
const technicalAnalysis = (args.technical_analysis as string) ?? "";
const pocDescription = (args.poc_description as string) ?? "";
const pocCode = (args.poc_script_code as string) ?? "";
const { language: pocLang, code: pocCode } = parseFencedCode((args.poc_script_code as string) ?? "");
const remediation = (args.remediation_steps as string) ?? "";
const cve = (args.cve as string) ?? "";
const cwe = (args.cwe as string) ?? "";
@@ -59,7 +60,7 @@ export default function VulnReportRenderer({ args, result }: ToolRendererProps)
<div>
<span className="text-emerald-400/60 text-sm font-semibold">Proof of Concept</span>
{pocDescription && <div className="mt-1"><Markdown text={pocDescription} /></div>}
{pocCode && <MdCodeBlock>{pocCode}</MdCodeBlock>}
{pocCode && <MdCodeBlock className={pocLang ? `language-${pocLang}` : undefined}>{pocCode}</MdCodeBlock>}
</div>
)}
{remediation && (
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import hljs from "@/lib/hljs";
import { highlightCode } from "@/lib/hljs";
import "highlight.js/styles/github-dark.css";
import { Copy, Check } from "lucide-react";
import { copyToClipboard } from "@/lib/vulnerability-utils";
@@ -17,7 +17,7 @@ export function MdCodeBlock({
}) {
const [copied, setCopied] = useState(false);
const raw = String(children).replace(/\n$/, "");
const match = /language-(\w+)/.exec(className || "");
const match = /language-(\S+)/.exec(className || "");
const isBlock = raw.includes("\n") || match;
if (!isBlock) {
@@ -35,16 +35,7 @@ export function MdCodeBlock({
: fileName
: null;
let highlighted: string;
if (match) {
try {
highlighted = hljs.highlight(raw, { language: match[1], ignoreIllegals: true }).value;
} catch {
highlighted = hljs.highlightAuto(raw).value;
}
} else {
highlighted = hljs.highlightAuto(raw).value;
}
const highlighted = highlightCode(raw, match?.[1]);
const lines = highlighted.split("\n");
@@ -1,12 +1,13 @@
"use client";
import { useState } from "react";
import hljs from "@/lib/hljs";
import { highlightCode } from "@/lib/hljs";
import "highlight.js/styles/github-dark.css";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Copy, Check } from "lucide-react";
import { copyToClipboard } from "@/lib/vulnerability-utils";
import { parseFencedCode } from "@/lib/fenced-code";
import { rehypeCodeMeta, mdComponents } from "./MdCodeBlock";
interface PocBlockProps {
@@ -20,9 +21,12 @@ export function PocBlock({ description, scriptCode, onCopy }: PocBlockProps) {
if (!description && !scriptCode) return null;
const { language, code } = parseFencedCode(scriptCode);
const highlighted = highlightCode(code, language);
const copy = () => {
if (!scriptCode) return;
copyToClipboard(scriptCode);
if (!code) return;
copyToClipboard(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
onCopy?.();
@@ -43,7 +47,7 @@ export function PocBlock({ description, scriptCode, onCopy }: PocBlockProps) {
</ReactMarkdown>
</div>
)}
{scriptCode && (
{code && (
<div className="group/poc relative rounded-md border border-[#2a2a2a] overflow-hidden">
<div className="flex items-stretch">
<span className="relative flex items-center text-[13px] text-[#999] font-mono px-4 py-2 border-r border-[#2a2a2a]">PoC Script<span className="absolute top-0 inset-x-0 h-0.5 bg-white/60 rounded-full" /></span>
@@ -64,7 +68,7 @@ export function PocBlock({ description, scriptCode, onCopy }: PocBlockProps) {
<pre className="font-mono text-[12px] leading-[22px] whitespace-pre [font-variant-ligatures:none]">
<code
dangerouslySetInnerHTML={{
__html: hljs.highlight(scriptCode, { language: "python" }).value,
__html: highlighted,
}}
/>
</pre>
@@ -1,8 +1,8 @@
"use client";
import React, { useState } from "react";
import { Clock, CheckCircle2, Ban, History, BellOff, Wrench, GitMerge, GitPullRequest } from "lucide-react";
import { ProInlineCta } from "@/components/ProCta";
import { Clock, CheckCircle2, Ban, History, BellOff, Wrench, GitMerge } from "lucide-react";
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
import { Vulnerability, VulnerabilityStatus, SEVERITY_COLORS, STATUS_META, isSeverityOverridden } from "@/types/issues";
import { formatTimeAgo } from "@/lib/utils";
import { getSeverityDot } from "@/lib/vulnerability-utils";
@@ -29,6 +29,15 @@ const STATUS_BANNER: Record<VulnerabilityStatus, { icon: React.ElementType; labe
type BottomTab = "fix" | "reproduction";
// Team-workflow actions shown top-right of the finding header; each links out
// to sign-up. `requiresCode` actions only appear when the finding has concrete
// code locations to act on -- an autofix PR makes no sense for a black-box
// finding with no code to change.
const WORKFLOW_CTAS: { label: string; slug: string; icon: React.ElementType; requiresCode?: boolean }[] = [
{ label: "Auto-fix & open a PR", slug: "autofix", icon: Wrench, requiresCode: true },
{ label: "Sync to Jira / Linear", slug: "integrations", icon: GitMerge },
];
interface VulnerabilityDetailProps {
vulnerability: Vulnerability;
}
@@ -54,8 +63,9 @@ export default function VulnerabilityDetail({ vulnerability }: VulnerabilityDeta
return (
<div className="space-y-6">
{/* Header */}
<div>
{/* Header: title/badges on the left, workflow actions top-right. */}
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="mb-2">
{vulnerability.display_number && (
<span className="text-xs font-mono text-[#555] block mb-1">
@@ -88,6 +98,26 @@ export default function VulnerabilityDetail({ vulnerability }: VulnerabilityDeta
</>
)}
</div>
</div>
<div className="flex flex-shrink-0 flex-wrap items-center gap-2">
{WORKFLOW_CTAS.filter((cta) => !cta.requiresCode || hasCodeLocations).map((cta) => {
const Icon = cta.icon;
return (
<a
key={cta.slug}
href={ctaUrl(SIGNUP_URL, cta.slug)}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackCta(cta.slug, "finding_detail")}
className="inline-flex items-center gap-1.5 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity hover:opacity-90"
>
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
{cta.label}
</a>
);
})}
</div>
</div>
{/* Status banner */}
@@ -230,36 +260,6 @@ export default function VulnerabilityDetail({ vulnerability }: VulnerabilityDeta
</div>
</div>
{/* Team-workflow CTAs (Pro). Highest-intent surface: act on this finding. */}
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
<p className="text-sm font-semibold text-white">Ship the fix with your team</p>
<p className="mt-0.5 text-xs text-[#666]">
Take this finding into your team's workflow.
</p>
<div className="mt-3 flex flex-wrap gap-2.5">
<ProInlineCta
label="Auto-fix & open a PR"
desc="Fix it for you and open a PR, retested."
slug="autofix"
icon={Wrench}
surface="finding_detail"
/>
<ProInlineCta
label="Sync to Jira / Linear"
desc="Two-way sync findings to Jira, Linear, and Slack."
slug="integrations"
icon={GitMerge}
surface="finding_detail"
/>
<ProInlineCta
label="Catch this in PR reviews"
desc="Pentest every pull request your team opens."
slug="pr_reviews"
icon={GitPullRequest}
surface="finding_detail"
/>
</div>
</div>
</div>
);
}
+18 -4
View File
@@ -6,10 +6,9 @@ import {
} from "@/lib/local-run-parser";
/**
* Data seam for the local viewer. Replaces strix-app's browser file-picker
* (`loadFromTexts`) with fetches against the local Python server's JSON
* endpoints (same origin, relative URLs). Produces the same in-memory
* `LoadedRun` shape the UI renders, plus a `finished` flag driving live polling.
* Data seam for the local viewer: fetches against the local Python server's
* JSON endpoints (same origin, relative URLs), producing the in-memory
* `LoadedRun` shape the UI renders plus a `finished` flag driving live polling.
*
* The server serves a live in-progress run and a finished one identically; the
* only signal is `run.finished`.
@@ -201,6 +200,21 @@ export async function steerAgent(agentId: string, message: string): Promise<Stee
return { ok: false, error: String(data.error ?? "unavailable") };
}
export type SubmitFeedbackResult = { ok: true } | { ok: false; error: string };
/**
* POST /api/feedback. Sends a feedback message plus a work email (no
* verification) to the local server, which relays it to Strix.
*/
export async function submitFeedback(
message: string,
email: string
): Promise<SubmitFeedbackResult> {
const { ok, data } = await postJson("/api/feedback", { message, email });
if (ok && data.ok === true) return { ok: true };
return { ok: false, error: String(data.error ?? "unavailable") };
}
export async function fetchAuthStatus(): Promise<AuthStatus> {
const obj = (await getJson("/api/auth/status")) as Partial<AuthStatus>;
return { verified: obj?.verified === true, email: obj?.email ?? null };
+156 -3
View File
@@ -19,7 +19,161 @@ body {
font-family: var(--font-geist-sans);
}
/* Tab content transition (lifted from strix-app globals.css) */
/* 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;
@@ -32,12 +186,11 @@ body {
transform: translateY(0px);
}
}
.animate-tab-in {
animation: tab-in 200ms ease-out;
}
/* Markdown prose styling (lifted from strix-app globals.css) */
/* Markdown prose styling */
.prose-markdown {
font-size: 14px;
line-height: 1.7;
@@ -0,0 +1,27 @@
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] };
}
+18
View File
@@ -11,4 +11,22 @@ 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;
@@ -1,174 +0,0 @@
import type React from "react";
import {
GitPullRequest,
Layers,
Globe,
Puzzle,
Users,
Search,
LayoutDashboard,
AlertTriangle,
MessageSquare,
Network,
Database,
} from "lucide-react";
/**
* Platform (Pro / Enterprise) feature catalog. Powers both the unified sidebar
* nav rows and the in-app FeatureDetail upsell view. Everything is "Pro" except
* Networks, which is "Enterprise". No lock icons anywhere.
*/
export type FeatureTier = "Pro" | "Enterprise";
export interface ProFeature {
slug: string;
title: string;
icon: React.ElementType;
tier: FeatureTier;
/** Short one-liner for the sidebar nav row (two-line layout). */
navDesc: string;
/** Headline shown on the FeatureDetail upsell page. */
headline: string;
/** Longer sentence shown on the FeatureDetail upsell page. */
description: string;
}
// Flat catalog of every platform feature, keyed by slug for routing. The
// sidebar groups these into capability themes (see PLATFORM_THEMES); nothing
// here implies a tier ordering.
export const PLATFORM_FEATURES: ProFeature[] = [
{
slug: "pr_reviews",
title: "PR Reviews",
icon: GitPullRequest,
tier: "Pro",
navDesc: "Pentest every pull request",
headline: "Pentest every pull request",
description:
"Strix reviews every pull request your team opens and catches exploitable changes before they merge.",
},
{
slug: "repositories",
title: "Repositories",
icon: Layers,
tier: "Pro",
navDesc: "Connect your team's repos",
headline: "Connect your team's repositories",
description:
"Link your org's repositories so Strix can scan them continuously and track findings over time.",
},
{
slug: "domains",
title: "Domains",
icon: Globe,
tier: "Pro",
navDesc: "Cover the domains you own",
headline: "Cover every domain you own",
description:
"Add the domains your team owns and let Strix watch them for newly exposed paths and drift.",
},
{
slug: "integrations",
title: "Integrations",
icon: Puzzle,
tier: "Pro",
navDesc: "Sync to Jira, Linear, Slack",
headline: "Sync findings to your tools",
description:
"Two-way sync findings to Jira, Linear, and Slack so fixes happen where your team already works.",
},
{
slug: "members",
title: "Members",
icon: Users,
tier: "Pro",
navDesc: "Invite your team, set roles",
headline: "Bring your whole team",
description:
"Invite your team, set roles, and share findings and run history across your org.",
},
{
slug: "pentests",
title: "Pentests",
icon: Search,
tier: "Pro",
navDesc: "Deeper scans on managed infra",
headline: "Launch deeper pentests",
description:
"Run deeper, longer pentests on managed infrastructure whenever you need them.",
},
{
slug: "dashboard",
title: "Dashboard",
icon: LayoutDashboard,
tier: "Pro",
navDesc: "Everything in one place",
headline: "See everything in one place",
description:
"Track every project, run, and finding across your org from a single dashboard.",
},
{
slug: "platform_issues",
title: "Issues",
icon: AlertTriangle,
tier: "Pro",
navDesc: "Triage across your org",
headline: "Triage findings across your org",
description:
"Manage and triage findings across every project and repository in one queue.",
},
{
slug: "chat",
title: "Chat",
icon: MessageSquare,
tier: "Pro",
navDesc: "Ask about any finding",
headline: "Ask Strix anything",
description:
"Ask the agents about any finding, run, or part of your app in natural language.",
},
{
slug: "networks",
title: "Networks",
icon: Network,
tier: "Enterprise",
navDesc: "Reach internal, VPN-only targets",
headline: "Scan internal networks",
description:
"Connect private networks to scan internal applications, VPN-only services, and RFC1918 targets.",
},
{
slug: "knowledge",
title: "Knowledge",
icon: Database,
tier: "Pro",
navDesc: "Give agents context",
headline: "Give agents context",
description:
"Teach Strix about your systems and business logic so every run gets smarter.",
},
];
export const FEATURES: Record<string, ProFeature> = Object.fromEntries(
PLATFORM_FEATURES.map((f) => [f.slug, f])
);
/**
* Order the platform rows appear in the sidebar's single, ungrouped nav list
* (after the run/local rows). No section headers; tier is shown only by each
* row's inline tag.
*/
export const PLATFORM_ORDER: string[] = [
"pr_reviews",
"repositories",
"domains",
"integrations",
"members",
"pentests",
"networks",
"chat",
"dashboard",
"platform_issues",
"knowledge",
];
@@ -57,5 +57,5 @@ export function parseTarget(target: string): ParsedTarget {
*/
export function runTitle(target: string | null, fallback: string): string {
if (target) return parseTarget(target).display.replace(/\/$/, "");
return fallback || "Untitled scan";
return fallback || "Untitled pentest";
}
+72 -18
View File
@@ -151,19 +151,35 @@ def _styles() -> dict[str, ParagraphStyle]:
"Finding", fontName=_SANS_BOLD, fontSize=13, leading=17, textColor=_INK, spaceBefore=6
)
styles["field_label"] = ParagraphStyle(
"FieldLabel", fontName=_SANS_BOLD, fontSize=8.5, leading=12, textColor=_MUTED,
spaceBefore=10, spaceAfter=2,
"FieldLabel",
fontName=_SANS_BOLD,
fontSize=8.5,
leading=12,
textColor=_MUTED,
spaceBefore=10,
spaceAfter=2,
)
styles["body"] = ParagraphStyle(
"Body", fontName=_SANS, fontSize=10, leading=15, textColor=_TEXT, spaceAfter=8
)
styles["md_heading"] = ParagraphStyle(
"MdHeading", fontName=_SANS_BOLD, fontSize=11, leading=15, textColor=_INK,
spaceBefore=10, spaceAfter=4,
"MdHeading",
fontName=_SANS_BOLD,
fontSize=11,
leading=15,
textColor=_INK,
spaceBefore=10,
spaceAfter=4,
)
styles["bullet"] = ParagraphStyle(
"Bullet", fontName=_SANS, fontSize=10, leading=15, textColor=_TEXT,
leftIndent=16, firstLineIndent=-11, spaceAfter=3,
"Bullet",
fontName=_SANS,
fontSize=10,
leading=15,
textColor=_TEXT,
leftIndent=16,
firstLineIndent=-11,
spaceAfter=3,
)
styles["meta_inline"] = ParagraphStyle(
"MetaInline", fontName=_SANS, fontSize=9, leading=13, textColor=_MUTED, spaceBefore=4
@@ -172,23 +188,44 @@ def _styles() -> dict[str, ParagraphStyle]:
# a bordered paragraph's top padding, so too small a gap lets the background
# box bleed up over the field label above it.
styles["code"] = ParagraphStyle(
"Code", fontName=_MONO, fontSize=8, leading=11, textColor=_TEXT,
backColor=_LIGHT_BG, borderColor=_BORDER, borderWidth=0.5, borderPadding=8,
spaceBefore=12, spaceAfter=12,
"Code",
fontName=_MONO,
fontSize=8,
leading=11,
textColor=_TEXT,
backColor=_LIGHT_BG,
borderColor=_BORDER,
borderWidth=0.5,
borderPadding=8,
spaceBefore=12,
spaceAfter=12,
)
styles["count"] = ParagraphStyle(
"Count", fontName=_SANS_BOLD, fontSize=30, leading=32, alignment=TA_CENTER
)
styles["count_label"] = ParagraphStyle(
"CountLabel", fontName=_SANS_BOLD, fontSize=8, leading=12, textColor=_MUTED,
alignment=TA_CENTER, spaceBefore=4,
"CountLabel",
fontName=_SANS_BOLD,
fontSize=8,
leading=12,
textColor=_MUTED,
alignment=TA_CENTER,
spaceBefore=4,
)
styles["badge"] = ParagraphStyle(
"Badge", fontName=_SANS_BOLD, fontSize=9, leading=11, textColor=colors.white,
"Badge",
fontName=_SANS_BOLD,
fontSize=9,
leading=11,
textColor=colors.white,
alignment=TA_CENTER,
)
styles["confidential"] = ParagraphStyle(
"Confidential", fontName=_SANS_BOLD, fontSize=9, leading=12, textColor=colors.white,
"Confidential",
fontName=_SANS_BOLD,
fontSize=9,
leading=12,
textColor=colors.white,
alignment=TA_CENTER,
)
return styles
@@ -256,8 +293,10 @@ def _severity_grid(styles: dict[str, ParagraphStyle], counts: dict[str, int]) ->
color = _SEVERITY_COLORS[name]
count_style = ParagraphStyle(f"Count{name}", parent=styles["count"], textColor=color)
cells.append(
[Paragraph(str(counts.get(name, 0)), count_style),
Paragraph(name.upper(), styles["count_label"])]
[
Paragraph(str(counts.get(name, 0)), count_style),
Paragraph(name.upper(), styles["count_label"]),
]
)
col = (_PAGE_W - 40 * mm) / 4
table = Table([cells], colWidths=[col] * 4)
@@ -320,8 +359,10 @@ def _cover(
("DURATION", _duration(record.get("start_time"), record.get("end_time"))),
]
meta_table = Table(
[[Paragraph(label, styles["meta_label"]), Paragraph(_esc(value), styles["meta_value"])]
for label, value in meta_rows],
[
[Paragraph(label, styles["meta_label"]), Paragraph(_esc(value), styles["meta_value"])]
for label, value in meta_rows
],
colWidths=[38 * mm, _PAGE_W - 40 * mm - 38 * mm],
)
meta_table.setStyle(
@@ -462,6 +503,18 @@ def _markdown_flowables( # noqa: PLR0915 - cohesive block parser, splitting hur
return flow
_FENCE_RE = re.compile(r"^```([^\n`]*)\n(.*?)\n?```$", re.DOTALL)
def _strip_code_fence(value: Any) -> Any:
"""Drop a wrapping markdown code fence so raw-code fields don't show the
``` ```lang ``` marker lines literally in the PDF."""
if not isinstance(value, str):
return value
match = _FENCE_RE.match(value.strip())
return match.group(2) if match else value
def _field_block(
styles: dict[str, ParagraphStyle], label: str, value: Any, *, code: bool = False
) -> list[Flowable]:
@@ -503,7 +556,8 @@ def _finding_flowables(
story.extend(_field_block(styles, "Impact", vuln.get("impact")))
story.extend(_field_block(styles, "Technical analysis", vuln.get("technical_analysis")))
story.extend(_field_block(styles, "Proof of concept", vuln.get("poc_description")))
story.extend(_field_block(styles, "PoC script", vuln.get("poc_script_code"), code=True))
poc_script = _strip_code_fence(vuln.get("poc_script_code"))
story.extend(_field_block(styles, "PoC script", poc_script, code=True))
story.extend(_field_block(styles, "Evidence", vuln.get("evidence"), code=True))
remediation = vuln.get("remediation_steps")
+38
View File
@@ -174,6 +174,8 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
self._handle_forget()
elif path == "/api/report/send":
self._handle_report_send()
elif path == "/api/feedback":
self._handle_feedback()
elif path == "/api/agents/steer":
self._handle_steer()
else:
@@ -218,6 +220,10 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
purpose = body.get("purpose")
posthog.viewer_email_event(str(event), purpose=str(purpose) if purpose else None)
elif event == "agent_steered":
from strix.telemetry import posthog
posthog.viewer_agent_steered()
self.send_response(HTTPStatus.NO_CONTENT)
self.end_headers()
@@ -379,6 +385,37 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
{"ok": True, "password": password, "filename": filename},
)
# Cap on a feedback message so a runaway client cannot flood the relay.
_FEEDBACK_MESSAGE_MAX = 5000
def _handle_feedback(self) -> None:
# Requires this process's session capability, like the other POSTs,
# so an exposed --host port can't be used to spam the relay.
if not self._has_session():
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
return
body = self._read_body()
email = str(body.get("email") or "").strip()
message = str(body.get("message") or "").strip()
if not email:
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_email"})
return
if not message:
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_message"})
return
message = message[: self._FEEDBACK_MESSAGE_MAX]
try:
auth.feedback_submit(email, message)
except auth.RelayError as exc:
self._send_relay_error(exc)
return
# Server-authoritative: fire only after a successful relay (respects
# the telemetry opt-out; no message/email content is sent).
from strix.telemetry import posthog
posthog.viewer_feedback_submitted()
self._send_json(HTTPStatus.OK, {"ok": True})
# Cap on a steering message so a runaway client cannot flood the agent.
_STEER_MESSAGE_MAX = 4000
@@ -413,6 +450,7 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
status_by_code = {
"rate_limited": HTTPStatus.TOO_MANY_REQUESTS,
"invalid_email": HTTPStatus.BAD_REQUEST,
"invalid_message": HTTPStatus.BAD_REQUEST,
"work_email_required": HTTPStatus.BAD_REQUEST,
"invalid_code": HTTPStatus.FORBIDDEN,
"reverify": HTTPStatus.UNAUTHORIZED,
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>Strix Results</title>
<script type="module" crossorigin src="./assets/index-BU_tk5L-.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-C0NveaV7.css">
<script type="module" crossorigin src="./assets/index-Dd1cyttN.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-vV8wxCG6.css">
</head>
<body>
<div id="root"></div>
+64 -5
View File
@@ -3,12 +3,14 @@
from __future__ import annotations
import json
from types import SimpleNamespace
from typing import Any, cast
import pytest
from agents.tool import FunctionTool
from agents.tool import CustomTool, FunctionTool
from strix.agents import factory
from strix.config import load_settings
def _capturing_exec_tool(captured: dict[str, str]) -> FunctionTool:
@@ -32,10 +34,37 @@ async def test_wrap_exec_command_defaults_shell_to_bash() -> None:
result = await wrapped.on_invoke_tool(cast("Any", None), json.dumps({"cmd": "source /tmp/env"}))
assert result == "ok"
assert json.loads(captured["raw_input"]) == {
"cmd": "source /tmp/env",
"shell": "bash",
}
parsed = json.loads(captured["raw_input"])
assert parsed["cmd"] == "source /tmp/env"
assert parsed["shell"] == "bash"
expected_cap = load_settings().context.tool_output_max_tokens
assert parsed["max_output_tokens"] == expected_cap
@pytest.mark.asyncio
async def test_wrap_exec_command_preserves_smaller_explicit_output_cap() -> None:
captured: dict[str, str] = {}
wrapped = factory._wrap_exec_command(_capturing_exec_tool(captured))
await wrapped.on_invoke_tool(
cast("Any", None), json.dumps({"cmd": "echo hi", "max_output_tokens": 42})
)
assert json.loads(captured["raw_input"])["max_output_tokens"] == 42
@pytest.mark.asyncio
async def test_wrap_exec_command_clamps_oversized_explicit_output_cap() -> None:
captured: dict[str, str] = {}
wrapped = factory._wrap_exec_command(_capturing_exec_tool(captured))
ceiling = load_settings().context.tool_output_max_tokens
await wrapped.on_invoke_tool(
cast("Any", None),
json.dumps({"cmd": "echo hi", "max_output_tokens": ceiling * 100}),
)
assert json.loads(captured["raw_input"])["max_output_tokens"] == ceiling
@pytest.mark.asyncio
@@ -49,3 +78,33 @@ async def test_wrap_exec_command_preserves_explicit_shell(shell: str) -> None:
)
assert json.loads(captured["raw_input"])["shell"] == shell
@pytest.mark.asyncio
async def test_responses_filesystem_custom_tool_output_is_bounded() -> None:
async def invoke(_ctx: Any, _inp: str) -> str:
return "line\n" * 50_000
toolset = SimpleNamespace(
read_file=CustomTool(name="read_file", description="read", on_invoke_tool=invoke)
)
factory._configure_filesystem_tools(toolset, chat_completions=False)
assert isinstance(toolset.read_file, CustomTool)
result = await toolset.read_file.on_invoke_tool(cast("Any", None), "{}")
assert "truncated" in result
assert len(result) < len("line\n" * 50_000)
@pytest.mark.asyncio
async def test_chat_completions_filesystem_custom_tool_becomes_function_tool() -> None:
async def invoke(_ctx: Any, _inp: str) -> str:
return "ok"
toolset = SimpleNamespace(
read_file=CustomTool(name="read_file", description="read", on_invoke_tool=invoke)
)
factory._configure_filesystem_tools(toolset, chat_completions=True)
assert isinstance(toolset.read_file, FunctionTool)
+106
View File
@@ -0,0 +1,106 @@
"""Tests for the `strix auth` CLI: subcommand routing and provider naming."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import pytest
from strix.config import codex
from strix.interface import auth_cli
if TYPE_CHECKING:
from pathlib import Path
@pytest.fixture(autouse=True)
def _tmp_store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(codex, "AUTH_PATH", tmp_path / "home" / ".strix" / "subscription-auth.json")
def test_login_provider_is_chatgpt() -> None:
assert auth_cli.LOGIN_PROVIDER == "chatgpt"
assert codex.PROVIDER in auth_cli._ACCEPTED_PROVIDERS
assert "chatgpt" in auth_cli._ACCEPTED_PROVIDERS
def test_unknown_subcommand_returns_usage_error() -> None:
assert auth_cli.run_auth(["bogus"]) == 2
def test_help_returns_zero() -> None:
assert auth_cli.run_auth(["--help"]) == 0
def test_status_not_signed_in() -> None:
assert auth_cli.run_auth(["status"]) == 1
def test_login_rejects_unsupported_provider(monkeypatch: pytest.MonkeyPatch) -> None:
def _should_not_run(*_args: Any, **_kwargs: Any) -> dict[str, Any]:
msg = "OAuth flow must not start for an unsupported provider"
raise AssertionError(msg)
monkeypatch.setattr(auth_cli, "_run_oauth_flow", _should_not_run)
assert auth_cli.run_auth(["login", "gemini"]) == 2
def test_finish_requires_state_on_loopback(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(codex, "exchange_code", lambda *_: {"ok": True})
# Loopback (require_state=True): missing or mismatched state is rejected.
with pytest.raises(codex.CodexAuthError) as missing:
auth_cli._finish("code", None, "verifier", "expected", require_state=True)
assert missing.value.code == "state_mismatch"
with pytest.raises(codex.CodexAuthError) as mismatch:
auth_cli._finish("code", "wrong", "verifier", "expected", require_state=True)
assert mismatch.value.code == "state_mismatch"
# Matching state proceeds to the exchange.
assert auth_cli._finish("code", "expected", "verifier", "expected", require_state=True) == {
"ok": True
}
def test_finish_manual_paste_allows_absent_state(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(codex, "exchange_code", lambda *_: {"ok": True})
# Manual paste (require_state=False): a bare code with no state is accepted,
# but a present-and-wrong state is still rejected.
assert auth_cli._finish("code", None, "verifier", "expected", require_state=False) == {
"ok": True
}
with pytest.raises(codex.CodexAuthError):
auth_cli._finish("code", "wrong", "verifier", "expected", require_state=False)
def test_finish_rejects_missing_code() -> None:
with pytest.raises(codex.CodexAuthError) as exc:
auth_cli._finish(None, "expected", "verifier", "expected", require_state=True)
assert exc.value.code == "no_code"
def test_model_subcommand_removed() -> None:
assert auth_cli.run_auth(["model", "gpt-5.5"]) == 2
@pytest.mark.parametrize("provider", ["chatgpt", "codex", "ChatGPT"])
def test_login_accepts_provider_aliases(provider: str, monkeypatch: pytest.MonkeyPatch) -> None:
reached = {"flow": False}
def _fake_flow(*_args: Any, **_kwargs: Any) -> dict[str, Any]:
reached["flow"] = True
return {
"type": "oauth",
"provider": "codex",
"access": "a",
"refresh": "r",
"account_id": "acct",
"expires_at": 0,
}
monkeypatch.setattr(auth_cli, "_run_oauth_flow", _fake_flow)
monkeypatch.setattr(codex, "save_record", lambda _record: None)
assert auth_cli.run_auth(["login", provider]) == 0
assert reached["flow"] is True
+294
View File
@@ -0,0 +1,294 @@
"""Tests for ChatGPT (Codex) subscription auth: PKCE, token handling, store."""
from __future__ import annotations
import base64
import hashlib
import json
import time
from typing import TYPE_CHECKING, Any
import pytest
from strix.config import codex
if TYPE_CHECKING:
from pathlib import Path
def _fake_jwt(account_id: str) -> str:
def seg(obj: dict[str, Any]) -> str:
return base64.urlsafe_b64encode(json.dumps(obj).encode()).rstrip(b"=").decode()
header = seg({"alg": "none"})
payload = seg({"https://api.openai.com/auth": {"chatgpt_account_id": account_id}})
return f"{header}.{payload}.sig"
@pytest.fixture(autouse=True)
def _tmp_store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
path = tmp_path / "home" / ".strix" / "subscription-auth.json"
monkeypatch.setattr(codex, "AUTH_PATH", path)
return path
def test_pkce_challenge_matches_verifier_and_is_unpadded() -> None:
verifier, challenge = codex.generate_pkce()
expected = (
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
)
assert challenge == expected
assert "=" not in verifier
assert "=" not in challenge
def test_authorize_url_carries_pkce_and_client() -> None:
url = codex.build_authorize_url("chal", "st8")
assert codex.AUTHORIZE_URL in url
assert "code_challenge=chal" in url
assert "code_challenge_method=S256" in url
assert f"client_id={codex.CLIENT_ID}" in url
assert "state=st8" in url
@pytest.mark.parametrize(
("value", "expected"),
[
("http://localhost:1455/auth/callback?code=AAA&state=BBB", ("AAA", "BBB")),
("AAA#BBB", ("AAA", "BBB")),
("code=AAA&state=BBB", ("AAA", "BBB")),
("AAA", ("AAA", None)),
("", (None, None)),
],
)
def test_parse_redirect_input(value: str, expected: tuple[str | None, str | None]) -> None:
assert codex.parse_redirect_input(value) == expected
@pytest.mark.parametrize(
("model", "expected"),
[
("chatgpt/gpt-5.4", "gpt-5.4"),
("ChatGPT/GPT-5.5", "GPT-5.5"),
(" chatgpt/gpt-5.4 ", "gpt-5.4"),
("openai/gpt-5.4", None), # metered API path
("anthropic/claude-opus-4-8", None),
("gpt-5.4", None),
("chatgpt/", None),
("", None),
(None, None),
],
)
def test_subscription_model(model: str | None, expected: str | None) -> None:
assert codex.subscription_model(model) == expected
def test_auth_mode() -> None:
assert codex.auth_mode("chatgpt/gpt-5.4") == "subscription"
assert codex.auth_mode("openai/gpt-5.4") == "api_key"
assert codex.auth_mode("anthropic/claude-opus-4-8") == "api_key"
assert codex.auth_mode(None) == "api_key"
def test_is_content_guardrail_error() -> None:
# The backend's real wording (from a live gpt-5.6-sol block).
raw = RuntimeError(
"This content was flagged for possible cybersecurity risk. If this seems "
"wrong, try rephrasing. To get authorized, join the Trusted Access for Cyber program."
)
assert codex.is_content_guardrail_error(raw) is True
# The already-typed error is recognized regardless of its message wording.
assert codex.is_content_guardrail_error(codex.CodexContentGuardrailError("gpt-5.6-sol")) is True
# Unrelated errors are not misclassified.
assert codex.is_content_guardrail_error(RuntimeError("rate limit exceeded")) is False
def test_content_guardrail_error_message() -> None:
err = codex.CodexContentGuardrailError("gpt-5.6-sol")
assert err.model == "gpt-5.6-sol"
assert "gpt-5.6-sol" in str(err)
assert "STRIX_LLM" in str(err)
def test_account_id_from_jwt() -> None:
assert codex._account_id_from_jwt(_fake_jwt("acct-42")) == "acct-42"
assert codex._account_id_from_jwt("not-a-jwt") is None
assert codex._account_id_from_jwt("") is None
def test_store_roundtrip_and_logout() -> None:
assert codex.read_record() is None
assert codex.is_authenticated() is False
codex.save_record(
{
"type": "oauth",
"provider": "codex",
"access": _fake_jwt("acct-42"),
"refresh": "r1",
"account_id": "acct-42",
"expires_at": time.time() + 3600,
}
)
record = codex.read_record()
assert record is not None
assert record["account_id"] == "acct-42"
assert codex.is_authenticated() is True
codex.logout()
assert codex.read_record() is None
codex.logout() # no-op when already gone
def test_read_record_rejects_incomplete_records() -> None:
codex.save_record({"type": "oauth", "access": "a"}) # missing refresh/account
assert codex.read_record() is None
assert codex.is_authenticated() is False
def test_get_valid_token_returns_stored_when_fresh(monkeypatch: pytest.MonkeyPatch) -> None:
def _boom(_payload: dict[str, str]) -> dict[str, Any]:
msg = "should not refresh a fresh token"
raise AssertionError(msg)
monkeypatch.setattr(codex, "_post_form", _boom)
codex.save_record(
{
"type": "oauth",
"provider": "codex",
"access": "access-fresh",
"refresh": "r1",
"account_id": "acct-42",
"expires_at": time.time() + 3600,
}
)
assert codex.get_valid_token() == ("access-fresh", "acct-42")
def test_get_valid_token_refreshes_and_persists_rotation(monkeypatch: pytest.MonkeyPatch) -> None:
calls = {"n": 0}
def _fake_post(payload: dict[str, str]) -> dict[str, Any]:
calls["n"] += 1
assert payload["grant_type"] == "refresh_token"
assert payload["refresh_token"] == "r1"
return {"access_token": _fake_jwt("acct-42"), "refresh_token": "r2", "expires_in": 3600}
monkeypatch.setattr(codex, "_post_form", _fake_post)
codex.save_record(
{
"type": "oauth",
"provider": "codex",
"access": "stale",
"refresh": "r1",
"account_id": "acct-42",
"expires_at": time.time() - 10, # already expired
}
)
_access, account_id = codex.get_valid_token()
assert calls["n"] == 1
assert account_id == "acct-42"
# Rotated refresh token was written back to the store.
record = codex.read_record()
assert record is not None
assert record["refresh"] == "r2"
def test_get_valid_token_uses_token_rotated_by_another_process(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Simulate a parallel Strix process rotating the token while we wait for the
# refresh guard: the pre-guard read sees the stale token, the in-guard read
# sees the winner's fresh one, so we must NOT exchange the now-dead refresh.
records = [
{
"type": "oauth",
"provider": "codex",
"access": "stale",
"refresh": "r1",
"account_id": "acct",
"expires_at": time.time() - 10,
},
{
"type": "oauth",
"provider": "codex",
"access": "fresh-from-other-process",
"refresh": "r2",
"account_id": "acct",
"expires_at": time.time() + 3600,
},
]
calls = {"n": 0}
def _fake_read() -> dict[str, Any]:
record = records[min(calls["n"], len(records) - 1)]
calls["n"] += 1
return record
def _boom(_payload: dict[str, str]) -> dict[str, Any]:
msg = "must not refresh a token another process already rotated"
raise AssertionError(msg)
monkeypatch.setattr(codex, "read_record", _fake_read)
monkeypatch.setattr(codex, "_post_form", _boom)
access, account_id = codex.get_valid_token()
assert access == "fresh-from-other-process"
assert account_id == "acct"
def _expired_record(refresh: str, access: str) -> dict[str, Any]:
return {
"type": "oauth",
"provider": "codex",
"access": access,
"refresh": refresh,
"account_id": "acct-42",
"expires_at": time.time() - 10,
}
def test_get_valid_token_recovers_when_refresh_loses_race(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Lock failed open: our in-guard read still saw the stale token, so we tried to
# refresh and lost the race (invalid_grant). By then a peer has saved a fresh
# token — recover from it instead of failing the scan on the dead one.
codex.save_record(_expired_record("r1", "stale"))
def _fake_post(_payload: dict[str, str]) -> dict[str, Any]:
codex.save_record(
{
"type": "oauth",
"provider": "codex",
"access": "fresh-from-peer",
"refresh": "r2",
"account_id": "acct-42",
"expires_at": time.time() + 3600,
}
)
raise codex.CodexAuthError("token_http_error", "HTTP 400: invalid_grant")
monkeypatch.setattr(codex, "_post_form", _fake_post)
assert codex.get_valid_token() == ("fresh-from-peer", "acct-42")
def test_get_valid_token_reraises_refresh_error_without_rotation(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Refresh fails and no peer rotated the token: surface the error, don't mask it.
codex.save_record(_expired_record("r1", "stale"))
def _fake_post(_payload: dict[str, str]) -> dict[str, Any]:
raise codex.CodexAuthError("token_http_error", "HTTP 400: invalid_grant")
monkeypatch.setattr(codex, "_post_form", _fake_post)
with pytest.raises(codex.CodexAuthError):
codex.get_valid_token()
def test_get_valid_token_raises_when_not_signed_in() -> None:
with pytest.raises(codex.CodexAuthError) as exc:
codex.get_valid_token()
assert exc.value.code == "not_authenticated"
+225
View File
@@ -0,0 +1,225 @@
"""Regression test for the ChatGPT Codex backend's streaming requirement.
The backend rejects non-streamed requests with ``{"detail": "Stream must be set
to true"}``. ``_CodexResponsesModel`` must therefore issue a streamed request
even from the non-streaming ``get_response`` path and aggregate the events into
a single response. A local server that mimics that behaviour proves the wrapper
works where the stock responses model would fail.
"""
from __future__ import annotations
import json
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import TYPE_CHECKING, Any
import pytest
from agents.model_settings import ModelSettings
from agents.models.interface import ModelTracing
from agents.models.openai_responses import OpenAIResponsesModel
from openai import AsyncOpenAI, BadRequestError
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
from strix.config import codex
from strix.config.models import _CodexResponsesModel
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Iterator
def _response_payload() -> dict[str, Any]:
return {
"id": "resp_1",
"object": "response",
"created_at": 0,
"status": "completed",
"model": "gpt-5.5",
"output": [
{
"type": "message",
"id": "m1",
"status": "completed",
"role": "assistant",
"content": [{"type": "output_text", "text": "OK", "annotations": []}],
}
],
"usage": {
"input_tokens": 1,
"output_tokens": 1,
"total_tokens": 2,
"input_tokens_details": {"cached_tokens": 0},
"output_tokens_details": {"reasoning_tokens": 0},
},
"parallel_tool_calls": False,
"tool_choice": "auto",
"tools": [],
"metadata": {},
"temperature": 1.0,
"top_p": 1.0,
"error": None,
"incomplete_details": None,
"instructions": None,
"max_output_tokens": None,
}
_CAPTURED: dict[str, Any] = {}
class _Handler(BaseHTTPRequestHandler):
def log_message(self, *args: Any) -> None:
pass
def do_POST(self) -> None:
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length) or b"{}")
_CAPTURED.clear()
_CAPTURED.update(body)
if not body.get("stream"):
payload = json.dumps({"detail": "Stream must be set to true"}).encode()
self.send_response(400)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
return
event = {
"type": "response.completed",
"sequence_number": 0,
"response": _response_payload(),
}
frame = f"event: response.completed\ndata: {json.dumps(event)}\n\n".encode()
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.end_headers()
self.wfile.write(frame)
@pytest.fixture
def backend_url() -> Iterator[str]:
server = HTTPServer(("127.0.0.1", 0), _Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_address[1]}/backend-api/codex"
finally:
server.shutdown()
server.server_close()
def _client(base_url: str) -> AsyncOpenAI:
return AsyncOpenAI(api_key="tok", base_url=base_url)
def _call_kwargs() -> dict[str, Any]:
return {
"system_instructions": "s",
"input": "hi",
"model_settings": ModelSettings(
store=False, response_include=["reasoning.encrypted_content"]
),
"tools": [],
"output_schema": None,
"handoffs": [],
"tracing": ModelTracing.DISABLED,
"previous_response_id": None,
"conversation_id": None,
"prompt": None,
}
@pytest.mark.asyncio
async def test_stock_model_fails_on_non_streamed_backend(backend_url: str) -> None:
model = OpenAIResponsesModel(model="gpt-5.5", openai_client=_client(backend_url))
with pytest.raises(BadRequestError, match="Stream must be set to true"):
await model.get_response(**_call_kwargs())
@pytest.mark.asyncio
async def test_codex_model_streams_and_aggregates(backend_url: str) -> None:
model = _CodexResponsesModel(model="gpt-5.5", openai_client=_client(backend_url))
response = await model.get_response(**_call_kwargs())
message = response.output[0]
assert isinstance(message, ResponseOutputMessage)
text = message.content[0]
assert isinstance(text, ResponseOutputText)
assert text.text == "OK"
assert response.usage.total_tokens == 2
class _TrackingStream:
"""An async iterator that yields, then raises, and records if it was closed."""
def __init__(self, events: list[Any], error: Exception | None) -> None:
self._events = iter(events)
self._error = error
self.closed = False
def __aiter__(self) -> _TrackingStream:
return self
async def __anext__(self) -> Any:
try:
return next(self._events)
except StopIteration:
if self._error is not None:
raise self._error from None
raise StopAsyncIteration from None
async def aclose(self) -> None:
self.closed = True
async def _drain(gen: AsyncIterator[Any]) -> list[Any]:
return [event async for event in gen]
@pytest.mark.asyncio
async def test_guarded_converts_guardrail_error() -> None:
# A mid-stream backend rejection becomes a typed, model-tagged error.
model = _CodexResponsesModel(model="gpt-5.6-sol", openai_client=_client("http://x/backend-api"))
guardrail = RuntimeError("This content was flagged for possible cybersecurity risk.")
stream = _TrackingStream(["a", "b"], guardrail)
with pytest.raises(codex.CodexContentGuardrailError) as exc_info:
await _drain(model._guarded(stream))
assert exc_info.value.model == "gpt-5.6-sol"
assert stream.closed is True # underlying stream is released
@pytest.mark.asyncio
async def test_guarded_passes_through_other_errors() -> None:
# A non-guardrail error propagates unchanged (still not swallowed).
model = _CodexResponsesModel(model="gpt-5.5", openai_client=_client("http://x/backend-api"))
boom = RuntimeError("some unrelated failure")
stream = _TrackingStream(["a"], boom)
with pytest.raises(RuntimeError, match="some unrelated failure"):
await _drain(model._guarded(stream))
assert stream.closed is True
@pytest.mark.asyncio
async def test_guarded_yields_all_events_when_clean() -> None:
model = _CodexResponsesModel(model="gpt-5.4", openai_client=_client("http://x/backend-api"))
stream = _TrackingStream(["a", "b", "c"], None)
assert await _drain(model._guarded(stream)) == ["a", "b", "c"]
assert stream.closed is True
@pytest.mark.asyncio
async def test_codex_model_self_enforces_backend_requirements(backend_url: str) -> None:
# The caller passes ordinary settings; the model must impose the backend's
# requirements (stream, store=false, encrypted reasoning) and the configured
# reasoning effort itself.
model = _CodexResponsesModel(
model="gpt-5.4", openai_client=_client(backend_url), reasoning_effort="high"
)
kwargs = _call_kwargs()
kwargs["model_settings"] = ModelSettings() # nothing special from the caller
await model.get_response(**kwargs)
assert _CAPTURED["stream"] is True
assert _CAPTURED["store"] is False
assert _CAPTURED["include"] == ["reasoning.encrypted_content"]
assert _CAPTURED["reasoning"] == {"effort": "high"}
+11 -1
View File
@@ -6,10 +6,11 @@ import json
from typing import TYPE_CHECKING
import pytest
from pydantic import AliasChoices, Field
from pydantic import AliasChoices, Field, ValidationError
from pydantic.fields import FieldInfo
from strix.config import loader
from strix.config.settings import ContextSettings
if TYPE_CHECKING:
@@ -120,6 +121,15 @@ def test_read_json_overrides_uses_json_when_no_alias_in_environ(tmp_path: Path)
assert loader._read_json_overrides(path) == {"llm": {"api_key": "sk-file"}}
def test_tool_output_max_bytes_rejects_sub_notice_values() -> None:
with pytest.raises(ValidationError):
ContextSettings(STRIX_TOOL_OUTPUT_MAX_BYTES=64)
def test_tool_output_max_bytes_accepts_floor() -> None:
assert ContextSettings(STRIX_TOOL_OUTPUT_MAX_BYTES=1024).tool_output_max_bytes == 1024
# --------------------------------------------------------------------------- #
# _aliases_for
# --------------------------------------------------------------------------- #
+94
View File
@@ -0,0 +1,94 @@
"""Tests for the dedicated deduplication model configuration."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from strix.config import loader
from strix.config.settings import DedupeSettings
from strix.report.dedupe import _dedupe_model_settings
if TYPE_CHECKING:
from pathlib import Path
import pytest
def test_dedupe_key_sent_per_call_not_via_global_env() -> None:
dedupe = DedupeSettings(STRIX_DEDUPE_MODEL="deepseek/cheap", DEDUPE_LLM_API_KEY="dedupe-key")
settings = _dedupe_model_settings(dedupe, "deepseek/cheap", 300)
# The key rides on the request, so a shared-provider main key can't clobber
# it (and vice versa) through the global provider env var.
assert (settings.extra_args or {})["api_key"] == "dedupe-key"
def test_dedupe_settings_omit_api_key_when_unset() -> None:
dedupe = DedupeSettings(STRIX_DEDUPE_MODEL="deepseek/cheap")
settings = _dedupe_model_settings(dedupe, "deepseek/cheap", 300)
assert "api_key" not in (settings.extra_args or {})
assert "api_base" not in (settings.extra_args or {})
def test_dedupe_endpoint_sent_per_call() -> None:
dedupe = DedupeSettings(
STRIX_DEDUPE_MODEL="openai/cheap",
DEDUPE_LLM_API_KEY="dedupe-key",
DEDUPE_LLM_API_BASE="https://dedupe.example/v1",
)
settings = _dedupe_model_settings(dedupe, "openai/cheap", 300)
# A distinct dedupe endpoint rides on the request instead of the
# process-wide base URL, so it can't clobber the main model's endpoint.
assert (settings.extra_args or {})["api_base"] == "https://dedupe.example/v1"
assert (settings.extra_args or {})["api_key"] == "dedupe-key"
def test_dedupe_defaults_are_empty() -> None:
settings = DedupeSettings()
assert settings.model is None
assert settings.reasoning_effort is None
assert settings.api_key is None
def test_dedupe_model_read_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("STRIX_DEDUPE_MODEL", "deepseek/deepseek-v4-flash")
monkeypatch.setenv("STRIX_DEDUPE_REASONING_EFFORT", "low")
settings = DedupeSettings()
assert settings.model == "deepseek/deepseek-v4-flash"
assert settings.reasoning_effort == "low"
def test_config_file_loads_dedupe_model(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
for key in ("STRIX_LLM", "STRIX_DEDUPE_MODEL", "STRIX_DEDUPE_REASONING_EFFORT"):
monkeypatch.delenv(key, raising=False)
path = tmp_path / "config.json"
path.write_text(
json.dumps(
{
"env": {
"STRIX_LLM": "openai/root",
"STRIX_DEDUPE_MODEL": "deepseek/cheap",
"STRIX_DEDUPE_REASONING_EFFORT": "minimal",
}
}
),
encoding="utf-8",
)
loader._cached = None
loader._override = path
try:
settings = loader.load_settings()
finally:
loader._cached = None
loader._override = None
assert settings.dedupe.model == "deepseek/cheap"
assert settings.dedupe.reasoning_effort == "minimal"
# Main model stays independent of the dedupe override.
assert settings.llm.model == "openai/root"
+78
View File
@@ -0,0 +1,78 @@
"""Tests for stripping the markdown code fence off stored code fields."""
from __future__ import annotations
from pygments.lexers import BashLexer, PythonLexer
from strix.report.writer import (
guess_language_name,
parse_fenced_code,
resolve_lexer,
safe_fence,
)
from strix.viewer.report_pdf import _strip_code_fence
def test_parse_fenced_code_extracts_language_and_body() -> None:
language, code = parse_fenced_code("```python\nimport requests\nprint(1)\n```")
assert language == "python"
assert code == "import requests\nprint(1)"
def test_parse_fenced_code_uses_first_token_of_info_string() -> None:
language, code = parse_fenced_code("```python title=app.py\nx = 1\n```")
assert language == "python"
assert code == "x = 1"
def test_parse_fenced_code_handles_non_python_language() -> None:
language, code = parse_fenced_code("```http\nGET / HTTP/1.1\n```")
assert language == "http"
assert code == "GET / HTTP/1.1"
def test_parse_fenced_code_passes_through_unfenced() -> None:
language, code = parse_fenced_code("import requests\nprint(1)")
assert language is None
assert code == "import requests\nprint(1)"
def test_parse_fenced_code_fence_without_language() -> None:
language, code = parse_fenced_code("```\nplain\n```")
assert language is None
assert code == "plain"
def test_strip_code_fence_removes_fence() -> None:
assert _strip_code_fence("```python\nx = 1\n```") == "x = 1"
def test_strip_code_fence_passes_through_non_string_and_unfenced() -> None:
assert _strip_code_fence(None) is None
assert _strip_code_fence("x = 1") == "x = 1"
def test_resolve_lexer_honors_explicit_language() -> None:
assert isinstance(resolve_lexer("bash", "echo hi"), BashLexer)
def test_resolve_lexer_falls_back_to_python_when_unresolvable() -> None:
# Unknown language name and empty body -> nothing to auto-detect -> Python.
assert isinstance(resolve_lexer("not-a-language", ""), PythonLexer)
def test_guess_language_name_defaults_to_python_when_inconclusive() -> None:
assert guess_language_name("") == "python"
def test_parse_fenced_code_handles_crlf() -> None:
language, code = parse_fenced_code("```python\r\nx = 1\r\n```")
assert language == "python"
assert code == "x = 1"
def test_safe_fence_widens_past_embedded_backticks() -> None:
# A PoC body containing a ``` run must be wrapped in a longer fence so it
# can't terminate the block early.
assert safe_fence("plain code") == "```"
assert safe_fence("has ```\nfence inside") == "````"
+35
View File
@@ -106,3 +106,38 @@ def test_nested_symlinks_inside_linked_dir(tmp_path: Path) -> None:
assert (staged / "pkg" / "shared_link" / "conf.json").read_text() == "{}\n"
assert not (staged / "pkg" / "shared_link" / "escape").exists()
assert not (staged / "shared" / "escape").exists()
def test_staged_path_has_no_symlink_ancestor(tmp_path: Path, monkeypatch) -> None: # noqa: ANN001
"""The staging directory itself must never sit behind a symlink.
``tempfile.mkdtemp()`` honors ``$TMPDIR``, and on macOS the default
``$TMPDIR`` resolves through ``/var``, which is itself a symlink to
``/private/var``. ``LocalDir`` rejects any symlink component in its
source path, so returning the raw ``mkdtemp()`` result breaks every
local-dir upload on macOS whenever the source tree contains a symlink.
This reproduces that shape without depending on the host OS layout.
"""
repo = _make_repo(tmp_path)
(repo / "link.py").symlink_to(repo / "pkg" / "mod.py")
real_tmp_root = tmp_path / "real_tmp"
real_tmp_root.mkdir()
symlinked_tmp_root = tmp_path / "tmp_symlink"
symlinked_tmp_root.symlink_to(real_tmp_root)
def fake_mkdtemp(prefix: str = "") -> str:
real_dir = real_tmp_root / f"{prefix}fake"
real_dir.mkdir()
return str(symlinked_tmp_root / real_dir.name)
monkeypatch.setattr(
"strix.runtime.local_dir_staging.tempfile.mkdtemp", fake_mkdtemp
)
upload_path, staged = stage_symlink_safe_dir(repo)
assert staged is not None
assert upload_path == staged
for path in (staged, *staged.parents):
assert not path.is_symlink(), f"staged path has a symlink ancestor: {path}"
+17 -4
View File
@@ -13,12 +13,15 @@ import asyncio
from agents.retry import ModelRetryNormalizedError, RetryPolicyContext
from strix.config import codex
from strix.config.models import DEFAULT_MODEL_RETRY, _retry_statusless_provider_errors
def _context(normalized: ModelRetryNormalizedError) -> RetryPolicyContext:
def _context(
normalized: ModelRetryNormalizedError, error: Exception | None = None
) -> RetryPolicyContext:
return RetryPolicyContext(
error=RuntimeError("boom"),
error=error or RuntimeError("boom"),
attempt=1,
max_retries=5,
stream=True,
@@ -27,11 +30,11 @@ def _context(normalized: ModelRetryNormalizedError) -> RetryPolicyContext:
)
def _retries(normalized: ModelRetryNormalizedError) -> bool:
def _retries(normalized: ModelRetryNormalizedError, error: Exception | None = None) -> bool:
"""Evaluate the composed DEFAULT_MODEL_RETRY policy for a normalized error."""
policy = DEFAULT_MODEL_RETRY.policy
assert policy is not None
decision = asyncio.run(policy(_context(normalized)))
decision = asyncio.run(policy(_context(normalized, error)))
return bool(getattr(decision, "retry", decision))
@@ -63,6 +66,16 @@ def test_timeout_error_is_retried() -> None:
assert _retries(ModelRetryNormalizedError(is_network_error=True)) is True
def test_content_guardrail_error_is_not_retried() -> None:
# A guardrail block is status-less, so it would match the statusless policy;
# the guard must keep it from being retried (retrying never clears it).
guardrail = codex.CodexContentGuardrailError("gpt-5.6-sol")
assert _retries(ModelRetryNormalizedError(status_code=None), guardrail) is False
# A raw provider error carrying the backend's wording is excluded too.
raw = RuntimeError("This content was flagged for possible cybersecurity risk.")
assert _retries(ModelRetryNormalizedError(status_code=None), raw) is False
def test_policy_helper_matches_statusless_only() -> None:
assert _retry_statusless_provider_errors(_context(ModelRetryNormalizedError())) is True
assert (
+4
View File
@@ -42,9 +42,11 @@ def test_recommended_models_are_matched_case_insensitively() -> None:
"model_name",
[
"gpt-5.5",
"chatgpt/gpt-5.4",
"litellm/openai/gpt-5.4-pro",
"azure_ai/gpt-5.5-pro",
"bedrock_mantle/openai.gpt-5.5",
"anthropic/claude-opus-5",
"anthropic/claude-opus-4-8",
"anthropic.claude-opus-4-8",
"anthropic/claude-opus-4-7",
@@ -60,8 +62,10 @@ def test_recommended_models_are_matched_case_insensitively() -> None:
"deepseek/deepseek-reasoner",
"dashscope/qwen3-max-2026-01-23",
"qwen3.7-max",
"dashscope/qwen3.8-max",
"moonshot/kimi-k2.6",
"kimi-k2.7-code",
"moonshot/kimi-k3",
],
)
def test_frontier_model_families_are_accepted(model_name: str) -> None:
+60
View File
@@ -0,0 +1,60 @@
"""Tests for per-tool-output bounding."""
from __future__ import annotations
import re
from strix.tools.output_store import bound_text
def test_small_output_passes_through_unchanged() -> None:
text = "line 1\nline 2\nline 3"
assert bound_text(text, max_lines=100, max_bytes=10_000) == text
def test_line_limit_keeps_head_and_tail() -> None:
text = "\n".join(str(i) for i in range(1000))
bounded = bound_text(text, max_lines=10, max_bytes=1_000_000)
assert bounded.startswith("0\n1\n2\n3\n4")
assert bounded.rstrip().endswith("999")
assert "truncated" in bounded
assert len(bounded.splitlines()) < 30
def test_byte_limit_enforced_on_single_long_line() -> None:
text = "x" * 100_000
bounded = bound_text(text, max_lines=2_000, max_bytes=1_000)
assert "truncated" in bounded
assert len(bounded.encode("utf-8")) <= 1_000
def test_multibyte_characters_not_split() -> None:
text = "😀" * 50_000
bounded = bound_text(text, max_lines=2_000, max_bytes=1_000)
# Must remain valid UTF-8 (no mid-character cut).
assert bounded == bounded.encode("utf-8").decode("utf-8")
assert "truncated" in bounded
def test_notice_reports_dropped_counts() -> None:
text = "\n".join("y" * 10 for _ in range(500))
bounded = bound_text(text, max_lines=10, max_bytes=1_000_000)
assert "lines" in bounded
assert "bytes" in bounded
def test_dropped_line_count_accounts_for_byte_trimming() -> None:
# Tight byte budget drops whole lines from head/tail; the notice must count them.
text = "\n".join(f"line-{i}" for i in range(200))
bounded = bound_text(text, max_lines=20, max_bytes=40)
match = re.search(r"\[\.\.\. (\d+) lines", bounded)
assert match is not None, bounded
dropped = int(match.group(1))
kept = [ln for ln in bounded.splitlines() if ln and "truncated" not in ln]
assert dropped == 200 - len(kept)
assert dropped > 200 - 20
+5 -3
View File
@@ -119,9 +119,11 @@ def test_render_vulnerability_md_poc_code_cannot_break_out_of_fence() -> None:
injected = "curl x\n```\n\n## Injected Heading\n![x](https://evil.example/beacon.png)"
md = render_vulnerability_md(_sample_report(poc_script_code=injected))
lines = md.split("\n")
fence = next(ln for ln in lines[lines.index("## Proof of Concept") + 1 :] if ln.strip())
assert set(fence) == {"`"}
assert len(fence) >= 4 # wider than the payload's 3-backtick run
opening = next(ln for ln in lines[lines.index("## Proof of Concept") + 1 :] if ln.strip())
ticks = opening[: len(opening) - len(opening.lstrip("`"))]
assert len(ticks) >= 4 # wider than the payload's 3-backtick run
assert "`" not in opening.removeprefix(ticks) # backtick run + language tag only
assert f"\n{ticks}\n" in md # pure-backtick closing fence of the same width
assert injected in md # the payload survives verbatim, inside the fence
+2 -1
View File
@@ -46,7 +46,8 @@ def _patch_engine_scaffold(
reasoning_effort="high",
force_required_tool_choice=False,
timeout=300,
)
),
runtime=types.SimpleNamespace(max_context_images=3),
)
monkeypatch.setattr(runner, "load_settings", lambda: settings)
monkeypatch.setattr(runner, "configure_sdk_model_defaults", lambda _settings: None)
+172
View File
@@ -0,0 +1,172 @@
import hashlib
import io
import json
import platform
import time
from pathlib import Path
import pytest
from rich.console import Console
from strix.interface import update_check
@pytest.fixture(autouse=True)
def _isolated_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(update_check, "_CACHE_PATH", tmp_path / "update-check.json")
monkeypatch.setattr(update_check, "_background_thread", None)
monkeypatch.delenv("STRIX_NO_UPDATE_CHECK", raising=False)
for key in ("CI", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS_URL", "BUILDKITE", "CIRCLECI"):
monkeypatch.delenv(key, raising=False)
@pytest.mark.parametrize(
("latest", "current", "expected"),
[
("1.2.0", "1.1.0", True),
("1.1.0", "1.1.0", False),
("1.0.9", "1.1.0", False),
("2.0.0", "1.99.99", True),
("1.10.0", "1.9.0", True),
("v1.2.0", "1.1.0", True),
("not-a-version", "1.1.0", False),
("1.2.0", "unknown", False),
],
)
def test_is_newer(latest: str, current: str, expected: bool) -> None:
assert update_check._is_newer(latest, current) is expected
def test_get_available_update_from_cache(monkeypatch: pytest.MonkeyPatch) -> None:
update_check._CACHE_PATH.write_text(
json.dumps({"latest_version": "9.9.9", "checked_at": time.time()})
)
monkeypatch.setattr(update_check, "get_version", lambda: "1.0.0")
assert update_check.get_available_update() == "9.9.9"
def test_get_available_update_up_to_date(monkeypatch: pytest.MonkeyPatch) -> None:
update_check._CACHE_PATH.write_text(
json.dumps({"latest_version": "1.0.0", "checked_at": time.time()})
)
monkeypatch.setattr(update_check, "get_version", lambda: "1.0.0")
assert update_check.get_available_update() is None
def test_get_available_update_disabled_by_env(monkeypatch: pytest.MonkeyPatch) -> None:
update_check._CACHE_PATH.write_text(
json.dumps({"latest_version": "9.9.9", "checked_at": time.time()})
)
monkeypatch.setattr(update_check, "get_version", lambda: "1.0.0")
monkeypatch.setenv("STRIX_NO_UPDATE_CHECK", "1")
assert update_check.get_available_update() is None
def test_get_available_update_disabled_in_ci(monkeypatch: pytest.MonkeyPatch) -> None:
update_check._CACHE_PATH.write_text(
json.dumps({"latest_version": "9.9.9", "checked_at": time.time()})
)
monkeypatch.setattr(update_check, "get_version", lambda: "1.0.0")
monkeypatch.setenv("CI", "true")
assert update_check.get_available_update() is None
def test_get_available_update_corrupt_cache(monkeypatch: pytest.MonkeyPatch) -> None:
update_check._CACHE_PATH.write_text("{not json")
monkeypatch.setattr(update_check, "get_version", lambda: "1.0.0")
assert update_check.get_available_update() is None
def test_background_check_skipped_when_fresh(monkeypatch: pytest.MonkeyPatch) -> None:
update_check._CACHE_PATH.write_text(
json.dumps({"latest_version": "1.0.0", "checked_at": time.time()})
)
called = False
def fake_refresh() -> None:
nonlocal called
called = True
monkeypatch.setattr(update_check, "_refresh_cache", fake_refresh)
update_check.start_background_check()
assert update_check._background_thread is None
assert called is False
def test_background_check_runs_when_stale(monkeypatch: pytest.MonkeyPatch) -> None:
update_check._CACHE_PATH.write_text(
json.dumps({"latest_version": "1.0.0", "checked_at": time.time() - 2 * 24 * 60 * 60})
)
monkeypatch.setattr(update_check, "_fetch_latest_version", lambda: "1.2.3")
update_check.start_background_check()
assert update_check._background_thread is not None
update_check._background_thread.join(timeout=5)
cache = json.loads(update_check._CACHE_PATH.read_text())
assert cache["latest_version"] == "1.2.3"
def test_skipped_version_suppresses_update(monkeypatch: pytest.MonkeyPatch) -> None:
update_check._CACHE_PATH.write_text(
json.dumps({"latest_version": "9.9.9", "checked_at": time.time()})
)
monkeypatch.setattr(update_check, "get_version", lambda: "1.0.0")
update_check.skip_version("9.9.9")
assert update_check.get_available_update() is None
assert update_check.get_available_update(respect_skip=False) == "9.9.9"
def test_newer_release_overrides_skipped_version(monkeypatch: pytest.MonkeyPatch) -> None:
update_check._CACHE_PATH.write_text(
json.dumps(
{"latest_version": "9.9.10", "checked_at": time.time(), "skipped_version": "9.9.9"}
)
)
monkeypatch.setattr(update_check, "get_version", lambda: "1.0.0")
assert update_check.get_available_update() == "9.9.10"
def test_write_cache_preserves_existing_fields() -> None:
update_check.skip_version("9.9.9")
update_check._write_cache(latest_version="1.2.3", checked_at=123.0)
cache = json.loads(update_check._CACHE_PATH.read_text())
assert cache == {"latest_version": "1.2.3", "checked_at": 123.0, "skipped_version": "9.9.9"}
def test_get_upgrade_command_all_methods() -> None:
assert update_check.get_upgrade_command("binary") == "strix --update"
assert update_check.get_upgrade_command("pipx") == "pipx upgrade strix-agent"
assert update_check.get_upgrade_command("uv") == "uv tool upgrade strix-agent"
assert update_check.get_upgrade_command("pip") == "pip install --upgrade strix-agent"
def test_self_update_non_binary_prints_command(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(update_check, "is_binary_install", lambda: False)
buffer = io.StringIO()
assert update_check.self_update(Console(file=buffer)) is False
assert "upgrade" in buffer.getvalue()
def test_self_update_already_latest(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(update_check, "is_binary_install", lambda: True)
monkeypatch.setattr(update_check, "_fetch_latest_version", lambda: "1.0.0")
monkeypatch.setattr(update_check, "get_version", lambda: "1.0.0")
assert update_check.self_update() is True
def test_sha256_file(tmp_path: Path) -> None:
path = tmp_path / "blob"
path.write_bytes(b"strix")
assert update_check._sha256_file(path) == hashlib.sha256(b"strix").hexdigest()
def test_release_target(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(platform, "system", lambda: "Linux")
monkeypatch.setattr(platform, "machine", lambda: "x86_64")
assert update_check._release_target() == "linux-x86_64"
monkeypatch.setattr(platform, "system", lambda: "Darwin")
monkeypatch.setattr(platform, "machine", lambda: "arm64")
assert update_check._release_target() == "macos-arm64"
monkeypatch.setattr(platform, "machine", lambda: "riscv64")
assert update_check._release_target() is None
+46
View File
@@ -0,0 +1,46 @@
"""Subscription runs track tokens but report zero cost."""
from __future__ import annotations
from agents.usage import Usage
from strix.report.usage import LLMUsageLedger
def _usage() -> Usage:
usage = Usage()
usage.requests = 1
usage.input_tokens = 1000
usage.output_tokens = 200
usage.total_tokens = 1200
return usage
def test_zero_cost_ledger_keeps_tokens_but_reports_no_cost() -> None:
ledger = LLMUsageLedger()
ledger.zero_cost = True
ledger.record(agent_id="a", usage=_usage(), agent_name="strix", model="gpt-5.5")
record = ledger.to_record()
assert record["cost"] == 0.0
assert record["total_tokens"] == 1200
assert record["input_tokens"] == 1000
assert record["output_tokens"] == 200
assert ledger.total_cost == 0.0
def test_zero_cost_ledger_ignores_observed_cost() -> None:
ledger = LLMUsageLedger()
ledger.zero_cost = True
ledger.record_observed_cost(4.20)
assert ledger.total_cost == 0.0
def test_normal_ledger_still_estimates_cost() -> None:
# Sanity check the flag is opt-in: without it, an OpenAI-native model still
# accrues an estimated cost (proves zeroing is what suppresses it).
ledger = LLMUsageLedger()
ledger.record(agent_id="a", usage=_usage(), agent_name="strix", model="gpt-5.5")
assert ledger.to_record()["total_tokens"] == 1200
# Cost estimation depends on litellm's cost map; it should be >= 0 and not error.
assert ledger.total_cost >= 0.0
+58 -6
View File
@@ -191,6 +191,62 @@ def test_server_event_endpoint_forwards_email_funnel(
httpd.server_close()
def test_server_event_endpoint_forwards_agent_steered(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
run_dir = _make_run(tmp_path, "steerevt", status="running", end_time=None)
_bundle(tmp_path, monkeypatch)
seen: list[bool] = []
monkeypatch.setattr("strix.telemetry.posthog.viewer_agent_steered", lambda: seen.append(True))
httpd, url, _ = serve(run_dir, open_browser=False)
try:
req = urllib.request.Request( # noqa: S310 - localhost test server
f"{url}/api/event",
data=json.dumps({"event": "agent_steered"}).encode(),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req) as resp: # noqa: S310
assert resp.status == 204
assert seen == [True]
finally:
httpd.shutdown()
httpd.server_close()
def test_feedback_records_telemetry_on_success(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
run_dir = _make_run(tmp_path, "fbtel", status="running", end_time=None)
_bundle(tmp_path, monkeypatch)
sent: list[bool] = []
monkeypatch.setattr("strix.viewer.auth.feedback_submit", lambda *_a: None)
monkeypatch.setattr(
"strix.telemetry.posthog.viewer_feedback_submitted", lambda: sent.append(True)
)
httpd, url, token = serve(run_dir, open_browser=False)
try:
cookie = _session_cookie(url, token)
# A successful, session-holding submission relays and records telemetry.
status, _ = _post(
url, "/api/feedback", {"email": "a@b.com", "message": "hi"}, cookie=cookie
)
assert status == 200
assert sent == [True]
# A cookie-less caller is rejected and records nothing.
sent.clear()
status, _ = _post(url, "/api/feedback", {"email": "a@b.com", "message": "hi"})
assert status == 403
assert sent == []
finally:
httpd.shutdown()
httpd.server_close()
def _post(
url: str, path: str, payload: Mapping[str, object], *, cookie: str | None = None
) -> tuple[int, bytes]:
@@ -395,9 +451,7 @@ def test_report_send_requires_session_cookie(
httpd.server_close()
def test_report_send_rejects_live_run(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def test_report_send_rejects_live_run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
# A running scan would only produce a partial report, so the endpoint must
# fail closed even for a verified, session-holding caller.
run_dir = _make_run(tmp_path, "live", status="running", end_time=None)
@@ -406,9 +460,7 @@ def test_report_send_rejects_live_run(
httpd, url, token = serve(run_dir, open_browser=False)
try:
status, _ = _post(
url, "/api/report/send", {}, cookie=_session_cookie(url, token)
)
status, _ = _post(url, "/api/report/send", {}, cookie=_session_cookie(url, token))
assert status == 409
finally:
httpd.shutdown()
Generated
+52 -52
View File
@@ -469,55 +469,55 @@ wheels = [
[[package]]
name = "cryptography"
version = "46.0.7"
version = "48.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
{ url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
{ url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
{ url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
{ url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
{ url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
{ url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
{ url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
{ url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
{ url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
{ url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
{ url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
{ url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
{ url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" },
{ url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" },
{ url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" },
{ url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" },
{ url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" },
{ url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" },
{ url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" },
{ url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" },
{ url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" },
{ url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" },
{ url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" },
{ url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" },
{ url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
{ url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
{ url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
{ url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
{ url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
{ url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
{ url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
{ url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
{ url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
{ url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
{ url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
{ url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
{ url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
{ url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" },
{ url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" },
{ url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" },
{ url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" },
{ url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" },
{ url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" },
{ url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" },
{ url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" },
{ url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" },
{ url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" },
{ url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" },
{ url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" },
{ url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" },
{ url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" },
{ url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" },
{ url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" },
{ url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" },
{ url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" },
{ url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" },
{ url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" },
{ url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" },
{ url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" },
{ url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" },
{ url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" },
{ url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" },
{ url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" },
{ url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498, upload-time = "2026-06-09T22:31:29.154Z" },
{ url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790, upload-time = "2026-06-09T22:31:56.697Z" },
{ url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" },
{ url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" },
{ url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" },
{ url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" },
{ url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" },
{ url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" },
{ url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" },
{ url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" },
{ url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" },
{ url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" },
{ url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" },
{ url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" },
{ url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" },
{ url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" },
]
[[package]]
@@ -1685,11 +1685,11 @@ wheels = [
[[package]]
name = "pyasn1"
version = "0.6.3"
version = "0.6.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" },
{ url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" },
]
[[package]]
@@ -2340,11 +2340,11 @@ wheels = [
[[package]]
name = "setuptools"
version = "82.0.1"
version = "83.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" }
sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" },
{ url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" },
]
[[package]]
@@ -2411,7 +2411,7 @@ wheels = [
[[package]]
name = "strix-agent"
version = "1.2.0"
version = "1.3.1"
source = { editable = "." }
dependencies = [
{ name = "caido-sdk-client" },
@@ -2454,7 +2454,7 @@ dev = [
requires-dist = [
{ name = "boto3", marker = "extra == 'bedrock'", specifier = ">=1.28.0" },
{ name = "caido-sdk-client", specifier = ">=0.2.0" },
{ name = "cryptography", specifier = ">=42" },
{ name = "cryptography", specifier = ">=48.0.1,<49" },
{ name = "cvss", specifier = ">=3.2" },
{ name = "docker", specifier = ">=7.1.0" },
{ name = "google-auth", marker = "extra == 'vertex'", specifier = ">=2.0.0" },