mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/usestrix/strix.git
synced 2026-09-21 00:23:52 +08:00
Compare commits
34 Commits
devin/1788
...
feat/error
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74f8f3c306 | ||
|
|
7a1de951ac | ||
|
|
c2c84f1131 | ||
|
|
bb7e82b6ea | ||
|
|
9cc9de8cdc | ||
|
|
a3bf864e1e | ||
|
|
e60fd83931 | ||
|
|
7f46dd17d3 | ||
|
|
afa7c4a77f | ||
|
|
f6d9790ecb | ||
|
|
5d015df6b1 | ||
|
|
1edafd3e80 | ||
|
|
f1e24fe3f2 | ||
|
|
e644f4a02c | ||
|
|
1ebe1007e8 | ||
|
|
53d2e5cfeb | ||
|
|
7708f717d5 | ||
|
|
a8642de76c | ||
|
|
75b89018d3 | ||
|
|
129f938094 | ||
|
|
b438632e12 | ||
|
|
0ab7244807 | ||
|
|
c514f712f4 | ||
|
|
3e88e498b9 | ||
|
|
ce0db30252 | ||
|
|
941c960650 | ||
|
|
46b4e6cb64 | ||
|
|
b5c3807fef | ||
|
|
42baa7c09e | ||
|
|
8fdf6a5c09 | ||
|
|
46cf2f52f3 | ||
|
|
3de9471431 | ||
|
|
a071022182 | ||
|
|
d26b1ab0de |
@@ -1,3 +1,6 @@
|
||||
# Built viewer bundles are generated output, not hand-edited source.
|
||||
exclude: ^strix/interface/viewer/static/assets/
|
||||
|
||||
repos:
|
||||
# Ruff for fast linting and formatting
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
@@ -9,21 +12,18 @@ repos:
|
||||
- id: ruff-format
|
||||
name: ruff-format
|
||||
|
||||
# MyPy for static type checking
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.17.1
|
||||
# MyPy for static type checking. Runs the project's own mypy from the uv
|
||||
# environment (`make dev-install`) so it sees the same dependencies and
|
||||
# stubs as `make check-all`.
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies: [
|
||||
types-requests,
|
||||
types-python-dateutil,
|
||||
pydantic,
|
||||
fastapi,
|
||||
pytest,
|
||||
hatchling,
|
||||
"openai-agents[litellm]>=0.19.0,<0.20",
|
||||
]
|
||||
args: [--install-types, --non-interactive]
|
||||
name: mypy
|
||||
entry: uv run mypy
|
||||
language: system
|
||||
types_or: [python, pyi]
|
||||
files: ^(strix|tests)/
|
||||
require_serial: true
|
||||
|
||||
# Built-in hooks for basic file checks
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
@@ -62,5 +62,6 @@ ci:
|
||||
autoupdate_branch: ""
|
||||
autoupdate_commit_msg: "[pre-commit.ci] pre-commit autoupdate"
|
||||
autoupdate_schedule: weekly
|
||||
skip: []
|
||||
# pre-commit.ci cannot run `language: system` hooks; mypy runs via `make check-all`.
|
||||
skip: [mypy]
|
||||
submodules: false
|
||||
|
||||
@@ -28,7 +28,7 @@ Target-specific workflows built on the same engine:
|
||||
- **Open-source CLI (self-hosted):** free, fully local, BYO LLM key, needs Docker. Best for local dev loops, air-gapped/offline, and full control.
|
||||
```bash
|
||||
curl -sSL https://strix.ai/install | bash # install
|
||||
export STRIX_LLM="openai/gpt-5.4" # any LiteLLM model id
|
||||
export STRIX_LLM="openrouter/z-ai/glm-5.3" # any LiteLLM model id
|
||||
export LLM_API_KEY="<key>"
|
||||
strix -n -t ./ --scan-mode quick --max-budget 10 # headless scan; always use -n
|
||||
```
|
||||
|
||||
@@ -31,7 +31,7 @@ Thank you for your interest in contributing to Strix! This guide will help you g
|
||||
|
||||
3. **Configure your LLM provider**
|
||||
```bash
|
||||
export STRIX_LLM="openai/gpt-5.4"
|
||||
export STRIX_LLM="openrouter/z-ai/glm-5.3"
|
||||
export LLM_API_KEY="your-api-key"
|
||||
```
|
||||
|
||||
|
||||
175
README.md
175
README.md
@@ -82,7 +82,7 @@ Strix are autonomous AI penetration testing agents that act just like real hacke
|
||||
curl -sSL https://strix.ai/install | bash
|
||||
|
||||
# Configure your AI provider
|
||||
export STRIX_LLM="openai/gpt-5.4"
|
||||
export STRIX_LLM="openrouter/z-ai/glm-5.3"
|
||||
export LLM_API_KEY="your-api-key"
|
||||
|
||||
# Run your first security assessment
|
||||
@@ -116,7 +116,9 @@ Strix is agent-ready. Give Claude Code, Cursor, Codex, or any [SKILL.md-compatib
|
||||
npx skills add usestrix/strix
|
||||
```
|
||||
|
||||
This installs nine skills: **penetration-testing-with-strix** (run headless scans and read results), **managed-pentesting-with-strix** (drive the managed [app.strix.ai](https://app.strix.ai) platform via REST — no local Docker or LLM key), **fix-security-vulnerabilities-with-strix** (remediate + re-scan to verify), **ci-security-scanning-with-strix** (PR scanning in CI), plus target-specific workflows: **application-security-testing**, **web-app-penetration-testing**, **api-security-testing**, **owasp-top-10-testing**, and **find-security-vulnerabilities-in-code**. Agents can run Strix two ways with the same engine — the open-source CLI locally, or the managed cloud when there's no local infra — and read [`AGENTS.md`](AGENTS.md) for a quick reference, [docs.strix.ai/llms.txt](https://docs.strix.ai/llms.txt) for the CLI docs, and [docs.app.strix.ai](https://docs.app.strix.ai) for the API.
|
||||
This installs nine skills for running pentests, fixing findings, and CI scanning, against code, web apps, APIs, and the OWASP Top 10. Agents can use the local CLI or the managed cloud with the same engine.
|
||||
|
||||
See [`AGENTS.md`](AGENTS.md) for the quick reference, [docs.strix.ai/llms.txt](https://docs.strix.ai/llms.txt) for the CLI, and [docs.app.strix.ai](https://docs.app.strix.ai) for the API.
|
||||
|
||||
---
|
||||
|
||||
@@ -172,18 +174,9 @@ strix view my-run-name
|
||||
strix view --host 0.0.0.0 --port 8080 --no-open
|
||||
```
|
||||
|
||||
`strix view` starts a lightweight local server (bound to `127.0.0.1` on a random port) and opens your browser to a private, tokened link. Nothing leaves your machine: the dashboard reads the run's files straight off disk, with no cloud account or upload required. The UI ships prebuilt with Strix, so there is no extra install and no JS build step.
|
||||
The dashboard shows the findings, a live map of the agent team, and past runs. Nothing leaves your machine, and the UI ships prebuilt. `strix view` binds to `127.0.0.1` and prints a tokened link that grants access to the run, so share it carefully.
|
||||
|
||||
Use `--host 0.0.0.0` to make the viewer reachable from other machines. Replace `0.0.0.0` in the printed URL with the server's reachable IP or hostname. The token in that URL grants access to the selected run's scan data, history, and steering, so only share it with trusted users and restrict the port with your firewall. Requests without the token-derived session cannot read run data.
|
||||
|
||||
### What's in the dashboard
|
||||
|
||||
- **Overview**: run status, target, and a severity breakdown of everything found so far.
|
||||
- **Vulnerabilities**: each validated finding with its severity, details, and reproduction steps.
|
||||
- **Agent graph**: a live map of the multi-agent team, showing which agent is doing what.
|
||||
- **Steering**: send instructions to a live scan from the browser to redirect the agents mid-run.
|
||||
- **History**: browse past runs on this machine and jump between them.
|
||||
- **Reports**: generate a shareable report and email it to yourself or your team.
|
||||
See the [viewer documentation](https://docs.strix.ai/usage/viewer) for the options and for reaching the viewer from another machine.
|
||||
|
||||
---
|
||||
|
||||
@@ -209,18 +202,9 @@ having to discover them by crawling. Pair the spec with the live base URL so the
|
||||
agent knows where to send traffic:
|
||||
|
||||
```bash
|
||||
# OpenAPI / Swagger file (.json / .yaml)
|
||||
# OpenAPI / Swagger file, Postman export, or a live collection by id
|
||||
strix --target ./openapi.yaml --target https://api.your-app.com
|
||||
|
||||
# Postman collection export
|
||||
strix --target ./collection.postman_collection.json --target https://api.your-app.com
|
||||
|
||||
# Postman collection pulled live by id (no manual export)
|
||||
export POSTMAN_API_KEY="PMAK-..."
|
||||
strix --target postman://<collection-uuid>
|
||||
|
||||
# ...with a Postman environment to resolve {{baseUrl}} / token variables
|
||||
strix --target "postman://<collection-uuid>?env=<environment-uuid>"
|
||||
strix --target postman://<collection-uuid> --target https://api.your-app.com
|
||||
```
|
||||
|
||||
|
||||
@@ -235,20 +219,10 @@ strix -t https://github.com/org/app -t https://your-app.com
|
||||
|
||||
# Targets from a file, one target per non-empty, non-comment line
|
||||
strix --target-list ./targets.txt
|
||||
|
||||
# White-box source-aware scan (local repository)
|
||||
strix --target ./app-directory --scan-mode standard
|
||||
|
||||
# Focused testing with custom instructions
|
||||
strix --target api.your-app.com --instruction "Focus on business logic flaws and IDOR vulnerabilities"
|
||||
|
||||
# Provide detailed instructions through file (e.g., rules of engagement, scope, exclusions)
|
||||
strix --target api.your-app.com --instruction-file ./instruction.md
|
||||
|
||||
# Force PR diff-scope against a specific base branch
|
||||
strix -n --target ./ --scan-mode quick --scope-mode diff --diff-base origin/main
|
||||
```
|
||||
|
||||
See the [CLI reference](https://docs.strix.ai/usage/cli) for every option, including scan modes, diff scope, instruction files, and budgets.
|
||||
|
||||
### Headless Mode
|
||||
|
||||
Run Strix programmatically without interactive UI using the `-n/--non-interactive` flag - perfect for servers and automated jobs. The CLI prints real-time vulnerability findings and the final report before exiting. Exits with non-zero code when vulnerabilities are found.
|
||||
@@ -287,153 +261,55 @@ jobs:
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> In CI pull request runs, Strix automatically scopes quick reviews to changed files.
|
||||
> If diff-scope cannot resolve, ensure checkout uses full history (`fetch-depth: 0`) or pass
|
||||
> `--diff-base` explicitly.
|
||||
> In CI pull request runs, Strix automatically scopes quick reviews to changed files, which is why the
|
||||
> checkout above fetches full history. See the
|
||||
> [CI/CD documentation](https://docs.strix.ai/integrations/github-actions) for the details.
|
||||
|
||||
### Configuration
|
||||
|
||||
```bash
|
||||
export STRIX_LLM="openai/gpt-5.4"
|
||||
export STRIX_LLM="openrouter/z-ai/glm-5.3"
|
||||
export LLM_API_KEY="your-api-key"
|
||||
|
||||
# Optional
|
||||
export LLM_API_BASE="your-api-base-url" # if using a local model, e.g. Ollama, LMStudio
|
||||
export PERPLEXITY_API_KEY="your-api-key" # for search capabilities
|
||||
export STRIX_REASONING_EFFORT="high" # control thinking effort (default: high, quick scan: medium)
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Strix automatically saves your configuration to `~/.strix/cli-config.json`, so you don't have to re-enter it on every run.
|
||||
> See the [configuration reference](https://docs.strix.ai/advanced/configuration) for every environment variable.
|
||||
|
||||
#### 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
|
||||
|
||||
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
|
||||
strix auth status # show the active sign-in, or logout to forget it
|
||||
```
|
||||
|
||||
#### Use the managed platform: `strix cloud`
|
||||
|
||||
The `strix cloud` commands drive the managed platform ([app.strix.ai](https://app.strix.ai)) from the terminal. Sign in once with the device flow. The sign-in creates your account and workspace on first use and stores a personal API token in `~/.strix/platform-auth.json`:
|
||||
Run scans on [app.strix.ai](https://app.strix.ai) from the terminal, without Docker or an LLM key:
|
||||
|
||||
```bash
|
||||
strix cloud login # browser approval, then workspace + scope profile
|
||||
strix cloud login --workspace "My Team" # select a workspace by name or ID
|
||||
strix cloud whoami # fast local account/workspace status
|
||||
strix cloud session # verify remote session + consent ceiling
|
||||
strix cloud logout # revoke remotely, then remove locally
|
||||
```
|
||||
|
||||
The default **Recommended** scope preset supports normal scan work, local source uploads,
|
||||
workspace switching, and user-approved credit top-ups. It excludes credential creation;
|
||||
request `tokens:write` explicitly (or choose Full) when needed. For strict least privilege, pass an explicit list such as
|
||||
`--scopes scans:read scans:write uploads:write billing:read`. Named automation
|
||||
profiles are also available with `--scope-profile minimal|recommended|full`.
|
||||
|
||||
Every operation of the [REST API](https://docs.app.strix.ai) has a matching command in the form `strix cloud <resource> <verb>`:
|
||||
|
||||
```bash
|
||||
strix cloud # list all resources
|
||||
strix cloud scans # run the safe default (`scans list`)
|
||||
strix cloud scans help # list the verbs of a resource
|
||||
strix cloud domains add --domain example.com --asset-type web_app
|
||||
strix cloud login # browser sign-in, one credential per install
|
||||
strix cloud scans start --source . --yes --wait # scan local code, approving the upload
|
||||
strix cloud scans start --engagement-type live_test --domain-ids <uuid> --wait
|
||||
strix cloud scans start --source . --dry-run --show-files --json # review + capture source.archive_sha256
|
||||
SOURCE_SHA256="<reviewed source.archive_sha256>"
|
||||
strix cloud scans start --source . --approve-sha256 "$SOURCE_SHA256" --wait
|
||||
strix cloud vulns list --severity critical
|
||||
strix cloud credits # credit balance
|
||||
strix cloud billing topup --credits 20 --yes # explicitly approve agent payment after HTTP 402
|
||||
```
|
||||
|
||||
Workspaces and account setup also work from the terminal:
|
||||
Every [REST API](https://docs.app.strix.ai) operation has a matching `strix cloud <resource> <verb>` command. Run `strix cloud` to list the resources, and add `help` to a resource to list its verbs. Output is JSON when stdout is not a terminal or when you pass `--json`. Binary downloads are the exception: redirect the raw bytes, or combine `--output FILE --json` for download metadata.
|
||||
|
||||
```bash
|
||||
strix cloud workspaces list # numbered list; `workspace` is also accepted
|
||||
strix cloud workspaces create --name "My Team" # admin + organizations:write
|
||||
strix cloud workspaces use 2 # switch by list number, exact name, or ID
|
||||
strix cloud session scopes # granted scopes + login ceiling
|
||||
strix cloud session scopes set minimal # narrow without another browser sign-in
|
||||
strix cloud billing subscribe --plan strix_cloud # opens the hosted checkout page
|
||||
strix cloud billing portal # opens the billing portal
|
||||
strix cloud integrations install github # opens the app installation page
|
||||
strix cloud domains verify <domain-id> # prints the DNS record to add
|
||||
```
|
||||
|
||||
The last four commands end at a person. Strix creates the link, opens the browser for an interactive terminal, and always prints the URL. The user enters the card, approves the installation, or adds the DNS record. Pass `--no-browser` to print the URL only.
|
||||
|
||||
The commands work for humans and agents: terminal output favors names, branches, lifecycle states, and numbered selectors, while redirected output (or `--json`) preserves complete machine-readable records and IDs. Human lists retain the selectors needed by follow-up commands but omit internal organization/user IDs; a selector too long for the compact table is repeated losslessly in a copyable block. Paginated lists print the next `--page` or `--offset`, and detail views preserve useful prose within a safe terminal bound; use `--json` for the complete record. Token lists distinguish API keys from named CLI device sessions. Binary downloads are the exception: intentionally redirect their raw bytes, or use `--output FILE --json` to write the file and receive structured download metadata. There are no prompts when stdin is not a terminal. Exit codes: `0` success, `1` error, `2` invalid usage, `4` authentication or plan limit, `5` payment required. `--token` and `STRIX_API_TOKEN` are stateless per-command overrides and never replace the stored sign-in; pair a CLI-session override with `--workspace-id` or `STRIX_WORKSPACE_ID`.
|
||||
|
||||
A browser sign-in creates one reusable credential per CLI installation. Logging in again on the
|
||||
same installation replaces its secret instead of accumulating keys. Workspace switches keep that
|
||||
credential and expiry, preserve the server-side scope preference, cap access by the target role,
|
||||
and can never exceed the login consent ceiling. Each process pins its starting workspace, so a
|
||||
concurrent switch fails safely instead of sending a stale command to another organization.
|
||||
`strix cloud logout` revokes the server session before deleting the local token; use
|
||||
`--local-only` only when you deliberately cannot reach the server.
|
||||
|
||||
Write commands take request fields as flags, and every write command also accepts one JSON object with `--data`:
|
||||
|
||||
```bash
|
||||
strix cloud scans start --data '{"engagement_type":"code_review"}' # literal JSON
|
||||
strix cloud scans start --data @request.json # read a file
|
||||
cat request.json | strix cloud scans start --data - # read standard input
|
||||
```
|
||||
|
||||
For an agent or CI local-source scan, run `--dry-run --show-files --json`, review the manifest,
|
||||
and capture `source.archive_sha256`. Rerun with the same `--source`, every `--exclude`, and any
|
||||
`--include-*` selection flags, replacing `--dry-run` with `--approve-sha256 HASH`; Strix
|
||||
rebuilds the archive and refuses to upload it if the digest changed. `--yes` instead approves
|
||||
only the snapshot built in that one invocation. It is suitable for a deliberate human or
|
||||
one-shot approval, not as a digest-bound two-step agent/CI handoff.
|
||||
|
||||
The safe default honors `.gitignore` and `.strixignore` and excludes hidden paths, secret-like
|
||||
files, VCS metadata, dependencies/build output, symlinks, and nested archives. Opt in
|
||||
separately with `--include-hidden`, `--include-sensitive`, or `--include-archives`. The client
|
||||
caps a bundle at 20,000 files, 25 MiB per file, 250 MiB expanded, and 50 MiB compressed, and
|
||||
the service independently validates the archive. Source alone infers a code review; adding a
|
||||
domain infers a live test. You can always pass `--engagement-type` explicitly.
|
||||
|
||||
Strix removes the temporary local archive after every invocation. It deletes a staged remote
|
||||
upload after a definitive scan rejection. If a network error, `5xx` response, malformed
|
||||
success response, or interruption makes the launch outcome ambiguous, it retains the upload and reports its `upload_id` with
|
||||
`launch_outcome_unknown: true`; if automatic deletion cannot be confirmed, it reports the ID
|
||||
with `cleanup_unknown: true`. Check `strix cloud scans list` before retrying. If no scan is
|
||||
linked to the retained upload, delete it with `strix cloud uploads delete UPLOAD_ID`.
|
||||
|
||||
Non-Enterprise scans consume the deterministic estimate shown for their scope (a source-only
|
||||
code review at the default `ultra` tier currently starts at 60 credits). Enterprise scans are
|
||||
plan-included and do not consume the credit wallet. Report downloads need Enterprise,
|
||||
schedules need Pro, and billing writes need an admin token. Plan blocks exit `4`; an
|
||||
insufficient credit wallet exits `5` without creating or charging a scan.
|
||||
|
||||
Enable native tab completion once per shell session:
|
||||
|
||||
```bash
|
||||
source <(strix completions zsh) # use bash instead of zsh when appropriate
|
||||
strix completions fish | source
|
||||
```
|
||||
See the [cloud CLI documentation](https://docs.strix.ai/cloud/cli) for scopes, workspaces, billing, and source-upload options.
|
||||
|
||||
#### Connect your own MCP servers
|
||||
|
||||
Strix can connect to Model Context Protocol (MCP) servers you list and expose their tools to the agent during a run. Create `~/.strix/mcp-servers.json` with a JSON list of servers. Each entry is either a local `stdio` server that Strix launches as a subprocess, or a remote `http` server:
|
||||
Strix can connect to Model Context Protocol (MCP) servers you list and expose their tools to the agent during a run. Create `~/.strix/mcp-servers.json` with a JSON list of local `stdio` servers or remote `http` servers:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "local_fs",
|
||||
"transport": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/project"]
|
||||
},
|
||||
{
|
||||
"name": "github",
|
||||
"transport": "http",
|
||||
@@ -444,13 +320,16 @@ Strix can connect to Model Context Protocol (MCP) servers you list and expose th
|
||||
]
|
||||
```
|
||||
|
||||
Each server's tools are namespaced by `name` (for example `local_fs_read_file`). Omit `allowed_tools` to expose every tool the server offers, or set it to a list to restrict which tools the agent can call. The file is optional, and a server that fails to connect is skipped without failing the run. You can point Strix at a different file with `STRIX_MCP_CONFIG`.
|
||||
Each server's tools are namespaced by `name`, for example `github_list_issues`. See the [MCP documentation](https://docs.strix.ai/integrations/mcp) for the full schema, tool filtering, and `stdio` servers.
|
||||
|
||||
**Recommended models for best results:**
|
||||
|
||||
- [Z.ai GLM-5.3 on OpenRouter](https://openrouter.ai/z-ai/glm-5.3) - `openrouter/z-ai/glm-5.3` (the default pick)
|
||||
- [OpenAI GPT-5.4](https://openai.com/api/) - `openai/gpt-5.4`
|
||||
- [Anthropic Claude Sonnet 4.6](https://claude.com/platform/api) - `anthropic/claude-sonnet-4-6`
|
||||
- [Google Gemini 3 Pro Preview](https://cloud.google.com/vertex-ai) - `vertex_ai/gemini-3-pro-preview`
|
||||
- [DeepSeek V4 Pro](https://platform.deepseek.com) - `deepseek/deepseek-v4-pro`
|
||||
- [Moonshot Kimi K3](https://platform.kimi.ai) - `moonshot/kimi-k3`
|
||||
|
||||
See the [LLM Providers documentation](https://docs.strix.ai/llm-providers/overview) for all supported providers including Vertex AI, Bedrock, Azure, and local models.
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ Configure Strix using environment variables or a config file.
|
||||
## LLM Configuration
|
||||
|
||||
<ParamField path="STRIX_LLM" type="string" required>
|
||||
Model name in LiteLLM format (e.g., `openai/gpt-5.4`, `anthropic/claude-sonnet-4-6`).
|
||||
Model name in LiteLLM format (e.g., `openrouter/z-ai/glm-5.3`, `openai/gpt-5.4`).
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="LLM_API_KEY" type="string">
|
||||
@@ -80,6 +80,22 @@ affecting the agents that do the actual testing.
|
||||
API key for Perplexity AI. Enables real-time web search during scans for OSINT and vulnerability research.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="EXA_API_KEY" type="string">
|
||||
API key for Exa. Enables real-time web search through the Exa `/search` endpoint. Exa also powers the `web_get_contents` tool, which fetches the full text of a page through the Exa `/contents` endpoint. This is the preferred web search provider.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="STRIX_WEB_SEARCH_PROVIDER" default="auto" type="string">
|
||||
Web search provider: `auto`, `perplexity`, or `exa`. With `auto`, Strix uses Exa when `EXA_API_KEY` is set, and Perplexity otherwise. Set an explicit provider to pin one when you configure both keys.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="STRIX_EXA_SEARCH_TYPE" default="auto" type="string">
|
||||
Exa search mode: `auto`, `fast`, `instant`, `deep-lite`, `deep`, or `deep-reasoning`. Lower modes return results faster. Higher modes plan across more steps and take more time. This setting applies only to the Exa provider.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="STRIX_EXA_NUM_RESULTS" default="5" type="integer">
|
||||
Number of Exa results to return, from `1` to `100`. Each result includes a title, a URL, and a short security-focused summary. To read a full page, the agent calls `web_get_contents` with the result URL. This setting applies only to the Exa provider.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="POSTMAN_API_KEY" type="string">
|
||||
Postman API key (`PMAK-…`). Enables fetching Postman collections by id as a target (`postman://<collection-uid>`), and Postman environments (`postman://<collection-uid>?env=<environment-uid>`) to resolve collection variables. Not needed when passing a local collection export file.
|
||||
</ParamField>
|
||||
@@ -145,7 +161,7 @@ strix --target ./app --config /path/to/config.json
|
||||
```json
|
||||
{
|
||||
"env": {
|
||||
"STRIX_LLM": "openai/gpt-5.4",
|
||||
"STRIX_LLM": "openrouter/z-ai/glm-5.3",
|
||||
"LLM_API_KEY": "sk-...",
|
||||
"STRIX_REASONING_EFFORT": "high"
|
||||
}
|
||||
@@ -156,10 +172,11 @@ strix --target ./app --config /path/to/config.json
|
||||
|
||||
```bash
|
||||
# Required
|
||||
export STRIX_LLM="openai/gpt-5.4"
|
||||
export STRIX_LLM="openrouter/z-ai/glm-5.3"
|
||||
export LLM_API_KEY="sk-..."
|
||||
|
||||
# Optional: Enable web search
|
||||
# Optional: Enable web search (Exa preferred, Perplexity supported)
|
||||
export EXA_API_KEY="..."
|
||||
export PERPLEXITY_API_KEY="pplx-..."
|
||||
|
||||
# Optional: Custom timeouts
|
||||
|
||||
103
docs/cloud/cli.mdx
Normal file
103
docs/cloud/cli.mdx
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
title: "Cloud CLI"
|
||||
description: "Drive app.strix.ai from the terminal with strix cloud"
|
||||
---
|
||||
|
||||
The `strix cloud` commands drive the managed platform ([app.strix.ai](https://app.strix.ai)) from the terminal. You do not need Docker or an LLM key.
|
||||
|
||||
## Sign In
|
||||
|
||||
Sign in once with the browser device flow. The sign-in creates your account and workspace on first use, and it stores a personal API token in `~/.strix/platform-auth.json`.
|
||||
|
||||
```bash
|
||||
strix cloud login # browser approval, then workspace and scope profile
|
||||
strix cloud login --workspace "My Team" # select a workspace by name or ID
|
||||
strix cloud whoami # local account and workspace status
|
||||
strix cloud session # verify the remote session and consent ceiling
|
||||
strix cloud logout # revoke remotely, then remove the local token
|
||||
```
|
||||
|
||||
A browser sign-in creates one reusable credential for each CLI installation. A second sign-in on the same installation replaces the secret instead of adding another key. `strix cloud logout` revokes the server session before it deletes the local token. Use `--local-only` when you cannot reach the server.
|
||||
|
||||
## Scopes
|
||||
|
||||
The default **Recommended** preset covers normal scan work, local source uploads, workspace switching, and user-approved credit top-ups. It excludes credential creation, so request `tokens:write` when you need it.
|
||||
|
||||
```bash
|
||||
strix cloud login --scopes scans:read scans:write uploads:write billing:read
|
||||
strix cloud login --scope-profile minimal # also accepts recommended or full
|
||||
strix cloud session scopes # granted scopes and the login ceiling
|
||||
strix cloud session scopes set minimal # narrow without another browser sign-in
|
||||
```
|
||||
|
||||
A workspace switch keeps the credential and its expiry, preserves the server-side scope preference, and caps access by the target role. A switch can never exceed the login consent ceiling. Each process pins the workspace it started with, so a concurrent switch fails safely instead of sending a stale command to another organization.
|
||||
|
||||
## Commands
|
||||
|
||||
Every operation of the [REST API](https://docs.app.strix.ai) has a matching command in the form `strix cloud <resource> <verb>`.
|
||||
|
||||
```bash
|
||||
strix cloud # list all resources
|
||||
strix cloud scans # run the safe default (scans list)
|
||||
strix cloud scans help # list the verbs of a resource
|
||||
strix cloud domains add --domain example.com --asset-type web_app
|
||||
strix cloud scans start --engagement-type live_test --domain-ids <uuid> --wait
|
||||
strix cloud vulns list --severity critical
|
||||
strix cloud credits # credit balance
|
||||
```
|
||||
|
||||
Write commands take request fields as flags. Every write command also accepts one JSON object with `--data`:
|
||||
|
||||
```bash
|
||||
strix cloud scans start --data '{"engagement_type":"code_review"}' # literal JSON
|
||||
strix cloud scans start --data @request.json # read a file
|
||||
cat request.json | strix cloud scans start --data - # read standard input
|
||||
```
|
||||
|
||||
`--token` and `STRIX_API_TOKEN` are stateless overrides for a single command, and they never replace the stored sign-in. Pair a CLI-session override with `--workspace-id` or `STRIX_WORKSPACE_ID`.
|
||||
|
||||
## Workspaces And Account Setup
|
||||
|
||||
```bash
|
||||
strix cloud workspaces list # numbered list; workspace is also accepted
|
||||
strix cloud workspaces create --name "My Team" # needs admin and organizations:write
|
||||
strix cloud workspaces use 2 # switch by list number, exact name, or ID
|
||||
strix cloud billing topup --credits 20 --yes # approve an agent payment after HTTP 402
|
||||
strix cloud billing subscribe --plan strix_cloud # opens the hosted checkout page
|
||||
strix cloud billing portal # opens the billing portal
|
||||
strix cloud integrations install github # opens the app installation page
|
||||
strix cloud domains verify <domain-id> # prints the DNS record to add
|
||||
```
|
||||
|
||||
The last four commands end at a person. Strix creates the link, opens the browser for an interactive terminal, and always prints the URL. The user enters the card, approves the installation, or adds the DNS record. Pass `--no-browser` to print the URL only.
|
||||
|
||||
## Output And Exit Codes
|
||||
|
||||
The commands work for people and for agents. Terminal output favors names, branches, lifecycle states, and numbered selectors. Redirected output, and `--json`, preserve the complete machine-readable record.
|
||||
|
||||
- Human lists keep the selectors that follow-up commands need, and they omit internal organization and user IDs. A selector that is too long for the compact table is repeated losslessly in a copyable block.
|
||||
- Paginated lists print the next `--page` or `--offset`. Detail views keep useful prose within a safe terminal bound, so use `--json` for the complete record.
|
||||
- Token lists separate API keys from named CLI device sessions.
|
||||
- Binary downloads are the exception to JSON output. Redirect the raw bytes on purpose, or use `--output FILE --json` to write the file and receive structured download metadata.
|
||||
- There are no prompts when stdin is not a terminal.
|
||||
|
||||
Exit codes: `0` success, `1` error, `2` invalid usage, `4` authentication or plan limit, `5` payment required.
|
||||
|
||||
## Credits And Plan Limits
|
||||
|
||||
Non-Enterprise scans consume the deterministic estimate shown for their scope. A source-only code review at the default `ultra` tier currently starts at 60 credits. Enterprise scans are plan-included and do not consume the credit wallet.
|
||||
|
||||
Report downloads need Enterprise, schedules need Pro, and billing writes need an admin token. A plan block exits `4`. An insufficient credit wallet exits `5` without the creation of a scan and without a charge.
|
||||
|
||||
## Local Source Scans
|
||||
|
||||
See [Scan Local Source](/cloud/overview#scan-local-source) for the upload approval flow, the exclusion rules, and the size limits.
|
||||
|
||||
## Tab Completion
|
||||
|
||||
Enable native tab completion once for each shell session:
|
||||
|
||||
```bash
|
||||
source <(strix completions zsh) # use bash instead of zsh when appropriate
|
||||
strix completions fish | source
|
||||
```
|
||||
@@ -33,7 +33,7 @@ description: "Contribute to Strix development"
|
||||
</Step>
|
||||
<Step title="Configure LLM">
|
||||
```bash
|
||||
export STRIX_LLM="openai/gpt-5.4"
|
||||
export STRIX_LLM="openrouter/z-ai/glm-5.3"
|
||||
export LLM_API_KEY="your-api-key"
|
||||
```
|
||||
</Step>
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
"pages": [
|
||||
"usage/cli",
|
||||
"usage/scan-modes",
|
||||
"usage/instructions"
|
||||
"usage/instructions",
|
||||
"usage/viewer"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -77,7 +78,8 @@
|
||||
{
|
||||
"group": "Strix Cloud",
|
||||
"pages": [
|
||||
"cloud/overview"
|
||||
"cloud/overview",
|
||||
"cloud/cli"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -78,7 +78,7 @@ Strix uses a graph of specialized agents for comprehensive security testing:
|
||||
curl -sSL https://strix.ai/install | bash
|
||||
|
||||
# Configure
|
||||
export STRIX_LLM="openai/gpt-5.4"
|
||||
export STRIX_LLM="openrouter/z-ai/glm-5.3"
|
||||
export LLM_API_KEY="your-api-key"
|
||||
|
||||
# Scan
|
||||
|
||||
@@ -37,7 +37,7 @@ Add these secrets to your repository:
|
||||
|
||||
| Secret | Description |
|
||||
|--------|-------------|
|
||||
| `STRIX_LLM` | Model name (e.g., `openai/gpt-5.4`) |
|
||||
| `STRIX_LLM` | Model name (e.g., `openrouter/z-ai/glm-5.3`) |
|
||||
| `LLM_API_KEY` | API key for your LLM provider |
|
||||
|
||||
## Exit Codes
|
||||
|
||||
@@ -17,6 +17,9 @@ export LLM_API_BASE="https://api.novita.ai/openai"
|
||||
|
||||
| Model | Configuration |
|
||||
|-------|---------------|
|
||||
| GLM-5.3 | `openai/zai-org/glm-5.3` |
|
||||
| Kimi K3 | `openai/moonshotai/kimi-k3` |
|
||||
| DeepSeek V4 Pro | `openai/deepseek/deepseek-v4-pro` |
|
||||
| Kimi K2.5 | `openai/moonshotai/kimi-k2.5` |
|
||||
| GLM-5 | `openai/zai-org/glm-5` |
|
||||
| MiniMax M2.5 | `openai/minimax/minimax-m2.5` |
|
||||
|
||||
@@ -8,7 +8,7 @@ description: "Configure Strix with models via OpenRouter"
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
export STRIX_LLM="openrouter/openai/gpt-5.4"
|
||||
export STRIX_LLM="openrouter/z-ai/glm-5.3"
|
||||
export LLM_API_KEY="sk-or-..."
|
||||
```
|
||||
|
||||
@@ -18,9 +18,12 @@ Access any model on OpenRouter using the format `openrouter/<provider>/<model>`:
|
||||
|
||||
| Model | Configuration |
|
||||
|-------|---------------|
|
||||
| GLM-5.3 (default) | `openrouter/z-ai/glm-5.3` |
|
||||
| GPT-5.4 | `openrouter/openai/gpt-5.4` |
|
||||
| Claude Sonnet 4.6 | `openrouter/anthropic/claude-sonnet-4.6` |
|
||||
| Gemini 3 Pro | `openrouter/google/gemini-3-pro-preview` |
|
||||
| DeepSeek V4 Pro | `openrouter/deepseek/deepseek-v4-pro` |
|
||||
| Kimi K3 | `openrouter/moonshotai/kimi-k3` |
|
||||
| GLM-4.7 | `openrouter/z-ai/glm-4.7` |
|
||||
|
||||
## Get API Key
|
||||
|
||||
@@ -9,14 +9,17 @@ Strix uses [LiteLLM](https://docs.litellm.ai/docs/providers) for model compatibi
|
||||
|
||||
Set your model and API key:
|
||||
|
||||
| Model | Provider | Configuration |
|
||||
| ----------------- | ------------- | -------------------------------- |
|
||||
| GPT-5.4 | OpenAI | `openai/gpt-5.4` |
|
||||
| Claude Sonnet 4.6 | Anthropic | `anthropic/claude-sonnet-4-6` |
|
||||
| Gemini 3 Pro | Google Vertex | `vertex_ai/gemini-3-pro-preview` |
|
||||
| Model | Provider | Configuration |
|
||||
| -------------------- | ----------------- | -------------------------------- |
|
||||
| GLM-5.3 (default) | Z.ai (OpenRouter) | `openrouter/z-ai/glm-5.3` |
|
||||
| GPT-5.4 | OpenAI | `openai/gpt-5.4` |
|
||||
| Claude Sonnet 4.6 | Anthropic | `anthropic/claude-sonnet-4-6` |
|
||||
| Gemini 3 Pro | Google Vertex | `vertex_ai/gemini-3-pro-preview` |
|
||||
| DeepSeek V4 Pro | DeepSeek | `deepseek/deepseek-v4-pro` |
|
||||
| Kimi K3 | Moonshot | `moonshot/kimi-k3` |
|
||||
|
||||
```bash
|
||||
export STRIX_LLM="openai/gpt-5.4"
|
||||
export STRIX_LLM="openrouter/z-ai/glm-5.3"
|
||||
export LLM_API_KEY="your-api-key"
|
||||
```
|
||||
|
||||
@@ -62,6 +65,7 @@ See the [Local Models guide](/llm-providers/local) for setup instructions and re
|
||||
Use LiteLLM's `provider/model-name` format:
|
||||
|
||||
```
|
||||
openrouter/z-ai/glm-5.3
|
||||
openai/gpt-5.4
|
||||
anthropic/claude-sonnet-4-6
|
||||
vertex_ai/gemini-3-pro-preview
|
||||
|
||||
@@ -28,12 +28,12 @@ description: "Install Strix and run your first security scan"
|
||||
Set your LLM provider:
|
||||
|
||||
```bash
|
||||
export STRIX_LLM="openai/gpt-5.4"
|
||||
export STRIX_LLM="openrouter/z-ai/glm-5.3"
|
||||
export LLM_API_KEY="your-api-key"
|
||||
```
|
||||
|
||||
<Tip>
|
||||
For best results, use `openai/gpt-5.4`, `anthropic/claude-opus-4-6`, or `openai/gpt-5.2`.
|
||||
For best results, use `openrouter/z-ai/glm-5.3` (the default pick), `openai/gpt-5.4`, `anthropic/claude-opus-4-6`, or `openai/gpt-5.2`.
|
||||
</Tip>
|
||||
|
||||
## Run Your First Scan
|
||||
|
||||
@@ -28,6 +28,6 @@ Strix agents use specialized tools to test your applications like a real penetra
|
||||
| -------------- | ---------------------------------------- |
|
||||
| Python Runtime | Write and execute custom exploit scripts |
|
||||
| File Editor | Read and modify source code |
|
||||
| Web Search | Real-time OSINT via Perplexity |
|
||||
| Web Search | Real-time OSINT with Exa or Perplexity |
|
||||
| Notes | Document findings during the scan |
|
||||
| Reporting | Generate vulnerability reports with PoCs |
|
||||
|
||||
49
docs/usage/viewer.mdx
Normal file
49
docs/usage/viewer.mdx
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: "Local Web Viewer"
|
||||
description: "Browse a run in a local dashboard with strix view"
|
||||
---
|
||||
|
||||
Every scan writes its results to disk as it runs. `strix view` serves those files in a local dashboard, for a live run or a finished one.
|
||||
|
||||
```bash
|
||||
strix view # the most recent run
|
||||
strix view my-run-name # a specific run under ./strix_runs
|
||||
strix view --host 0.0.0.0 --port 8080 --no-open
|
||||
```
|
||||
|
||||
The UI ships prebuilt with Strix, so there is no extra install and no JavaScript build step. The dashboard reads the run files straight off disk. Nothing leaves your machine, and you do not need a cloud account.
|
||||
|
||||
## Options
|
||||
|
||||
<ParamField path="run" type="string">
|
||||
Run name under `./strix_runs`. Defaults to the most recent run.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="--host" type="string" default="127.0.0.1">
|
||||
Host to bind to. Use `0.0.0.0` to reach the viewer from other machines.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="--port" type="number" default="0">
|
||||
Port to serve on. The default selects an available ephemeral port.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="--no-open" type="boolean">
|
||||
Do not open the browser automatically.
|
||||
</ParamField>
|
||||
|
||||
## What Is In The Dashboard
|
||||
|
||||
- **Overview** — run status, target, and a severity breakdown of everything found so far.
|
||||
- **Vulnerabilities** — each validated finding with its severity, details, and reproduction steps.
|
||||
- **Agent graph** — a live map of the multi-agent team, and what each agent is doing.
|
||||
- **Steering** — send instructions to a live scan to redirect the agents during the run. Steering works only in the dashboard the running scan opens. A standalone `strix view` has no live scan to steer.
|
||||
- **History** — browse past runs on this machine and move between them. Verify your email address in the dashboard to unlock the other runs.
|
||||
- **Reports** — generate a shareable report and send it by email. Verify your email address first.
|
||||
|
||||
## Sharing The Link
|
||||
|
||||
<Warning>
|
||||
The token in the printed URL grants access to the run data, and to the steering of a live scan. Share it only with trusted users.
|
||||
</Warning>
|
||||
|
||||
To reach the viewer from another machine, start it with `--host 0.0.0.0` and replace `0.0.0.0` in the printed URL with a reachable IP address or hostname. Restrict the port with your firewall. A request without the token-derived session cannot read run data.
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "strix-agent"
|
||||
version = "1.5.3"
|
||||
version = "1.6.1"
|
||||
description = "Open-source AI Hackers for your apps"
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
@@ -250,6 +250,7 @@ ignore = [
|
||||
# Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a
|
||||
# circular dependency with strix.telemetry / strix.interface.viewer.report_pdf.
|
||||
"strix/interface/viewer/server.py" = ["N802", "PLC0415"]
|
||||
"strix/interface/cloud/payment_proxy.py" = ["N802"]
|
||||
# Lazy telemetry import to avoid importing PostHog before the viewer starts.
|
||||
"strix/interface/viewer/cli.py" = ["PLC0415"]
|
||||
# Lazy imports inside functions to avoid circular dependency with
|
||||
@@ -413,6 +414,8 @@ known_third_party = ["pydantic", "litellm"]
|
||||
# ============================================================================
|
||||
|
||||
[tool.bandit]
|
||||
exclude_dirs = ["docs", "build", "dist"]
|
||||
# Tests are covered by ruff's flake8-bandit rules (see per-file-ignores above),
|
||||
# which is where fixture tokens and loopback URL opens are already waived.
|
||||
exclude_dirs = ["docs", "build", "dist", "tests"]
|
||||
skips = ["B101", "B601", "B404", "B603", "B607"] # Skip assert, shell injection, subprocess import and partial path checks
|
||||
severity = "medium"
|
||||
|
||||
@@ -11,7 +11,7 @@ metadata:
|
||||
|
||||
APIs fail differently from web UIs: there is no rendered surface to crawl, the interesting bugs are authorization-shaped rather than injection-shaped, and the same endpoint behaves differently per token. This workflow targets those specifics with Strix's autonomous agents, using the current [OWASP API Security Top 10 (2023)](https://owasp.org/API-Security/editions/2023/en/0x11-t10/) as the coverage checklist. For the web-app equivalent, the current edition is the OWASP Top 10:2025 — see **owasp-top-10-testing**.
|
||||
|
||||
Install, LLM setup, full CLI flags, and the managed-cloud path are in the **penetration-testing-with-strix** skill. Read it if `strix --version` fails or the target is not an API.
|
||||
Install, LLM setup, full CLI flags, and the managed-cloud path are in the **penetration-testing-with-strix** skill. Read it if `strix --version` fails or the target is not an API. For a run with no Docker and no LLM key, the same binary drives the managed platform: `strix cloud login`, then `strix cloud scans start ...` (details in **managed-pentesting-with-strix**).
|
||||
|
||||
## 1. Gather what the agents need
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ metadata:
|
||||
|
||||
Entry point for "make my application secure" requests, where the target is not yet a single URL or repo. The job here is to pick the right test per asset, run it, and produce one ranked plan — not to run everything at maximum depth.
|
||||
|
||||
Install, LLM setup, all CLI flags, and the managed-cloud path live in the **penetration-testing-with-strix** skill. Read it first if `strix --version` fails.
|
||||
Install, LLM setup, all CLI flags, and the managed-cloud path live in the **penetration-testing-with-strix** skill. Read it first if `strix --version` fails. For a run with no Docker and no LLM key, the same binary drives the managed platform: `strix cloud login`, then `strix cloud scans start ...` (details in **managed-pentesting-with-strix**).
|
||||
|
||||
Only test assets the user owns or is authorized to test. Confirm authorization before the first run, and prefer staging over production, because the agents send real exploit payloads and can change data.
|
||||
|
||||
|
||||
@@ -113,11 +113,11 @@ Gate the pipeline on the exit code (see the budget/fail-open caveat above — gi
|
||||
|
||||
# Option B — Managed platform (no runner infra)
|
||||
|
||||
No workflow file, no Docker, no LLM key. Two ways to use it:
|
||||
No workflow file, no Docker, no LLM key. Three ways to use it:
|
||||
|
||||
1. **PR-review app (zero code):** the user installs the Strix GitHub/GitLab/Bitbucket app and enables PR reviews for the repo in the app.strix.ai dashboard. Every PR is then reviewed automatically, with findings posted as PR comments. Nothing to add to the repo. This is the lowest-effort path — recommend it first when the user just wants PR gating.
|
||||
|
||||
2. **API-triggered from any pipeline:** if you want to trigger from an existing pipeline (or a system without the SCM app), call the API with a token that has `pr_reviews:write` (or `scans:write`). Store the token as a CI secret; ask the user to create it at **Settings → API Access**. Example GitHub Actions step:
|
||||
2. **CLI-triggered from any pipeline:** if you want to trigger from an existing pipeline (or a system without the SCM app), use the same `strix` binary with a token that has `pr_reviews:write`. Store the token as a CI secret and ask the user to create it at **Settings → API Access**. Read the repository's `provider` and `installation_id` once with `strix cloud repos list`. Example GitHub Actions step:
|
||||
|
||||
```yaml
|
||||
- name: Strix PR review (managed)
|
||||
@@ -125,12 +125,25 @@ No workflow file, no Docker, no LLM key. Two ways to use it:
|
||||
env:
|
||||
STRIX_API_TOKEN: ${{ secrets.STRIX_API_TOKEN }}
|
||||
run: |
|
||||
curl -sS --fail https://app.strix.ai/api/v1/pr-reviews/start \
|
||||
-H "Authorization: Bearer $STRIX_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"repository_full_name\":\"${{ github.repository }}\",\"pr_number\":${{ github.event.pull_request.number }}}"
|
||||
curl -sSL https://strix.ai/install | bash
|
||||
strix cloud pr-reviews start \
|
||||
--provider github \
|
||||
--installation-id "${{ vars.STRIX_INSTALLATION_ID }}" \
|
||||
--repository-full-name "${{ github.repository }}" \
|
||||
--pr-number "${{ github.event.pull_request.number }}"
|
||||
```
|
||||
|
||||
To gate the build on results, poll the PR review / scan status and fail on unresolved criticals/highs. Full endpoints (PR reviews, scans, SARIF export, schedules for scheduled deep scans) are in the **managed-pentesting-with-strix** skill.
|
||||
Output is JSON when stdout is not a terminal, and there are no prompts without a TTY. To gate the build on results, poll `strix cloud pr-reviews get <id> --json` and fail on unresolved criticals or highs. The raw REST endpoint (`POST /api/v1/pr-reviews/start`) works too when the pipeline cannot install the CLI.
|
||||
|
||||
3. **Source upload from a pipeline without an SCM app:** upload the checked-out tree as a cloud code review (`scans:write` and `uploads:write`). The two-step digest handoff keeps a human in control of what leaves the runner:
|
||||
|
||||
```bash
|
||||
strix cloud scans start --source . --dry-run --show-files --json # review, capture source.archive_sha256
|
||||
strix cloud scans start --source . --approve-sha256 "$SOURCE_SHA256" --wait
|
||||
```
|
||||
|
||||
Exit codes: `0` success, `4` auth or plan limit, `5` payment required. Non-Enterprise scans consume credits.
|
||||
|
||||
Full CLI coverage (PR reviews, scans, SARIF export, schedules) is in the **managed-pentesting-with-strix** skill.
|
||||
|
||||
Recommend Option B for most teams (no maintenance, central dashboard); use Option A when scans must stay entirely within your own infrastructure.
|
||||
|
||||
@@ -11,7 +11,7 @@ metadata:
|
||||
|
||||
White-box security review with Strix: the agents read the source to build a model of routes, sinks, and authorization checks, then attempt real exploitation. Findings come with a proof-of-concept, so the output is a short list of proven issues rather than the hundreds of "potential" hits a pattern-matching scanner produces.
|
||||
|
||||
Install, LLM setup, all flags, and the managed-cloud path are in the **penetration-testing-with-strix** skill.
|
||||
Install, LLM setup, all flags, and the managed-cloud path are in the **penetration-testing-with-strix** skill. For a run with no Docker and no LLM key, the same binary drives the managed platform: `strix cloud login`, then `strix cloud scans start ...` (details in **managed-pentesting-with-strix**).
|
||||
|
||||
## Run it
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Get the findings from wherever the scan ran:
|
||||
- **OSS CLI** — artifacts in `strix_runs/<run-name>/`:
|
||||
- `vulnerabilities/*.md` — one finding per file: description, severity, PoC steps or script, affected code locations, remediation guidance.
|
||||
- `vulnerabilities.json` — the same findings as JSON (ids, severity, CWE/CVE, `code_locations` with `fix_before`/`fix_after` suggestions when available).
|
||||
- **Cloud (app.strix.ai)** — fetch the scan's `vulnerabilities[]` via `GET /api/v1/scans/{scanId}` (or `GET /api/v1/vulnerabilities` org-wide). Each carries `severity, cwe, endpoint, method, impact, technical_analysis, poc_description, poc_script_code` and, for code findings, `code_file`/`code_diff`/`code_before`/`code_after`. See the **managed-pentesting-with-strix** skill for auth.
|
||||
- **Cloud (app.strix.ai)** — pull findings with the CLI: `strix cloud vulns list --scan-id <scan-id> --json` (or `strix cloud scans get <scan-id> --json | jq '.vulnerabilities'`, or `strix cloud vulns list --severity critical` org-wide). Each finding carries `severity, cwe, endpoint, method, impact, technical_analysis, poc_description, poc_script_code` and, for code findings, `code_file`/`code_diff`/`code_before`/`code_after`. After a fix is verified, mark it with `strix cloud vulns update <id> --status fixed`. See the **managed-pentesting-with-strix** skill for `strix cloud login` and scopes.
|
||||
|
||||
Order work by severity: critical → high → medium → low. Every Strix finding was validated with a working proof-of-concept, so do not dismiss findings as false positives without re-testing the PoC yourself.
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ Non-Enterprise scans consume org credits. Enterprise engagements are plan-includ
|
||||
strix cloud credits
|
||||
```
|
||||
|
||||
When the balance is too low, buy credits with `strix cloud billing topup` (`billing:write`, admin token). The server answers the first request with **HTTP 402 and a machine-payment challenge** (Stripe Machine Payments Protocol). The CLI pays the challenge with the `mppx` client when Node.js is available — the user approves the spend in their agent wallet, for example the [Link Agent Wallet](https://link.com/agents). The response returns the receipt (`credits_granted`, `duplicate`, `reference`) and the new balance.
|
||||
When the balance is too low, buy credits with `strix cloud billing topup` (`billing:write`, admin token). The server answers the first request with **HTTP 402 and a machine-payment challenge** (Stripe Machine Payments Protocol). The CLI pays the challenge with the Stripe Link wallet client when Node.js is available — the user approves the spend in the [Link app](https://link.com/agents). The response returns the receipt (`credits_granted`, `duplicate`, `reference`) and the new balance.
|
||||
|
||||
A default-tier source-only code review currently starts at 60 credits. Source uploads are not free: they launch an ordinary `code_review` and use the same deterministic scope estimator. The service checks the full balance before launch, reserves credits atomically only after validation succeeds, and does not create or charge a rejected scan. Retests and Enterprise scans are exempt.
|
||||
|
||||
@@ -105,7 +105,7 @@ strix cloud billing topup --credits 20 --yes # explicit approval; skips the TT
|
||||
strix cloud billing topup --credits 20 --no-pay # print the 402 challenge without paying
|
||||
```
|
||||
|
||||
The default payment path is the Stripe agent wallet. Tell the user to set it up one time at [link.com/agents](https://link.com/agents). After setup, the user approves each payment in the Link app, and no keys or variables are necessary.
|
||||
The default payment path is the Stripe Link wallet. When no wallet is connected, an interactive `strix cloud billing topup` starts the Link sign-in for the user and prints the verification link. The user approves the connection one time in the Link app, and then approves each payment there. No keys or variables are necessary. In a non-interactive process, the command stops and tells the user to connect the wallet at [link.com/agents](https://link.com/agents) or to use the hosted checkout link.
|
||||
|
||||
In a non-interactive agent or CI process, payment never proceeds unless the command includes `--yes`. Show the challenge or estimated spend to the user and obtain approval before adding it. `--no-pay` always stops after printing the challenge.
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ The OWASP Top 10 is a taxonomy of risk categories, not a test suite — "OWASP T
|
||||
|
||||
**Use the current edition: [OWASP Top 10:2025](https://owasp.org/Top10/)** (8th installment, superseding 2021). Ask the user before targeting an older edition — some compliance checklists still reference 2021, and a report labelled with the wrong edition is misleading. Key differences from 2021: **SSRF is folded into A01**, **A03 Software Supply Chain Failures** expands the old "Vulnerable and Outdated Components", and **A10 Mishandling of Exceptional Conditions** is new; A02 Security Misconfiguration moved 5→2.
|
||||
|
||||
Install, LLM setup, and the managed-cloud alternative: **penetration-testing-with-strix**.
|
||||
Install, LLM setup, and the managed-cloud alternative: **penetration-testing-with-strix**. For a run with no Docker and no LLM key, the same binary drives the managed platform: `strix cloud login`, then `strix cloud scans start ...` (details in **managed-pentesting-with-strix**).
|
||||
|
||||
## What is and is not testable by an agent
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ metadata:
|
||||
Strix runs autonomous AI pentesting agents that dynamically exploit a target and only report findings validated with a working proof-of-concept. There are **two ways to run it, built on the same engine and producing the same findings** — pick per situation, and mix them freely:
|
||||
|
||||
- **Open-source CLI** (self-hosted) — runs on your machine in a Docker sandbox with your own LLM key. Free, fully local, BYO-LLM, air-gap capable. Docs: [docs.strix.ai](https://docs.strix.ai).
|
||||
- **Cloud API** (managed) — runs on Strix's infrastructure via `https://app.strix.ai/api/v1`. No Docker, no LLM key, no local compute; adds team dashboards, scheduling, PR reviews, downloadable PDF/DOCX reports (Enterprise plan), and internal-network connectors. Docs: [docs.app.strix.ai](https://docs.app.strix.ai). Full workflow in the **managed-pentesting-with-strix** skill.
|
||||
- **Managed cloud** — runs on Strix's infrastructure, driven from the same CLI (`strix cloud ...`) or the REST API at `https://app.strix.ai/api/v1`. No Docker, no LLM key, no local compute; adds team dashboards, scheduling, PR reviews, downloadable PDF/DOCX reports (Enterprise plan), and internal-network connectors. Docs: [docs.app.strix.ai](https://docs.app.strix.ai). Full workflow in the **managed-pentesting-with-strix** skill.
|
||||
|
||||
## Which one? (decide, do not default)
|
||||
|
||||
@@ -122,27 +122,33 @@ Artifacts land in `strix_runs/<run-name>/`:
|
||||
|
||||
---
|
||||
|
||||
# Option B — Cloud API (managed, no local infra)
|
||||
# Option B — Managed cloud (no local infra)
|
||||
|
||||
Full details, asset registration, polling, reports, PR reviews, schedules, and webhooks are in the **managed-pentesting-with-strix** skill. Minimal launch-and-poll:
|
||||
The same `strix` binary drives the managed platform. Every command starts with `strix cloud`. Full details — asset registration, source uploads, reports, PR reviews, schedules, webhooks, and billing — are in the **managed-pentesting-with-strix** skill. Minimal flow:
|
||||
|
||||
```bash
|
||||
export STRIX_API_TOKEN="<token>" # org-scoped bearer, from Settings → API Access at app.strix.ai
|
||||
BASE=https://app.strix.ai/api/v1
|
||||
# 1. Sign in (device flow — the user confirms a code in the browser; this also
|
||||
# creates the account and workspace when needed)
|
||||
strix cloud login
|
||||
|
||||
# 1. Launch a scan against an already-registered domain/repo asset
|
||||
scan_id=$(curl -sS "$BASE/scans" \
|
||||
-H "Authorization: Bearer $STRIX_API_TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"engagement_type":"live_test","domain_ids":["<domain-uuid>"]}' | jq -r .scan_id)
|
||||
# If you need specific scopes, request them with --scopes:
|
||||
# strix cloud login --scopes scans:read scans:write assets:read assets:write \
|
||||
# vulnerabilities:read billing:read billing:write
|
||||
|
||||
# 2. Poll until terminal (pending → running → completed/failed/cancelled)
|
||||
curl -sS "$BASE/scans/$scan_id" -H "Authorization: Bearer $STRIX_API_TOKEN" | jq '.status'
|
||||
# 2. Register and verify the target domain (verification prints a DNS record for the user)
|
||||
strix cloud domains add --domain staging.example.com --asset-type web_app
|
||||
strix cloud domains verify <domain-id>
|
||||
|
||||
# 3. Read validated findings from the scan detail's `vulnerabilities[]`, or export SARIF
|
||||
curl -sS "$BASE/scans/$scan_id/sarif" -H "Authorization: Bearer $STRIX_API_TOKEN" -o findings.sarif
|
||||
# 3. Launch and wait
|
||||
strix cloud scans start --engagement-type live_test --domain-ids <domain-id> --wait
|
||||
|
||||
# 4. Read validated findings
|
||||
strix cloud vulns list --severity critical
|
||||
```
|
||||
|
||||
Ask the user to create the token (and register the target as a domain/repository asset) if they have not. If Docker/local prerequisites are not already satisfied, use this path instead of trying to install infra.
|
||||
For a local repository, `strix cloud scans start --source .` uploads the working tree (needs `uploads:write`) and infers a code review. When credits run out, `strix cloud billing topup` starts an agent-payable Stripe challenge — the managed skill covers the payment flow. Output is JSON when stdout is not a terminal, so the commands compose in scripts.
|
||||
|
||||
The raw REST API works too (`https://app.strix.ai/api/v1`, org-scoped bearer token — see [docs.app.strix.ai](https://docs.app.strix.ai)). If Docker or local prerequisites are not already satisfied, use this path instead of trying to install infra.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ metadata:
|
||||
|
||||
Black-box (and optionally source-assisted) penetration testing of a running web app with Strix's autonomous agents. Every reported finding is validated with a working exploit, so there are no signature-based false positives to triage.
|
||||
|
||||
Install, LLM setup, all CLI flags, and the managed-cloud alternative are covered in the **penetration-testing-with-strix** skill — read it if the target is not a running web app, or if `strix --version` fails. This skill is the web-app-specific workflow.
|
||||
Install, LLM setup, all CLI flags, and the managed-cloud alternative are covered in the **penetration-testing-with-strix** skill — read it if the target is not a running web app, or if `strix --version` fails. For a run with no Docker and no LLM key, the same binary drives the managed platform: `strix cloud login`, then `strix cloud scans start ...` (details in **managed-pentesting-with-strix**). This skill is the web-app-specific workflow.
|
||||
|
||||
## 1. Confirm authorization and scope
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ from strix.tools.reporting.tool import (
|
||||
create_vulnerability_report,
|
||||
get_report,
|
||||
list_reports,
|
||||
update_vulnerability_report,
|
||||
)
|
||||
from strix.tools.respond.tool import respond_to_user
|
||||
from strix.tools.thinking.tool import think
|
||||
@@ -68,7 +69,7 @@ from strix.tools.todo.tools import (
|
||||
mark_todo_pending,
|
||||
update_todo,
|
||||
)
|
||||
from strix.tools.web_search.tool import web_search
|
||||
from strix.tools.web_search.tool import web_get_contents, web_search
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -578,8 +579,10 @@ _BASE_TOOLS: tuple[Tool, ...] = (
|
||||
save_threat_model,
|
||||
amend_threat_model,
|
||||
web_search,
|
||||
web_get_contents,
|
||||
create_vulnerability_report,
|
||||
create_dependency_report,
|
||||
update_vulnerability_report,
|
||||
list_reports,
|
||||
get_report,
|
||||
list_requests,
|
||||
|
||||
@@ -239,7 +239,8 @@ VALIDATION REQUIREMENTS:
|
||||
- Before filing any report, run the counterevidence pass: argue the strongest case AGAINST the finding, record what you found in the `counterevidence` field, set `confidence` honestly (a static-only trace you couldn't execute is at best `medium`), and state what evidence would change the severity. See the counterevidence and severity-calibration knowledge above.
|
||||
- A vulnerability is ONLY considered reported when a reporting agent uses create_vulnerability_report (or create_dependency_report for known-CVE dependency/supply-chain findings) with full details. Mentions in agent_finish, finish_scan, or generic messages are NOT sufficient
|
||||
- Reporting and fixing are ONE step, not two: when source is available, the reporting agent derives the concrete fix and files it INLINE via create_vulnerability_report (`code_locations` with `fix_before`/`fix_after` + `fix_pr_body`) — the report is not complete without it. Do NOT report first and then spawn a separate downstream agent to re-derive and re-apply the same patch; that just re-does the analysis and wastes tokens. (Do not silently patch a finding WITHOUT filing a report — the report, with its embedded fix, is the deliverable.)
|
||||
- DEDUPLICATION: The create_vulnerability_report tool uses LLM-based deduplication. If it rejects your report as a duplicate, DO NOT attempt to re-submit the same vulnerability. Accept the rejection and move on to testing other areas. The vulnerability has already been reported by another agent
|
||||
- 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. If your evidence proves more than the finding it matched (a working exploit where that one had only a static trace, a chain that raises the impact), revise that finding with update_vulnerability_report using the duplicate_of id — never re-file it.
|
||||
- REVISING A FINDING: use update_vulnerability_report (report id + the fields you want to replace + update_reason) when you learn something a finding already on file does not carry — you built the PoC after filing it, a chain raised its impact, further testing weakened it, or its counterevidence/remediation/code locations were wrong. Editing a finding needs no duplicate verdict, and it is always better than filing a second report for the same issue. Read the finding first with get_report, and pass only the fields that change.
|
||||
- REVIEWING FILED FINDINGS (orchestrator/root agent): use list_reports to see every vulnerability filed so far in this scan (by any agent, root or child) — metadata-first with per-severity counts — and get_report to read one finding in full by its id. These are read-only orchestration tools: the root agent uses them to track coverage, avoid dispatching work on already-covered ground, assemble the finish_scan executive summary, and reason about attack-chaining across confirmed findings. Leaf/specialist agents should NOT call them — just do your assigned testing and file findings. Each entry shows which agent filed it (agent_name), and your own entries are flagged by_you. list_notes/get_note do the same for notes.
|
||||
|
||||
STATE & COORDINATION TOOLS (when and how):
|
||||
|
||||
@@ -183,7 +183,7 @@ def build_authorize_url(challenge: str, state: str) -> str:
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"state": state,
|
||||
"id_token_add_organizations": "true",
|
||||
"id_token_add_organizations": "true", # nosec B105 - boolean flag, not a secret
|
||||
"codex_cli_simplified_flow": "true",
|
||||
"originator": ORIGINATOR,
|
||||
}
|
||||
|
||||
@@ -10,11 +10,13 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pydantic import AliasChoices, BaseModel
|
||||
|
||||
from strix.config.settings import Settings
|
||||
from strix.config.settings import LlmSettings, Settings
|
||||
from strix.utils.secret_files import write_secret_text
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
from pydantic.fields import FieldInfo
|
||||
|
||||
|
||||
@@ -25,6 +27,11 @@ _DEFAULT_PATH: Path = Path.home() / ".strix" / "cli-config.json"
|
||||
_override: Path | None = None
|
||||
_cached: Settings | None = None
|
||||
|
||||
# Model, API key, and API base describe one provider connection. When the shell
|
||||
# changes any of them, the stored values of the others no longer belong together
|
||||
# and are dropped rather than mixed with the new value.
|
||||
_LINKED_LLM_FIELDS = ("model", "api_key", "api_base")
|
||||
|
||||
|
||||
def load_settings() -> Settings:
|
||||
"""Resolve settings from env + JSON file + defaults. Memoized.
|
||||
@@ -54,22 +61,31 @@ def apply_config_override(path: Path) -> None:
|
||||
|
||||
|
||||
def persist_current() -> None:
|
||||
"""Write currently-set env vars to the active config file (0o600)."""
|
||||
"""Merge currently-set env vars into the active config file (0o600).
|
||||
|
||||
Values already in the file survive when their env var is unset, so a
|
||||
run that gets its settings from the file does not erase them. An env
|
||||
var set to the empty string clears the field from the file. A change to
|
||||
any linked LLM connection var drops the whole stored connection first.
|
||||
"""
|
||||
s = load_settings()
|
||||
target = _override or _DEFAULT_PATH
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
env_block: dict[str, str] = {}
|
||||
for sub_name in s.model_fields:
|
||||
env_block = _drop_stale_llm_connection(_read_env_block(target))
|
||||
for sub_name in type(s).model_fields:
|
||||
sub_model = getattr(s, sub_name)
|
||||
if not isinstance(sub_model, BaseModel):
|
||||
continue
|
||||
for finfo in type(sub_model).model_fields.values():
|
||||
for alias in _aliases_for(finfo):
|
||||
value = os.environ.get(alias.upper())
|
||||
if value:
|
||||
env_block[alias.upper()] = value
|
||||
break
|
||||
aliases = [alias.upper() for alias in _aliases_for(finfo)]
|
||||
active = next((alias for alias in aliases if alias in os.environ), None)
|
||||
if active is None:
|
||||
continue
|
||||
for alias in aliases:
|
||||
env_block.pop(alias, None)
|
||||
if os.environ[active]:
|
||||
env_block[active] = os.environ[active]
|
||||
|
||||
write_secret_text(target, json.dumps({"env": env_block}, indent=2))
|
||||
|
||||
@@ -93,17 +109,9 @@ def _read_json_overrides(path: Path) -> dict[str, dict[str, Any]]:
|
||||
Only includes keys whose env var is NOT already set, so env always
|
||||
wins over the persisted file.
|
||||
"""
|
||||
if not path.exists():
|
||||
env_block_upper = _drop_stale_llm_connection(_read_env_block(path))
|
||||
if not env_block_upper:
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
env_block = data.get("env", {}) if isinstance(data, dict) else {}
|
||||
if not isinstance(env_block, dict):
|
||||
return {}
|
||||
|
||||
env_block_upper = {str(k).upper(): v for k, v in env_block.items()}
|
||||
env_present = {k.upper() for k in os.environ}
|
||||
|
||||
nested: dict[str, dict[str, Any]] = {}
|
||||
@@ -123,3 +131,38 @@ def _read_json_overrides(path: Path) -> dict[str, dict[str, Any]]:
|
||||
if sub_data:
|
||||
nested[sub_name] = sub_data
|
||||
return nested
|
||||
|
||||
|
||||
def _first_alias_value(aliases: list[str], source: Mapping[str, Any]) -> Any | None:
|
||||
return next((source[alias] for alias in aliases if alias in source), None)
|
||||
|
||||
|
||||
def _drop_stale_llm_connection(env_block: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Remove every linked LLM var from ``env_block`` if the shell changed any of them."""
|
||||
linked_aliases = [
|
||||
[alias.upper() for alias in _aliases_for(LlmSettings.model_fields[name])]
|
||||
for name in _LINKED_LLM_FIELDS
|
||||
]
|
||||
changed = any(
|
||||
(env_value := _first_alias_value(aliases, os.environ)) is not None
|
||||
and env_value != _first_alias_value(aliases, env_block)
|
||||
for aliases in linked_aliases
|
||||
)
|
||||
if not changed:
|
||||
return env_block
|
||||
stale = {alias for aliases in linked_aliases for alias in aliases}
|
||||
return {k: v for k, v in env_block.items() if k not in stale}
|
||||
|
||||
|
||||
def _read_env_block(path: Path) -> dict[str, Any]:
|
||||
"""Return the ``env`` block stored in ``path`` with upper-cased keys, or ``{}``."""
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
env_block = data.get("env", {}) if isinstance(data, dict) else {}
|
||||
if not isinstance(env_block, dict):
|
||||
return {}
|
||||
return {str(k).upper(): v for k, v in env_block.items()}
|
||||
|
||||
@@ -562,6 +562,8 @@ DEFAULT_MODEL_RETRY = ModelRetrySettings(
|
||||
)
|
||||
|
||||
RECOMMENDED_MODEL_NAMES = (
|
||||
"zai/glm-5.3",
|
||||
"zai/glm-5.3-flash",
|
||||
"openai/gpt-5.6-sol",
|
||||
"openai/gpt-5.6-terra",
|
||||
"openai/gpt-5.6-luna",
|
||||
@@ -570,6 +572,7 @@ RECOMMENDED_MODEL_NAMES = (
|
||||
"openai/gpt-5.5",
|
||||
"openai/gpt-5.4",
|
||||
"openai/gpt-5.3-codex",
|
||||
"anthropic/claude-fable-5-1",
|
||||
"anthropic/claude-fable-5",
|
||||
"anthropic/claude-opus-5",
|
||||
"anthropic/claude-opus-4-8",
|
||||
@@ -577,6 +580,8 @@ RECOMMENDED_MODEL_NAMES = (
|
||||
"anthropic/claude-sonnet-4-6",
|
||||
"vertex_ai/gemini-3.1-pro-preview",
|
||||
"gemini/gemini-3.1-pro-preview",
|
||||
"vertex_ai/gemini-3.7-flash",
|
||||
"gemini/gemini-3.7-flash",
|
||||
"gemini/gemini-3.6-flash",
|
||||
"deepseek/deepseek-v4-pro",
|
||||
"deepseek/deepseek-v4-flash",
|
||||
@@ -598,6 +603,7 @@ FRONTIER_MODEL_FAMILIES = (
|
||||
(("deepseek",), ("deepseek-v4", "deepseek-r1", "deepseek-reasoner")),
|
||||
(("alibaba", "dashscope", "qwen"), ("qwen3.8", "qwen3.7", "qwen3-max")),
|
||||
(("moonshot", "moonshotai", "kimi"), ("kimi-k3", "kimi-k2.7", "kimi-k2.6")),
|
||||
(("zai", "z-ai", "zai-org", "zhipuai"), ("glm-5.3", "glm-5.2")),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -120,6 +120,10 @@ class TelemetrySettings(BaseSettings):
|
||||
enabled: bool = Field(default=True, alias="STRIX_TELEMETRY")
|
||||
|
||||
|
||||
WebSearchProvider = Literal["auto", "perplexity", "exa"]
|
||||
ExaSearchType = Literal["auto", "fast", "instant", "deep-lite", "deep", "deep-reasoning"]
|
||||
|
||||
|
||||
class IntegrationSettings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
@@ -128,6 +132,25 @@ class IntegrationSettings(BaseSettings):
|
||||
alias="PERPLEXITY_API_KEY",
|
||||
repr=False,
|
||||
)
|
||||
exa_api_key: str | None = Field(
|
||||
default=None,
|
||||
alias="EXA_API_KEY",
|
||||
repr=False,
|
||||
)
|
||||
web_search_provider: WebSearchProvider = Field(
|
||||
default="auto",
|
||||
alias="STRIX_WEB_SEARCH_PROVIDER",
|
||||
)
|
||||
exa_search_type: ExaSearchType = Field(
|
||||
default="auto",
|
||||
alias="STRIX_EXA_SEARCH_TYPE",
|
||||
)
|
||||
exa_num_results: int = Field(
|
||||
default=5,
|
||||
ge=1,
|
||||
le=100,
|
||||
alias="STRIX_EXA_NUM_RESULTS",
|
||||
)
|
||||
postman_api_key: str | None = Field(
|
||||
default=None,
|
||||
alias="POSTMAN_API_KEY",
|
||||
|
||||
@@ -24,6 +24,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
Status = Literal["running", "waiting", "completed", "stopped", "crashed", "failed", "budget_paused"]
|
||||
|
||||
TERMINAL_STATUSES: frozenset[str] = frozenset({"completed", "stopped", "crashed", "failed"})
|
||||
|
||||
# Why an agent parked. The user can message any agent, so this - not the agent's
|
||||
# position in the tree - decides whether waiting is bounded: only an agent waiting
|
||||
# on other agents is re-checked on a timer.
|
||||
@@ -36,6 +38,10 @@ class AgentRuntime:
|
||||
task: asyncio.Task[Any] | None = None
|
||||
stream: Any | None = None
|
||||
interrupt_on_message: bool = False
|
||||
# Whether the agent's loop parks after a terminal state and can be woken by a
|
||||
# later message. A non-interactive loop returns instead, so once such an
|
||||
# agent is terminal nothing will ever read its mailbox again.
|
||||
resumable: bool = True
|
||||
wake: asyncio.Event = field(default_factory=asyncio.Event)
|
||||
mailbox: list[dict[str, Any]] = field(default_factory=list)
|
||||
user_wake_required: bool = False
|
||||
@@ -175,6 +181,7 @@ class AgentCoordinator:
|
||||
session: Session | None = None,
|
||||
task: asyncio.Task[Any] | None = None,
|
||||
interrupt_on_message: bool | None = None,
|
||||
resumable: bool | None = None,
|
||||
) -> None:
|
||||
async with self._lock:
|
||||
runtime = self.runtimes.setdefault(agent_id, AgentRuntime())
|
||||
@@ -184,6 +191,8 @@ class AgentCoordinator:
|
||||
runtime.task = task
|
||||
if interrupt_on_message is not None:
|
||||
runtime.interrupt_on_message = interrupt_on_message
|
||||
if resumable is not None:
|
||||
runtime.resumable = resumable
|
||||
|
||||
async def mark_running(self, agent_id: str) -> None:
|
||||
async with self._lock:
|
||||
@@ -275,10 +284,29 @@ class AgentCoordinator:
|
||||
self._parent_notified.add(agent_id)
|
||||
return True
|
||||
|
||||
def _unreachable_locked(self, agent_id: str) -> bool:
|
||||
"""True when the agent is terminal and no loop will ever read its mailbox."""
|
||||
if self.statuses.get(agent_id) not in TERMINAL_STATUSES:
|
||||
return False
|
||||
runtime = self.runtimes.get(agent_id)
|
||||
return runtime is not None and not runtime.resumable
|
||||
|
||||
async def reachability(self, agent_id: str) -> tuple[bool, Status | None]:
|
||||
"""Whether a message to ``agent_id`` can still be acted on, plus its status."""
|
||||
async with self._lock:
|
||||
status = self.statuses.get(agent_id)
|
||||
if status is None:
|
||||
return False, None
|
||||
return not self._unreachable_locked(agent_id), status
|
||||
|
||||
async def send(
|
||||
self, target_agent_id: str, message: dict[str, Any], *, interrupt: bool = True
|
||||
) -> bool:
|
||||
"""Queue a user/peer message in the target's mailbox and wake it."""
|
||||
"""Queue a user/peer message in the target's mailbox and wake it.
|
||||
|
||||
Returns False when nothing will ever read the message: the target is
|
||||
unknown, or it is terminal and its loop does not park for wake-ups.
|
||||
"""
|
||||
from_user = message.get("from") == "user"
|
||||
if from_user and self._budget_paused:
|
||||
await self.resume_from_budget_pause(exclude=target_agent_id)
|
||||
@@ -286,6 +314,13 @@ class AgentCoordinator:
|
||||
if target_agent_id not in self.statuses:
|
||||
logger.debug("agent.send dropped unknown target=%s", target_agent_id)
|
||||
return False
|
||||
if self._unreachable_locked(target_agent_id):
|
||||
logger.info(
|
||||
"agent.send dropped: target=%s is %s and cannot be woken",
|
||||
target_agent_id,
|
||||
self.statuses[target_agent_id],
|
||||
)
|
||||
return False
|
||||
runtime = self.runtimes.setdefault(target_agent_id, AgentRuntime())
|
||||
runtime.mailbox.append(dict(message))
|
||||
self.pending_counts[target_agent_id] = self.pending_counts.get(target_agent_id, 0) + 1
|
||||
|
||||
@@ -202,6 +202,7 @@ async def run_agent_loop(
|
||||
agent_id,
|
||||
session=session,
|
||||
interrupt_on_message=interactive,
|
||||
resumable=interactive,
|
||||
)
|
||||
result: RunResultBase | None = None
|
||||
|
||||
@@ -1006,7 +1007,7 @@ async def _start_child_runner(
|
||||
) -> None:
|
||||
session = open_agent_session(child_id, agents_db_path)
|
||||
sessions_to_close.append(session)
|
||||
await coordinator.attach_runtime(child_id, session=session)
|
||||
await coordinator.attach_runtime(child_id, session=session, resumable=interactive)
|
||||
|
||||
child_ctx: dict[str, Any] = dict(parent_ctx)
|
||||
child_ctx["agent_id"] = child_id
|
||||
|
||||
@@ -45,6 +45,7 @@ from strix.core.paths import run_dir_for, runtime_state_dir
|
||||
from strix.core.sessions import open_agent_session
|
||||
from strix.report.state import get_global_report_state
|
||||
from strix.runtime import session_manager
|
||||
from strix.telemetry import set_scan_phase
|
||||
from strix.telemetry.logging import set_scan_id, setup_scan_logging
|
||||
from strix.tools.output_store import (
|
||||
WORKSPACE_SPILL_DIR,
|
||||
@@ -116,6 +117,13 @@ def _record_mcp_connections(connections: list[ConnectedMcpServer]) -> None:
|
||||
report_state.record_mcp_connections([connection.name for connection in connections])
|
||||
|
||||
|
||||
def _note_exit_reason(reason: str) -> None:
|
||||
"""Record why the scan stopped so the end-of-scan beacon reports it."""
|
||||
report_state = get_global_report_state()
|
||||
if report_state is not None and report_state.scan_ended_exit_reason is None:
|
||||
report_state.scan_ended_exit_reason = reason
|
||||
|
||||
|
||||
def _persist_mcp_status(roster: list[dict[str, Any]]) -> None:
|
||||
"""Write the run's non-secret MCP connection status roster to run.json.
|
||||
|
||||
@@ -313,6 +321,7 @@ async def run_strix_scan(
|
||||
root_id = uuid.uuid4().hex[:8]
|
||||
|
||||
logger.info("Bringing up sandbox session for scan %s", scan_id)
|
||||
set_scan_phase("sandbox_init")
|
||||
bundle = await session_manager.create_or_reuse(
|
||||
scan_id,
|
||||
image=image,
|
||||
@@ -322,6 +331,7 @@ async def run_strix_scan(
|
||||
)
|
||||
report("Waiting for the first model response")
|
||||
logger.info("Sandbox ready for scan %s", scan_id)
|
||||
set_scan_phase("agent_setup")
|
||||
|
||||
sandbox_session = bundle["session"]
|
||||
|
||||
@@ -428,6 +438,7 @@ async def run_strix_scan(
|
||||
}
|
||||
for summary in mcp_registry.summaries()
|
||||
]
|
||||
|
||||
# Feed a non-secret connection roster (name / provider /
|
||||
# tool_count / dead) to two consumers: once now (all
|
||||
# currently healthy) and again whenever a connection later
|
||||
@@ -572,6 +583,7 @@ async def run_strix_scan(
|
||||
async with coordinator._lock:
|
||||
root_status = coordinator.statuses.get(root_id)
|
||||
|
||||
set_scan_phase("agent_loop")
|
||||
result = await run_agent_loop(
|
||||
agent=root_agent,
|
||||
initial_input=initial_input,
|
||||
@@ -609,6 +621,7 @@ async def run_strix_scan(
|
||||
return result # noqa: TRY300
|
||||
except BudgetExceededError as exc:
|
||||
logger.info("Scan %s stopped: %s", scan_id, exc)
|
||||
_note_exit_reason("budget_exceeded")
|
||||
if root_id is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await coordinator.set_status(root_id, "stopped")
|
||||
@@ -621,6 +634,7 @@ async def run_strix_scan(
|
||||
exc,
|
||||
scan_id,
|
||||
)
|
||||
_note_exit_reason("rate_limited")
|
||||
if root_id is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await coordinator.set_status(root_id, "stopped")
|
||||
|
||||
@@ -105,14 +105,15 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
|
||||
report_state.set_scan_config(scan_config)
|
||||
report_state.save_run_data()
|
||||
|
||||
def display_vulnerability(report: dict[str, Any]) -> None:
|
||||
def display_vulnerability(report: dict[str, Any], *, updated: bool = False) -> None:
|
||||
report_id = report.get("id", "unknown")
|
||||
|
||||
vuln_text = format_vulnerability_report(report)
|
||||
|
||||
suffix = " (updated)" if updated else ""
|
||||
vuln_panel = Panel(
|
||||
vuln_text,
|
||||
title=f"[bold red]{report_id.upper()}",
|
||||
title=f"[bold red]{report_id.upper()}{suffix}",
|
||||
title_align="left",
|
||||
border_style="red",
|
||||
padding=(1, 2),
|
||||
@@ -122,6 +123,9 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
|
||||
console.print()
|
||||
|
||||
report_state.vulnerability_found_callback = display_vulnerability
|
||||
report_state.vulnerability_updated_callback = lambda report: display_vulnerability(
|
||||
report, updated=True
|
||||
)
|
||||
|
||||
def cleanup_on_exit() -> None:
|
||||
report_state.cleanup()
|
||||
|
||||
@@ -14,7 +14,7 @@ import sys
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
|
||||
import strix.interface.cloud.http as http # noqa: PLR0402
|
||||
from strix.interface.cloud import http
|
||||
from strix.interface.cloud.render import json_mode
|
||||
from strix.interface.cloud.runner import resolve, run
|
||||
from strix.interface.cloud.session import run_session
|
||||
|
||||
@@ -9,6 +9,8 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import webbrowser
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
@@ -30,6 +32,23 @@ _MAX_WALLET_DETAIL_CHARS = 2_000
|
||||
# platform. This version is also old enough to remain installable in npm
|
||||
# environments that apply a short package-publication safety window.
|
||||
_MPPX_PACKAGE = "mppx@0.8.17"
|
||||
# Stripe's own wallet client. It runs the complete challenge flow: it creates a
|
||||
# spend request, waits for the person to approve it in the Link app, and retries
|
||||
# the payment with the approved credential.
|
||||
_LINK_CLI_PACKAGE = "@stripe/link-cli@0.13.1"
|
||||
_LINK_CLI_CLIENT_NAME = "Strix CLI"
|
||||
_LINK_LOGIN_TIMEOUT_S = 300
|
||||
# Poll every 2 seconds while the person approves the spend request in the Link
|
||||
# app. 150 attempts give the person 5 minutes.
|
||||
_LINK_APPROVAL_POLL_INTERVAL_S = 2
|
||||
_LINK_APPROVAL_MAX_ATTEMPTS = 150
|
||||
# Bound every wallet subprocess so a stalled npm download or wallet request
|
||||
# cannot block the top-up command forever. The poll step gets the full
|
||||
# approval window plus this margin.
|
||||
_WALLET_STEP_TIMEOUT_S = 300
|
||||
_LINK_APPROVAL_TIMEOUT_S = (
|
||||
_LINK_APPROVAL_POLL_INTERVAL_S * _LINK_APPROVAL_MAX_ATTEMPTS + _WALLET_STEP_TIMEOUT_S
|
||||
)
|
||||
_NPM_REGISTRY = "https://registry.npmjs.org"
|
||||
_WALLET_ENV_NAMES = frozenset(
|
||||
{
|
||||
@@ -58,6 +77,8 @@ _WALLET_ENV_NAMES = frozenset(
|
||||
"TMPDIR",
|
||||
"USERPROFILE",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_DATA_HOME",
|
||||
"XDG_STATE_HOME",
|
||||
"all_proxy",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
@@ -74,7 +95,7 @@ class _WalletClientResult:
|
||||
upstream_responses: tuple[WalletUpstreamResponse, ...]
|
||||
|
||||
|
||||
def run_topup( # noqa: PLR0911, PLR0912
|
||||
def run_topup( # noqa: PLR0911, PLR0912, PLR0915
|
||||
console: Console,
|
||||
args: argparse.Namespace,
|
||||
body: dict[str, Any],
|
||||
@@ -137,24 +158,25 @@ def run_topup( # noqa: PLR0911, PLR0912
|
||||
payment_method = getattr(args, "payment_method", None) or os.environ.get(
|
||||
"MPPX_STRIPE_PAYMENT_METHOD"
|
||||
)
|
||||
if not payment_method and (
|
||||
not as_json
|
||||
and not os.environ.get("MPPX_ACCOUNT")
|
||||
and not os.environ.get("MPPX_STRIPE_SECRET_KEY")
|
||||
):
|
||||
console.print(
|
||||
"[dim]Tip: payments need a wallet. Set up a Stripe agent wallet at "
|
||||
"https://link.com/agents, and the user approves each payment in the Link app. "
|
||||
"If the user does not want a wallet, run "
|
||||
"`strix cloud billing subscribe --plan strix_top_up` for a hosted checkout link.[/]"
|
||||
)
|
||||
use_link_wallet = payment_method is None and not _mppx_wallet_configured()
|
||||
if use_link_wallet:
|
||||
setup_error = _prepare_link_wallet(console, npx, as_json=as_json)
|
||||
if setup_error is not None:
|
||||
emit(
|
||||
console,
|
||||
{"error": setup_error, "challenge": challenge},
|
||||
as_json=as_json,
|
||||
)
|
||||
return http.EXIT_PAYMENT
|
||||
try:
|
||||
wallet_result = _run_wallet_client(
|
||||
console,
|
||||
npx,
|
||||
args,
|
||||
body,
|
||||
token=token,
|
||||
payment_method=payment_method,
|
||||
use_link_wallet=use_link_wallet,
|
||||
capture_output=as_json,
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
@@ -185,19 +207,20 @@ def run_topup( # noqa: PLR0911, PLR0912
|
||||
result = wallet_result.process
|
||||
confirmed_receipt = _confirmed_topup_receipt(wallet_result.upstream_responses)
|
||||
if confirmed_receipt is not None:
|
||||
if as_json:
|
||||
emit(console, confirmed_receipt, as_json=True)
|
||||
emit(console, confirmed_receipt, as_json=as_json)
|
||||
return http.EXIT_OK
|
||||
|
||||
stdout = str(getattr(result, "stdout", "") or "").strip()
|
||||
stderr = str(getattr(result, "stderr", "") or "").strip()
|
||||
if not as_json:
|
||||
console.print(
|
||||
"[yellow]The wallet exited without a confirmed receipt. The payment outcome is "
|
||||
"unknown; run `strix cloud billing credits` before retrying.[/]"
|
||||
)
|
||||
detail = _wallet_detail(stderr or stdout or "")
|
||||
if detail:
|
||||
console.print(f"[dim]Wallet output: {detail}[/]")
|
||||
return http.EXIT_PAYMENT
|
||||
|
||||
stdout = str(getattr(result, "stdout", "") or "").strip()
|
||||
stderr = str(getattr(result, "stderr", "") or "").strip()
|
||||
if result.returncode == 0:
|
||||
try:
|
||||
receipt = json.loads(stdout)
|
||||
@@ -262,15 +285,17 @@ def run_topup( # noqa: PLR0911, PLR0912
|
||||
|
||||
|
||||
def _run_wallet_client(
|
||||
console: Console,
|
||||
npx: str,
|
||||
args: argparse.Namespace,
|
||||
body: dict[str, Any],
|
||||
*,
|
||||
token: str | None,
|
||||
payment_method: str | None,
|
||||
use_link_wallet: bool,
|
||||
capture_output: bool,
|
||||
) -> _WalletClientResult:
|
||||
"""Run mppx through the loopback bridge without exposing the API token to it."""
|
||||
"""Run the wallet through the loopback bridge without exposing the API token."""
|
||||
upstream_url = f"{http.app_url()}/api/v1/billing/topup"
|
||||
body_json = json.dumps(body)
|
||||
wallet_env = _wallet_environment()
|
||||
@@ -281,46 +306,365 @@ def _run_wallet_client(
|
||||
global_config = wallet_root / "global.npmrc"
|
||||
user_config.touch(mode=0o600)
|
||||
global_config.touch(mode=0o600)
|
||||
npx_prefix = _npx_prefix(npx, wallet_root)
|
||||
with wallet_payment_bridge(
|
||||
upstream_url=upstream_url,
|
||||
api_token=http.api_token(token),
|
||||
workspace_id=http.expected_workspace_id(token_override=token is not None),
|
||||
expected_body=body_json.encode(),
|
||||
timeout=getattr(args, "timeout", None),
|
||||
response_observer=upstream_responses.append,
|
||||
) as wallet_url:
|
||||
command = [
|
||||
npx,
|
||||
"--yes",
|
||||
f"--registry={_NPM_REGISTRY}",
|
||||
"--ignore-scripts",
|
||||
f"--userconfig={user_config}",
|
||||
f"--globalconfig={global_config}",
|
||||
f"--cache={wallet_root / 'npm-cache'}",
|
||||
_MPPX_PACKAGE,
|
||||
wallet_url,
|
||||
"--fail",
|
||||
"-J",
|
||||
body_json,
|
||||
]
|
||||
if payment_method:
|
||||
command += ["-M", f"paymentMethod={payment_method}"]
|
||||
process = subprocess.run( # noqa: S603
|
||||
command,
|
||||
check=False,
|
||||
capture_output=capture_output,
|
||||
text=True,
|
||||
env=wallet_env,
|
||||
cwd=wallet_root,
|
||||
)
|
||||
if use_link_wallet:
|
||||
process = _run_link_wallet_flow(
|
||||
console,
|
||||
npx_prefix,
|
||||
wallet_url,
|
||||
body,
|
||||
body_json,
|
||||
wallet_env,
|
||||
wallet_root,
|
||||
quiet=capture_output,
|
||||
)
|
||||
else:
|
||||
command = [
|
||||
*npx_prefix,
|
||||
_MPPX_PACKAGE,
|
||||
wallet_url,
|
||||
"--fail",
|
||||
"-J",
|
||||
body_json,
|
||||
]
|
||||
if payment_method:
|
||||
command += ["-M", f"paymentMethod={payment_method}"]
|
||||
try:
|
||||
process = subprocess.run( # noqa: S603
|
||||
command,
|
||||
check=False,
|
||||
capture_output=capture_output,
|
||||
text=True,
|
||||
env=wallet_env,
|
||||
cwd=wallet_root,
|
||||
timeout=_LINK_APPROVAL_TIMEOUT_S,
|
||||
)
|
||||
except subprocess.TimeoutExpired as timeout_error:
|
||||
process = subprocess.CompletedProcess(
|
||||
args=command,
|
||||
returncode=1,
|
||||
stdout=_decoded_stream(timeout_error.stdout),
|
||||
stderr=(
|
||||
"The wallet step did not complete within "
|
||||
f"{_LINK_APPROVAL_TIMEOUT_S} seconds."
|
||||
),
|
||||
)
|
||||
return _WalletClientResult(process=process, upstream_responses=tuple(upstream_responses))
|
||||
|
||||
|
||||
def _run_link_wallet_flow(
|
||||
console: Console,
|
||||
npx_prefix: list[str],
|
||||
wallet_url: str,
|
||||
body: dict[str, Any],
|
||||
body_json: str,
|
||||
wallet_env: dict[str, str],
|
||||
wallet_root: Path,
|
||||
*,
|
||||
quiet: bool,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
"""Create the spend request, wait for approval in the Link app, then pay."""
|
||||
|
||||
def run_step(
|
||||
arguments: list[str],
|
||||
progress_message: str,
|
||||
timeout: int = _WALLET_STEP_TIMEOUT_S,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
command = [*npx_prefix, _LINK_CLI_PACKAGE, *arguments]
|
||||
|
||||
def run() -> subprocess.CompletedProcess[str]:
|
||||
try:
|
||||
return subprocess.run( # noqa: S603
|
||||
command,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=wallet_env,
|
||||
cwd=wallet_root,
|
||||
timeout=timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired as timeout_error:
|
||||
return subprocess.CompletedProcess(
|
||||
args=command,
|
||||
returncode=1,
|
||||
stdout=_decoded_stream(timeout_error.stdout),
|
||||
stderr=f"The wallet step did not complete within {timeout} seconds.",
|
||||
)
|
||||
|
||||
if quiet:
|
||||
return run()
|
||||
with console.status(progress_message):
|
||||
return run()
|
||||
|
||||
created = run_step(
|
||||
[
|
||||
"mpp",
|
||||
"pay",
|
||||
wallet_url,
|
||||
"--method",
|
||||
"POST",
|
||||
"--data",
|
||||
body_json,
|
||||
"--context",
|
||||
_payment_context(body),
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
"Starting the Stripe Link wallet…",
|
||||
)
|
||||
spend_request = _pending_spend_request(created.stdout)
|
||||
if spend_request is None:
|
||||
return created
|
||||
request_id, approval_url = spend_request
|
||||
|
||||
if not quiet:
|
||||
console.print(f"[yellow]Approve the payment in the Link app:[/] {approval_url}")
|
||||
if sys.stdin.isatty() and sys.stdout.isatty() and approval_url.startswith("https://"):
|
||||
with suppress(Exception):
|
||||
webbrowser.open(approval_url)
|
||||
polled = run_step(
|
||||
[
|
||||
"spend-request",
|
||||
"retrieve",
|
||||
request_id,
|
||||
"--interval",
|
||||
str(_LINK_APPROVAL_POLL_INTERVAL_S),
|
||||
"--max-attempts",
|
||||
str(_LINK_APPROVAL_MAX_ATTEMPTS),
|
||||
"--format",
|
||||
"jsonl",
|
||||
],
|
||||
"Waiting for the approval in the Link app…",
|
||||
timeout=_LINK_APPROVAL_TIMEOUT_S,
|
||||
)
|
||||
if _final_spend_request_status(polled.stdout) != "approved":
|
||||
return polled
|
||||
|
||||
return run_step(
|
||||
[
|
||||
"mpp",
|
||||
"pay",
|
||||
wallet_url,
|
||||
"--spend-request-id",
|
||||
request_id,
|
||||
"--method",
|
||||
"POST",
|
||||
"--data",
|
||||
body_json,
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
"Completing the payment…",
|
||||
)
|
||||
|
||||
|
||||
def _decoded_stream(stream: str | bytes | None) -> str:
|
||||
"""Return captured subprocess output as text."""
|
||||
if stream is None:
|
||||
return ""
|
||||
if isinstance(stream, bytes):
|
||||
return stream.decode(errors="replace")
|
||||
return stream
|
||||
|
||||
|
||||
def _embedded_json_documents(text: str) -> list[Any]:
|
||||
"""Extract JSON documents from wallet output that can contain other text."""
|
||||
documents: list[Any] = []
|
||||
decoder = json.JSONDecoder()
|
||||
position = 0
|
||||
while position < len(text):
|
||||
start_candidates = [
|
||||
index for index in (text.find("[", position), text.find("{", position)) if index != -1
|
||||
]
|
||||
if not start_candidates:
|
||||
break
|
||||
start = min(start_candidates)
|
||||
try:
|
||||
document, end = decoder.raw_decode(text, start)
|
||||
except ValueError:
|
||||
position = start + 1
|
||||
continue
|
||||
documents.append(document)
|
||||
position = end
|
||||
return documents
|
||||
|
||||
|
||||
def _spend_request_records(stdout: str) -> list[dict[str, Any]]:
|
||||
"""Parse spend-request records from JSON or JSON-lines wallet output."""
|
||||
records: list[dict[str, Any]] = []
|
||||
for candidate in _embedded_json_documents((stdout or "").strip()):
|
||||
items = candidate if isinstance(candidate, list) else [candidate]
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
record = cast("dict[str, Any]", item)
|
||||
data = record.get("data")
|
||||
if isinstance(data, dict):
|
||||
record = cast("dict[str, Any]", data)
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
def _pending_spend_request(stdout: str) -> tuple[str, str] | None:
|
||||
"""Find a spend request that waits for approval in the Link app."""
|
||||
for record in _spend_request_records(stdout):
|
||||
request_id = record.get("id")
|
||||
approval_url = record.get("approval_url")
|
||||
if (
|
||||
record.get("status") == "pending_approval"
|
||||
and isinstance(request_id, str)
|
||||
and request_id
|
||||
and isinstance(approval_url, str)
|
||||
):
|
||||
return request_id, approval_url
|
||||
return None
|
||||
|
||||
|
||||
def _final_spend_request_status(stdout: str) -> str | None:
|
||||
"""Return the last reported status from the approval poll output."""
|
||||
status: str | None = None
|
||||
for record in _spend_request_records(stdout):
|
||||
value = record.get("status")
|
||||
if isinstance(value, str):
|
||||
status = value
|
||||
return status
|
||||
|
||||
|
||||
def _npx_prefix(npx: str, wallet_root: Path) -> list[str]:
|
||||
"""Install the wallet client from a fixed registry without lifecycle scripts."""
|
||||
return [
|
||||
npx,
|
||||
"--yes",
|
||||
f"--registry={_NPM_REGISTRY}",
|
||||
"--ignore-scripts",
|
||||
f"--userconfig={wallet_root / 'user.npmrc'}",
|
||||
f"--globalconfig={wallet_root / 'global.npmrc'}",
|
||||
f"--cache={_wallet_npm_cache()}",
|
||||
]
|
||||
|
||||
|
||||
def _wallet_npm_cache() -> Path:
|
||||
"""Keep one private npm cache so the pinned wallet client installs once."""
|
||||
cache = Path.home() / ".strix" / "wallet-npm-cache"
|
||||
cache.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
return cache
|
||||
|
||||
|
||||
def _payment_context(body: dict[str, Any]) -> str:
|
||||
"""Describe the purchase for the person who approves it in the Link app."""
|
||||
credits_requested = body.get("credits")
|
||||
return (
|
||||
f"Strix scan credits. The Strix command line interface asks to buy "
|
||||
f"{credits_requested} scan credit(s) for the selected Strix workspace on "
|
||||
"app.strix.ai. Strix spends the credits on managed penetration test scans "
|
||||
"that the user starts."
|
||||
)
|
||||
|
||||
|
||||
def _mppx_wallet_configured() -> bool:
|
||||
"""Report whether the person already configured the mppx wallet client."""
|
||||
return bool(os.environ.get("MPPX_ACCOUNT") or os.environ.get("MPPX_STRIPE_SECRET_KEY"))
|
||||
|
||||
|
||||
def _run_link_cli(
|
||||
npx: str,
|
||||
arguments: list[str],
|
||||
*,
|
||||
capture_output: bool,
|
||||
timeout: float | None = None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
"""Run one Stripe Link wallet command in an isolated npm environment."""
|
||||
with tempfile.TemporaryDirectory(prefix="strix-wallet-") as wallet_cwd:
|
||||
wallet_root = Path(wallet_cwd)
|
||||
(wallet_root / "user.npmrc").touch(mode=0o600)
|
||||
(wallet_root / "global.npmrc").touch(mode=0o600)
|
||||
return subprocess.run( # noqa: S603
|
||||
[*_npx_prefix(npx, wallet_root), _LINK_CLI_PACKAGE, *arguments],
|
||||
check=False,
|
||||
capture_output=capture_output,
|
||||
text=True,
|
||||
env=_wallet_environment(),
|
||||
cwd=wallet_root,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def _link_wallet_authenticated(npx: str) -> bool:
|
||||
"""Report whether a Link wallet is already connected to this machine."""
|
||||
try:
|
||||
result = _run_link_cli(
|
||||
npx,
|
||||
["auth", "status", "--format", "json"],
|
||||
capture_output=True,
|
||||
timeout=_LINK_LOGIN_TIMEOUT_S,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
try:
|
||||
payload = json.loads(result.stdout or "null")
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if isinstance(payload, list):
|
||||
payload = payload[0] if payload else None
|
||||
return bool(isinstance(payload, dict) and payload.get("authenticated"))
|
||||
|
||||
|
||||
def _prepare_link_wallet(console: Console, npx: str, *, as_json: bool) -> str | None:
|
||||
"""Connect a Link wallet when none is present. Return an error message on failure."""
|
||||
if _link_wallet_authenticated(npx):
|
||||
return None
|
||||
|
||||
manual_setup = (
|
||||
"Payment needs a Stripe Link wallet. Run `strix cloud billing topup` in an "
|
||||
"interactive terminal to connect one, or set up the wallet at "
|
||||
"https://link.com/agents. For a browser checkout instead, run "
|
||||
"`strix cloud billing subscribe --plan strix_top_up`."
|
||||
)
|
||||
if as_json or not (sys.stdin.isatty() and sys.stdout.isatty()):
|
||||
return manual_setup
|
||||
|
||||
console.print(
|
||||
"[yellow]No Stripe Link wallet is connected.[/] Strix starts the Link sign-in now. "
|
||||
"Approve the connection in the Link app, then Strix continues the payment. "
|
||||
"The user approves every payment in the Link app."
|
||||
)
|
||||
try:
|
||||
_run_link_cli(
|
||||
npx,
|
||||
[
|
||||
"auth",
|
||||
"login",
|
||||
"--client-name",
|
||||
_LINK_CLI_CLIENT_NAME,
|
||||
"--interval",
|
||||
"3",
|
||||
"--timeout",
|
||||
str(_LINK_LOGIN_TIMEOUT_S),
|
||||
],
|
||||
capture_output=False,
|
||||
timeout=_LINK_LOGIN_TIMEOUT_S + 30,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return manual_setup
|
||||
if _link_wallet_authenticated(npx):
|
||||
return None
|
||||
return manual_setup
|
||||
|
||||
|
||||
def _wallet_environment() -> dict[str, str]:
|
||||
"""Pass only platform essentials and explicit wallet variables to npm/mppx."""
|
||||
environment = {
|
||||
name: value
|
||||
for name, value in os.environ.items()
|
||||
if name in _WALLET_ENV_NAMES or name.startswith("MPPX_")
|
||||
if name in _WALLET_ENV_NAMES or name.startswith(("LINK_", "MPPX_"))
|
||||
}
|
||||
for name in ("NO_PROXY", "no_proxy"):
|
||||
entries = [entry.strip() for entry in environment.get(name, "").split(",") if entry.strip()]
|
||||
|
||||
@@ -34,13 +34,30 @@ EXIT_AUTH = 4
|
||||
EXIT_PAYMENT = 5
|
||||
|
||||
|
||||
class CloudError(Exception):
|
||||
"""A failed cloud command. Carries the process exit code."""
|
||||
TOPUP_COMMAND = "strix cloud billing topup --credits <count>"
|
||||
BALANCE_COMMAND = "strix cloud billing credits"
|
||||
|
||||
def __init__(self, message: str, *, exit_code: int = EXIT_ERROR, payload: Any = None) -> None:
|
||||
|
||||
class CloudError(Exception):
|
||||
"""A failed cloud command. Carries the process exit code.
|
||||
|
||||
`next_step` is a short recovery instruction that the runner prints on its
|
||||
own line after the error, so a person or an agent can act without reading
|
||||
the docs.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
exit_code: int = EXIT_ERROR,
|
||||
payload: Any = None,
|
||||
next_step: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.exit_code = exit_code
|
||||
self.payload = payload
|
||||
self.next_step = next_step
|
||||
|
||||
|
||||
class CloudTransportError(CloudError):
|
||||
@@ -159,7 +176,7 @@ def request(
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_token(token)}",
|
||||
}
|
||||
workspace_id = _expected_workspace_id(token_override=token is not None)
|
||||
workspace_id = expected_workspace_id(token_override=token is not None)
|
||||
if workspace_id:
|
||||
headers["X-Strix-Workspace"] = workspace_id
|
||||
if idempotency_key is not None:
|
||||
@@ -185,7 +202,7 @@ def request(
|
||||
return response
|
||||
|
||||
|
||||
def _expected_workspace_id(*, token_override: bool) -> str | None:
|
||||
def expected_workspace_id(*, token_override: bool) -> str | None:
|
||||
"""Pin every request in this process to the workspace selected at startup."""
|
||||
if _workspace_id_override:
|
||||
return _workspace_id_override
|
||||
@@ -349,13 +366,43 @@ def check(response: requests.Response) -> Any:
|
||||
error_code = error_code or str(nested.get("code") or "")
|
||||
detail = str(nested.get("message") or detail)
|
||||
message = detail or f"HTTP {response.status_code}"
|
||||
if error_code == "scan_credit_limit_reached":
|
||||
raise CloudError(message, exit_code=EXIT_PAYMENT, payload=data)
|
||||
if error_code == "scan_credit_limit_reached" or response.status_code == 402:
|
||||
raise payment_required_error(data, detail=detail)
|
||||
if response.status_code in (401, 403):
|
||||
raise CloudError(message, exit_code=EXIT_AUTH, payload=data)
|
||||
if response.status_code == 402:
|
||||
hint = detail or (
|
||||
"not enough credits. Run `strix cloud billing topup --credits N` to buy credits."
|
||||
)
|
||||
raise CloudError(hint, exit_code=EXIT_PAYMENT, payload=data)
|
||||
raise CloudError(message, exit_code=EXIT_ERROR, payload=data)
|
||||
|
||||
|
||||
def topup_url() -> str:
|
||||
return f"{app_url()}/settings/billing"
|
||||
|
||||
|
||||
def topup_next_step(url: str | None = None) -> str:
|
||||
return (
|
||||
f"Buy credits with `{TOPUP_COMMAND}` or at {url or topup_url()}. "
|
||||
f"Run `{BALANCE_COMMAND}` to see the balance. Then retry this command."
|
||||
)
|
||||
|
||||
|
||||
def payment_required_error(data: Any, *, detail: str = "") -> CloudError:
|
||||
"""Build the error for an exhausted credit balance.
|
||||
|
||||
The platform sends the recovery instruction in `hint` and repeats it inside
|
||||
`detail`. The CLI shows the instruction once, on its own line, and adds its
|
||||
own instruction when the platform sends none.
|
||||
"""
|
||||
server_hint = ""
|
||||
server_url: str | None = None
|
||||
if isinstance(data, dict):
|
||||
raw = cast("dict[str, Any]", data)
|
||||
server_hint = str(raw.get("hint") or "").strip()
|
||||
raw_url = raw.get("topup_url")
|
||||
if isinstance(raw_url, str) and raw_url.startswith("https://"):
|
||||
server_url = raw_url
|
||||
message = detail.strip()
|
||||
if server_hint and message.endswith(server_hint):
|
||||
message = message[: -len(server_hint)].strip()
|
||||
if not message:
|
||||
message = "Not enough credits to run this command."
|
||||
next_step = server_hint or topup_next_step(server_url)
|
||||
return CloudError(message, exit_code=EXIT_PAYMENT, payload=data, next_step=next_step)
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
The ``mppx`` CLI accepts custom HTTP headers through ``-H`` only. Passing a
|
||||
Strix API token that way exposes it to process-listing tools. This module keeps
|
||||
the token in the Strix process and injects it while forwarding the wallet's two
|
||||
requests (challenge and paid retry) to the fixed billing endpoint.
|
||||
the token in the Strix process and injects it while forwarding the wallet's few
|
||||
requests (challenge probes and the paid retry) to the fixed billing endpoint.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,7 +25,7 @@ if TYPE_CHECKING:
|
||||
_DEFAULT_REQUEST_TIMEOUT_S = 120.0
|
||||
_MAX_REQUEST_BODY_BYTES = 64 * 1024
|
||||
_MAX_UPSTREAM_RESPONSE_BYTES = 1024 * 1024
|
||||
_MAX_WALLET_REQUESTS = 2
|
||||
_MAX_WALLET_REQUESTS = 3
|
||||
_HOP_BY_HOP_HEADERS = frozenset(
|
||||
{
|
||||
"connection",
|
||||
@@ -45,6 +45,7 @@ _HOP_BY_HOP_HEADERS = frozenset(
|
||||
class _BridgeState:
|
||||
upstream_url: str
|
||||
authorization: str
|
||||
workspace_id: str | None
|
||||
expected_body: bytes
|
||||
path: str
|
||||
timeout: float
|
||||
@@ -53,7 +54,7 @@ class _BridgeState:
|
||||
lock: threading.Lock = field(default_factory=threading.Lock)
|
||||
|
||||
def claim_request(self) -> bool:
|
||||
"""Allow only the challenge request and its paid retry."""
|
||||
"""Allow only the challenge probes and the one paid retry."""
|
||||
with self.lock:
|
||||
if self.request_count >= _MAX_WALLET_REQUESTS:
|
||||
return False
|
||||
@@ -112,6 +113,7 @@ def _forward_request_headers(handler: BaseHTTPRequestHandler) -> dict[str, str]:
|
||||
"x-forwarded-proto",
|
||||
"x-real-ip",
|
||||
"x-strix-authorization",
|
||||
"x-strix-workspace",
|
||||
"x-vercel-forwarded-for",
|
||||
}
|
||||
return {name: value for name, value in handler.headers.items() if name.lower() not in blocked}
|
||||
@@ -163,6 +165,8 @@ def _make_handler(state: _BridgeState) -> type[BaseHTTPRequestHandler]:
|
||||
|
||||
headers = _forward_request_headers(self)
|
||||
headers["X-Strix-Authorization"] = state.authorization
|
||||
if state.workspace_id:
|
||||
headers["X-Strix-Workspace"] = state.workspace_id
|
||||
try:
|
||||
response = requests.request(
|
||||
"POST",
|
||||
@@ -243,6 +247,7 @@ def wallet_payment_bridge(
|
||||
*,
|
||||
upstream_url: str,
|
||||
api_token: str,
|
||||
workspace_id: str | None = None,
|
||||
expected_body: bytes,
|
||||
timeout: float | None = None,
|
||||
response_observer: Callable[[WalletUpstreamResponse], None] | None = None,
|
||||
@@ -258,6 +263,7 @@ def wallet_payment_bridge(
|
||||
state = _BridgeState(
|
||||
upstream_url=upstream_url,
|
||||
authorization=f"Bearer {api_token}",
|
||||
workspace_id=workspace_id,
|
||||
expected_body=expected_body,
|
||||
path=path,
|
||||
timeout=timeout or _DEFAULT_REQUEST_TIMEOUT_S,
|
||||
|
||||
@@ -678,8 +678,9 @@ def _build_parser(group: str, verb_label: str, cmd: Cmd) -> argparse.ArgumentPar
|
||||
default=None,
|
||||
metavar="PM_ID",
|
||||
help=(
|
||||
"Stripe payment method for the card payment. "
|
||||
"Defaults to MPPX_STRIPE_PAYMENT_METHOD."
|
||||
"Pay with the mppx wallet client and this Stripe payment method "
|
||||
"instead of the Stripe Link wallet. Defaults to "
|
||||
"MPPX_STRIPE_PAYMENT_METHOD."
|
||||
),
|
||||
)
|
||||
if cmd.path == "/scans" and cmd.method == "POST":
|
||||
@@ -1034,10 +1035,14 @@ def _emit_error(
|
||||
payload = {"error": str(exc)}
|
||||
if exc.payload is not None:
|
||||
payload["detail"] = exc.payload
|
||||
if exc.next_step:
|
||||
payload["next_step"] = exc.next_step
|
||||
sys.stdout.write(json.dumps(payload, indent=2, default=str) + "\n")
|
||||
return
|
||||
target = Console(stderr=True) if to_stderr else console
|
||||
target.print(f"[red]Error:[/] {escape(sanitize_terminal_text(exc))}")
|
||||
if exc.next_step:
|
||||
target.print(f"[yellow]Next step:[/] {escape(sanitize_terminal_text(exc.next_step))}")
|
||||
|
||||
|
||||
def _emit_interrupted(console: Console, *, as_json: bool, to_stderr: bool) -> None:
|
||||
|
||||
@@ -203,6 +203,17 @@ def prepare_source(
|
||||
"""Select safe source files and build a bounded temporary ZIP archive."""
|
||||
source = Path(value).expanduser().resolve()
|
||||
if not source.is_dir():
|
||||
if source.is_file() and (
|
||||
source.name.lower().endswith(_ARCHIVE_SUFFIXES) or _has_archive_magic(source)
|
||||
):
|
||||
raise http.CloudError(
|
||||
f"--source must be a directory, not an archive: {source}",
|
||||
next_step=(
|
||||
"Extract the archive and pass the directory to --source. Strix packs the "
|
||||
"directory and excludes dependencies, build output, and secret-like files. "
|
||||
"Add --dry-run --show-files to review the selection first."
|
||||
),
|
||||
)
|
||||
raise http.CloudError(f"--source must be a directory: {source}")
|
||||
manifest = select_source(
|
||||
source,
|
||||
@@ -224,14 +235,34 @@ def prepare_source(
|
||||
archive_bytes = archive_path.stat().st_size
|
||||
if archive_bytes > MAX_ARCHIVE_BYTES:
|
||||
archive_path.unlink(missing_ok=True)
|
||||
raise http.CloudError(
|
||||
"source archive is larger than the 50 MB upload limit; narrow --source or "
|
||||
"add --exclude patterns."
|
||||
)
|
||||
raise _archive_too_large_error(manifest, archive_bytes)
|
||||
digest = _sha256(archive_path)
|
||||
return SourceBundle(manifest, archive_path, archive_bytes, digest)
|
||||
|
||||
|
||||
_LARGEST_FILES_SHOWN = 5
|
||||
|
||||
|
||||
def _format_mib(size: int) -> str:
|
||||
return f"{size / (1024 * 1024):.1f} MiB"
|
||||
|
||||
|
||||
def _archive_too_large_error(manifest: SourceManifest, archive_bytes: int) -> http.CloudError:
|
||||
"""Name the largest selected files so the user knows what to exclude."""
|
||||
largest = sorted(manifest.files, key=lambda item: item.size, reverse=True)
|
||||
listed = ", ".join(
|
||||
f"{item.archive_name} ({_format_mib(item.size)})" for item in largest[:_LARGEST_FILES_SHOWN]
|
||||
)
|
||||
return http.CloudError(
|
||||
f"the source archive is {_format_mib(archive_bytes)}, larger than the "
|
||||
f"{_format_mib(MAX_ARCHIVE_BYTES)} upload limit. Largest files: {listed}.",
|
||||
next_step=(
|
||||
"Add --exclude patterns for large files or directories, or point --source at a "
|
||||
"smaller directory. Run with --dry-run --show-files to review the selection."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def select_source(
|
||||
source: Path,
|
||||
*,
|
||||
|
||||
@@ -1066,7 +1066,8 @@ SPEC: dict[str, dict[str, Cmd]] = {
|
||||
"request": Cmd(
|
||||
"POST",
|
||||
"/uploads/request",
|
||||
"Request an upload URL.",
|
||||
"Request an upload URL. To scan local source, prefer `strix cloud scans start "
|
||||
"--source DIR`, which packs, uploads, and starts the scan in one step.",
|
||||
body=(
|
||||
P("file_name", required=True, help="File name."),
|
||||
P("file_size", "int", required=True, help="File size in bytes."),
|
||||
|
||||
@@ -8,17 +8,29 @@ from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
|
||||
from strix.config import codex, load_settings
|
||||
from strix.config import IntegrationSettings, codex, load_settings
|
||||
from strix.interface.utils import (
|
||||
check_docker_connection,
|
||||
image_exists,
|
||||
process_pull_line,
|
||||
)
|
||||
from strix.telemetry import report_error
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _missing_web_search_vars(integrations: IntegrationSettings) -> list[str]:
|
||||
"""Mirror the web_search provider rules: which key(s) the selected provider needs."""
|
||||
if integrations.web_search_provider == "exa":
|
||||
return [] if integrations.exa_api_key else ["EXA_API_KEY"]
|
||||
if integrations.web_search_provider == "perplexity":
|
||||
return [] if integrations.perplexity_api_key else ["PERPLEXITY_API_KEY"]
|
||||
if integrations.exa_api_key or integrations.perplexity_api_key:
|
||||
return []
|
||||
return ["EXA_API_KEY", "PERPLEXITY_API_KEY"]
|
||||
|
||||
|
||||
def validate_environment() -> None:
|
||||
logger.info("Validating environment")
|
||||
console = Console()
|
||||
@@ -33,6 +45,7 @@ def validate_environment() -> None:
|
||||
f"[red]STRIX_LLM={settings.llm.model} uses your ChatGPT subscription, "
|
||||
"but you're not signed in.[/] Run [cyan]strix auth login chatgpt[/] first."
|
||||
)
|
||||
report_error("subscription_not_signed_in")
|
||||
sys.exit(1)
|
||||
logger.info("Environment OK (ChatGPT subscription)")
|
||||
return
|
||||
@@ -46,8 +59,7 @@ def validate_environment() -> None:
|
||||
if not settings.llm.api_base:
|
||||
missing_optional_vars.append("LLM_API_BASE")
|
||||
|
||||
if not settings.integrations.perplexity_api_key:
|
||||
missing_optional_vars.append("PERPLEXITY_API_KEY")
|
||||
missing_optional_vars.extend(_missing_web_search_vars(settings.integrations))
|
||||
|
||||
if missing_required_vars:
|
||||
error_text = Text()
|
||||
@@ -70,7 +82,7 @@ def validate_environment() -> None:
|
||||
error_text.append("• ", style="white")
|
||||
error_text.append("STRIX_LLM", style="bold cyan")
|
||||
error_text.append(
|
||||
" - Model name to use (e.g., 'openai/gpt-5.4' or "
|
||||
" - Model name to use (e.g., 'openrouter/z-ai/glm-5.3' or "
|
||||
"'anthropic/claude-opus-4-7')\n",
|
||||
style="white",
|
||||
)
|
||||
@@ -89,7 +101,14 @@ def validate_environment() -> None:
|
||||
error_text.append("• ", style="white")
|
||||
error_text.append("PERPLEXITY_API_KEY", style="bold cyan")
|
||||
error_text.append(
|
||||
" - API key for Perplexity AI web search (enables real-time research)\n",
|
||||
" - API key for Perplexity AI web search (alternative to Exa)\n",
|
||||
style="white",
|
||||
)
|
||||
elif var == "EXA_API_KEY":
|
||||
error_text.append("• ", style="white")
|
||||
error_text.append("EXA_API_KEY", style="bold cyan")
|
||||
error_text.append(
|
||||
" - API key for Exa web search (enables real-time research)\n",
|
||||
style="white",
|
||||
)
|
||||
elif var == "STRIX_REASONING_EFFORT":
|
||||
@@ -102,7 +121,7 @@ def validate_environment() -> None:
|
||||
)
|
||||
|
||||
error_text.append("\nExample setup:\n", style="white")
|
||||
error_text.append("export STRIX_LLM='openai/gpt-5.4'\n", style="dim white")
|
||||
error_text.append("export STRIX_LLM='openrouter/z-ai/glm-5.3'\n", style="dim white")
|
||||
|
||||
if missing_optional_vars:
|
||||
for var in missing_optional_vars:
|
||||
@@ -116,6 +135,8 @@ def validate_environment() -> None:
|
||||
error_text.append(
|
||||
"export PERPLEXITY_API_KEY='your-perplexity-key-here'\n", style="dim white"
|
||||
)
|
||||
elif var == "EXA_API_KEY":
|
||||
error_text.append("export EXA_API_KEY='your-exa-key-here'\n", style="dim white")
|
||||
elif var == "STRIX_REASONING_EFFORT":
|
||||
error_text.append(
|
||||
"export STRIX_REASONING_EFFORT='high'\n",
|
||||
@@ -134,6 +155,7 @@ def validate_environment() -> None:
|
||||
console.print("\n")
|
||||
console.print(panel)
|
||||
console.print()
|
||||
report_error("missing_required_config")
|
||||
sys.exit(1)
|
||||
logger.info(
|
||||
"Environment OK (optional missing: %s)",
|
||||
@@ -161,6 +183,7 @@ def check_docker_installed() -> None:
|
||||
padding=(1, 2),
|
||||
)
|
||||
console.print("\n", panel, "\n")
|
||||
report_error("docker_not_installed")
|
||||
sys.exit(1)
|
||||
logger.debug("Docker CLI present")
|
||||
|
||||
@@ -208,6 +231,7 @@ def pull_docker_image() -> None:
|
||||
padding=(1, 2),
|
||||
)
|
||||
console.print(panel, "\n")
|
||||
report_error("image_pull_failed", e)
|
||||
sys.exit(1)
|
||||
|
||||
logger.info("Docker image %s ready", image)
|
||||
|
||||
@@ -41,7 +41,8 @@ from strix.interface.update_check import (
|
||||
from strix.interface.utils import (
|
||||
build_final_stats_text,
|
||||
)
|
||||
from strix.telemetry import posthog, scarf
|
||||
from strix.llm.warmup import start_import_warmup, wait_for_import_warmup
|
||||
from strix.telemetry import posthog, report_error, scarf, set_scan_phase
|
||||
from strix.telemetry.logging import configure_dependency_logging
|
||||
|
||||
|
||||
@@ -391,22 +392,22 @@ def _print_model_connection_error(exc: BaseException, model_name: str) -> None:
|
||||
def _bootstrap_scan(args: argparse.Namespace) -> None:
|
||||
"""Warm up the model and prepare the run for a non-interactive scan.
|
||||
|
||||
Interactive launches only validate the environment here; the model
|
||||
preflight and run preparation happen inside the TUI so the interface
|
||||
paints immediately instead of waiting on a model round trip.
|
||||
Interactive launches skip this: the model preflight and run preparation
|
||||
happen inside the TUI so the interface paints immediately instead of
|
||||
waiting on a model round trip.
|
||||
"""
|
||||
validate_environment()
|
||||
if not args.non_interactive:
|
||||
return
|
||||
set_scan_phase("preflight")
|
||||
try:
|
||||
asyncio.run(warm_up_llm(show_model_warning=True))
|
||||
except ModelConnectionError as exc:
|
||||
report_error("model_connection_failed", exc)
|
||||
_print_model_connection_error(exc, exc.model_name)
|
||||
sys.exit(1)
|
||||
persist_current()
|
||||
try:
|
||||
prepare_run(args)
|
||||
except ValueError as e:
|
||||
report_error("scan_preparation_failed", e)
|
||||
_print_error_panel("SCAN PREPARATION FAILED", str(e))
|
||||
sys.exit(1)
|
||||
telemetry_start(args)
|
||||
@@ -453,8 +454,6 @@ def main() -> None:
|
||||
|
||||
sys.exit(run_cloud(sys.argv[2:]))
|
||||
|
||||
from strix.llm.warmup import start_import_warmup
|
||||
|
||||
start_import_warmup()
|
||||
|
||||
args = parse_arguments()
|
||||
@@ -467,10 +466,12 @@ def main() -> None:
|
||||
|
||||
check_docker_installed()
|
||||
pull_docker_image()
|
||||
validate_environment()
|
||||
|
||||
# In setup mode the TUI collects the target, then runs prepare_run(),
|
||||
# warm-up, and telemetry itself once the user starts the scan.
|
||||
if not args.needs_setup:
|
||||
# Everything below imports the scan engine; do not race the warm-up thread.
|
||||
wait_for_import_warmup()
|
||||
|
||||
if args.non_interactive:
|
||||
_bootstrap_scan(args)
|
||||
|
||||
from strix.report.state import get_global_report_state
|
||||
@@ -481,18 +482,21 @@ def main() -> None:
|
||||
from strix.interface.cli import run_cli
|
||||
|
||||
asyncio.run(run_cli(args))
|
||||
# Headless runs have no user to quit: the agent either finished
|
||||
# (already beaconed as finished_by_tool) or stopped on its own.
|
||||
exit_reason = "agent_stopped"
|
||||
else:
|
||||
asyncio.run(run_tui(args))
|
||||
except InteractiveSetupUnavailableError as exc:
|
||||
exit_reason = "error"
|
||||
report_error("interactive_setup_unavailable", exc)
|
||||
_print_error_panel("INTERACTIVE SETUP UNAVAILABLE", str(exc))
|
||||
sys.exit(1)
|
||||
except KeyboardInterrupt:
|
||||
exit_reason = "interrupted"
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
exit_reason = "error"
|
||||
posthog.error("unhandled_exception")
|
||||
scarf.error("unhandled_exception")
|
||||
report_error("unhandled_exception", exc)
|
||||
raise
|
||||
finally:
|
||||
report_state = get_global_report_state()
|
||||
@@ -511,6 +515,7 @@ def main() -> None:
|
||||
|
||||
if not args.run_name:
|
||||
# Setup mode where the user quit before starting a scan: nothing ran.
|
||||
notify_update(Console())
|
||||
return
|
||||
|
||||
results_path = run_dir_for(args.run_name)
|
||||
|
||||
@@ -36,7 +36,8 @@ if TYPE_CHECKING:
|
||||
_STOPPABLE_AGENT_STATUSES = frozenset({"running", "waiting", "budget_paused"})
|
||||
|
||||
ChangeCallback = Callable[[], None]
|
||||
StartCallback = Callable[[bool], Awaitable[None]]
|
||||
StartCallback = Callable[[], Awaitable[None]]
|
||||
VerifyCallback = Callable[[], Awaitable[None]]
|
||||
QuitCallback = Callable[[], Awaitable[None]]
|
||||
|
||||
|
||||
@@ -51,6 +52,7 @@ class TuiController:
|
||||
coordinator: Any = None,
|
||||
report_state: ReportState | None = None,
|
||||
on_start: StartCallback | None = None,
|
||||
on_verify: VerifyCallback | None = None,
|
||||
on_quit: QuitCallback | None = None,
|
||||
on_change: ChangeCallback | None = None,
|
||||
) -> None:
|
||||
@@ -99,7 +101,6 @@ class TuiController:
|
||||
# A target-less launch enters the live view and asks there before
|
||||
# anything is prepared; this holds the directory awaiting that answer.
|
||||
self.pending_workspace_mount: str | None = None
|
||||
self._pending_verify = True
|
||||
self.messages: list[dict[str, str]] = []
|
||||
self._next_message_id = 1
|
||||
self.error: str | None = None
|
||||
@@ -112,6 +113,7 @@ class TuiController:
|
||||
self.viewer_url: str | None = None
|
||||
self._viewer_httpd: Any = None
|
||||
self._on_start = on_start
|
||||
self._on_verify = on_verify
|
||||
self._on_quit = on_quit
|
||||
self._on_change = on_change
|
||||
|
||||
@@ -328,12 +330,6 @@ class TuiController:
|
||||
async def _start(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if self.scan_started or self._start_in_progress:
|
||||
raise RuntimeError("Scan is already starting or running")
|
||||
# A bare prompt launches optimistically, like a coding agent: it skips
|
||||
# the network model preflight and surfaces any model error live. A named
|
||||
# target keeps the preflight so a real scan does not commit blind.
|
||||
verify = payload.get("verify", True)
|
||||
if not isinstance(verify, bool):
|
||||
raise TypeError("verify must be a boolean")
|
||||
# Launching with no target mounts the working directory, so it requires
|
||||
# the user's explicit confirmation rather than happening silently.
|
||||
mount_working_dir = payload.get("mount_working_dir", False)
|
||||
@@ -344,27 +340,44 @@ class TuiController:
|
||||
raise ValueError("No model configured. Set STRIX_LLM first.")
|
||||
if self._on_start is None:
|
||||
raise RuntimeError("Scan start is unavailable")
|
||||
if not self.targets and not mount_working_dir:
|
||||
raise ValueError("No target set. Add a target first.")
|
||||
# The model check runs while still on the start screen, for a bare
|
||||
# prompt as much as for a named target, so a failure lands in the setup
|
||||
# log where the user can fix it and retry rather than in a dead run.
|
||||
await self._verify_model()
|
||||
if not self.targets:
|
||||
if not mount_working_dir:
|
||||
raise ValueError("No target set. Add a target first.")
|
||||
# Mounting the working directory needs the user's confirmation, and
|
||||
# that is asked in the live view. Enter it now and prepare nothing
|
||||
# until the answer arrives, so declining leaves no run behind.
|
||||
self.pending_workspace_mount = str(Path.cwd())
|
||||
self._pending_verify = verify
|
||||
self.setup_mode = False
|
||||
self.scan_started = True
|
||||
self.scan_state = "preparing"
|
||||
return {"started": True}
|
||||
await self._begin_scan(verify)
|
||||
await self._begin_scan()
|
||||
return {"started": True}
|
||||
|
||||
async def _begin_scan(self, verify: bool) -> None:
|
||||
async def _verify_model(self) -> None:
|
||||
if self._on_verify is None:
|
||||
return
|
||||
self._start_in_progress = True
|
||||
try:
|
||||
await self._on_verify()
|
||||
finally:
|
||||
self._start_in_progress = False
|
||||
|
||||
async def _begin_scan(self) -> None:
|
||||
if self._on_start is None:
|
||||
raise RuntimeError("Scan start is unavailable")
|
||||
self._start_in_progress = True
|
||||
try:
|
||||
await self._on_start(verify)
|
||||
await self._on_start()
|
||||
except Exception as exc:
|
||||
if not self.setup_mode:
|
||||
# The live view is already up, so the failure has to show there.
|
||||
self.fail_preparation(str(exc))
|
||||
raise
|
||||
finally:
|
||||
self._start_in_progress = False
|
||||
self.setup_mode = False
|
||||
@@ -384,7 +397,7 @@ class TuiController:
|
||||
# the whole of the input either way; the working directory is only an
|
||||
# extra the agent may look at, so the run goes ahead without one.
|
||||
self.workspace_mount = mount if approved else None
|
||||
await self._begin_scan(self._pending_verify)
|
||||
await self._begin_scan()
|
||||
return {"approved": approved}
|
||||
|
||||
async def _send_message(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
@@ -45,23 +45,20 @@ func (m *Model) submitSetupPrompt(value string) (tea.Model, tea.Cmd) {
|
||||
if len(fields) > targets {
|
||||
commands = append(commands, send(m.client, "setup.set_instruction", map[string]any{"instruction": value}))
|
||||
}
|
||||
// With a target, verify the model connection before the scan commits to it.
|
||||
// A bare prompt launches optimistically, like a coding agent, and mounts the
|
||||
// working directory - the backend asks about that from the live view, so the
|
||||
// prompt is held here in case it is declined.
|
||||
verify := targets > 0 || len(m.snapshot.Targets) > 0
|
||||
payload := map[string]any{"verify": verify}
|
||||
if verify {
|
||||
m.setupMsg("Verifying model connection...", render.Col(amber))
|
||||
} else {
|
||||
// The backend verifies the model connection before either kind of launch
|
||||
// and reports on it through the setup log. A bare prompt mounts the working
|
||||
// directory - the backend asks about that from the live view, so the prompt
|
||||
// is held here in case it is declined.
|
||||
payload := map[string]any{}
|
||||
if targets == 0 && len(m.snapshot.Targets) == 0 {
|
||||
m.pendingPrompt = value
|
||||
payload["mount_working_dir"] = true
|
||||
}
|
||||
commands = append(commands, send(m.client, "setup.start", payload))
|
||||
// Ordered, not batched: setup.start leaves setup mode, so it must be the
|
||||
// last command to reach the backend. Batched sends race, and once the
|
||||
// preflight is skipped setup.start wins, making the target and instruction
|
||||
// commands land after the guard closes and fail with a red error.
|
||||
// last command to reach the backend. Batched sends race, and if setup.start
|
||||
// wins the target and instruction commands land after the guard closes and
|
||||
// fail with a red error.
|
||||
return *m, tea.Sequence(commands...)
|
||||
}
|
||||
|
||||
|
||||
@@ -94,25 +94,6 @@ func commandTypes(envelopes []protocol.Envelope) []string {
|
||||
return types
|
||||
}
|
||||
|
||||
// startVerify returns the verify flag on the setup.start command, and whether
|
||||
// a setup.start command was present at all.
|
||||
func startVerify(t *testing.T, envelopes []protocol.Envelope) (verify, found bool) {
|
||||
t.Helper()
|
||||
for _, envelope := range envelopes {
|
||||
if envelope.Type != "setup.start" {
|
||||
continue
|
||||
}
|
||||
var payload struct {
|
||||
Verify bool `json:"verify"`
|
||||
}
|
||||
if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return payload.Verify, true
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
func contains(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
@@ -160,10 +141,6 @@ func TestSetupPromptWithoutTargetLaunchesAndRequestsMount(t *testing.T) {
|
||||
if mount, found := startPayloadFlag(t, envelopes, "mount_working_dir"); !found || !mount {
|
||||
t.Fatalf("mount was not requested: mount_working_dir=%v found=%v", mount, found)
|
||||
}
|
||||
// A bare prompt launches optimistically: no model preflight.
|
||||
if verify, found := startVerify(t, envelopes); !found || verify {
|
||||
t.Fatalf("bare prompt should launch with verify=false, got verify=%v found=%v", verify, found)
|
||||
}
|
||||
// setup.start leaves setup mode, so it must be the last command sent.
|
||||
if start, instr := firstIndex(types, "setup.start"), lastIndex(types, "setup.set_instruction"); start < instr {
|
||||
t.Fatalf("setup.start (%d) must come after setup.set_instruction (%d): %v", start, instr, types)
|
||||
@@ -273,9 +250,8 @@ func TestSetupPromptWithTargetLaunches(t *testing.T) {
|
||||
t.Fatalf("missing %s in %v", want, types)
|
||||
}
|
||||
}
|
||||
// A named target keeps the upfront model check.
|
||||
if verify, found := startVerify(t, envelopes); !found || !verify {
|
||||
t.Fatalf("targeted prompt should launch with verify=true, got verify=%v found=%v", verify, found)
|
||||
if _, found := startPayloadFlag(t, envelopes, "mount_working_dir"); found {
|
||||
t.Fatalf("a targeted prompt must not ask to mount the working directory: %v", types)
|
||||
}
|
||||
// The target and instruction must reach the backend before setup.start
|
||||
// closes the setup guard.
|
||||
|
||||
@@ -194,3 +194,28 @@ func TestVulnerabilityReportRendersCalibrationFields(t *testing.T) {
|
||||
"Fix Verification", "bypass review reasoned only",
|
||||
)
|
||||
}
|
||||
|
||||
func TestVulnerabilityReportUpdateRendersReportAndReason(t *testing.T) {
|
||||
out := ansi.Strip(Tool(tool("update_vulnerability_report",
|
||||
map[string]any{
|
||||
"report_id": "vuln-0009",
|
||||
"update_reason": "built a working unauthenticated file write against the endpoint",
|
||||
"poc_script_code": "curl -X PATCH https://target/files/uuid",
|
||||
},
|
||||
map[string]any{
|
||||
"success": true,
|
||||
"action": "updated",
|
||||
"report_id": "vuln-0009",
|
||||
"severity": "critical",
|
||||
"cvss_score": 9.3,
|
||||
"updated_fields": []any{"poc_script_code"},
|
||||
},
|
||||
"completed")))
|
||||
requireContains(t, out,
|
||||
"Vulnerability Report Updated",
|
||||
"vuln-0009",
|
||||
"built a working unauthenticated file write",
|
||||
"CRITICAL",
|
||||
"9.3",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,6 +84,8 @@ func Tool(data map[string]any) string {
|
||||
return renderViewImage(args, result)
|
||||
case "create_vulnerability_report":
|
||||
return renderVulnerabilityReport(args, result)
|
||||
case "update_vulnerability_report":
|
||||
return renderVulnerabilityReportUpdate(args, result)
|
||||
case "create_dependency_report":
|
||||
return renderDependencyReport(args, result)
|
||||
case "list_reports":
|
||||
|
||||
@@ -12,15 +12,27 @@ import (
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func renderVulnerabilityReport(args map[string]any, result any) string {
|
||||
return renderReport(args, result, "Vulnerability Report", "Creating report...")
|
||||
}
|
||||
|
||||
// A revision names the report it changes and carries only the fields it
|
||||
// replaces, so it renders the same sections with the ones it left alone absent.
|
||||
func renderVulnerabilityReportUpdate(args map[string]any, result any) string {
|
||||
return renderReport(args, result, "Vulnerability Report Updated", "Updating report...")
|
||||
}
|
||||
|
||||
func renderReport(args map[string]any, result any, heading, pending string) string {
|
||||
resultMap, _ := result.(map[string]any)
|
||||
var b strings.Builder
|
||||
b.WriteString("🐞 " + Bold(ReportHdr).Render("Vulnerability Report"))
|
||||
b.WriteString("🐞 " + Bold(ReportHdr).Render(heading))
|
||||
|
||||
field := func(label, value string) {
|
||||
if value != "" {
|
||||
b.WriteString("\n\n" + Bold(Field).Render(label+": ") + value)
|
||||
}
|
||||
}
|
||||
reportID := StringValue(args["report_id"])
|
||||
field("Report", reportID)
|
||||
title := StringValue(args["title"])
|
||||
field("Title", title)
|
||||
|
||||
@@ -59,6 +71,7 @@ func renderVulnerabilityReport(args map[string]any, result any) string {
|
||||
}
|
||||
}
|
||||
|
||||
section("Reason", StringValue(args["update_reason"]))
|
||||
section("Description", StringValue(args["description"]))
|
||||
section("Impact", StringValue(args["impact"]))
|
||||
section("Technical Analysis", StringValue(args["technical_analysis"]))
|
||||
@@ -76,8 +89,8 @@ func renderVulnerabilityReport(args map[string]any, result any) string {
|
||||
// was verified belongs next to it rather than in the artifact alone.
|
||||
section("Fix Verification", StringValue(args["fix_verification"]))
|
||||
|
||||
if title == "" {
|
||||
b.WriteString("\n " + Dim().Render("Creating report..."))
|
||||
if title == "" && reportID == "" {
|
||||
b.WriteString("\n " + Dim().Render(pending))
|
||||
}
|
||||
return "\n\n" + b.String() + "\n\n"
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ from strix.interface.tui.sidecar import (
|
||||
)
|
||||
from strix.interface.utils import read_workspace_files
|
||||
from strix.report.state import ReportState, set_global_report_state
|
||||
from strix.telemetry import report_error, set_scan_phase
|
||||
from strix.utils.resource_paths import get_strix_resource_path
|
||||
|
||||
|
||||
@@ -63,11 +64,14 @@ class GoTuiRuntime:
|
||||
self.scan_error: BaseException | None = None
|
||||
self._last_sync_fingerprint = ""
|
||||
self._error_noted_agents: set[str] = set()
|
||||
self.model_verified = False
|
||||
self._setup_preflight: asyncio.Task[None] | None = None
|
||||
self.controller = TuiController(
|
||||
args,
|
||||
live_view=self.live_view,
|
||||
coordinator=self.coordinator,
|
||||
on_start=self.start_from_setup,
|
||||
on_verify=self.ensure_model_verified,
|
||||
on_quit=self.quit,
|
||||
)
|
||||
self.server = TuiBackendServer(self.controller)
|
||||
@@ -102,9 +106,58 @@ class GoTuiRuntime:
|
||||
self.report_state.vulnerability_found_callback = lambda _report: (
|
||||
self.controller.notify_changed()
|
||||
)
|
||||
self.report_state.vulnerability_updated_callback = lambda _report: (
|
||||
self.controller.notify_changed()
|
||||
)
|
||||
self.controller.notify_changed()
|
||||
|
||||
async def start_from_setup(self, verify: bool = True) -> None:
|
||||
async def check_setup_model(self) -> None:
|
||||
"""Verify the model route as soon as the start screen is up.
|
||||
|
||||
The same round trip a direct launch makes in prepare_and_start, run in
|
||||
the background so the screen paints first and the outcome lands in the
|
||||
setup log before the user has finished typing.
|
||||
"""
|
||||
if not (load_settings().llm.model or "").strip():
|
||||
return
|
||||
try:
|
||||
await self._preflight_model()
|
||||
except Exception as exc:
|
||||
logger.exception("Go TUI setup model preflight failed")
|
||||
self.controller.add_message(f"Model connection failed: {exc}", "error")
|
||||
return
|
||||
self.controller.add_message("Model connection verified")
|
||||
|
||||
async def ensure_model_verified(self) -> None:
|
||||
"""Hold a setup launch until the model has answered once."""
|
||||
preflight = self._setup_preflight
|
||||
if preflight is not None and not preflight.done():
|
||||
await asyncio.shield(preflight)
|
||||
if self.model_verified:
|
||||
return
|
||||
try:
|
||||
await self._preflight_model()
|
||||
except Exception as exc:
|
||||
logger.exception("Go TUI setup model preflight failed")
|
||||
report_error("model_connection_failed", exc)
|
||||
raise RuntimeError(f"Model connection failed: {exc}") from exc
|
||||
|
||||
async def _preflight_model(self) -> None:
|
||||
model = (load_settings().llm.model or "").strip()
|
||||
self.controller.add_message("Verifying model connection...")
|
||||
set_scan_phase("preflight")
|
||||
await preflight_model_connection(model)
|
||||
self.model_verified = True
|
||||
|
||||
def _start_preparation(self) -> asyncio.Task[None]:
|
||||
"""Kick off the work that runs behind the freshly painted TUI."""
|
||||
if self.controller.setup_mode:
|
||||
self._setup_preflight = asyncio.create_task(self.check_setup_model())
|
||||
return self._setup_preflight
|
||||
self.controller.begin_preparation()
|
||||
return asyncio.create_task(self.prepare_and_start())
|
||||
|
||||
async def start_from_setup(self) -> None:
|
||||
candidate = deepcopy(self.args)
|
||||
candidate.scan_mode = self.controller.scan_mode
|
||||
candidate.instruction = self.controller.instruction
|
||||
@@ -121,16 +174,7 @@ class GoTuiRuntime:
|
||||
if isinstance(target, dict) and target.get("original")
|
||||
]
|
||||
targets_changed = self.controller.targets != existing_targets
|
||||
model = (load_settings().llm.model or "").strip()
|
||||
# A bare prompt launches optimistically: it skips the network preflight
|
||||
# and lets any model error surface once the agent starts, like a coding
|
||||
# agent. A named target keeps the upfront check.
|
||||
if verify:
|
||||
try:
|
||||
await preflight_model_connection(model)
|
||||
except Exception as exc:
|
||||
logger.exception("Go TUI setup model preflight failed")
|
||||
raise RuntimeError(f"Model connection failed: {exc}") from exc
|
||||
persist_current()
|
||||
# A confirmed target-less launch mounts the working directory for the
|
||||
# agent to work in, without making it a scan target.
|
||||
candidate.workspace_mount = self.controller.workspace_mount
|
||||
@@ -140,7 +184,11 @@ class GoTuiRuntime:
|
||||
candidate.target = list(self.controller.targets)
|
||||
candidate.target_list = []
|
||||
build_targets_info(candidate)
|
||||
prepare_run(candidate)
|
||||
try:
|
||||
prepare_run(candidate)
|
||||
except Exception as exc:
|
||||
report_error("scan_preparation_failed", exc)
|
||||
raise
|
||||
telemetry_start(candidate)
|
||||
|
||||
vars(self.args).update(vars(candidate))
|
||||
@@ -154,13 +202,21 @@ class GoTuiRuntime:
|
||||
launch so the interface appears immediately.
|
||||
"""
|
||||
model = (load_settings().llm.model or "").strip()
|
||||
set_scan_phase("preflight")
|
||||
try:
|
||||
await preflight_model_connection(model)
|
||||
except Exception as exc:
|
||||
logger.exception("Go TUI scan preparation failed")
|
||||
report_error("model_connection_failed", exc)
|
||||
self.controller.fail_preparation(str(exc))
|
||||
return
|
||||
try:
|
||||
persist_current()
|
||||
prepare_run(self.args)
|
||||
telemetry_start(self.args)
|
||||
except Exception as exc:
|
||||
logger.exception("Go TUI scan preparation failed")
|
||||
report_error("scan_preparation_failed", exc)
|
||||
self.controller.fail_preparation(str(exc))
|
||||
return
|
||||
self.controller.scan_state = "running"
|
||||
@@ -199,6 +255,9 @@ class GoTuiRuntime:
|
||||
self.controller.scan_state = "completed" if report_status == "completed" else "stopped"
|
||||
except Exception as exc:
|
||||
logger.exception("Go TUI scan failed")
|
||||
report_error("unhandled_exception", exc)
|
||||
if self.report_state is not None and self.report_state.scan_ended_exit_reason is None:
|
||||
self.report_state.scan_ended_exit_reason = "error"
|
||||
self.scan_error = exc
|
||||
self.controller.error = str(exc)
|
||||
self.controller.scan_state = "failed"
|
||||
@@ -373,9 +432,7 @@ class GoTuiRuntime:
|
||||
)
|
||||
process, backend_socket = await launch_tui_process(command, env, cwd)
|
||||
await self.server.start(backend_socket)
|
||||
if not self.controller.setup_mode:
|
||||
self.controller.begin_preparation()
|
||||
prepare_task = asyncio.create_task(self.prepare_and_start())
|
||||
prepare_task = self._start_preparation()
|
||||
sync_task = asyncio.create_task(self.sync_state())
|
||||
return_code = await wait_process(process)
|
||||
check_return_code(return_code)
|
||||
|
||||
@@ -19,6 +19,7 @@ from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
|
||||
from strix.config import load_settings
|
||||
from strix.telemetry import report_error
|
||||
from strix.utils.api_spec import detect_spec_format
|
||||
|
||||
|
||||
@@ -1602,7 +1603,8 @@ def check_docker_connection() -> Any:
|
||||
|
||||
try:
|
||||
return docker.from_env()
|
||||
except DockerException:
|
||||
except DockerException as exc:
|
||||
report_error("docker_unavailable", exc)
|
||||
console = Console()
|
||||
error_text = Text()
|
||||
error_text.append("DOCKER NOT AVAILABLE", style="bold red")
|
||||
|
||||
@@ -87,7 +87,7 @@ def run_view(argv: list[str]) -> None:
|
||||
|
||||
posthog.viewer_opened(source="cli", live=live)
|
||||
|
||||
state_label = "[#eab308]live[/]" if live else "[#22c55e]finished[/]"
|
||||
state_label = _state_label(summary)
|
||||
console.print()
|
||||
console.print(f"Serving [bold white]{run_name}[/] ({state_label}) at:")
|
||||
# Print the URL alone on its own line with soft_wrap so Rich never inserts a
|
||||
@@ -107,6 +107,18 @@ def run_view(argv: list[str]) -> None:
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def _state_label(summary: dict[str, object]) -> str:
|
||||
if not summary.get("finished", False):
|
||||
return "[#eab308]live[/]"
|
||||
|
||||
status = summary.get("status")
|
||||
if status == "failed":
|
||||
return "[#ef4444]failed[/]"
|
||||
if status in {"stopped", "interrupted"}:
|
||||
return f"[#eab308]{status}[/]"
|
||||
return "[#22c55e]finished[/]"
|
||||
|
||||
|
||||
def _resolve_run_dir(run: str | None, console: Console) -> Path:
|
||||
if run:
|
||||
run_dir = run_dir_for(run)
|
||||
|
||||
@@ -116,7 +116,7 @@ const CATEGORY_TOOLS: Record<ToolCategory, readonly string[]> = {
|
||||
filesystem: ["apply_patch", "view_image", "str_replace_editor", "list_files", "search_files"],
|
||||
// Caido proxy tools (legacy: send_request)
|
||||
proxy: ["list_requests", "view_request", "repeat_request", "list_sitemap", "view_sitemap_entry", "scope_rules", "send_request"],
|
||||
reporting: ["create_vulnerability_report", "list_reports", "get_report"],
|
||||
reporting: ["create_vulnerability_report", "update_vulnerability_report", "list_reports", "get_report"],
|
||||
thinking: ["think"],
|
||||
agents: ["create_agent", "agent_finish", "send_message_to_agent", "wait_for_agents", "view_agent_graph", "stop_agent"],
|
||||
search: ["web_search"],
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
"""Background pre-import of the heavy scan dependencies.
|
||||
|
||||
The scan engine's import graph (the agents SDK, OpenAI client, LiteLLM, the
|
||||
Caido SDK, the Docker SDK) costs seconds to import cold, but none of it is
|
||||
needed until a scan actually starts. Importing it on a daemon thread at CLI
|
||||
entry overlaps that cost with the I/O-bound startup work that always precedes
|
||||
a scan (argument parsing, Docker checks, image pull, TUI setup), so by the
|
||||
time the scan begins the modules are already in ``sys.modules``. Any thread
|
||||
that needs one of them before the warm-up finishes just blocks on the normal
|
||||
import lock, so behaviour is unchanged either way.
|
||||
Caido SDK) costs seconds to import cold, but none of it is needed until a scan
|
||||
actually starts. Importing it on a daemon thread at CLI entry overlaps that
|
||||
cost with the I/O-bound startup work that always precedes a scan (argument
|
||||
parsing, Docker checks, image pull, TUI setup). The Docker SDK is not on the
|
||||
list: the Docker checks import it on the main thread during that same window.
|
||||
|
||||
The main thread must call :func:`wait_for_import_warmup` before its first
|
||||
import from that graph. Two threads that enter the same package graph from
|
||||
different modules hold each other's import locks, and CPython breaks the cycle
|
||||
by failing one of the imports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
|
||||
|
||||
@@ -24,45 +26,17 @@ WARMUP_MODULES = (
|
||||
"strix.core.runner",
|
||||
"litellm",
|
||||
"caido_sdk_client",
|
||||
"docker",
|
||||
)
|
||||
|
||||
_lock = threading.Lock()
|
||||
_thread: threading.Thread | None = None
|
||||
|
||||
|
||||
def _purge_orphaned_modules(before: frozenset[str]) -> None:
|
||||
"""Remove submodules stranded by an import attempt that just failed.
|
||||
|
||||
When a package import fails partway (for example CPython's import-lock
|
||||
deadlock avoidance breaking a cross-thread cycle), the failed package is
|
||||
removed from ``sys.modules`` but submodules it already finished stay
|
||||
behind. A later import of one of those submodules then short-circuits on
|
||||
the cached entry without re-importing its parent, and re-entering the
|
||||
parent from inside a submodule crashes with "partially initialized
|
||||
module". Dropping the orphans (cached submodules whose ancestor package is
|
||||
gone) restores a clean slate, and touches nothing another thread imported
|
||||
successfully.
|
||||
"""
|
||||
added = set(sys.modules) - before
|
||||
for name in added:
|
||||
parent = name.rpartition(".")[0]
|
||||
while parent:
|
||||
if parent not in sys.modules:
|
||||
sys.modules.pop(name, None)
|
||||
logger.debug("Import warm-up purged orphaned module %r", name)
|
||||
break
|
||||
parent = parent.rpartition(".")[0]
|
||||
|
||||
|
||||
def _warm(modules: tuple[str, ...]) -> None:
|
||||
for name in modules:
|
||||
before = frozenset(sys.modules)
|
||||
try:
|
||||
importlib.import_module(name)
|
||||
except Exception: # noqa: BLE001 - a failed warm-up must never fail the run.
|
||||
logger.debug("Import warm-up for %r failed", name, exc_info=True)
|
||||
_purge_orphaned_modules(before)
|
||||
|
||||
|
||||
def start_import_warmup(modules: tuple[str, ...] = WARMUP_MODULES) -> threading.Thread:
|
||||
@@ -72,11 +46,15 @@ def start_import_warmup(modules: tuple[str, ...] = WARMUP_MODULES) -> threading.
|
||||
runtime that has no local Docker) warm a narrower set.
|
||||
"""
|
||||
global _thread # noqa: PLW0603
|
||||
with _lock:
|
||||
if _thread is not None:
|
||||
return _thread
|
||||
if _thread is None:
|
||||
_thread = threading.Thread(
|
||||
target=_warm, args=(modules,), name="strix-import-warmup", daemon=True
|
||||
)
|
||||
_thread.start()
|
||||
return _thread
|
||||
return _thread
|
||||
|
||||
|
||||
def wait_for_import_warmup() -> None:
|
||||
"""Block until the warm-up thread has finished, if one was started."""
|
||||
if _thread is not None:
|
||||
_thread.join()
|
||||
|
||||
@@ -44,6 +44,56 @@ def _strix_version() -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
# Content a revision may replace. The identity of the finding (id, timestamp,
|
||||
# finding_class) and its original author stay put. dependency_metadata is
|
||||
# replaced whole, so a caller carries the package identity over itself.
|
||||
UPDATABLE_REPORT_FIELDS = frozenset(
|
||||
{
|
||||
"title",
|
||||
"dependency_metadata",
|
||||
"severity",
|
||||
"description",
|
||||
"impact",
|
||||
"target",
|
||||
"technical_analysis",
|
||||
"poc_description",
|
||||
"poc_script_code",
|
||||
"remediation_steps",
|
||||
"evidence",
|
||||
"assumptions",
|
||||
"counterevidence",
|
||||
"confidence",
|
||||
"confidence_rationale",
|
||||
"severity_change_conditions",
|
||||
"fix_effort",
|
||||
"cvss",
|
||||
"cvss_breakdown",
|
||||
"endpoint",
|
||||
"method",
|
||||
"cve",
|
||||
"cwe",
|
||||
"code_locations",
|
||||
"fix_verification",
|
||||
"fix_pr_body",
|
||||
}
|
||||
)
|
||||
|
||||
_LOWERCASE_REPORT_FIELDS = frozenset({"severity", "confidence", "fix_effort"})
|
||||
|
||||
# Fields that only describe another field. A revision may raise the rating or
|
||||
# replace the locations without restating the reasoning behind the old one, and
|
||||
# that leftover reasoning then contradicts the finding it annotates
|
||||
# ("confidence: high" beside a rationale calling the evidence unconfirmed). When
|
||||
# the field they describe changes and the update carries no replacement, they
|
||||
# are dropped rather than kept.
|
||||
_DEPENDENT_REPORT_FIELDS: dict[str, tuple[str, ...]] = {
|
||||
"confidence": ("confidence_rationale",),
|
||||
"severity": ("severity_change_conditions",),
|
||||
"cvss": ("cvss_breakdown",),
|
||||
"code_locations": ("fix_verification",),
|
||||
}
|
||||
|
||||
|
||||
def _clean_title(title: str) -> str:
|
||||
"""Return a single-line finding title.
|
||||
|
||||
@@ -117,7 +167,7 @@ def get_global_report_state() -> Optional["ReportState"]:
|
||||
return _global_report_state
|
||||
|
||||
|
||||
def set_global_report_state(report_state: "ReportState") -> None:
|
||||
def set_global_report_state(report_state: Optional["ReportState"]) -> None:
|
||||
global _global_report_state # noqa: PLW0603
|
||||
_global_report_state = report_state
|
||||
# New run: drop any streamed-cost entries a prior run left unconsumed.
|
||||
@@ -169,6 +219,7 @@ class ReportState:
|
||||
|
||||
self.caido_url: str | None = None
|
||||
self.vulnerability_found_callback: Callable[[dict[str, Any]], None] | None = None
|
||||
self.vulnerability_updated_callback: Callable[[dict[str, Any]], None] | None = None
|
||||
|
||||
self._sarif_repo_ctx: dict[str, Any] | None = None
|
||||
self._sarif_repo_ctx_ready: bool = False
|
||||
@@ -236,6 +287,12 @@ class ReportState:
|
||||
)
|
||||
self.vulnerability_reports = [r for r in data if isinstance(r, dict)]
|
||||
for r in self.vulnerability_reports:
|
||||
# A finding written before the class was persisted still carries the
|
||||
# metadata of its class, so name the class it always had.
|
||||
if not r.get("finding_class"):
|
||||
r["finding_class"] = (
|
||||
"dependency_cve" if r.get("dependency_metadata") else "dynamic"
|
||||
)
|
||||
title = r.get("title")
|
||||
stale_md = False
|
||||
if isinstance(title, str):
|
||||
@@ -357,6 +414,100 @@ class ReportState:
|
||||
self.save_run_data()
|
||||
return report_id
|
||||
|
||||
def update_vulnerability_report(
|
||||
self,
|
||||
report_id: str,
|
||||
fields: dict[str, Any],
|
||||
*,
|
||||
update_reason: str | None = None,
|
||||
updated_by_agent_id: str | None = None,
|
||||
updated_by_agent_name: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Apply a revision to an existing report, keeping its id.
|
||||
|
||||
A field that only describes a field this update replaces is dropped when
|
||||
the update carries no replacement for it, so the revised report cannot
|
||||
state a new rating beside the superseded reasoning for the old one.
|
||||
|
||||
Returns the revised report, or ``None`` when the id is unknown or when
|
||||
nothing in ``fields`` changes it.
|
||||
"""
|
||||
report = next((r for r in self.vulnerability_reports if r.get("id") == report_id), None)
|
||||
if report is None:
|
||||
logger.warning("cannot update unknown vulnerability report %s", report_id)
|
||||
return None
|
||||
|
||||
changed: dict[str, Any] = {}
|
||||
for key, raw_value in fields.items():
|
||||
if key not in UPDATABLE_REPORT_FIELDS or raw_value is None:
|
||||
continue
|
||||
value = raw_value
|
||||
if isinstance(value, str):
|
||||
value = _clean_title(value) if key == "title" else value.strip()
|
||||
if key in _LOWERCASE_REPORT_FIELDS:
|
||||
value = value.lower()
|
||||
if not value:
|
||||
continue
|
||||
if report.get(key) == value:
|
||||
continue
|
||||
changed[key] = value
|
||||
|
||||
superseded = {
|
||||
dependent
|
||||
for primary, dependents in _DEPENDENT_REPORT_FIELDS.items()
|
||||
if primary in changed
|
||||
for dependent in dependents
|
||||
if dependent not in changed and report.get(dependent) not in (None, "", [], {})
|
||||
}
|
||||
|
||||
if not changed and not superseded:
|
||||
logger.info("update for %s carried no new content; keeping it as is", report_id)
|
||||
return None
|
||||
|
||||
entry: dict[str, Any] = {
|
||||
"timestamp": datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC"),
|
||||
"fields": sorted(changed),
|
||||
}
|
||||
if superseded:
|
||||
entry["dropped_fields"] = sorted(superseded)
|
||||
if update_reason and update_reason.strip():
|
||||
entry["reason"] = update_reason.strip()[:500]
|
||||
if updated_by_agent_id:
|
||||
entry["agent_id"] = updated_by_agent_id
|
||||
if updated_by_agent_name:
|
||||
entry["agent_name"] = updated_by_agent_name
|
||||
for key in ("severity", "cvss", "confidence"):
|
||||
if key in changed and report.get(key) is not None:
|
||||
entry[f"previous_{key}"] = report[key]
|
||||
|
||||
raw_history = report.get("update_history")
|
||||
history: list[dict[str, Any]] = (
|
||||
[e for e in raw_history if isinstance(e, dict)] if isinstance(raw_history, list) else []
|
||||
)
|
||||
history.append(entry)
|
||||
|
||||
report.update(changed)
|
||||
for dependent in superseded:
|
||||
report.pop(dependent, None)
|
||||
report["update_history"] = history
|
||||
report["updated_at"] = entry["timestamp"]
|
||||
|
||||
# The markdown on disk still shows the superseded evidence, so let the
|
||||
# writer re-render it.
|
||||
self._saved_vuln_ids.discard(report_id)
|
||||
|
||||
logger.info(
|
||||
"Updated vulnerability report %s (%s)",
|
||||
report_id,
|
||||
", ".join(entry["fields"]) or "no field replaced",
|
||||
)
|
||||
|
||||
if self.vulnerability_updated_callback:
|
||||
self.vulnerability_updated_callback(report)
|
||||
|
||||
self.save_run_data()
|
||||
return report
|
||||
|
||||
def get_existing_vulnerabilities(self) -> list[dict[str, Any]]:
|
||||
return list(self.vulnerability_reports)
|
||||
|
||||
|
||||
@@ -356,4 +356,41 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
|
||||
lines.append(str(report["assumptions"]))
|
||||
lines.append("")
|
||||
|
||||
lines.extend(render_update_history(report.get("update_history")))
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_update_history(history: Any) -> list[str]:
|
||||
"""Render the audit trail of every revision a report has received."""
|
||||
if not isinstance(history, list):
|
||||
return []
|
||||
entries: list[dict[str, Any]] = [
|
||||
cast("dict[str, Any]", e) for e in history if isinstance(e, dict)
|
||||
]
|
||||
if not entries:
|
||||
return []
|
||||
|
||||
lines = ["## Update History\n"]
|
||||
for entry in entries:
|
||||
author = str(entry.get("agent_name") or entry.get("agent_id") or "an agent")
|
||||
raw_fields = entry.get("fields")
|
||||
fields: list[Any] = raw_fields if isinstance(raw_fields, list) else []
|
||||
changed = ", ".join(str(field) for field in fields)
|
||||
timestamp = str(entry.get("timestamp") or "unknown")
|
||||
lines.append(f"**{timestamp}** — {author} updated: {changed}")
|
||||
raw_dropped = entry.get("dropped_fields")
|
||||
if isinstance(raw_dropped, list) and raw_dropped:
|
||||
dropped = ", ".join(str(field) for field in raw_dropped)
|
||||
lines.append(f" Dropped as superseded: {dropped}")
|
||||
for key, label in (
|
||||
("previous_severity", "severity"),
|
||||
("previous_cvss", "CVSS"),
|
||||
("previous_confidence", "confidence"),
|
||||
):
|
||||
if entry.get(key) is not None:
|
||||
lines.append(f" Previous {label}: {entry[key]}")
|
||||
if entry.get("reason"):
|
||||
lines.append(f" Reason: {entry['reason']}")
|
||||
lines.append("")
|
||||
return lines
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
from collections import Counter
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
@@ -8,7 +7,6 @@ from typing import TypeGuard
|
||||
|
||||
import yaml
|
||||
|
||||
from strix.telemetry import posthog, scarf
|
||||
from strix.utils.resource_paths import get_strix_resource_path
|
||||
|
||||
|
||||
@@ -241,16 +239,19 @@ def validate_requested_skills(skill_list: list[str], max_skills: int = 5) -> str
|
||||
return None
|
||||
|
||||
|
||||
_LOADED_SKILLS: set[str] = set()
|
||||
|
||||
|
||||
def _track_skill_loaded(skill_name: str, file_path: Path) -> None:
|
||||
builtin = get_strix_resource_path("skills")
|
||||
if not file_path.is_relative_to(builtin):
|
||||
skill_name = "custom"
|
||||
_LOADED_SKILLS.add(skill_name)
|
||||
|
||||
def _send() -> None:
|
||||
posthog.skill_loaded(skill_name)
|
||||
scarf.skill_loaded(skill_name)
|
||||
|
||||
threading.Thread(target=_send, daemon=True).start()
|
||||
def get_loaded_skill_names() -> list[str]:
|
||||
"""Distinct skills loaded so far in this process (custom skills collapse to ``"custom"``)."""
|
||||
return sorted(_LOADED_SKILLS)
|
||||
|
||||
|
||||
def _candidate_skill_files(skill_name: str) -> list[Path]:
|
||||
|
||||
@@ -12,11 +12,11 @@ Privacy is our priority. All collected data is anonymized by default. Each sessi
|
||||
|
||||
We collect only very **basic** usage data including:
|
||||
|
||||
**Session Errors:** Duration and error types (not messages or stack traces)\
|
||||
**Session Errors:** Duration, the failure category, the scan phase, and the exception class name (not messages or stack traces)\
|
||||
**System Context:** OS type, architecture, Strix version\
|
||||
**Scan Context:** Scan mode (quick/standard/deep), scan type (whitebox/blackbox)\
|
||||
**Model Usage:** Which LLM model is being used and whether it runs via an API key or a model subscription (not prompts or responses)\
|
||||
**Feature Usage:** Which built-in skills are loaded\
|
||||
**Feature Usage:** Which built-in skills were used during a scan (reported once, at scan end)\
|
||||
**Aggregate Metrics:** Vulnerability counts by severity and weakness category (CWE)
|
||||
|
||||
### What We **Never** Collect
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
from . import posthog, scarf
|
||||
from ._common import set_scan_phase
|
||||
|
||||
|
||||
def report_error(error_type: str, exc: BaseException | None = None) -> None:
|
||||
"""Beacon a failure category, plus the exception class when one is given.
|
||||
|
||||
Only class names travel: never the message, arguments, or traceback.
|
||||
"""
|
||||
posthog.error(error_type, exc)
|
||||
scarf.error(error_type, exc)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"posthog",
|
||||
"report_error",
|
||||
"scarf",
|
||||
"set_scan_phase",
|
||||
]
|
||||
|
||||
@@ -5,7 +5,7 @@ import platform
|
||||
import sys
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
@@ -54,3 +54,43 @@ def base_props() -> dict[str, Any]:
|
||||
"python": f"{sys.version_info.major}.{sys.version_info.minor}",
|
||||
"strix_version": get_version(),
|
||||
}
|
||||
|
||||
|
||||
# Coarse stage of the current run, attached to ``error`` beacons so a failure
|
||||
# can be placed without a message or trace. Process-local, like the rest of the
|
||||
# CLI telemetry: one process runs one scan.
|
||||
_scan_phase = "startup"
|
||||
|
||||
|
||||
def set_scan_phase(phase: str) -> None:
|
||||
global _scan_phase # noqa: PLW0603
|
||||
_scan_phase = phase
|
||||
|
||||
|
||||
def get_scan_phase() -> str:
|
||||
return _scan_phase
|
||||
|
||||
|
||||
def _exception_name(exc: BaseException) -> str:
|
||||
cls = type(exc)
|
||||
package = cls.__module__.split(".")[0]
|
||||
return cls.__name__ if package == "builtins" else f"{package}.{cls.__name__}"
|
||||
|
||||
|
||||
def _unwrap_group(exc: BaseException) -> BaseException:
|
||||
if not isinstance(exc, BaseExceptionGroup):
|
||||
return exc
|
||||
group = cast("BaseExceptionGroup[BaseException]", exc)
|
||||
return group.exceptions[0] if group.exceptions else group
|
||||
|
||||
|
||||
def exception_props(exc: BaseException) -> dict[str, str]:
|
||||
"""Class names only. Messages, arguments, and tracebacks never leave the machine."""
|
||||
exc = _unwrap_group(exc)
|
||||
props = {"exception_type": _exception_name(exc)}
|
||||
cause = exc.__cause__
|
||||
if cause is None and not exc.__suppress_context__:
|
||||
cause = exc.__context__
|
||||
if cause is not None:
|
||||
props["exception_cause"] = _exception_name(cause)
|
||||
return props
|
||||
|
||||
@@ -4,10 +4,14 @@ from typing import TYPE_CHECKING, Any
|
||||
import requests
|
||||
|
||||
from strix.config import load_settings
|
||||
from strix.skills import get_loaded_skill_names
|
||||
from strix.telemetry._common import (
|
||||
SEND_TIMEOUT,
|
||||
SESSION_ID,
|
||||
base_props,
|
||||
exception_props,
|
||||
get_scan_phase,
|
||||
get_version,
|
||||
is_first_run,
|
||||
)
|
||||
|
||||
@@ -35,7 +39,12 @@ def _send(event: str, properties: dict[str, Any]) -> bool:
|
||||
"api_key": _POSTHOG_PUBLIC_API_KEY,
|
||||
"event": event,
|
||||
"distinct_id": SESSION_ID,
|
||||
"properties": properties,
|
||||
"properties": {
|
||||
**properties,
|
||||
"$lib": "strix-cli",
|
||||
"$lib_version": get_version(),
|
||||
"$process_person_profile": False,
|
||||
},
|
||||
}
|
||||
with requests.post(f"{_POSTHOG_HOST}/capture/", json=payload, timeout=SEND_TIMEOUT):
|
||||
pass
|
||||
@@ -82,16 +91,6 @@ def finding(severity: str, cwe: str | None = None, is_cve: bool = False) -> None
|
||||
)
|
||||
|
||||
|
||||
def skill_loaded(skill_name: str) -> None:
|
||||
_send(
|
||||
"skill_loaded",
|
||||
{
|
||||
**base_props(),
|
||||
"skill": skill_name,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def end(report_state: "ReportState", exit_reason: str = "completed") -> None:
|
||||
if report_state.posthog_scan_ended_sent:
|
||||
return
|
||||
@@ -130,6 +129,7 @@ def end(report_state: "ReportState", exit_reason: str = "completed") -> None:
|
||||
"vulnerabilities_total": len(report_state.vulnerability_reports),
|
||||
**{f"vulnerabilities_{k}": v for k, v in vulnerabilities_counts.items()},
|
||||
**llm_props,
|
||||
"skills": get_loaded_skill_names(),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -180,6 +180,12 @@ def viewer_agent_steered() -> None:
|
||||
_send("viewer_agent_steered", {**base_props()})
|
||||
|
||||
|
||||
def error(error_type: str) -> None:
|
||||
props = {**base_props(), "error_type": error_type}
|
||||
def error(error_type: str, exc: BaseException | None = None) -> None:
|
||||
props: dict[str, Any] = {
|
||||
**base_props(),
|
||||
"error_type": error_type,
|
||||
"phase": get_scan_phase(),
|
||||
}
|
||||
if exc is not None:
|
||||
props.update(exception_props(exc))
|
||||
_send("error", props)
|
||||
|
||||
@@ -7,10 +7,13 @@ from typing import TYPE_CHECKING, Any
|
||||
import requests
|
||||
|
||||
from strix.config import load_settings
|
||||
from strix.skills import get_loaded_skill_names
|
||||
from strix.telemetry._common import (
|
||||
SEND_TIMEOUT,
|
||||
SESSION_ID,
|
||||
base_props,
|
||||
exception_props,
|
||||
get_scan_phase,
|
||||
get_version,
|
||||
is_first_run,
|
||||
)
|
||||
@@ -90,17 +93,6 @@ def finding(severity: str, cwe: str | None = None, is_cve: bool = False) -> None
|
||||
)
|
||||
|
||||
|
||||
def skill_loaded(skill_name: str) -> None:
|
||||
_send(
|
||||
"skill_loaded",
|
||||
{
|
||||
**base_props(),
|
||||
"session": SESSION_ID,
|
||||
"skill": skill_name,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def end(report_state: ReportState, exit_reason: str = "completed") -> None:
|
||||
if report_state.scarf_scan_ended_sent:
|
||||
return
|
||||
@@ -140,14 +132,18 @@ def end(report_state: ReportState, exit_reason: str = "completed") -> None:
|
||||
"vulnerabilities_total": len(report_state.vulnerability_reports),
|
||||
**{f"vulnerabilities_{k}": v for k, v in vulnerabilities_counts.items()},
|
||||
**llm_props,
|
||||
"skills": ",".join(get_loaded_skill_names()),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def error(error_type: str) -> None:
|
||||
def error(error_type: str, exc: BaseException | None = None) -> None:
|
||||
props: dict[str, Any] = {
|
||||
**base_props(),
|
||||
"session": SESSION_ID,
|
||||
"error_type": error_type,
|
||||
"phase": get_scan_phase(),
|
||||
}
|
||||
if exc is not None:
|
||||
props.update(exception_props(exc))
|
||||
_send("error", props)
|
||||
|
||||
@@ -15,6 +15,7 @@ from agents import RunContextWrapper, function_tool
|
||||
from strix.core.agents import Status, coordinator_from_context
|
||||
from strix.core.execution import notify_parent_on_terminal
|
||||
from strix.core.hooks import LLM_TURN_KEY
|
||||
from strix.report.state import get_global_report_state
|
||||
from strix.skills import validate_requested_skills
|
||||
|
||||
|
||||
@@ -28,6 +29,40 @@ def _ctx(ctx: RunContextWrapper) -> dict[str, Any]:
|
||||
return ctx.context if isinstance(ctx.context, dict) else {}
|
||||
|
||||
|
||||
def _filed_reports_by(agent_id: str) -> list[dict[str, Any]]:
|
||||
"""Vulnerability reports the agent actually filed, from report state.
|
||||
|
||||
The narrative ``findings`` an agent hands to ``agent_finish`` is prose; a
|
||||
parent that wants to act on a child's work needs the report ids. Read them
|
||||
from the report state rather than trusting the child's description.
|
||||
"""
|
||||
state = get_global_report_state()
|
||||
if state is None:
|
||||
return []
|
||||
filed: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for report in state.get_existing_vulnerabilities():
|
||||
if report.get("agent_id") != agent_id:
|
||||
continue
|
||||
report_id = str(report.get("id") or "")
|
||||
if not report_id or report_id in seen:
|
||||
continue
|
||||
seen.add(report_id)
|
||||
filed.append(report)
|
||||
return filed
|
||||
|
||||
|
||||
def _render_filed_report(report: dict[str, Any]) -> str:
|
||||
line = f"- {report.get('id')}"
|
||||
severity = report.get("severity")
|
||||
if severity:
|
||||
line += f" [{str(severity).upper()}]"
|
||||
title = report.get("title")
|
||||
if title:
|
||||
line += f" {title}"
|
||||
return line
|
||||
|
||||
|
||||
def _render_completion_report(
|
||||
*,
|
||||
agent_name: str,
|
||||
@@ -38,6 +73,7 @@ def _render_completion_report(
|
||||
findings: list[str],
|
||||
recommendations: list[str],
|
||||
open_items: list[str],
|
||||
filed_reports: list[dict[str, Any]] | None = None,
|
||||
) -> str:
|
||||
"""Render a child's completion report as plain structured text.
|
||||
|
||||
@@ -63,6 +99,12 @@ def _render_completion_report(
|
||||
lines.append("Findings:")
|
||||
lines.extend(f"- {f}" for f in findings)
|
||||
lines.append("")
|
||||
lines.append("Vulnerability reports filed by this agent (authoritative; use these ids):")
|
||||
if filed_reports:
|
||||
lines.extend(_render_filed_report(r) for r in filed_reports)
|
||||
else:
|
||||
lines.append("- (none)")
|
||||
lines.append("")
|
||||
lines.append("Open items (unresolved, need follow-up):")
|
||||
if open_items:
|
||||
lines.extend(f"- {o}" for o in open_items)
|
||||
@@ -149,8 +191,11 @@ async def send_message_to_agent(
|
||||
**Don't** use for routine "hello/status" pings, for context the
|
||||
target already has (children inherit parent history), or when
|
||||
parent/child completion via ``agent_finish`` already covers the
|
||||
flow. Messages to any registered agent wake it, regardless of
|
||||
flow. In interactive runs a message wakes the target regardless of
|
||||
status, so a follow-up can restart a completed/stopped/failed agent.
|
||||
In non-interactive runs a finished agent is gone for good: the call
|
||||
fails with the target's status, and you should read its filed
|
||||
reports (``list_reports``) or spawn a new agent instead of waiting.
|
||||
|
||||
Args:
|
||||
target_agent_id: Recipient's 8-char id.
|
||||
@@ -195,10 +240,23 @@ async def send_message_to_agent(
|
||||
},
|
||||
)
|
||||
if not delivered:
|
||||
_, status = await coordinator.reachability(target_agent_id)
|
||||
if status is None:
|
||||
error = f"Target agent '{target_agent_id}' not found"
|
||||
else:
|
||||
error = (
|
||||
f"Target agent '{target_agent_id}' is '{status}' and cannot be woken in "
|
||||
"this run; it will never read this message. Its filed reports are in "
|
||||
"list_reports / get_report. Do not wait_for_agents on it - spawn a new "
|
||||
"agent if more work is needed."
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Target agent '{target_agent_id}' not found or message delivery failed",
|
||||
"error": error,
|
||||
"target_agent_id": target_agent_id,
|
||||
"target_status": status,
|
||||
"delivery_status": "not_delivered",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
@@ -364,6 +422,31 @@ async def wait_for_agents( # noqa: PLR0911
|
||||
default=str,
|
||||
)
|
||||
|
||||
# Non-interactive agents cannot be woken once terminal, so with nobody
|
||||
# running or waiting there is no message left to wait for.
|
||||
if not await coordinator.active_agents_except(me):
|
||||
_, statuses, names, _ = await coordinator.graph_snapshot()
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"wait_outcome": "no_active_agents",
|
||||
"reason": reason,
|
||||
"agents": [
|
||||
{"agent_id": aid, "name": names.get(aid, aid), "status": status}
|
||||
for aid, status in statuses.items()
|
||||
if aid != me
|
||||
],
|
||||
"note": (
|
||||
"No other agent is running or waiting, so no message can arrive. "
|
||||
"Finished agents' results are in list_reports / get_report and their "
|
||||
"completion reports are already in your history. Continue your own "
|
||||
"work, spawn a new agent, or finish."
|
||||
),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
await coordinator.park_waiting(me, wait_kind="agents")
|
||||
try:
|
||||
await asyncio.wait_for(coordinator.wait_for_message(me), timeout_seconds)
|
||||
@@ -610,6 +693,9 @@ async def agent_finish(
|
||||
default=str,
|
||||
)
|
||||
|
||||
filed_reports = _filed_reports_by(me)
|
||||
filed_report_ids = [str(r.get("id")) for r in filed_reports]
|
||||
|
||||
parent_notified = False
|
||||
if report_to_parent and await coordinator.claim_parent_notice(me):
|
||||
async with coordinator._lock:
|
||||
@@ -623,6 +709,7 @@ async def agent_finish(
|
||||
findings=list(findings or []),
|
||||
recommendations=list(final_recommendations or []),
|
||||
open_items=list(open_items or []),
|
||||
filed_reports=filed_reports,
|
||||
)
|
||||
await coordinator.send(
|
||||
parent_id,
|
||||
@@ -632,6 +719,7 @@ async def agent_finish(
|
||||
"content": report,
|
||||
"type": "completion",
|
||||
"priority": "high",
|
||||
"filed_report_ids": filed_report_ids,
|
||||
},
|
||||
)
|
||||
parent_notified = True
|
||||
@@ -642,10 +730,11 @@ async def agent_finish(
|
||||
await notify_parent_on_terminal(coordinator, me, "completed")
|
||||
|
||||
logger.info(
|
||||
"agent_finish: %s success=%s findings=%d parent_notified=%s",
|
||||
"agent_finish: %s success=%s findings=%d filed_reports=%d parent_notified=%s",
|
||||
me,
|
||||
success,
|
||||
len(findings or []),
|
||||
len(filed_report_ids),
|
||||
parent_notified,
|
||||
)
|
||||
|
||||
@@ -656,6 +745,7 @@ async def agent_finish(
|
||||
"parent_notified": parent_notified,
|
||||
"agent_id": me,
|
||||
"summary": result_summary,
|
||||
"filed_report_ids": filed_report_ids,
|
||||
"findings_count": len(findings or []),
|
||||
"open_items_count": len(open_items or []),
|
||||
"has_recommendations": bool(final_recommendations),
|
||||
|
||||
@@ -50,13 +50,6 @@ def _unknown_connection(connection: str, registry: McpRegistry) -> str:
|
||||
return f"Unknown MCP connection {connection!r}. Available connections: {available}."
|
||||
|
||||
|
||||
def _unavailable_connection(connection: str) -> str:
|
||||
return (
|
||||
f"MCP connection {connection!r} is unavailable: its live session failed and "
|
||||
"could not be reconnected, so it is unavailable for the rest of this run."
|
||||
)
|
||||
|
||||
|
||||
def _format_tool(tool: MCPTool) -> str:
|
||||
schema = json.dumps(tool.inputSchema or {"type": "object"}, indent=2, ensure_ascii=False)
|
||||
description = (tool.description or "").strip() or "(no description)"
|
||||
@@ -114,8 +107,8 @@ async def describe_mcp(ctx: RunContextWrapper, connection: str) -> str:
|
||||
return _unknown_connection(connection, registry)
|
||||
try:
|
||||
tools = await entry.session.list_tools()
|
||||
except McpConnectionUnavailableError:
|
||||
return _unavailable_connection(connection)
|
||||
except McpConnectionUnavailableError as exc:
|
||||
return str(exc)
|
||||
if not tools:
|
||||
return f"MCP connection {connection!r} offers no tools."
|
||||
header = f"MCP connection {connection!r} offers {len(tools)} tool(s):"
|
||||
@@ -170,8 +163,8 @@ async def call_mcp(
|
||||
return invalid_arguments
|
||||
try:
|
||||
available = await entry.session.list_tools()
|
||||
except McpConnectionUnavailableError:
|
||||
return _errored_tool_output(_unavailable_connection(connection))
|
||||
except McpConnectionUnavailableError as exc:
|
||||
return _errored_tool_output(str(exc))
|
||||
valid_names = {mcp_tool.name for mcp_tool in available}
|
||||
if tool not in valid_names:
|
||||
offered = ", ".join(sorted(valid_names)) or "(none)"
|
||||
|
||||
@@ -109,10 +109,12 @@ def _call_semaphore(name: str, limit: int) -> asyncio.Semaphore:
|
||||
|
||||
|
||||
class McpConnectionUnavailableError(RuntimeError):
|
||||
"""A dead MCP connection could not be reached and did not come back.
|
||||
"""The MCP connection cannot take requests right now.
|
||||
|
||||
Raised by :meth:`SupervisedMcpSession.list_tools` when the connection is dead
|
||||
so the read-only dispatch tools (``describe_mcp``) can report it cleanly.
|
||||
or in a quarantine cooldown. Its message is the session's own status text, so
|
||||
the dispatch tools (``describe_mcp``, ``call_mcp``) can pass it to the agent
|
||||
as-is: a cooldown reads as temporary, a dead connection as final.
|
||||
:meth:`SupervisedMcpSession.dispatch` does not raise it: a call to a dead
|
||||
connection returns the standard failed-tool output instead.
|
||||
"""
|
||||
@@ -607,12 +609,7 @@ class SupervisedMcpSession:
|
||||
return _Outcome(call_failure=failure)
|
||||
self._mark_dead(failure, attempt=attempt)
|
||||
return _Outcome(dead=True)
|
||||
if (
|
||||
phase == "call"
|
||||
and failure.kind == "protocol"
|
||||
and failure.status is not None
|
||||
and 400 <= failure.status <= 499
|
||||
):
|
||||
if phase == "call" and failure.kind == "protocol":
|
||||
return _Outcome(call_failure=failure)
|
||||
if attempt == _MAX_ATTEMPTS:
|
||||
await self._quarantine(failure, attempt=attempt)
|
||||
@@ -729,6 +726,13 @@ class SupervisedMcpSession:
|
||||
"that resource, then retry."
|
||||
)
|
||||
if failure.kind == "protocol":
|
||||
if failure.status is None:
|
||||
return (
|
||||
f"MCP connection {self._name!r} rejected this call: the provider "
|
||||
"returned an error for this request, not the connection. The connection "
|
||||
"is still available. The resource may not exist or the arguments may be "
|
||||
"wrong. Check them with describe_mcp, then retry or move on."
|
||||
)
|
||||
return (
|
||||
f"MCP connection {self._name!r} rejected this call as invalid "
|
||||
f"(status={failure.status}): the request itself was malformed, not the "
|
||||
|
||||
@@ -12,13 +12,17 @@ import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from agents import RunContextWrapper, function_tool
|
||||
|
||||
from strix.tools.nullish import clean_optional
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from strix.report.state import ReportState
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -257,6 +261,329 @@ def _validate_fix_verification(
|
||||
]
|
||||
|
||||
|
||||
def _finding_class_of(report: dict[str, Any]) -> str:
|
||||
"""Resolve the class of a stored finding.
|
||||
|
||||
A finding filed before ``finding_class`` was persisted still carries the
|
||||
metadata of its class. A record with dependency metadata is a dependency
|
||||
finding even when the field is absent, so read the metadata before falling
|
||||
back to dynamic.
|
||||
"""
|
||||
declared = str(report.get("finding_class") or "").lower()
|
||||
if declared:
|
||||
return declared
|
||||
if report.get("dependency_metadata"):
|
||||
return "dependency_cve"
|
||||
return "dynamic"
|
||||
|
||||
|
||||
_UPDATE_TEXT_FIELDS = (
|
||||
"title",
|
||||
"description",
|
||||
"impact",
|
||||
"target",
|
||||
"technical_analysis",
|
||||
"poc_description",
|
||||
"poc_script_code",
|
||||
"remediation_steps",
|
||||
"evidence",
|
||||
"assumptions",
|
||||
"counterevidence",
|
||||
"confidence_rationale",
|
||||
"severity_change_conditions",
|
||||
"endpoint",
|
||||
"method",
|
||||
"fix_verification",
|
||||
"fix_pr_body",
|
||||
"contextual_cvss_reasoning",
|
||||
)
|
||||
|
||||
|
||||
def _collect_update_changes( # noqa: PLR0912
|
||||
fields: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], list[str]]:
|
||||
"""Validate the fields a revision replaces and return them with any errors."""
|
||||
errors: list[str] = []
|
||||
changes: dict[str, Any] = {}
|
||||
|
||||
for name in _UPDATE_TEXT_FIELDS:
|
||||
value = clean_optional(fields.get(name))
|
||||
if value is not None:
|
||||
changes[name] = value
|
||||
|
||||
confidence = clean_optional(fields.get("confidence"))
|
||||
if confidence is not None:
|
||||
confidence = confidence.lower()
|
||||
if confidence not in _VALID_CONFIDENCE:
|
||||
errors.append(
|
||||
f"Invalid confidence: {confidence!r}. Must be one of: {sorted(_VALID_CONFIDENCE)}"
|
||||
)
|
||||
else:
|
||||
changes["confidence"] = confidence
|
||||
|
||||
fix_effort = clean_optional(fields.get("fix_effort"))
|
||||
if fix_effort is not None:
|
||||
fix_effort = fix_effort.lower()
|
||||
if fix_effort not in _VALID_FIX_EFFORT:
|
||||
errors.append(
|
||||
f"Invalid fix_effort: {fix_effort!r}. Must be one of: {sorted(_VALID_FIX_EFFORT)}"
|
||||
)
|
||||
else:
|
||||
changes["fix_effort"] = fix_effort
|
||||
|
||||
breakdown = fields.get("cvss_breakdown")
|
||||
if breakdown is not None:
|
||||
breakdown_errors = _validate_cvss_breakdown(breakdown)
|
||||
errors.extend(breakdown_errors)
|
||||
if not breakdown_errors:
|
||||
try:
|
||||
cvss_score, severity, _vector = _calculate_cvss(breakdown)
|
||||
except ValueError as exc:
|
||||
errors.append(str(exc))
|
||||
else:
|
||||
# The rating belongs to the vector, so a revised vector carries
|
||||
# its own score and severity rather than leaving the old ones.
|
||||
changes["cvss_breakdown"] = breakdown
|
||||
changes["cvss"] = cvss_score
|
||||
changes["severity"] = severity
|
||||
|
||||
raw_locations = fields.get("code_locations")
|
||||
locations = _normalize_code_locations(raw_locations)
|
||||
if locations:
|
||||
errors.extend(_validate_code_locations(locations))
|
||||
errors.extend(_validate_fix_verification(locations, changes.get("fix_verification")))
|
||||
changes["code_locations"] = locations
|
||||
elif raw_locations:
|
||||
errors.append(
|
||||
"code_locations were dropped as unusable - every location needs a relative "
|
||||
"'file' and an integer 'start_line'"
|
||||
)
|
||||
|
||||
cve, cwe, identifier_errors = _validate_identifiers(
|
||||
clean_optional(fields.get("cve")), clean_optional(fields.get("cwe"))
|
||||
)
|
||||
errors.extend(identifier_errors)
|
||||
if cve:
|
||||
changes["cve"] = cve
|
||||
if cwe:
|
||||
changes["cwe"] = cwe
|
||||
|
||||
return changes, errors
|
||||
|
||||
|
||||
# Evidence that only a dynamic finding carries. A dependency finding describes a
|
||||
# package, not a request against an endpoint.
|
||||
_DYNAMIC_ONLY_UPDATE_FIELDS = (
|
||||
"endpoint",
|
||||
"method",
|
||||
"poc_description",
|
||||
"poc_script_code",
|
||||
)
|
||||
|
||||
# A dependency finding is rated in the context of the codebase that pins it, and
|
||||
# that rating is only shown with the reasoning behind it.
|
||||
_DEPENDENCY_ONLY_UPDATE_FIELDS = ("contextual_cvss_reasoning",)
|
||||
|
||||
|
||||
def _reject_cross_class_revision(
|
||||
report_id: str,
|
||||
matched_class: str,
|
||||
offending: list[str],
|
||||
) -> dict[str, Any]:
|
||||
logger.info(
|
||||
"Revision of %s carries fields (%s) a %s finding does not hold; rejecting",
|
||||
report_id,
|
||||
", ".join(offending),
|
||||
matched_class,
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
f"Report '{report_id}' is a {matched_class} finding, so it cannot carry "
|
||||
f"{', '.join(offending)}. File your proof as its own vulnerability report "
|
||||
"instead of writing it onto this one."
|
||||
),
|
||||
"report_id": report_id,
|
||||
"finding_class": matched_class,
|
||||
"rejected_fields": offending,
|
||||
}
|
||||
|
||||
|
||||
def _rate_dependency_revision(
|
||||
report_id: str,
|
||||
matched: dict[str, Any],
|
||||
changes: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Turn a replacement ``cvss_breakdown`` into the contextual rating of a dependency.
|
||||
|
||||
A dependency record keeps its rating as ``cvss``/``severity`` plus the
|
||||
contextual breakdown, vector and reasoning inside ``dependency_metadata``.
|
||||
The package identity in that metadata is copied over untouched. A new
|
||||
breakdown needs its own reasoning. The reasoning alone can be corrected
|
||||
when the record already carries the breakdown it explains.
|
||||
"""
|
||||
breakdown = changes.pop("cvss_breakdown", None)
|
||||
reasoning = changes.pop("contextual_cvss_reasoning", None)
|
||||
if breakdown is None and reasoning is None:
|
||||
return None
|
||||
|
||||
metadata = dict(matched.get("dependency_metadata") or {})
|
||||
if breakdown is None and not metadata.get("contextual_cvss_breakdown"):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Validation failed",
|
||||
"errors": [
|
||||
"cvss_breakdown is required: this dependency finding carries no "
|
||||
"contextual rating yet, so contextual_cvss_reasoning has nothing to explain"
|
||||
],
|
||||
"report_id": report_id,
|
||||
}
|
||||
if reasoning is None:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Validation failed",
|
||||
"errors": [
|
||||
"contextual_cvss_reasoning is required: a dependency finding is re-rated "
|
||||
"with the cvss_breakdown observed in this codebase together with the "
|
||||
"reasoning a reader can check"
|
||||
],
|
||||
"report_id": report_id,
|
||||
}
|
||||
|
||||
if breakdown is not None:
|
||||
score, _severity, vector = _calculate_cvss(breakdown)
|
||||
metadata["contextual_cvss_breakdown"] = breakdown
|
||||
metadata["contextual_cvss_score"] = score
|
||||
metadata["contextual_cvss_vector"] = vector
|
||||
metadata["contextual_cvss_reasoning"] = reasoning[:_MAX_CONTEXTUAL_REASONING_CHARS]
|
||||
changes["dependency_metadata"] = metadata
|
||||
return None
|
||||
|
||||
|
||||
def _fit_revision_to_class(
|
||||
report_state: ReportState,
|
||||
report_id: str,
|
||||
changes: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Keep a revision inside the class of the finding it names.
|
||||
|
||||
A finding keeps its class and the metadata that belongs to it. Writing an
|
||||
exploit onto a dependency record would leave it carrying a package pin next
|
||||
to a request against an endpoint, so the proof belongs in its own dynamic
|
||||
finding instead. A dependency finding is still re-rated, through the
|
||||
contextual CVSS it was filed with.
|
||||
"""
|
||||
matched = next(
|
||||
(r for r in report_state.get_existing_vulnerabilities() if r.get("id") == report_id),
|
||||
None,
|
||||
)
|
||||
if matched is None:
|
||||
return None
|
||||
|
||||
matched_class = _finding_class_of(matched)
|
||||
foreign = (
|
||||
_DEPENDENCY_ONLY_UPDATE_FIELDS
|
||||
if matched_class == "dynamic"
|
||||
else _DYNAMIC_ONLY_UPDATE_FIELDS
|
||||
)
|
||||
offending = [name for name in foreign if name in changes]
|
||||
if offending:
|
||||
return _reject_cross_class_revision(report_id, matched_class, offending)
|
||||
if matched_class == "dynamic":
|
||||
return None
|
||||
return _rate_dependency_revision(report_id, matched, changes)
|
||||
|
||||
|
||||
def _read_revision(
|
||||
report_id: str, update_reason: str, fields: dict[str, Any]
|
||||
) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
||||
"""Return the changes a revision asks for, or the reason it cannot be acted on."""
|
||||
if not report_id or not str(update_reason or "").strip():
|
||||
missing = "report_id" if not report_id else "update_reason"
|
||||
return {}, {
|
||||
"success": False,
|
||||
"error": (
|
||||
f"{missing} cannot be empty - name the report you are revising and state "
|
||||
"what you learned that it does not yet carry"
|
||||
),
|
||||
}
|
||||
|
||||
changes, errors = _collect_update_changes(fields)
|
||||
if errors:
|
||||
return {}, {"success": False, "error": "Validation failed", "errors": errors}
|
||||
if not changes:
|
||||
return {}, {
|
||||
"success": False,
|
||||
"error": "No fields to update - pass at least one field you want to replace",
|
||||
}
|
||||
return changes, None
|
||||
|
||||
|
||||
def _do_update(
|
||||
*,
|
||||
report_id: str,
|
||||
update_reason: str,
|
||||
fields: dict[str, Any],
|
||||
agent_id: str | None = None,
|
||||
agent_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply an agent's own revision to a report it can name.
|
||||
|
||||
Editing a finding is its own operation and the only way a filed finding
|
||||
changes. Deduplication never reaches this path: it only decides whether a
|
||||
new candidate is a finding already on file.
|
||||
"""
|
||||
report_id = (report_id or "").strip()
|
||||
changes, rejection = _read_revision(report_id, update_reason, fields)
|
||||
if rejection is not None:
|
||||
return rejection
|
||||
|
||||
from strix.report.state import get_global_report_state
|
||||
|
||||
report_state = get_global_report_state()
|
||||
if report_state is None:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Report state unavailable - no reports have been filed yet",
|
||||
}
|
||||
|
||||
class_error = _fit_revision_to_class(report_state, report_id, changes)
|
||||
if class_error is not None:
|
||||
return class_error
|
||||
|
||||
updated = report_state.update_vulnerability_report(
|
||||
report_id,
|
||||
changes,
|
||||
update_reason=update_reason,
|
||||
updated_by_agent_id=agent_id,
|
||||
updated_by_agent_name=agent_name,
|
||||
)
|
||||
if updated is None:
|
||||
known = [r.get("id") for r in report_state.get_existing_vulnerabilities()]
|
||||
if report_id not in known:
|
||||
error = f"Report with id '{report_id}' not found"
|
||||
else:
|
||||
error = f"Report '{report_id}' already says this - nothing in your update changes it"
|
||||
return {"success": False, "error": error, "report_id": report_id}
|
||||
|
||||
logger.info(
|
||||
"Vulnerability report %s revised by its author: severity=%s cvss=%s fields=%s",
|
||||
report_id,
|
||||
updated.get("severity"),
|
||||
updated.get("cvss"),
|
||||
", ".join(sorted(changes)),
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"action": "updated",
|
||||
"message": f"Report '{report_id}' now carries your revision. Do not file it again.",
|
||||
"report_id": report_id,
|
||||
"updated_fields": sorted(changes),
|
||||
"severity": updated.get("severity"),
|
||||
"cvss_score": updated.get("cvss"),
|
||||
}
|
||||
|
||||
|
||||
async def _do_create(
|
||||
*,
|
||||
title: str,
|
||||
@@ -359,9 +686,37 @@ async def _do_create(
|
||||
"endpoint": endpoint,
|
||||
"method": method,
|
||||
}
|
||||
report_fields: dict[str, Any] = {
|
||||
"title": title,
|
||||
"description": description,
|
||||
"severity": severity,
|
||||
"impact": impact,
|
||||
"target": target,
|
||||
"technical_analysis": technical_analysis,
|
||||
"poc_description": poc_description,
|
||||
"poc_script_code": poc_script_code,
|
||||
"remediation_steps": remediation_steps,
|
||||
"evidence": evidence,
|
||||
"assumptions": assumptions,
|
||||
"counterevidence": counterevidence,
|
||||
"confidence": confidence,
|
||||
"confidence_rationale": confidence_rationale,
|
||||
"severity_change_conditions": severity_change_conditions,
|
||||
"fix_effort": fix_effort,
|
||||
"cvss": cvss_score,
|
||||
"cvss_breakdown": cvss_breakdown,
|
||||
"endpoint": endpoint,
|
||||
"method": method,
|
||||
"cve": cve,
|
||||
"cwe": cwe,
|
||||
"code_locations": parsed_locations,
|
||||
"fix_verification": fix_verification,
|
||||
"fix_pr_body": fix_pr_body,
|
||||
}
|
||||
|
||||
dedupe = await check_duplicate(candidate, existing)
|
||||
if dedupe.get("is_duplicate"):
|
||||
duplicate_id = dedupe.get("duplicate_id", "")
|
||||
duplicate_id = str(dedupe.get("duplicate_id") or "")
|
||||
duplicate_title = next(
|
||||
(r.get("title", "Unknown") for r in existing if r.get("id") == duplicate_id),
|
||||
"",
|
||||
@@ -379,31 +734,7 @@ async def _do_create(
|
||||
}
|
||||
|
||||
report_id = report_state.add_vulnerability_report(
|
||||
title=title,
|
||||
description=description,
|
||||
severity=severity,
|
||||
impact=impact,
|
||||
target=target,
|
||||
technical_analysis=technical_analysis,
|
||||
poc_description=poc_description,
|
||||
poc_script_code=poc_script_code,
|
||||
remediation_steps=remediation_steps,
|
||||
evidence=evidence,
|
||||
assumptions=assumptions,
|
||||
counterevidence=counterevidence,
|
||||
confidence=confidence,
|
||||
confidence_rationale=confidence_rationale,
|
||||
severity_change_conditions=severity_change_conditions,
|
||||
fix_effort=fix_effort,
|
||||
cvss=cvss_score,
|
||||
cvss_breakdown=cvss_breakdown,
|
||||
endpoint=endpoint,
|
||||
method=method,
|
||||
cve=cve,
|
||||
cwe=cwe,
|
||||
code_locations=parsed_locations,
|
||||
fix_verification=fix_verification,
|
||||
fix_pr_body=fix_pr_body,
|
||||
**report_fields,
|
||||
agent_id=agent_id if isinstance(agent_id, str) else None,
|
||||
agent_name=agent_name if isinstance(agent_name, str) else None,
|
||||
)
|
||||
@@ -512,7 +843,9 @@ async def create_vulnerability_report(
|
||||
Automatic LLM-based **deduplication** rejects reports that describe
|
||||
the same root cause on the same asset as an existing report. If you
|
||||
get a ``duplicate_of`` response, do NOT retry — move on to other
|
||||
areas.
|
||||
areas. When you have learned something a filed finding does not yet
|
||||
carry, revise that finding with ``update_vulnerability_report``
|
||||
instead of filing this report again.
|
||||
|
||||
**Counterevidence pass (required before filing)**: actively build the
|
||||
strongest case that this finding is NOT exploitable, or less severe
|
||||
@@ -886,6 +1219,147 @@ async def create_vulnerability_report(
|
||||
return json.dumps(result, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
@function_tool(timeout=60, strict_mode=False)
|
||||
async def update_vulnerability_report(
|
||||
ctx: RunContextWrapper,
|
||||
report_id: str,
|
||||
update_reason: str,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
impact: str | None = None,
|
||||
target: str | None = None,
|
||||
technical_analysis: str | None = None,
|
||||
poc_description: str | None = None,
|
||||
poc_script_code: str | None = None,
|
||||
remediation_steps: str | None = None,
|
||||
evidence: str | None = None,
|
||||
assumptions: str | None = None,
|
||||
counterevidence: str | None = None,
|
||||
confidence: str | None = None,
|
||||
confidence_rationale: str | None = None,
|
||||
severity_change_conditions: str | None = None,
|
||||
fix_effort: str | None = None,
|
||||
cvss_breakdown: dict[str, str] | None = None,
|
||||
endpoint: str | None = None,
|
||||
method: str | None = None,
|
||||
cve: str | None = None,
|
||||
cwe: str | None = None,
|
||||
code_locations: list[dict[str, Any]] | None = None,
|
||||
fix_verification: str | None = None,
|
||||
fix_pr_body: str | None = None,
|
||||
contextual_cvss_reasoning: str | None = None,
|
||||
) -> str:
|
||||
"""Revise a vulnerability report that is already filed, keeping its id.
|
||||
|
||||
Use this when you learn something a filed finding does not yet carry:
|
||||
|
||||
- You built the working exploit after filing the finding on static
|
||||
evidence, so the PoC and the confidence change.
|
||||
- You chained the finding with another one and the real impact is
|
||||
higher, so the impact narrative and the CVSS vector change.
|
||||
- Further testing narrowed or weakened the finding, so the severity
|
||||
must come down.
|
||||
- Counterevidence, remediation, or a code location was wrong or
|
||||
incomplete.
|
||||
|
||||
This is not deduplication. You do not need a duplicate verdict to
|
||||
revise your own finding, and you must not file a second report for a
|
||||
finding you can revise. Call ``list_reports`` or ``get_report`` first
|
||||
to find the id and read what the report already says.
|
||||
|
||||
Pass only the fields you want to replace. Every other field stays as
|
||||
it is. Reporting rules of ``create_vulnerability_report`` apply to
|
||||
every field you pass, including the markdown and tone rules.
|
||||
|
||||
Notes on specific fields:
|
||||
|
||||
- ``cvss_breakdown`` replaces the whole vector. The score and the
|
||||
severity are recalculated from it, so pass all 8 metrics. On a
|
||||
dependency finding it replaces the contextual rating and needs
|
||||
``contextual_cvss_reasoning`` with it. Pass the reasoning alone to
|
||||
correct only the explanation of the rating already on file.
|
||||
- A dependency finding never carries ``endpoint``, ``method`` or a PoC.
|
||||
File a proven exploit of the package as its own report.
|
||||
- A field that only explains another field is dropped when the field
|
||||
it explains changes and you pass no replacement. Pass
|
||||
``confidence_rationale`` with a new ``confidence``, and
|
||||
``severity_change_conditions`` with a new ``cvss_breakdown``.
|
||||
- ``code_locations`` replaces the whole list. A location carrying
|
||||
``fix_after`` needs ``fix_verification``.
|
||||
|
||||
The report keeps its id, its original author, and its filing time. The
|
||||
revision is recorded in the report as update history, so state the
|
||||
reason plainly.
|
||||
|
||||
Args:
|
||||
report_id: Id of the report to revise (format ``vuln-NNNN``).
|
||||
update_reason: What you learned that the report does not yet
|
||||
carry, in one or two sentences.
|
||||
title: Replacement title.
|
||||
description: Replacement overview.
|
||||
impact: Replacement impact narrative.
|
||||
target: Replacement affected asset.
|
||||
technical_analysis: Replacement technical details.
|
||||
poc_description: Replacement PoC steps (no code).
|
||||
poc_script_code: Replacement exploit script or payload.
|
||||
remediation_steps: Replacement remediation prose (no code).
|
||||
evidence: Replacement evidence.
|
||||
assumptions: Replacement exploitability prerequisites.
|
||||
counterevidence: Replacement case against the finding.
|
||||
confidence: ``high`` / ``medium`` / ``low``.
|
||||
confidence_rationale: The gap behind a confidence below ``high``.
|
||||
severity_change_conditions: What would move the severity now.
|
||||
fix_effort: ``trivial`` / ``low`` / ``medium`` / ``high``.
|
||||
cvss_breakdown: All 8 CVSS metrics. Replaces the score and the
|
||||
severity too.
|
||||
endpoint: Replacement endpoint.
|
||||
method: Replacement HTTP method.
|
||||
cve: Replacement CVE id.
|
||||
cwe: Replacement CWE id.
|
||||
code_locations: Replacement code locations.
|
||||
fix_verification: Verification statement for an applyable fix.
|
||||
fix_pr_body: Replacement fix PR body.
|
||||
contextual_cvss_reasoning: Dependency findings only. What you
|
||||
observed in this codebase that justifies the contextual
|
||||
``cvss_breakdown``.
|
||||
"""
|
||||
agent_id, agent_name = _caller_identity(ctx)
|
||||
result = await asyncio.to_thread(
|
||||
_do_update,
|
||||
report_id=report_id,
|
||||
update_reason=update_reason,
|
||||
fields={
|
||||
"title": title,
|
||||
"description": description,
|
||||
"impact": impact,
|
||||
"target": target,
|
||||
"technical_analysis": technical_analysis,
|
||||
"poc_description": poc_description,
|
||||
"poc_script_code": poc_script_code,
|
||||
"remediation_steps": remediation_steps,
|
||||
"evidence": evidence,
|
||||
"assumptions": assumptions,
|
||||
"counterevidence": counterevidence,
|
||||
"confidence": confidence,
|
||||
"confidence_rationale": confidence_rationale,
|
||||
"severity_change_conditions": severity_change_conditions,
|
||||
"fix_effort": fix_effort,
|
||||
"cvss_breakdown": cvss_breakdown,
|
||||
"endpoint": endpoint,
|
||||
"method": method,
|
||||
"cve": cve,
|
||||
"cwe": cwe,
|
||||
"code_locations": code_locations,
|
||||
"fix_verification": fix_verification,
|
||||
"fix_pr_body": fix_pr_body,
|
||||
"contextual_cvss_reasoning": contextual_cvss_reasoning,
|
||||
},
|
||||
agent_id=agent_id,
|
||||
agent_name=agent_name,
|
||||
)
|
||||
return json.dumps(result, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
_DEP_SEVERITY_FROM_CVSS = {
|
||||
(9.0, 10.0): "critical",
|
||||
(7.0, 9.0): "high",
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""``web_search`` — Perplexity-backed security-focused web search."""
|
||||
"""Security-focused web research tools (Exa or Perplexity)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import requests
|
||||
from agents import RunContextWrapper, function_tool
|
||||
@@ -13,6 +14,10 @@ from agents import RunContextWrapper, function_tool
|
||||
from strix.config import load_settings
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -41,22 +46,7 @@ Structure your response to be comprehensive yet concise, emphasizing the most cr
|
||||
security implications and details."""
|
||||
|
||||
|
||||
def _do_search(query: str) -> dict[str, Any]: # noqa: PLR0911 - each error class needs its own sanitized return
|
||||
if not query or not query.strip():
|
||||
return {"success": False, "error": "Query cannot be empty"}
|
||||
|
||||
api_key = load_settings().integrations.perplexity_api_key
|
||||
if not api_key:
|
||||
logger.warning("web_search invoked without PERPLEXITY_API_KEY configured")
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
"Web search is not configured for this scan "
|
||||
"(operator needs to set PERPLEXITY_API_KEY). Proceed without it"
|
||||
),
|
||||
}
|
||||
logger.info("web_search query (len=%d): %s", len(query), query[:120])
|
||||
|
||||
def _perplexity_content(api_key: str, query: str) -> str:
|
||||
url = "https://api.perplexity.ai/chat/completions"
|
||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||
payload = {
|
||||
@@ -66,61 +56,269 @@ def _do_search(query: str) -> dict[str, Any]: # noqa: PLR0911 - each error clas
|
||||
{"role": "user", "content": query},
|
||||
],
|
||||
}
|
||||
with requests.post(url, headers=headers, json=payload, timeout=300) as response:
|
||||
response.raise_for_status()
|
||||
return str(response.json()["choices"][0]["message"]["content"])
|
||||
|
||||
|
||||
_EXA_PAGE_MAX_CHARS = 20000
|
||||
_EXA_MAX_CONTENT_URLS = 10
|
||||
_EXA_SUMMARY_PROMPT = (
|
||||
"Summarize this page for a penetration tester. Keep concrete technical detail: "
|
||||
"affected products and exact versions, CVE and CWE identifiers, CVSS scores, "
|
||||
"exploitation preconditions, payloads or commands, and mitigations. "
|
||||
"Leave out marketing copy and navigation text."
|
||||
)
|
||||
|
||||
|
||||
def _exa_result_block(result: dict[str, Any]) -> str | None:
|
||||
result_url = str(result.get("url") or result.get("id") or "")
|
||||
if not result_url:
|
||||
return None
|
||||
title = str(result.get("title") or result_url)
|
||||
parts = [f"### {title}\n{result_url}"]
|
||||
summary = str(result.get("summary") or "").strip()
|
||||
if summary:
|
||||
parts.append(summary)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _exa_page_block(result: dict[str, Any]) -> str | None:
|
||||
result_url = str(result.get("url") or result.get("id") or "")
|
||||
text = str(result.get("text") or "").strip()
|
||||
if not result_url or not text:
|
||||
return None
|
||||
if len(text) > _EXA_PAGE_MAX_CHARS:
|
||||
text = f"{text[:_EXA_PAGE_MAX_CHARS]}\n[truncated at {_EXA_PAGE_MAX_CHARS} characters]"
|
||||
title = str(result.get("title") or result_url)
|
||||
return f"### {title}\n{result_url}\n\n{text}"
|
||||
|
||||
|
||||
def _exa_blocks(
|
||||
results: list[Any],
|
||||
render: Callable[[dict[str, Any]], str | None],
|
||||
) -> list[str]:
|
||||
blocks: list[str] = []
|
||||
for result in results:
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
block = render(cast("dict[str, Any]", result))
|
||||
if block:
|
||||
blocks.append(block)
|
||||
return blocks
|
||||
|
||||
|
||||
def _exa_post(api_key: str, endpoint: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
headers = {"x-api-key": api_key, "Content-Type": "application/json"}
|
||||
with requests.post(endpoint, headers=headers, json=payload, timeout=300) as response:
|
||||
response.raise_for_status()
|
||||
body: dict[str, Any] = response.json()
|
||||
return body
|
||||
|
||||
|
||||
def _exa_content(api_key: str, query: str, search_type: str, num_results: int) -> str:
|
||||
body = _exa_post(
|
||||
api_key,
|
||||
"https://api.exa.ai/search",
|
||||
{
|
||||
"query": f"{_SYSTEM_PROMPT}\n\n{query}",
|
||||
"type": search_type,
|
||||
"numResults": num_results,
|
||||
"contents": {"summary": {"query": _EXA_SUMMARY_PROMPT}},
|
||||
},
|
||||
)
|
||||
blocks = _exa_blocks(body.get("results") or [], _exa_result_block)
|
||||
if not blocks:
|
||||
raise ValueError("Exa response has no results")
|
||||
return "\n\n".join(blocks)
|
||||
|
||||
|
||||
def _normalize_url(url: str) -> str:
|
||||
"""Canonical form for matching: case-fold scheme and host only, drop a trailing slash."""
|
||||
parts = urlsplit(url.strip())
|
||||
return urlunsplit(
|
||||
(parts.scheme.lower(), parts.netloc.lower(), parts.path.rstrip("/"), parts.query, "")
|
||||
)
|
||||
|
||||
|
||||
def _exa_page_text(api_key: str, urls: list[str]) -> tuple[str, set[str]]:
|
||||
"""Fetch page text and report which of the requested URLs Exa returned."""
|
||||
body = _exa_post(api_key, "https://api.exa.ai/contents", {"urls": urls, "text": True})
|
||||
blocks: list[str] = []
|
||||
fetched: set[str] = set()
|
||||
results: list[Any] = body.get("results") or []
|
||||
for result in results:
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
page = cast("dict[str, Any]", result)
|
||||
block = _exa_page_block(page)
|
||||
if not block:
|
||||
continue
|
||||
blocks.append(block)
|
||||
fetched.add(_normalize_url(str(page.get("url") or page.get("id") or "")))
|
||||
if not blocks:
|
||||
raise ValueError("Exa returned no page contents")
|
||||
return "\n\n".join(blocks), fetched
|
||||
|
||||
|
||||
def _resolve_provider( # noqa: PLR0911 - each provider/missing-key case needs its own return
|
||||
integrations: Any,
|
||||
) -> tuple[str, str] | dict[str, Any]:
|
||||
"""Pick the search provider and its key, or return a sanitized error dict."""
|
||||
provider = integrations.web_search_provider
|
||||
perplexity_key = integrations.perplexity_api_key
|
||||
exa_key = integrations.exa_api_key
|
||||
|
||||
if provider == "perplexity":
|
||||
if not perplexity_key:
|
||||
return _not_configured_error("PERPLEXITY_API_KEY")
|
||||
return ("perplexity", perplexity_key)
|
||||
if provider == "exa":
|
||||
if not exa_key:
|
||||
return _not_configured_error("EXA_API_KEY")
|
||||
return ("exa", exa_key)
|
||||
|
||||
if exa_key:
|
||||
return ("exa", exa_key)
|
||||
if perplexity_key:
|
||||
return ("perplexity", perplexity_key)
|
||||
return _not_configured_error("EXA_API_KEY or PERPLEXITY_API_KEY")
|
||||
|
||||
|
||||
def _not_configured_error(missing: str) -> dict[str, Any]:
|
||||
logger.warning("web_search invoked without %s configured", missing)
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
"Web search is not configured for this scan "
|
||||
f"(operator needs to set {missing}). Proceed without it"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _guarded_call[T]( # noqa: PLR0911 - each error class needs its own sanitized return
|
||||
tool: str,
|
||||
rejected_hint: str,
|
||||
fetch: Callable[[], T],
|
||||
) -> T | dict[str, Any]:
|
||||
"""Run a provider call and translate any failure into a sanitized error dict."""
|
||||
try:
|
||||
with requests.post(url, headers=headers, json=payload, timeout=300) as response:
|
||||
response.raise_for_status()
|
||||
content = response.json()["choices"][0]["message"]["content"]
|
||||
return fetch()
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning("web_search timed out")
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Web search timed out. Try again or shorten the query",
|
||||
}
|
||||
logger.warning("%s timed out", tool)
|
||||
return {"success": False, "error": f"{tool} timed out. Try again or narrow the request"}
|
||||
except requests.exceptions.HTTPError as exc:
|
||||
status = exc.response.status_code if exc.response is not None else None
|
||||
logger.exception("web_search HTTP error status=%s", status)
|
||||
logger.exception("%s HTTP error status=%s", tool, status)
|
||||
if status is not None and 400 <= status < 500:
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
"Web search rejected the query. Refine it "
|
||||
"(more specific, shorter, no unusual characters) and retry"
|
||||
),
|
||||
}
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Web search service is unavailable. Try again later",
|
||||
}
|
||||
return {"success": False, "error": rejected_hint}
|
||||
return {"success": False, "error": f"{tool} service is unavailable. Try again later"}
|
||||
except requests.exceptions.RequestException:
|
||||
logger.exception("web_search network error")
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Web search network error. Try again later",
|
||||
}
|
||||
logger.exception("%s network error", tool)
|
||||
return {"success": False, "error": f"{tool} network error. Try again later"}
|
||||
except (KeyError, IndexError, ValueError):
|
||||
logger.exception("web_search response shape unexpected")
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Web search returned an unexpected response. Try again",
|
||||
}
|
||||
logger.exception("%s response shape unexpected", tool)
|
||||
return {"success": False, "error": f"{tool} returned an unexpected response. Try again"}
|
||||
except Exception:
|
||||
logger.exception("web_search failed")
|
||||
logger.exception("%s failed", tool)
|
||||
return {"success": False, "error": f"{tool} failed unexpectedly"}
|
||||
|
||||
|
||||
def _do_search(query: str) -> dict[str, Any]:
|
||||
if not query or not query.strip():
|
||||
return {"success": False, "error": "Query cannot be empty"}
|
||||
|
||||
integrations = load_settings().integrations
|
||||
resolved = _resolve_provider(integrations)
|
||||
if isinstance(resolved, dict):
|
||||
return resolved
|
||||
provider, api_key = resolved
|
||||
logger.info("web_search provider=%s query (len=%d): %s", provider, len(query), query[:120])
|
||||
|
||||
def fetch() -> str:
|
||||
if provider == "exa":
|
||||
return _exa_content(
|
||||
api_key,
|
||||
query,
|
||||
integrations.exa_search_type,
|
||||
integrations.exa_num_results,
|
||||
)
|
||||
return _perplexity_content(api_key, query)
|
||||
|
||||
outcome = _guarded_call(
|
||||
"Web search",
|
||||
(
|
||||
"Web search rejected the query. Refine it "
|
||||
"(more specific, shorter, no unusual characters) and retry"
|
||||
),
|
||||
fetch,
|
||||
)
|
||||
if isinstance(outcome, dict):
|
||||
return outcome
|
||||
return {
|
||||
"success": True,
|
||||
"query": query,
|
||||
"provider": provider,
|
||||
"content": outcome,
|
||||
}
|
||||
|
||||
|
||||
def _do_get_contents(urls: list[str]) -> dict[str, Any]:
|
||||
cleaned = [url.strip() for url in urls if url and url.strip()]
|
||||
if not cleaned:
|
||||
return {"success": False, "error": "Provide at least one URL"}
|
||||
if len(cleaned) > _EXA_MAX_CONTENT_URLS:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Web search failed unexpectedly",
|
||||
"error": f"Too many URLs. Pass at most {_EXA_MAX_CONTENT_URLS} per call",
|
||||
}
|
||||
else:
|
||||
|
||||
integrations = load_settings().integrations
|
||||
api_key = integrations.exa_api_key
|
||||
if not api_key:
|
||||
return _not_configured_error("EXA_API_KEY")
|
||||
if integrations.web_search_provider == "perplexity":
|
||||
logger.warning("web_get_contents invoked while the provider is pinned to Perplexity")
|
||||
return {
|
||||
"success": True,
|
||||
"query": query,
|
||||
"content": content,
|
||||
"success": False,
|
||||
"error": (
|
||||
"Page fetching needs the Exa provider "
|
||||
"(operator pinned STRIX_WEB_SEARCH_PROVIDER to perplexity). "
|
||||
"Use web_search instead"
|
||||
),
|
||||
}
|
||||
|
||||
logger.info("web_get_contents urls=%d", len(cleaned))
|
||||
outcome = _guarded_call(
|
||||
"Page fetch",
|
||||
"Page fetch was rejected. Check the URLs are complete, public, and correctly formed",
|
||||
lambda: _exa_page_text(api_key, cleaned),
|
||||
)
|
||||
if isinstance(outcome, dict):
|
||||
return outcome
|
||||
content, fetched = outcome
|
||||
missing = [url for url in cleaned if _normalize_url(url) not in fetched]
|
||||
result: dict[str, Any] = {
|
||||
"success": True,
|
||||
"urls": [url for url in cleaned if url not in missing],
|
||||
"provider": "exa",
|
||||
"content": content,
|
||||
}
|
||||
if missing:
|
||||
logger.warning(
|
||||
"web_get_contents returned %d of %d pages", len(cleaned) - len(missing), len(cleaned)
|
||||
)
|
||||
result["failed_urls"] = missing
|
||||
result["warning"] = (
|
||||
f"Exa returned no content for {len(missing)} of {len(cleaned)} requested URLs. "
|
||||
"Those pages are missing from the content below"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@function_tool(timeout=330)
|
||||
async def web_search(ctx: RunContextWrapper, query: str) -> str:
|
||||
"""Real-time web search via Perplexity — your primary research tool.
|
||||
"""Real-time web search (Exa or Perplexity) — your primary research tool.
|
||||
|
||||
Use it liberally for anything that's not in your training data:
|
||||
|
||||
@@ -150,6 +348,12 @@ async def web_search(ctx: RunContextWrapper, query: str) -> str:
|
||||
exploits, Kali-compatible tooling, and concrete code/command
|
||||
examples.
|
||||
|
||||
With the Exa provider you get a ranked list of results, each with a
|
||||
title, URL, and a short security-focused summary. Read the result
|
||||
you need, then call ``web_get_contents`` with its URL to pull the
|
||||
full page text when a summary is not enough. With Perplexity you get
|
||||
a single synthesized cited answer.
|
||||
|
||||
**Good example queries** (each is a full sentence, names a
|
||||
version/product, and asks one concrete thing):
|
||||
|
||||
@@ -177,3 +381,33 @@ async def web_search(ctx: RunContextWrapper, query: str) -> str:
|
||||
"""
|
||||
result = await asyncio.to_thread(_do_search, query)
|
||||
return json.dumps(result, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
@function_tool(timeout=330)
|
||||
async def web_get_contents(ctx: RunContextWrapper, urls: list[str]) -> str:
|
||||
"""Fetch the full, cleaned text of specific web pages (Exa only).
|
||||
|
||||
Use this as the drill-down step after ``web_search``: when a result's
|
||||
summary is not enough, pass that result's URL here to read the whole
|
||||
page. Good for reading a full advisory, a CVE writeup,
|
||||
an exploit proof-of-concept, or vendor documentation end to end.
|
||||
|
||||
Prefer ``web_search`` first to find the right pages, then fetch only
|
||||
the few URLs worth reading in full — each page can be large, so avoid
|
||||
fetching many pages you do not need.
|
||||
|
||||
This tool needs the Exa provider (``EXA_API_KEY``). When the operator
|
||||
pins the provider to Perplexity, it returns an error and you should
|
||||
use ``web_search`` instead.
|
||||
|
||||
Some pages block extraction. When a page returns no content, the
|
||||
result lists it under ``failed_urls`` and the ``content`` field holds
|
||||
only the pages that came back. Check ``failed_urls`` before you
|
||||
conclude that a page had nothing useful.
|
||||
|
||||
Args:
|
||||
urls: The page URLs to fetch, at most 10 per call. Use complete,
|
||||
public URLs (for example the ones returned by ``web_search``).
|
||||
"""
|
||||
result = await asyncio.to_thread(_do_get_contents, urls)
|
||||
return json.dumps(result, ensure_ascii=False, default=str)
|
||||
|
||||
@@ -24,3 +24,30 @@ def _isolate_mcp_config(
|
||||
monkeypatch.setenv("STRIX_MCP_CONFIG", str(missing))
|
||||
monkeypatch.delenv("STRIX_MCP_ONLY", raising=False)
|
||||
monkeypatch.delenv("STRIX_MCP_EXCLUDE", raising=False)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _plain_terminal(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Make Rich output identical on every developer's machine.
|
||||
|
||||
Many CLI tests force ``isatty()`` to ``True`` to exercise the human-readable
|
||||
code path and then assert on the plain text. Rich picks its color system
|
||||
from ``TERM``, ``COLORTERM``, and ``FORCE_COLOR``, so on a real terminal
|
||||
those assertions would meet ANSI escape codes instead of the words they
|
||||
look for. A dumb terminal renders the same text without any styling.
|
||||
"""
|
||||
monkeypatch.setenv("TERM", "dumb")
|
||||
for name in ("COLORTERM", "FORCE_COLOR", "NO_COLOR", "TTY_COMPATIBLE"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_wallet_config(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Keep a developer's real mppx wallet out of the top-up tests.
|
||||
|
||||
``strix cloud billing topup`` chooses the Stripe Link flow or the
|
||||
preconfigured mppx wallet from these variables, so leaving them set would
|
||||
silently switch which branch a test runs.
|
||||
"""
|
||||
for name in ("MPPX_ACCOUNT", "MPPX_STRIPE_SECRET_KEY", "MPPX_STRIPE_PAYMENT_METHOD"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
258
tests/test_agent_graph_coordination.py
Normal file
258
tests/test_agent_graph_coordination.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""Tests for parent/child coordination once a non-interactive child has finished.
|
||||
|
||||
A non-interactive agent's loop returns after its terminal state, so nothing will
|
||||
ever read a message sent to it afterwards. Messaging it must say so instead of
|
||||
reporting delivery, waiting on it must return at once, and its completion report
|
||||
must carry the ids of the reports it actually filed so the parent does not have
|
||||
to go asking.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import pytest
|
||||
from agents.tool_context import ToolContext
|
||||
|
||||
from strix.core.agents import AgentCoordinator
|
||||
from strix.report.state import ReportState, set_global_report_state
|
||||
from strix.tools.agents_graph.tools import agent_finish, send_message_to_agent, wait_for_agents
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def report_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[ReportState]:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
state = ReportState(run_name="test-run")
|
||||
set_global_report_state(state)
|
||||
yield state
|
||||
set_global_report_state(None)
|
||||
|
||||
|
||||
async def _graph(*, interactive: bool) -> AgentCoordinator:
|
||||
coordinator = AgentCoordinator()
|
||||
await coordinator.register("root", "strix", parent_id=None)
|
||||
await coordinator.register("child", "Validator", parent_id="root")
|
||||
await coordinator.attach_runtime("root", resumable=interactive)
|
||||
await coordinator.attach_runtime("child", resumable=interactive)
|
||||
return coordinator
|
||||
|
||||
|
||||
async def _call(
|
||||
tool: Any, coordinator: AgentCoordinator, agent_id: str, args: dict[str, Any], **extra: Any
|
||||
) -> dict[str, Any]:
|
||||
ctx = ToolContext(
|
||||
context={"coordinator": coordinator, "agent_id": agent_id, **extra},
|
||||
tool_name=tool.name,
|
||||
tool_call_id="call-1",
|
||||
tool_arguments="{}",
|
||||
)
|
||||
raw: str = await tool.on_invoke_tool(ctx, json.dumps(args))
|
||||
return cast("dict[str, Any]", json.loads(raw))
|
||||
|
||||
|
||||
# --- send_message_to_agent -------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_to_finished_non_interactive_child_is_not_delivered() -> None:
|
||||
coordinator = await _graph(interactive=False)
|
||||
await coordinator.set_status("child", "completed")
|
||||
|
||||
result = await _call(
|
||||
send_message_to_agent,
|
||||
coordinator,
|
||||
"root",
|
||||
{"target_agent_id": "child", "message": "did you file it?", "message_type": "query"},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["delivery_status"] == "not_delivered"
|
||||
assert result["target_status"] == "completed"
|
||||
assert "list_reports" in result["error"]
|
||||
assert coordinator.pending_counts.get("child", 0) == 0
|
||||
assert coordinator.runtimes["child"].mailbox == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("status", ["stopped", "failed", "crashed"])
|
||||
async def test_every_terminal_non_interactive_status_is_unreachable(status: str) -> None:
|
||||
coordinator = await _graph(interactive=False)
|
||||
await coordinator.set_status("child", status)
|
||||
|
||||
assert await coordinator.send("child", {"from": "root", "content": "hi"}) is False
|
||||
assert await coordinator.reachability("child") == (False, status)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("status", ["running", "waiting"])
|
||||
async def test_message_to_live_child_is_delivered(status: str) -> None:
|
||||
coordinator = await _graph(interactive=False)
|
||||
await coordinator.set_status("child", status)
|
||||
|
||||
result = await _call(
|
||||
send_message_to_agent,
|
||||
coordinator,
|
||||
"root",
|
||||
{"target_agent_id": "child", "message": "wrap up"},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["delivery_status"] == "delivered"
|
||||
assert coordinator.pending_counts["child"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_to_finished_interactive_child_still_wakes_it() -> None:
|
||||
# An interactive loop parks after finishing and resumes on a message.
|
||||
coordinator = await _graph(interactive=True)
|
||||
await coordinator.set_status("child", "completed")
|
||||
|
||||
result = await _call(
|
||||
send_message_to_agent,
|
||||
coordinator,
|
||||
"root",
|
||||
{"target_agent_id": "child", "message": "one more thing"},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert coordinator.pending_counts["child"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_target_is_reported_as_not_found() -> None:
|
||||
coordinator = await _graph(interactive=False)
|
||||
|
||||
result = await _call(
|
||||
send_message_to_agent,
|
||||
coordinator,
|
||||
"root",
|
||||
{"target_agent_id": "ghost", "message": "hello"},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["target_status"] is None
|
||||
assert "not found" in result["error"]
|
||||
|
||||
|
||||
# --- wait_for_agents -------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_returns_at_once_when_no_child_can_answer() -> None:
|
||||
coordinator = await _graph(interactive=False)
|
||||
await coordinator.set_status("child", "completed")
|
||||
# The completion report was already consumed in an earlier turn.
|
||||
|
||||
result = await _call(
|
||||
wait_for_agents,
|
||||
coordinator,
|
||||
"root",
|
||||
{"reason": "waiting for validator", "timeout_seconds": 240},
|
||||
)
|
||||
|
||||
assert result["wait_outcome"] == "no_active_agents"
|
||||
assert result["agents"] == [{"agent_id": "child", "name": "Validator", "status": "completed"}]
|
||||
assert coordinator.statuses["root"] == "running"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_delivers_a_pending_report_before_checking_liveness() -> None:
|
||||
coordinator = await _graph(interactive=False)
|
||||
await coordinator.send("root", {"from": "child", "type": "completion", "content": "done"})
|
||||
await coordinator.set_status("child", "completed")
|
||||
|
||||
result = await _call(wait_for_agents, coordinator, "root", {"timeout_seconds": 5})
|
||||
|
||||
assert result["wait_outcome"] == "message_arrived"
|
||||
assert result["pending_messages"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_still_parks_while_a_child_is_running() -> None:
|
||||
coordinator = await _graph(interactive=False)
|
||||
|
||||
result = await _call(wait_for_agents, coordinator, "root", {"timeout_seconds": 1})
|
||||
|
||||
assert result["wait_outcome"] == "timeout"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interactive_wait_parks_even_without_active_children() -> None:
|
||||
# In an interactive run a finished child can be woken later, so parking is
|
||||
# legitimate; the run loop's own auto-resume bounds the wait.
|
||||
coordinator = await _graph(interactive=True)
|
||||
await coordinator.set_status("child", "completed")
|
||||
|
||||
result = await _call(
|
||||
wait_for_agents, coordinator, "root", {"timeout_seconds": 5}, interactive=True
|
||||
)
|
||||
|
||||
assert result["wait_outcome"] == "waiting"
|
||||
|
||||
|
||||
# --- agent_finish ----------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_finish_lists_the_reports_the_child_filed(report_state: ReportState) -> None:
|
||||
coordinator = await _graph(interactive=False)
|
||||
mine = report_state.add_vulnerability_report(
|
||||
title="IDOR on /api/audits", severity="high", agent_id="child", agent_name="Validator"
|
||||
)
|
||||
report_state.add_vulnerability_report(title="Root's own", severity="low", agent_id="root")
|
||||
|
||||
result = await _call(
|
||||
agent_finish,
|
||||
coordinator,
|
||||
"child",
|
||||
{"result_summary": "confirmed", "findings": ["IDOR confirmed"]},
|
||||
parent_id="root",
|
||||
)
|
||||
|
||||
assert result["filed_report_ids"] == [mine]
|
||||
delivered = coordinator.runtimes["root"].mailbox
|
||||
assert len(delivered) == 1
|
||||
assert delivered[0]["filed_report_ids"] == [mine]
|
||||
body = delivered[0]["content"]
|
||||
assert f"- {mine} [HIGH] IDOR on /api/audits" in body
|
||||
assert "Root's own" not in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_finish_states_explicitly_when_nothing_was_filed(
|
||||
report_state: ReportState,
|
||||
) -> None:
|
||||
coordinator = await _graph(interactive=False)
|
||||
report_state.add_vulnerability_report(title="Someone else's", severity="low", agent_id="root")
|
||||
|
||||
result = await _call(
|
||||
agent_finish,
|
||||
coordinator,
|
||||
"child",
|
||||
{"result_summary": "nothing exploitable", "findings": ["ruled out X"]},
|
||||
parent_id="root",
|
||||
)
|
||||
|
||||
assert result["filed_report_ids"] == []
|
||||
body = coordinator.runtimes["root"].mailbox[0]["content"]
|
||||
assert "Vulnerability reports filed by this agent" in body
|
||||
assert body.index("filed by this agent") < body.index("- (none)")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_finish_without_report_state_still_completes() -> None:
|
||||
set_global_report_state(None)
|
||||
coordinator = await _graph(interactive=False)
|
||||
|
||||
result = await _call(
|
||||
agent_finish, coordinator, "child", {"result_summary": "done"}, parent_id="root"
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["filed_report_ids"] == []
|
||||
@@ -6,6 +6,7 @@ import io
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
@@ -16,7 +17,7 @@ import requests
|
||||
from rich.console import Console
|
||||
|
||||
from strix.interface import cloud, platform_cli
|
||||
from strix.interface.cloud import billing, http, payment_proxy, render, runner, workspaces
|
||||
from strix.interface.cloud import http, render, runner, workspaces
|
||||
from strix.interface.cloud.spec import GROUP_HELP, SPEC
|
||||
|
||||
|
||||
@@ -147,7 +148,7 @@ def test_read_groups_have_safe_defaults(group: str, verb: str) -> None:
|
||||
resolved = runner.resolve(group, [])
|
||||
assert resolved is not None
|
||||
command, remaining = resolved
|
||||
assert command is runner.SPEC[group][verb]
|
||||
assert command is SPEC[group][verb]
|
||||
assert remaining == []
|
||||
|
||||
|
||||
@@ -533,6 +534,69 @@ def test_insufficient_credits_exits_with_payment_code(monkeypatch: pytest.Monkey
|
||||
assert cloud.run_cloud(["scans", "start", "--domain-ids", "d1"]) == http.EXIT_PAYMENT
|
||||
|
||||
|
||||
def test_insufficient_credits_always_prints_topup_instruction(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
http,
|
||||
"request",
|
||||
lambda *_a, **_k: FakeResponse(
|
||||
status_code=402,
|
||||
payload={"detail": "Out of credits.", "code": "scan_credit_limit_reached"},
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
argv = ["scans", "start", "--domain-ids", "d1", "--app-url", "https://app.strix.ai"]
|
||||
assert cloud.run_cloud(argv) == http.EXIT_PAYMENT
|
||||
output = " ".join(capsys.readouterr().out.split())
|
||||
assert "Error: Out of credits." in output
|
||||
assert "Next step:" in output
|
||||
assert "strix cloud billing topup --credits <count>" in output
|
||||
assert "https://app.strix.ai/settings/billing" in output
|
||||
assert "strix cloud billing credits" in output
|
||||
|
||||
|
||||
def test_insufficient_credits_shows_platform_hint_once(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
hint = "Buy credits at https://app.strix.ai/settings/billing. Then retry this request."
|
||||
payload = {
|
||||
"detail": f"Out of credits. {hint}",
|
||||
"code": "scan_credit_limit_reached",
|
||||
"hint": hint,
|
||||
"topup_url": "https://app.strix.ai/settings/billing",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
http, "request", lambda *_a, **_k: FakeResponse(status_code=402, payload=payload)
|
||||
)
|
||||
assert cloud.run_cloud(["scans", "start", "--domain-ids", "d1", "--json"]) == http.EXIT_PAYMENT
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["error"] == "Out of credits."
|
||||
assert result["next_step"] == hint
|
||||
assert result["topup_url"] == "https://app.strix.ai/settings/billing"
|
||||
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
assert cloud.run_cloud(["scans", "start", "--domain-ids", "d1"]) == http.EXIT_PAYMENT
|
||||
output = " ".join(capsys.readouterr().out.split())
|
||||
assert output.count(hint) == 1
|
||||
assert "Error: Out of credits." in output
|
||||
assert f"Next step: {hint}" in output
|
||||
|
||||
|
||||
def test_payment_required_without_body_names_the_topup_command(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
http, "request", lambda *_a, **_k: FakeResponse(status_code=402, payload={})
|
||||
)
|
||||
argv = ["scans", "start", "--domain-ids", "d1", "--json", "--app-url", "https://app.strix.ai"]
|
||||
assert cloud.run_cloud(argv) == http.EXIT_PAYMENT
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["error"] == "Not enough credits to run this command."
|
||||
assert "strix cloud billing topup --credits <count>" in result["next_step"]
|
||||
assert "https://app.strix.ai/settings/billing" in result["next_step"]
|
||||
|
||||
|
||||
def test_data_rejects_non_object() -> None:
|
||||
assert cloud.run_cloud(["scans", "start", "--data", "[1,2]"]) == http.EXIT_USAGE
|
||||
assert cloud.run_cloud(["scans", "start", "--data", "not json"]) == http.EXIT_USAGE
|
||||
@@ -561,7 +625,7 @@ def test_stored_token_is_never_sent_to_a_different_platform_origin(
|
||||
lambda: {"api_token": "stored-secret", "app_url": "https://app.strix.ai"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
http.requests,
|
||||
requests,
|
||||
"request",
|
||||
lambda *_args, **_kwargs: pytest.fail("a mismatched origin must not receive the token"),
|
||||
)
|
||||
@@ -576,7 +640,7 @@ def test_stored_token_requires_an_issuer_binding(monkeypatch: pytest.MonkeyPatch
|
||||
monkeypatch.setattr(http, "_app_url_override", "https://app.strix.ai")
|
||||
monkeypatch.setattr(http, "read_record", lambda: {"api_token": "legacy-secret"})
|
||||
monkeypatch.setattr(
|
||||
http.requests,
|
||||
requests,
|
||||
"request",
|
||||
lambda *_args, **_kwargs: pytest.fail("an unbound token must not be sent"),
|
||||
)
|
||||
@@ -602,7 +666,7 @@ def test_stored_token_is_sent_only_to_its_bound_platform(
|
||||
seen.update(url=url, headers=kwargs["headers"])
|
||||
return FakeResponse(payload={"balance": 1})
|
||||
|
||||
monkeypatch.setattr(http.requests, "request", request)
|
||||
monkeypatch.setattr(requests, "request", request)
|
||||
response = http.request("GET", "/billing/credits")
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -626,7 +690,7 @@ def test_explicit_token_can_target_an_explicit_platform(
|
||||
seen.update(url=url, headers=kwargs["headers"])
|
||||
return FakeResponse(payload={"balance": 1})
|
||||
|
||||
monkeypatch.setattr(http.requests, "request", request)
|
||||
monkeypatch.setattr(requests, "request", request)
|
||||
override_value = "explicit-preview-" + str(1)
|
||||
response = http.request("GET", "/billing/credits", token=override_value)
|
||||
|
||||
@@ -706,9 +770,9 @@ def test_topup_noninteractive_requires_explicit_payment_approval(
|
||||
monkeypatch.setattr(
|
||||
http, "request", lambda *_a, **_k: FakeResponse(status_code=402, payload=challenge)
|
||||
)
|
||||
monkeypatch.setattr(runner.sys.stdin, "isatty", lambda: False)
|
||||
monkeypatch.setattr(sys.stdin, "isatty", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
billing.subprocess,
|
||||
subprocess,
|
||||
"run",
|
||||
lambda *_a, **_k: pytest.fail("wallet must not run without --yes"),
|
||||
)
|
||||
@@ -732,10 +796,10 @@ def test_topup_machine_output_never_prompts_even_with_terminal_stdin(
|
||||
monkeypatch.setattr(
|
||||
http, "request", lambda *_a, **_k: FakeResponse(status_code=402, payload=challenge)
|
||||
)
|
||||
monkeypatch.setattr(runner.sys.stdin, "isatty", lambda: True)
|
||||
monkeypatch.setattr(runner.sys.stdout, "isatty", lambda: stdout_tty)
|
||||
monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: stdout_tty)
|
||||
monkeypatch.setattr(
|
||||
runner.Console,
|
||||
Console,
|
||||
"input",
|
||||
lambda *_a, **_k: pytest.fail("machine-readable top-up must not prompt"),
|
||||
)
|
||||
@@ -839,7 +903,7 @@ def test_topup_keeps_token_out_of_wallet_process_and_forwards_payment(
|
||||
upstream.update(method=method, url=url, **kwargs)
|
||||
return FakeResponse(payload=receipt, content=json.dumps(receipt).encode())
|
||||
|
||||
monkeypatch.setattr(payment_proxy.requests, "request", fake_upstream_request)
|
||||
monkeypatch.setattr(requests, "request", fake_upstream_request)
|
||||
|
||||
def fake_run(command: list[str], **kwargs: Any) -> Any:
|
||||
commands.append(command)
|
||||
@@ -905,6 +969,7 @@ def test_topup_wallet_failure_is_one_redacted_json_object(
|
||||
http, "request", lambda *_a, **_k: FakeResponse(status_code=402, payload=challenge)
|
||||
)
|
||||
monkeypatch.setattr(http, "api_token", lambda *_a, **_k: "tok")
|
||||
monkeypatch.setenv("MPPX_ACCOUNT", "agent")
|
||||
monkeypatch.setattr(shutil, "which", lambda _name: "/usr/bin/npx")
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
@@ -946,6 +1011,7 @@ def test_topup_wallet_interruption_reports_unknown_payment_outcome(
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(http, "api_token", lambda *_a, **_k: "tok")
|
||||
monkeypatch.setenv("MPPX_ACCOUNT", "agent")
|
||||
monkeypatch.setattr(shutil, "which", lambda _name: "/usr/bin/npx")
|
||||
monkeypatch.setattr(
|
||||
subprocess, "run", lambda *_a, **_k: (_ for _ in ()).throw(KeyboardInterrupt)
|
||||
@@ -971,6 +1037,7 @@ def test_topup_non_json_wallet_success_requires_balance_verification(
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(http, "api_token", lambda *_a, **_k: "tok")
|
||||
monkeypatch.setenv("MPPX_ACCOUNT", "agent")
|
||||
monkeypatch.setattr(shutil, "which", lambda _name: "/usr/bin/npx")
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
@@ -997,6 +1064,7 @@ def test_topup_rejects_parseable_wallet_error_as_a_success(
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(http, "api_token", lambda *_a, **_k: "tok")
|
||||
monkeypatch.setenv("MPPX_ACCOUNT", "agent")
|
||||
monkeypatch.setattr(shutil, "which", lambda _name: "/usr/bin/npx")
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
@@ -1036,6 +1104,7 @@ def test_topup_does_not_trust_an_unobserved_wallet_receipt(
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(http, "api_token", lambda *_a, **_k: "tok")
|
||||
monkeypatch.setenv("MPPX_ACCOUNT", "agent")
|
||||
monkeypatch.setattr(shutil, "which", lambda _name: "/usr/bin/npx")
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
@@ -1056,7 +1125,7 @@ def test_topup_does_not_trust_an_unobserved_wallet_receipt(
|
||||
def test_topup_human_mode_requires_a_bridge_confirmed_receipt(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
http,
|
||||
"request",
|
||||
@@ -1066,9 +1135,10 @@ def test_topup_human_mode_requires_a_bridge_confirmed_receipt(
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(http, "api_token", lambda *_a, **_k: "tok")
|
||||
monkeypatch.setenv("MPPX_ACCOUNT", "agent")
|
||||
monkeypatch.setattr(shutil, "which", lambda _name: "/usr/bin/npx")
|
||||
monkeypatch.setattr(
|
||||
payment_proxy.requests,
|
||||
requests,
|
||||
"request",
|
||||
lambda *_a, **_k: FakeResponse(status_code=200, content=b"<html>not a receipt</html>"),
|
||||
)
|
||||
@@ -1597,7 +1667,7 @@ def test_handoff_links_reject_non_http_schemes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: Any,
|
||||
) -> None:
|
||||
monkeypatch.setattr(runner.sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
http,
|
||||
"request",
|
||||
@@ -1882,7 +1952,7 @@ def test_workspace_use_preserves_definitive_conflict(
|
||||
def test_group_help_lists_all_verbs_instead_of_default_verb_help(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
assert cloud.run_cloud(["workspaces", "-h"]) == 0
|
||||
output = capsys.readouterr().out
|
||||
assert "workspaces verbs" in output
|
||||
@@ -1906,7 +1976,7 @@ def test_workspace_alias_routes_to_workspaces(monkeypatch: pytest.MonkeyPatch) -
|
||||
def test_workspace_human_list_is_numbered_and_hides_ids(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
http,
|
||||
"request",
|
||||
@@ -1954,7 +2024,7 @@ def test_integrations_human_list_exposes_installation_id_and_json_stays_full(
|
||||
],
|
||||
"bitbucket_oauth_enabled": True,
|
||||
}
|
||||
monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(http, "request", lambda *_a, **_k: FakeResponse(payload=payload))
|
||||
|
||||
assert cloud.run_cloud(["integrations", "list"]) == 0
|
||||
@@ -1972,7 +2042,7 @@ def test_integrations_human_list_exposes_installation_id_and_json_stays_full(
|
||||
def test_pr_review_human_list_prioritizes_actionable_fields(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
http,
|
||||
"request",
|
||||
@@ -2047,7 +2117,7 @@ def test_pr_review_human_list_shows_pull_request_state(
|
||||
capsys: Any,
|
||||
pr_state: str,
|
||||
) -> None:
|
||||
monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
http,
|
||||
"request",
|
||||
@@ -2110,7 +2180,7 @@ def test_scan_human_list_identifies_internal_and_uploaded_targets(
|
||||
record: dict[str, Any],
|
||||
expected_targets: tuple[str, ...],
|
||||
) -> None:
|
||||
monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
http,
|
||||
"request",
|
||||
@@ -2274,7 +2344,7 @@ def test_human_lists_prioritize_actionable_fields(
|
||||
visible: tuple[str, ...],
|
||||
hidden: tuple[str, ...],
|
||||
) -> None:
|
||||
monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(http, "request", lambda *_a, **_k: FakeResponse(payload=payload))
|
||||
|
||||
assert cloud.run_cloud(command) == 0
|
||||
@@ -2288,7 +2358,7 @@ def test_human_lists_prioritize_actionable_fields(
|
||||
def test_token_human_list_shows_lifecycle_status(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
http,
|
||||
"request",
|
||||
@@ -2645,7 +2715,7 @@ def test_nonstandard_human_list_envelopes_are_actionable(
|
||||
visible: tuple[str, ...],
|
||||
hidden: tuple[str, ...],
|
||||
) -> None:
|
||||
monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(http, "request", lambda *_a, **_k: FakeResponse(payload=payload))
|
||||
|
||||
assert cloud.run_cloud(command) == 0
|
||||
@@ -2695,7 +2765,7 @@ def test_chat_credentials_human_view_separates_attached_and_available_sources(
|
||||
}
|
||||
],
|
||||
}
|
||||
monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(http, "request", lambda *_a, **_k: FakeResponse(payload=payload))
|
||||
|
||||
command = ["chat", "credentials", "chat-id", "--scan-ids", "source-scan-id"]
|
||||
@@ -2921,7 +2991,7 @@ def test_named_human_list_views_match_api_fields(
|
||||
visible: tuple[str, ...],
|
||||
hidden: tuple[str, ...],
|
||||
) -> None:
|
||||
monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(http, "request", lambda *_a, **_k: FakeResponse(payload=payload))
|
||||
|
||||
assert cloud.run_cloud(command) == 0
|
||||
@@ -2935,7 +3005,7 @@ def test_named_human_list_views_match_api_fields(
|
||||
def test_supply_chain_org_summary_human_view_shows_totals_and_repository_risk(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
http,
|
||||
"request",
|
||||
@@ -3096,7 +3166,7 @@ def test_wrapped_detail_human_views_are_unwrapped_and_actionable(
|
||||
visible: tuple[str, ...],
|
||||
hidden: tuple[str, ...],
|
||||
) -> None:
|
||||
monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(http, "request", lambda *_a, **_k: FakeResponse(payload=payload))
|
||||
|
||||
assert cloud.run_cloud(command) == 0
|
||||
@@ -3110,7 +3180,7 @@ def test_wrapped_detail_human_views_are_unwrapped_and_actionable(
|
||||
def test_trace_human_view_summarizes_events_and_preserves_selector(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
seen_query: dict[str, Any] = {}
|
||||
|
||||
def fake_trace_request(_method: str, _path: str, **kwargs: Any) -> FakeResponse:
|
||||
@@ -3206,7 +3276,7 @@ def test_trace_human_view_summarizes_events_and_preserves_selector(
|
||||
def test_paginated_human_list_shows_total_and_continuation_command(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
http,
|
||||
"request",
|
||||
@@ -3308,7 +3378,7 @@ def test_page_pagination_explains_an_out_of_range_page() -> None:
|
||||
def test_human_detail_preserves_long_prose_beyond_table_cell_limit(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
description = (
|
||||
" ".join(["authorization context"] * 12) + " final-description-marker\nsecond-line-marker"
|
||||
)
|
||||
@@ -3386,7 +3456,7 @@ def test_large_vulnerability_detail_prioritizes_evidence_and_remediation() -> No
|
||||
def test_test_user_human_view_joins_latest_verification(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
http,
|
||||
"request",
|
||||
@@ -3483,7 +3553,7 @@ def test_wide_knowledge_table_keeps_title_readable_with_long_identifiers() -> No
|
||||
def test_human_get_prioritizes_details_and_hides_internal_identity_fields(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
http,
|
||||
"request",
|
||||
|
||||
@@ -6,13 +6,16 @@ import argparse
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from rich.console import Console
|
||||
|
||||
from strix.interface import cloud, platform_cli
|
||||
from strix.interface.cloud import http, render, runner, source_scan
|
||||
from strix.interface.cloud.source_upload import prepare_source
|
||||
from strix.interface.main import main as interface_main
|
||||
|
||||
|
||||
@@ -169,7 +172,7 @@ def test_connector_enrollment_command_is_complete_multiline_and_terminal_safe(
|
||||
" -e LABEL=before\x1b]52;c;copied\x07after \\\n"
|
||||
" ghcr.io/usestrix/connector:latest"
|
||||
)
|
||||
monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
http,
|
||||
"request",
|
||||
@@ -293,8 +296,8 @@ def test_source_prompt_shows_paths_and_literal_confirmation(
|
||||
return "n"
|
||||
|
||||
monkeypatch.setattr(console, "input", answer)
|
||||
monkeypatch.setattr(source_scan.sys.stdin, "isatty", lambda: True)
|
||||
monkeypatch.setattr(source_scan.sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
args = argparse.Namespace(
|
||||
source=str(tmp_path),
|
||||
dry_run=False,
|
||||
@@ -324,7 +327,7 @@ def test_source_prompt_interruption_removes_temporary_archive(
|
||||
(tmp_path / "app.py").write_text("print('ok')\n", encoding="utf-8")
|
||||
console = Console(file=io.StringIO(), width=100)
|
||||
archive_paths: list[Path] = []
|
||||
original_prepare = source_scan.prepare_source
|
||||
original_prepare = prepare_source
|
||||
|
||||
def capture_bundle(*args: Any, **kwargs: Any) -> Any:
|
||||
bundle = original_prepare(*args, **kwargs)
|
||||
@@ -336,8 +339,8 @@ def test_source_prompt_interruption_removes_temporary_archive(
|
||||
|
||||
monkeypatch.setattr(source_scan, "prepare_source", capture_bundle)
|
||||
monkeypatch.setattr(console, "input", interrupt)
|
||||
monkeypatch.setattr(source_scan.sys.stdin, "isatty", lambda: True)
|
||||
monkeypatch.setattr(source_scan.sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
args = argparse.Namespace(
|
||||
source=str(tmp_path),
|
||||
dry_run=False,
|
||||
@@ -394,7 +397,7 @@ def test_device_login_rejects_non_http_verification_url(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
platform_cli.requests,
|
||||
requests,
|
||||
"post",
|
||||
lambda *_a, **_k: FakeResponse(
|
||||
{
|
||||
@@ -427,7 +430,7 @@ def test_boolean_query_values_are_lowercase_for_url_search_params(
|
||||
seen["params"] = kwargs.get("params")
|
||||
return FakeResponse({"items": []})
|
||||
|
||||
monkeypatch.setattr(http.requests, "request", fake_request)
|
||||
monkeypatch.setattr(requests, "request", fake_request)
|
||||
http.request("GET", "/test", query={"enabled": True, "disabled": False})
|
||||
assert seen["params"] == {"enabled": "true", "disabled": "false"}
|
||||
|
||||
@@ -481,7 +484,7 @@ def test_binary_response_refuses_to_write_to_a_terminal(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: Any,
|
||||
) -> None:
|
||||
monkeypatch.setattr(runner.sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
http,
|
||||
"request",
|
||||
@@ -513,7 +516,7 @@ def test_binary_response_can_be_intentionally_redirected(
|
||||
return None
|
||||
|
||||
redirected = RedirectedStdout()
|
||||
monkeypatch.setattr(runner.sys, "stdout", redirected)
|
||||
monkeypatch.setattr(sys, "stdout", redirected)
|
||||
monkeypatch.setattr(
|
||||
http,
|
||||
"request",
|
||||
@@ -534,7 +537,7 @@ def test_redirected_binary_errors_never_append_diagnostics_to_stdout(
|
||||
def iter_content(self, *, chunk_size: int) -> Any:
|
||||
assert chunk_size == 1024 * 1024
|
||||
yield b"%PDF-partial"
|
||||
raise http.requests.ConnectionError("connection lost")
|
||||
raise requests.ConnectionError("connection lost")
|
||||
|
||||
response = (
|
||||
FakeResponse({"detail": "report rejected"}, status_code=500)
|
||||
@@ -645,7 +648,7 @@ def test_binary_download_streams_and_preserves_existing_file_on_failure(
|
||||
def iter_content(self, *, chunk_size: int) -> Any:
|
||||
assert chunk_size == 1024 * 1024
|
||||
yield b"partial"
|
||||
raise http.requests.ConnectionError("connection lost")
|
||||
raise requests.ConnectionError("connection lost")
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
@@ -770,7 +773,7 @@ def test_session_help_is_specific_and_human_whoami_shows_scopes(
|
||||
"scopes": ["scans:read", "organizations:read"],
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(platform_cli.sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
assert cloud.run_cloud(["whoami", "--help"]) == 0
|
||||
who_help = capsys.readouterr().out
|
||||
assert "strix cloud whoami" in who_help
|
||||
@@ -784,7 +787,7 @@ def test_non_tty_whoami_and_logout_emit_json(
|
||||
) -> None:
|
||||
monkeypatch.delenv("STRIX_API_TOKEN", raising=False)
|
||||
monkeypatch.setattr(platform_cli, "AUTH_PATH", tmp_path / "auth.json")
|
||||
monkeypatch.setattr(platform_cli.sys.stdout, "isatty", lambda: False)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: False)
|
||||
platform_cli.save_record(
|
||||
{
|
||||
"api_token": "secret",
|
||||
@@ -799,7 +802,7 @@ def test_non_tty_whoami_and_logout_emit_json(
|
||||
assert json.loads(capsys.readouterr().out)["email"] == "agent@example.test"
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_cli.requests,
|
||||
requests,
|
||||
"delete",
|
||||
lambda *_args, **_kwargs: type("Response", (), {"status_code": 200})(),
|
||||
)
|
||||
@@ -831,7 +834,7 @@ def test_scope_picker_labels_match_the_server_presets() -> None:
|
||||
def test_noninteractive_login_never_prompts_for_workspace(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(platform_cli.sys.stdin, "isatty", lambda: False)
|
||||
monkeypatch.setattr(sys.stdin, "isatty", lambda: False)
|
||||
console = Console(file=io.StringIO())
|
||||
console.input = lambda *_args, **_kwargs: pytest.fail("must not prompt") # type: ignore[method-assign]
|
||||
|
||||
@@ -896,10 +899,10 @@ def test_device_flow_slow_down_never_exceeds_the_poll_interval_cap(
|
||||
sleeps.append(seconds)
|
||||
now += seconds
|
||||
|
||||
monkeypatch.setattr(platform_cli.requests, "post", post)
|
||||
monkeypatch.setattr(requests, "post", post)
|
||||
monkeypatch.setattr(platform_cli, "_app_url", lambda: "https://example.test")
|
||||
monkeypatch.setattr(platform_cli.time, "monotonic", monotonic)
|
||||
monkeypatch.setattr(platform_cli.time, "sleep", sleep)
|
||||
monkeypatch.setattr(time, "monotonic", monotonic)
|
||||
monkeypatch.setattr(time, "sleep", sleep)
|
||||
|
||||
with pytest.raises(platform_cli.PlatformAuthError, match="expired"):
|
||||
platform_cli._run_device_flow(
|
||||
@@ -933,9 +936,9 @@ def test_device_flow_accepts_external_authkit_url_and_binds_token_origin(
|
||||
),
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(platform_cli.requests, "post", lambda *_a, **_k: next(responses))
|
||||
monkeypatch.setattr(requests, "post", lambda *_a, **_k: next(responses))
|
||||
monkeypatch.setattr(platform_cli, "_app_url", lambda: "https://preview.strix.ai")
|
||||
monkeypatch.setattr(platform_cli.time, "sleep", lambda _seconds: None)
|
||||
monkeypatch.setattr(time, "sleep", lambda _seconds: None)
|
||||
|
||||
record = platform_cli._run_device_flow(
|
||||
Console(file=io.StringIO()),
|
||||
@@ -984,7 +987,7 @@ def test_root_help_accepts_json_before_help_and_leaf_help_stays_specific(
|
||||
def test_non_tty_dispatcher_always_emits_structured_json(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
monkeypatch.setattr(render.sys.stdout, "isatty", lambda: False)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: False)
|
||||
|
||||
assert cloud.run_cloud([]) == 0
|
||||
assert json.loads(capsys.readouterr().out)["command"] == "strix cloud"
|
||||
@@ -1020,7 +1023,7 @@ def test_source_upload_rejects_untrusted_destinations_before_reading_file(
|
||||
source.write_bytes(b"approved source")
|
||||
monkeypatch.setattr(http, "_app_url_override", "https://app.strix.ai")
|
||||
monkeypatch.setattr(
|
||||
http.requests,
|
||||
requests,
|
||||
"put",
|
||||
lambda *_args, **_kwargs: pytest.fail("an untrusted URL must not receive source bytes"),
|
||||
)
|
||||
@@ -1059,7 +1062,7 @@ def test_source_upload_allows_only_managed_or_same_origin_storage(
|
||||
return response
|
||||
|
||||
monkeypatch.setattr(http, "_app_url_override", app_url)
|
||||
monkeypatch.setattr(http.requests, "put", put)
|
||||
monkeypatch.setattr(requests, "put", put)
|
||||
http.upload_file(signed_url, "upload-token", source)
|
||||
|
||||
assert request_options["allow_redirects"] is False
|
||||
@@ -1080,7 +1083,7 @@ def test_source_upload_refuses_redirects_without_following_them(
|
||||
return response
|
||||
|
||||
monkeypatch.setattr(http, "_app_url_override", "https://app.strix.ai")
|
||||
monkeypatch.setattr(http.requests, "put", put)
|
||||
monkeypatch.setattr(requests, "put", put)
|
||||
|
||||
with pytest.raises(http.CloudError, match="unexpected redirect"):
|
||||
http.upload_file(
|
||||
@@ -1095,7 +1098,7 @@ def test_source_upload_refuses_redirects_without_following_them(
|
||||
def test_one_time_api_token_has_save_now_warning(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
http,
|
||||
"request",
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from strix.interface import cloud
|
||||
from strix.interface.cloud import http, runner
|
||||
@@ -30,7 +32,7 @@ class FakeResponse:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("STRIX_API_TOKEN", "idempotency-test-token")
|
||||
monkeypatch.setattr(runner.time, "sleep", lambda _seconds: None)
|
||||
monkeypatch.setattr(time, "sleep", lambda _seconds: None)
|
||||
|
||||
|
||||
def test_scan_start_generates_and_sends_one_stable_key(
|
||||
@@ -197,7 +199,7 @@ def test_http_client_places_key_in_the_header(monkeypatch: pytest.MonkeyPatch) -
|
||||
seen.update(kwargs)
|
||||
return FakeResponse({"ok": True})
|
||||
|
||||
monkeypatch.setattr(http.requests, "request", request)
|
||||
monkeypatch.setattr(requests, "request", request)
|
||||
http.request("POST", "/scans", body={}, idempotency_key="header-key")
|
||||
assert seen["headers"]["Idempotency-Key"] == "header-key"
|
||||
assert seen["headers"]["Authorization"] == "Bearer idempotency-test-token"
|
||||
|
||||
@@ -7,6 +7,7 @@ import urllib.request
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from strix.interface.cloud import payment_proxy
|
||||
|
||||
@@ -39,7 +40,8 @@ def _post(url: str, body: bytes, headers: dict[str, str] | None = None) -> bytes
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=2) as response: # noqa: S310
|
||||
return response.read()
|
||||
body_bytes: bytes = response.read()
|
||||
return body_bytes
|
||||
|
||||
|
||||
def test_bridge_bounds_decompressed_upstream_response(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -50,7 +52,7 @@ def test_bridge_bounds_decompressed_upstream_response(monkeypatch: pytest.Monkey
|
||||
return response
|
||||
|
||||
monkeypatch.setattr(payment_proxy, "_MAX_UPSTREAM_RESPONSE_BYTES", 4)
|
||||
monkeypatch.setattr(payment_proxy.requests, "request", fake_request)
|
||||
monkeypatch.setattr(requests, "request", fake_request)
|
||||
|
||||
with payment_proxy.wallet_payment_bridge(
|
||||
upstream_url="https://app.example.test/api/v1/billing/topup",
|
||||
@@ -80,10 +82,11 @@ def test_bridge_forwards_only_the_approved_request_and_protected_headers(
|
||||
captured.append(kwargs)
|
||||
return _StreamingResponse([b'{"ok":true}'])
|
||||
|
||||
monkeypatch.setattr(payment_proxy.requests, "request", fake_request)
|
||||
monkeypatch.setattr(requests, "request", fake_request)
|
||||
with payment_proxy.wallet_payment_bridge(
|
||||
upstream_url="https://app.example.test/api/v1/billing/topup",
|
||||
api_token="strix-secret", # noqa: S106
|
||||
workspace_id="org_trusted",
|
||||
expected_body=b'{"credits":5}',
|
||||
response_observer=observed.append,
|
||||
) as wallet_url:
|
||||
@@ -94,6 +97,7 @@ def test_bridge_forwards_only_the_approved_request_and_protected_headers(
|
||||
"Authorization": "Payment wallet-proof",
|
||||
"Proxy-Authorization": "Basic drop-me",
|
||||
"X-Strix-Authorization": "Bearer attacker",
|
||||
"X-Strix-Workspace": "org_attacker",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -101,6 +105,7 @@ def test_bridge_forwards_only_the_approved_request_and_protected_headers(
|
||||
headers = captured[0]["headers"]
|
||||
assert headers["Authorization"] == "Payment wallet-proof"
|
||||
assert headers["X-Strix-Authorization"] == "Bearer strix-secret"
|
||||
assert headers["X-Strix-Workspace"] == "org_trusted"
|
||||
assert "Proxy-Authorization" not in headers
|
||||
assert not any(name.lower() in {"host", "content-length"} for name in headers)
|
||||
assert observed == [
|
||||
@@ -113,7 +118,7 @@ def test_bridge_forwards_only_the_approved_request_and_protected_headers(
|
||||
assert len(captured) == 1
|
||||
|
||||
|
||||
def test_bridge_allows_only_two_valid_wallet_attempts(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_bridge_limits_valid_wallet_attempts(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls = 0
|
||||
|
||||
def fake_request(*_args: Any, **_kwargs: Any) -> _StreamingResponse:
|
||||
@@ -121,7 +126,7 @@ def test_bridge_allows_only_two_valid_wallet_attempts(monkeypatch: pytest.Monkey
|
||||
calls += 1
|
||||
return _StreamingResponse([b"{}"])
|
||||
|
||||
monkeypatch.setattr(payment_proxy.requests, "request", fake_request)
|
||||
monkeypatch.setattr(requests, "request", fake_request)
|
||||
with payment_proxy.wallet_payment_bridge(
|
||||
upstream_url="https://app.example.test/api/v1/billing/topup",
|
||||
api_token="strix-secret", # noqa: S106
|
||||
@@ -129,8 +134,9 @@ def test_bridge_allows_only_two_valid_wallet_attempts(monkeypatch: pytest.Monkey
|
||||
) as wallet_url:
|
||||
assert _post(wallet_url, b"{}") == b"{}"
|
||||
assert _post(wallet_url, b"{}") == b"{}"
|
||||
with pytest.raises(urllib.error.HTTPError) as third_request:
|
||||
assert _post(wallet_url, b"{}") == b"{}"
|
||||
with pytest.raises(urllib.error.HTTPError) as extra_request:
|
||||
_post(wallet_url, b"{}")
|
||||
|
||||
assert third_request.value.code == 429
|
||||
assert calls == 2
|
||||
assert extra_request.value.code == 429
|
||||
assert calls == payment_proxy._MAX_WALLET_REQUESTS
|
||||
|
||||
@@ -6,6 +6,7 @@ import json
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from rich.console import Console
|
||||
|
||||
from strix.interface import cloud, platform_cli, platform_identity
|
||||
@@ -55,7 +56,7 @@ def test_http_workspace_pin_is_captured_once(
|
||||
sent.append(dict(kwargs["headers"]))
|
||||
return Response({})
|
||||
|
||||
monkeypatch.setattr(http.requests, "request", fake_request)
|
||||
monkeypatch.setattr(requests, "request", fake_request)
|
||||
http.configure()
|
||||
platform_cli.save_record(
|
||||
{
|
||||
@@ -116,7 +117,7 @@ def test_logout_keeps_local_token_when_remote_outcome_is_not_definitive(
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
platform_cli.requests,
|
||||
requests,
|
||||
"delete",
|
||||
lambda *_args, **_kwargs: Response({"detail": "unavailable"}, 503),
|
||||
)
|
||||
|
||||
@@ -655,3 +655,44 @@ def test_incomplete_upload_credentials_delete_the_reserved_upload(
|
||||
monkeypatch.setattr(http, "request", fake_request)
|
||||
assert cloud.run_cloud(["scans", "start", "--source", str(tmp_path), "--yes", "--json"]) == 1
|
||||
assert ("DELETE", "/uploads/upload-incomplete") in paths
|
||||
|
||||
|
||||
def test_archive_source_is_rejected_with_directory_guidance(tmp_path: Path) -> None:
|
||||
archive = tmp_path / "backend.zip"
|
||||
with zipfile.ZipFile(archive, "w") as bundle:
|
||||
bundle.writestr("app.py", "print('safe')\n")
|
||||
|
||||
with pytest.raises(http.CloudError, match="not an archive") as raised:
|
||||
source_upload.prepare_source(
|
||||
str(archive),
|
||||
include_hidden=False,
|
||||
include_sensitive=False,
|
||||
include_archives=False,
|
||||
exclude=[],
|
||||
)
|
||||
assert raised.value.next_step is not None
|
||||
assert "--source" in raised.value.next_step
|
||||
assert "--dry-run --show-files" in raised.value.next_step
|
||||
|
||||
|
||||
def test_oversize_archive_names_largest_files_and_exclude_guidance(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
(tmp_path / "app.py").write_text("print('safe')\n", encoding="utf-8")
|
||||
(tmp_path / "big.bin").write_bytes(os.urandom(4096))
|
||||
monkeypatch.setattr(source_upload, "MAX_ARCHIVE_BYTES", 1024)
|
||||
|
||||
with pytest.raises(http.CloudError, match=r"larger than the 0\.0 MiB upload limit") as raised:
|
||||
source_upload.prepare_source(
|
||||
str(tmp_path),
|
||||
include_hidden=False,
|
||||
include_sensitive=False,
|
||||
include_archives=False,
|
||||
exclude=[],
|
||||
)
|
||||
message = str(raised.value)
|
||||
assert message.index("big.bin") < message.index("app.py")
|
||||
assert raised.value.next_step is not None
|
||||
assert "--exclude" in raised.value.next_step
|
||||
assert "--dry-run --show-files" in raised.value.next_step
|
||||
assert not list(tmp_path.glob("strix-source-*.zip"))
|
||||
|
||||
118
tests/test_cloud_wallet.py
Normal file
118
tests/test_cloud_wallet.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Tests for the Stripe Link wallet setup path of `strix cloud billing topup`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from strix.interface.cloud import billing
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pytest
|
||||
|
||||
|
||||
_MIN_LINK_CONTEXT_CHARS = 100
|
||||
|
||||
|
||||
def _completed(stdout: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.CompletedProcess(args=["link-cli"], returncode=0, stdout=stdout, stderr="")
|
||||
|
||||
|
||||
def test_payment_context_is_long_enough_for_link_approval() -> None:
|
||||
context = billing._payment_context({"credits": 5})
|
||||
assert len(context) >= _MIN_LINK_CONTEXT_CHARS
|
||||
assert "5" in context
|
||||
|
||||
|
||||
def test_mppx_wallet_configured_follows_environment(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("MPPX_ACCOUNT", raising=False)
|
||||
monkeypatch.delenv("MPPX_STRIPE_SECRET_KEY", raising=False)
|
||||
assert billing._mppx_wallet_configured() is False
|
||||
monkeypatch.setenv("MPPX_ACCOUNT", "agent")
|
||||
assert billing._mppx_wallet_configured() is True
|
||||
|
||||
|
||||
def test_link_wallet_authenticated_reads_status_list(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
billing,
|
||||
"_run_link_cli",
|
||||
lambda *_args, **_kwargs: _completed('[{"authenticated": true}]'),
|
||||
)
|
||||
assert billing._link_wallet_authenticated("npx") is True
|
||||
|
||||
|
||||
def test_link_wallet_authenticated_handles_unusable_output(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(billing, "_run_link_cli", lambda *_args, **_kwargs: _completed("not json"))
|
||||
assert billing._link_wallet_authenticated("npx") is False
|
||||
|
||||
|
||||
def test_link_wallet_authenticated_handles_launch_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def explode(*_args: Any, **_kwargs: Any) -> subprocess.CompletedProcess[str]:
|
||||
raise OSError
|
||||
|
||||
monkeypatch.setattr(billing, "_run_link_cli", explode)
|
||||
assert billing._link_wallet_authenticated("npx") is False
|
||||
|
||||
|
||||
def test_pending_spend_request_reads_the_created_record() -> None:
|
||||
stdout = (
|
||||
'[{"id": "lsrq_123", "status": "pending_approval", '
|
||||
'"approval_url": "https://app.link.com/activity/approve/lsrq_123"}]'
|
||||
)
|
||||
assert billing._pending_spend_request(stdout) == (
|
||||
"lsrq_123",
|
||||
"https://app.link.com/activity/approve/lsrq_123",
|
||||
)
|
||||
assert billing._pending_spend_request('[{"id": "lsrq_1", "status": "approved"}]') is None
|
||||
assert billing._pending_spend_request("not json") is None
|
||||
|
||||
|
||||
def test_pending_spend_request_tolerates_banner_text_around_pretty_json() -> None:
|
||||
stdout = (
|
||||
"Update available for @stripe/link-cli: 0.13.1 -> 0.16.0\n"
|
||||
"[\n {\n"
|
||||
' "id": "lsrq_9",\n'
|
||||
' "status": "pending_approval",\n'
|
||||
' "approval_url": "https://app.link.com/activity/approve/lsrq_9"\n'
|
||||
" }\n]"
|
||||
)
|
||||
assert billing._pending_spend_request(stdout) == (
|
||||
"lsrq_9",
|
||||
"https://app.link.com/activity/approve/lsrq_9",
|
||||
)
|
||||
|
||||
|
||||
def test_final_spend_request_status_reads_the_last_poll_line() -> None:
|
||||
stdout = '{"status": "pending_approval"}\n{"status": "approved"}\n'
|
||||
assert billing._final_spend_request_status(stdout) == "approved"
|
||||
assert billing._final_spend_request_status("") is None
|
||||
|
||||
|
||||
def test_final_spend_request_status_unwraps_chunk_envelopes() -> None:
|
||||
stdout = (
|
||||
'{"type":"chunk","data":{"id":"lsrq_9","status":"pending_approval"}}\n'
|
||||
'{"type":"chunk","data":{"id":"lsrq_9","status":"approved"}}\n'
|
||||
'{"type":"done","ok":true,"meta":{"command":"spend-request retrieve"}}\n'
|
||||
)
|
||||
assert billing._final_spend_request_status(stdout) == "approved"
|
||||
|
||||
|
||||
def test_prepare_link_wallet_skips_login_when_connected(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(billing, "_link_wallet_authenticated", lambda _npx: True)
|
||||
assert billing._prepare_link_wallet(Console(), "npx", as_json=True) is None
|
||||
|
||||
|
||||
def test_prepare_link_wallet_explains_setup_without_a_terminal(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(billing, "_link_wallet_authenticated", lambda _npx: False)
|
||||
message = billing._prepare_link_wallet(Console(), "npx", as_json=True)
|
||||
assert message is not None
|
||||
assert "https://link.com/agents" in message
|
||||
@@ -30,6 +30,8 @@ _LLM_ENV_KEYS = [
|
||||
"STRIX_FORCE_REQUIRED_TOOL_CHOICE",
|
||||
"LLM_TIMEOUT",
|
||||
"PERPLEXITY_API_KEY",
|
||||
"EXA_API_KEY",
|
||||
"STRIX_WEB_SEARCH_PROVIDER",
|
||||
# RuntimeSettings
|
||||
"STRIX_IMAGE",
|
||||
"STRIX_RUNTIME_BACKEND",
|
||||
@@ -80,6 +82,17 @@ def test_read_json_overrides_maps_to_nested_settings(tmp_path: Path) -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_read_json_overrides_maps_exa_and_provider(tmp_path: Path) -> None:
|
||||
path = tmp_path / "cli-config.json"
|
||||
path.write_text(
|
||||
json.dumps({"env": {"EXA_API_KEY": "exa-key", "STRIX_WEB_SEARCH_PROVIDER": "exa"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert loader._read_json_overrides(path) == {
|
||||
"integrations": {"exa_api_key": "exa-key", "web_search_provider": "exa"},
|
||||
}
|
||||
|
||||
|
||||
def test_read_json_overrides_skips_keys_already_in_environ(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
@@ -208,6 +221,191 @@ def test_persist_current_writes_env_block(tmp_path: Path, monkeypatch: pytest.Mo
|
||||
}
|
||||
|
||||
|
||||
def test_persist_current_keeps_file_values_when_env_is_unset(tmp_path: Path) -> None:
|
||||
target = tmp_path / "cli-config.json"
|
||||
target.write_text(
|
||||
json.dumps({"env": {"STRIX_LLM": "file-model", "LLM_API_KEY": "file-key"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
loader.apply_config_override(target)
|
||||
assert loader.load_settings().llm.model == "file-model"
|
||||
|
||||
loader.persist_current()
|
||||
|
||||
assert json.loads(target.read_text(encoding="utf-8")) == {
|
||||
"env": {"STRIX_LLM": "file-model", "LLM_API_KEY": "file-key"}
|
||||
}
|
||||
|
||||
|
||||
def test_persist_current_env_overrides_file_value(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
target = tmp_path / "cli-config.json"
|
||||
target.write_text(
|
||||
json.dumps({"env": {"STRIX_LLM": "file-model", "PERPLEXITY_API_KEY": "file-pplx"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
loader.apply_config_override(target)
|
||||
monkeypatch.setenv("PERPLEXITY_API_KEY", "env-pplx")
|
||||
|
||||
loader.persist_current()
|
||||
|
||||
assert json.loads(target.read_text(encoding="utf-8")) == {
|
||||
"env": {"STRIX_LLM": "file-model", "PERPLEXITY_API_KEY": "env-pplx"}
|
||||
}
|
||||
|
||||
|
||||
def test_linked_llm_model_change_drops_stored_key_and_base(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
target = tmp_path / "cli-config.json"
|
||||
target.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"env": {
|
||||
"STRIX_LLM": "file-model",
|
||||
"LLM_API_KEY": "file-key",
|
||||
"LLM_API_BASE": "http://file-base",
|
||||
"PERPLEXITY_API_KEY": "pplx",
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
loader.apply_config_override(target)
|
||||
monkeypatch.setenv("STRIX_LLM", "env-model")
|
||||
|
||||
llm = loader.load_settings().llm
|
||||
assert llm.model == "env-model"
|
||||
assert llm.api_key is None
|
||||
assert llm.api_base is None
|
||||
|
||||
loader.persist_current()
|
||||
|
||||
assert json.loads(target.read_text(encoding="utf-8")) == {
|
||||
"env": {"STRIX_LLM": "env-model", "PERPLEXITY_API_KEY": "pplx"}
|
||||
}
|
||||
|
||||
|
||||
def test_linked_llm_key_change_drops_stored_model(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
target = tmp_path / "cli-config.json"
|
||||
target.write_text(
|
||||
json.dumps({"env": {"STRIX_LLM": "file-model", "LLM_API_KEY": "file-key"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
loader.apply_config_override(target)
|
||||
monkeypatch.setenv("LLM_API_KEY", "new-key")
|
||||
|
||||
assert loader.load_settings().llm.model is None
|
||||
|
||||
loader.persist_current()
|
||||
|
||||
assert json.loads(target.read_text(encoding="utf-8")) == {"env": {"LLM_API_KEY": "new-key"}}
|
||||
|
||||
|
||||
def test_linked_llm_secondary_alias_in_env_is_not_a_change(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
target = tmp_path / "cli-config.json"
|
||||
target.write_text(
|
||||
json.dumps({"env": {"STRIX_LLM": "file-model", "LLM_API_KEY": "file-key"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
loader.apply_config_override(target)
|
||||
monkeypatch.setenv("LLM_API_KEY", "file-key")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "unrelated-global-key")
|
||||
|
||||
llm = loader.load_settings().llm
|
||||
assert llm.model == "file-model"
|
||||
assert llm.api_key == "file-key"
|
||||
|
||||
loader.persist_current()
|
||||
|
||||
assert json.loads(target.read_text(encoding="utf-8")) == {
|
||||
"env": {"STRIX_LLM": "file-model", "LLM_API_KEY": "file-key"}
|
||||
}
|
||||
|
||||
|
||||
def test_linked_llm_unchanged_env_keeps_stored_key(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
target = tmp_path / "cli-config.json"
|
||||
target.write_text(
|
||||
json.dumps({"env": {"STRIX_LLM": "file-model", "LLM_API_KEY": "file-key"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
loader.apply_config_override(target)
|
||||
monkeypatch.setenv("STRIX_LLM", "file-model")
|
||||
|
||||
assert loader.load_settings().llm.api_key == "file-key"
|
||||
|
||||
loader.persist_current()
|
||||
|
||||
assert json.loads(target.read_text(encoding="utf-8")) == {
|
||||
"env": {"STRIX_LLM": "file-model", "LLM_API_KEY": "file-key"}
|
||||
}
|
||||
|
||||
|
||||
def test_persist_current_env_alias_replaces_other_alias_in_file(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
target = tmp_path / "cli-config.json"
|
||||
target.write_text(json.dumps({"env": {"OPENAI_API_KEY": "old-key"}}), encoding="utf-8")
|
||||
loader.apply_config_override(target)
|
||||
monkeypatch.setenv("LLM_API_KEY", "new-key")
|
||||
|
||||
loader.persist_current()
|
||||
|
||||
assert json.loads(target.read_text(encoding="utf-8")) == {"env": {"LLM_API_KEY": "new-key"}}
|
||||
|
||||
|
||||
def test_persist_current_empty_env_clears_file_value(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
target = tmp_path / "cli-config.json"
|
||||
target.write_text(
|
||||
json.dumps({"env": {"STRIX_LLM": "file-model", "PERPLEXITY_API_KEY": "pplx"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
loader.apply_config_override(target)
|
||||
monkeypatch.setenv("PERPLEXITY_API_KEY", "")
|
||||
|
||||
loader.persist_current()
|
||||
|
||||
assert json.loads(target.read_text(encoding="utf-8")) == {"env": {"STRIX_LLM": "file-model"}}
|
||||
|
||||
|
||||
def test_persist_current_empty_primary_alias_does_not_save_sibling(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
target = tmp_path / "cli-config.json"
|
||||
target.write_text(json.dumps({"env": {"PERPLEXITY_API_KEY": "pplx"}}), encoding="utf-8")
|
||||
loader.apply_config_override(target)
|
||||
monkeypatch.setenv("LLM_API_KEY", "")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sibling-key")
|
||||
|
||||
assert loader.load_settings().llm.api_key == ""
|
||||
|
||||
loader.persist_current()
|
||||
|
||||
assert json.loads(target.read_text(encoding="utf-8")) == {"env": {"PERPLEXITY_API_KEY": "pplx"}}
|
||||
|
||||
|
||||
def test_persist_current_replaces_corrupt_file(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
target = tmp_path / "cli-config.json"
|
||||
target.write_text("{not json", encoding="utf-8")
|
||||
loader.apply_config_override(target)
|
||||
monkeypatch.setenv("STRIX_LLM", "env-model")
|
||||
|
||||
loader.persist_current()
|
||||
|
||||
assert json.loads(target.read_text(encoding="utf-8")) == {"env": {"STRIX_LLM": "env-model"}}
|
||||
|
||||
|
||||
def test_persist_current_sets_0600_mode(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("STRIX_LLM", "persisted-model")
|
||||
target = tmp_path / "cli-config.json"
|
||||
|
||||
@@ -114,7 +114,14 @@ 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"):
|
||||
for key in (
|
||||
"STRIX_LLM",
|
||||
"LLM_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"LLM_API_BASE",
|
||||
"STRIX_DEDUPE_MODEL",
|
||||
"STRIX_DEDUPE_REASONING_EFFORT",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
path = tmp_path / "config.json"
|
||||
path.write_text(
|
||||
|
||||
@@ -12,6 +12,7 @@ would let it escape and surface a traceback on every teardown.
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -22,6 +23,10 @@ from requests.exceptions import ConnectionError as RequestsConnectionError
|
||||
from strix.runtime.docker_client import StrixDockerSandboxClient
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agents.sandbox.session.sandbox_session import SandboxSession
|
||||
|
||||
|
||||
def _client_with_kill_error(exc: Exception) -> StrixDockerSandboxClient:
|
||||
"""A StrixDockerSandboxClient whose containers.get(...).kill() raises ``exc``."""
|
||||
client = StrixDockerSandboxClient.__new__(StrixDockerSandboxClient)
|
||||
@@ -31,9 +36,10 @@ def _client_with_kill_error(exc: Exception) -> StrixDockerSandboxClient:
|
||||
return client
|
||||
|
||||
|
||||
def _session() -> object:
|
||||
def _session(container_id: str | None = "abc123") -> SandboxSession:
|
||||
# delete() reads session._inner.state.container_id
|
||||
return SimpleNamespace(_inner=SimpleNamespace(state=SimpleNamespace(container_id="abc123")))
|
||||
fake = SimpleNamespace(_inner=SimpleNamespace(state=SimpleNamespace(container_id=container_id)))
|
||||
return cast("SandboxSession", fake)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -45,7 +51,7 @@ def _session() -> object:
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_swallows_best_effort_kill_errors(exc):
|
||||
async def test_delete_swallows_best_effort_kill_errors(exc: Exception) -> None:
|
||||
"""A torn-down socket (ConnectionError) or a gone/unhappy container
|
||||
(NotFound/APIError) during the kill must not propagate; delete() still
|
||||
delegates to the SDK's delete()."""
|
||||
@@ -62,7 +68,7 @@ async def test_delete_swallows_best_effort_kill_errors(exc):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_does_not_swallow_unrelated_errors():
|
||||
async def test_delete_does_not_swallow_unrelated_errors() -> None:
|
||||
"""A programming error (e.g. ValueError) is not part of best-effort kill and
|
||||
must still propagate."""
|
||||
client = _client_with_kill_error(ValueError("boom"))
|
||||
@@ -71,11 +77,11 @@ async def test_delete_does_not_swallow_unrelated_errors():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_noop_without_container_id():
|
||||
async def test_delete_noop_without_container_id() -> None:
|
||||
"""No container_id -> no kill attempt, just delegate."""
|
||||
client = StrixDockerSandboxClient.__new__(StrixDockerSandboxClient)
|
||||
client.docker_client = MagicMock()
|
||||
session = SimpleNamespace(_inner=SimpleNamespace(state=SimpleNamespace(container_id=None)))
|
||||
session = _session(container_id=None)
|
||||
|
||||
with patch.object(
|
||||
DockerSandboxClient, "delete", new=AsyncMock(return_value=session)
|
||||
|
||||
@@ -343,15 +343,19 @@ async def test_setup_preflights_model_before_starting(
|
||||
assert candidate.scope_mode == "diff"
|
||||
assert candidate.diff_base == "origin/main"
|
||||
|
||||
monkeypatch.setattr(go_tui, "persist_current", lambda: calls.append("persist"))
|
||||
monkeypatch.setattr(go_tui, "build_targets_info", build)
|
||||
monkeypatch.setattr(go_tui, "prepare_run", prepare)
|
||||
monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: calls.append("telemetry"))
|
||||
monkeypatch.setattr(runtime, "init_run_state", lambda: calls.append("state"))
|
||||
monkeypatch.setattr(runtime, "start_scan", lambda: calls.append("scan"))
|
||||
|
||||
# The controller runs these two in turn for every setup launch.
|
||||
await runtime.ensure_model_verified()
|
||||
await runtime.start_from_setup()
|
||||
|
||||
assert calls == ["preflight", "targets", "prepare", "telemetry", "state", "scan"]
|
||||
# The same steps, in the same order, as a direct launch's prepare_and_start.
|
||||
assert calls == ["preflight", "persist", "targets", "prepare", "telemetry", "state", "scan"]
|
||||
assert runtime.args.scan_mode == "quick"
|
||||
assert runtime.args.instruction == ""
|
||||
assert runtime.args.max_budget_usd == 8.5
|
||||
@@ -360,35 +364,138 @@ async def test_setup_preflights_model_before_starting(
|
||||
assert runtime.args.diff_base == "origin/main"
|
||||
|
||||
|
||||
def _setup_model(
|
||||
monkeypatch: pytest.MonkeyPatch, model: str | None = "openrouter/test-model"
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
go_tui,
|
||||
"load_settings",
|
||||
lambda: SimpleNamespace(llm=SimpleNamespace(model=model)),
|
||||
)
|
||||
|
||||
|
||||
def _setup_messages(runtime: GoTuiRuntime) -> list[tuple[str, str]]:
|
||||
return [(message["level"], message["text"]) for message in runtime.controller.messages]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_optimistic_setup_skips_model_preflight(
|
||||
async def test_setup_model_check_reports_success_in_the_setup_log(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
runtime = GoTuiRuntime(args())
|
||||
runtime.controller.targets = [str(Path.cwd())]
|
||||
calls: list[str] = []
|
||||
|
||||
async def preflight(model: str) -> None:
|
||||
calls.append(model)
|
||||
|
||||
_setup_model(monkeypatch)
|
||||
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
|
||||
|
||||
await runtime.check_setup_model()
|
||||
|
||||
assert calls == ["openrouter/test-model"]
|
||||
assert runtime.model_verified is True
|
||||
assert _setup_messages(runtime) == [
|
||||
("info", "Verifying model connection..."),
|
||||
("info", "Model connection verified"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_model_check_reports_failure_without_leaving_setup(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
runtime = GoTuiRuntime(args())
|
||||
|
||||
async def preflight(_model: str) -> None:
|
||||
raise TimeoutError("connection timed out")
|
||||
|
||||
_setup_model(monkeypatch)
|
||||
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
|
||||
|
||||
await runtime.check_setup_model()
|
||||
|
||||
assert runtime.model_verified is False
|
||||
assert runtime.controller.setup_mode is True
|
||||
assert runtime.controller.scan_state == "setup"
|
||||
assert _setup_messages(runtime)[-1] == (
|
||||
"error",
|
||||
"Model connection failed: connection timed out",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_model_check_waits_for_a_configured_model(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
runtime = GoTuiRuntime(args())
|
||||
|
||||
_setup_model(monkeypatch, model=None)
|
||||
monkeypatch.setattr(
|
||||
go_tui,
|
||||
"preflight_model_connection",
|
||||
lambda _model: pytest.fail("nothing to check without a model"),
|
||||
)
|
||||
|
||||
await runtime.check_setup_model()
|
||||
|
||||
assert runtime.model_verified is False
|
||||
assert runtime.controller.messages == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_model_verified_reuses_the_startup_check(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
runtime = GoTuiRuntime(args())
|
||||
release = asyncio.Event()
|
||||
calls: list[str] = []
|
||||
|
||||
async def preflight(_model: str) -> None:
|
||||
calls.append("preflight")
|
||||
await release.wait()
|
||||
|
||||
monkeypatch.setattr(
|
||||
go_tui,
|
||||
"load_settings",
|
||||
lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")),
|
||||
)
|
||||
_setup_model(monkeypatch)
|
||||
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
|
||||
monkeypatch.setattr(go_tui, "build_targets_info", lambda _args, **_kw: calls.append("targets"))
|
||||
monkeypatch.setattr(go_tui, "prepare_run", lambda _args: calls.append("prepare"))
|
||||
monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: calls.append("telemetry"))
|
||||
monkeypatch.setattr(runtime, "init_run_state", lambda: calls.append("state"))
|
||||
monkeypatch.setattr(runtime, "start_scan", lambda: calls.append("scan"))
|
||||
runtime._setup_preflight = asyncio.create_task(runtime.check_setup_model())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
await runtime.start_from_setup(verify=False)
|
||||
# A launch that arrives mid-check waits for it rather than racing a second
|
||||
# round trip.
|
||||
ensure = asyncio.create_task(runtime.ensure_model_verified())
|
||||
await asyncio.sleep(0)
|
||||
assert not ensure.done()
|
||||
release.set()
|
||||
await ensure
|
||||
|
||||
# No preflight: the scan launches straight through and any model error
|
||||
# surfaces once the agent runs.
|
||||
assert "preflight" not in calls
|
||||
assert calls == ["targets", "prepare", "telemetry", "state", "scan"]
|
||||
assert calls == ["preflight"]
|
||||
assert runtime.model_verified is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_model_verified_retries_after_a_failed_startup_check(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
runtime = GoTuiRuntime(args())
|
||||
outcomes = iter([TimeoutError("connection timed out"), None])
|
||||
calls: list[str] = []
|
||||
|
||||
async def preflight(_model: str) -> None:
|
||||
calls.append("preflight")
|
||||
outcome = next(outcomes)
|
||||
if outcome is not None:
|
||||
raise outcome
|
||||
|
||||
_setup_model(monkeypatch)
|
||||
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
|
||||
|
||||
await runtime.check_setup_model()
|
||||
assert runtime.model_verified is False
|
||||
|
||||
await runtime.ensure_model_verified()
|
||||
|
||||
assert calls == ["preflight", "preflight"]
|
||||
assert runtime.model_verified is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -400,15 +507,8 @@ async def test_confirmed_target_less_launch_mounts_workspace_without_targets(
|
||||
runtime.controller.workspace_mount = str(Path.home())
|
||||
prepared: list[argparse.Namespace] = []
|
||||
|
||||
async def preflight(_model: str) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
go_tui,
|
||||
"load_settings",
|
||||
lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")),
|
||||
)
|
||||
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
|
||||
_setup_model(monkeypatch)
|
||||
monkeypatch.setattr(go_tui, "persist_current", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
go_tui,
|
||||
"build_targets_info",
|
||||
@@ -419,7 +519,7 @@ async def test_confirmed_target_less_launch_mounts_workspace_without_targets(
|
||||
monkeypatch.setattr(runtime, "init_run_state", lambda: None)
|
||||
monkeypatch.setattr(runtime, "start_scan", lambda: None)
|
||||
|
||||
await runtime.start_from_setup(verify=False)
|
||||
await runtime.start_from_setup()
|
||||
|
||||
assert prepared[0].workspace_mount == str(Path.home())
|
||||
assert prepared[0].targets_info == []
|
||||
@@ -442,15 +542,8 @@ async def test_setup_preserves_prepared_cli_targets(
|
||||
runtime = GoTuiRuntime(runtime_args)
|
||||
calls: list[str] = []
|
||||
|
||||
async def preflight(_model: str) -> None:
|
||||
calls.append("preflight")
|
||||
|
||||
monkeypatch.setattr(
|
||||
go_tui,
|
||||
"load_settings",
|
||||
lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")),
|
||||
)
|
||||
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
|
||||
_setup_model(monkeypatch)
|
||||
monkeypatch.setattr(go_tui, "persist_current", lambda: calls.append("persist"))
|
||||
monkeypatch.setattr(
|
||||
go_tui,
|
||||
"build_targets_info",
|
||||
@@ -465,7 +558,7 @@ async def test_setup_preserves_prepared_cli_targets(
|
||||
|
||||
assert runtime.controller.targets == ["https://example.com"]
|
||||
assert runtime.args.targets_info[0]["type"] == "web"
|
||||
assert calls == ["preflight", "prepare", "telemetry", "state", "scan"]
|
||||
assert calls == ["persist", "prepare", "telemetry", "state", "scan"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -798,19 +891,17 @@ async def test_setup_preflight_failure_does_not_start_scan(
|
||||
nonlocal started
|
||||
started = True
|
||||
|
||||
monkeypatch.setattr(
|
||||
go_tui,
|
||||
"load_settings",
|
||||
lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")),
|
||||
)
|
||||
_setup_model(monkeypatch)
|
||||
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
|
||||
monkeypatch.setattr(go_tui, "persist_current", mark_started)
|
||||
monkeypatch.setattr(go_tui, "build_targets_info", mark_started)
|
||||
monkeypatch.setattr(runtime, "init_run_state", mark_started)
|
||||
monkeypatch.setattr(runtime, "start_scan", mark_started)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Model connection failed: 401 Unauthorized"):
|
||||
await runtime.start_from_setup()
|
||||
await runtime.ensure_model_verified()
|
||||
|
||||
assert runtime.model_verified is False
|
||||
assert started is False
|
||||
assert runtime.scan_task is None
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"""The import warm-up thread must never leave the import system poisoned.
|
||||
"""The import warm-up thread must never race the main thread into the engine.
|
||||
|
||||
Field failure: the warm-up thread's ``strix.core.runner`` import and the main
|
||||
thread's ``strix.report`` import both walked the agents SDK graph, and the two
|
||||
held each other's import locks (report -> dedupe -> agents while runner ->
|
||||
hooks -> report.state). CPython's deadlock avoidance breaks such a cycle by
|
||||
failing one import, which strands finished submodules in ``sys.modules`` with
|
||||
their parent package gone — and the next import of one of those submodules
|
||||
crashes with "partially initialized module".
|
||||
Two threads that enter the same package graph from different modules hold
|
||||
each other's import locks (warm-up: ``strix.core.runner`` -> ``agents``;
|
||||
main: ``agents.models.interface``). CPython breaks such a cycle by failing one
|
||||
of the imports, so the main thread waits for the warm-up before its first
|
||||
engine import.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -14,10 +12,16 @@ from __future__ import annotations
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
import threading
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from strix.llm import warmup
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pytest
|
||||
|
||||
|
||||
def _run(code: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run( # noqa: S603
|
||||
[sys.executable, "-c", textwrap.dedent(code)],
|
||||
@@ -56,44 +60,49 @@ def test_check_duplicate_resolves_lazily() -> None:
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_failed_warm_import_purges_orphaned_submodules() -> None:
|
||||
def test_wait_for_import_warmup_lets_main_thread_import_the_agents_graph() -> None:
|
||||
result = _run(
|
||||
"""
|
||||
import sys
|
||||
|
||||
from strix.llm.warmup import _warm
|
||||
from strix.llm.warmup import start_import_warmup, wait_for_import_warmup
|
||||
|
||||
# A package whose import fails after a submodule already completed:
|
||||
# CPython removes the package but leaves the submodule stranded.
|
||||
import pathlib
|
||||
import tempfile
|
||||
# Same shape as the CLI: warm-up starts, then the main thread needs a
|
||||
# module from the middle of the agents graph.
|
||||
start_import_warmup()
|
||||
wait_for_import_warmup()
|
||||
|
||||
root = pathlib.Path(tempfile.mkdtemp())
|
||||
pkg = root / "stranded_pkg"
|
||||
pkg.mkdir()
|
||||
(pkg / "ok.py").write_text("VALUE = 1")
|
||||
(pkg / "__init__.py").write_text("from . import ok\\nraise RuntimeError('boom')")
|
||||
sys.path.insert(0, str(root))
|
||||
from agents.models.interface import ModelTracing # noqa: F401
|
||||
|
||||
_warm(("stranded_pkg",))
|
||||
|
||||
assert "stranded_pkg" not in sys.modules
|
||||
assert "stranded_pkg.ok" not in sys.modules, "orphan survived the purge"
|
||||
|
||||
# And the subtree imports cleanly afterwards up to the real error.
|
||||
try:
|
||||
import stranded_pkg # noqa: F401
|
||||
except RuntimeError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected the package's own error")
|
||||
assert "agents" in sys.modules
|
||||
assert "agents.models" in sys.modules
|
||||
assert "strix.core.runner" in sys.modules
|
||||
"""
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_purge_does_not_touch_preexisting_or_healthy_modules() -> None:
|
||||
before = frozenset(sys.modules) - {"strix.llm.warmup"}
|
||||
warmup._purge_orphaned_modules(before)
|
||||
assert "strix.llm.warmup" in sys.modules # parent chain intact -> kept
|
||||
assert "strix" in sys.modules
|
||||
def test_wait_for_import_warmup_blocks_until_the_thread_finishes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
release = threading.Event()
|
||||
monkeypatch.setattr(warmup, "_warm", lambda _modules: release.wait())
|
||||
monkeypatch.setattr(warmup, "_thread", None)
|
||||
warmup.start_import_warmup(())
|
||||
|
||||
waiter = threading.Thread(target=warmup.wait_for_import_warmup)
|
||||
waiter.start()
|
||||
waiter.join(0.2)
|
||||
assert waiter.is_alive(), "returned before the warm-up finished"
|
||||
|
||||
release.set()
|
||||
waiter.join(5)
|
||||
assert not waiter.is_alive()
|
||||
|
||||
|
||||
def test_failed_warm_import_does_not_raise() -> None:
|
||||
warmup._warm(("strix_no_such_module_for_warmup_test",))
|
||||
|
||||
|
||||
def test_wait_for_import_warmup_is_a_no_op_without_a_thread() -> None:
|
||||
warmup.wait_for_import_warmup()
|
||||
|
||||
@@ -73,6 +73,41 @@ def test_hydrate_from_run_dir_strips_control_chars_from_title(
|
||||
assert md_path.read_text(encoding="utf-8").startswith("# XSS in search form\n")
|
||||
|
||||
|
||||
def test_hydrate_names_the_class_a_legacy_record_always_had(
|
||||
report_state: ReportState,
|
||||
) -> None:
|
||||
# A run started before the class was persisted still holds the package metadata
|
||||
# of a dependency finding, and resume must not read it as a dynamic one.
|
||||
(report_state.get_run_dir() / "vulnerabilities.json").write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"id": "vuln-0001",
|
||||
"title": "Directus 11.5.1 is affected by CVE-2025-55746",
|
||||
"severity": "medium",
|
||||
"timestamp": "2026-01-01 00:00:00 UTC",
|
||||
"dependency_metadata": {
|
||||
"package_name": "directus",
|
||||
"installed_version": "11.5.1",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "vuln-0002",
|
||||
"title": "Reflected XSS in search",
|
||||
"severity": "medium",
|
||||
"timestamp": "2026-01-01 00:00:00 UTC",
|
||||
},
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
report_state.hydrate_from_run_dir()
|
||||
|
||||
assert report_state.vulnerability_reports[0]["finding_class"] == "dependency_cve"
|
||||
assert report_state.vulnerability_reports[1]["finding_class"] == "dynamic"
|
||||
|
||||
|
||||
def _seed(state: ReportState) -> None:
|
||||
state.add_vulnerability_report(
|
||||
title="Reflected XSS in search",
|
||||
|
||||
@@ -301,6 +301,62 @@ async def test_call_http_rejection_preserves_session(
|
||||
await session.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_jsonrpc_error_preserves_session(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# A JSON-RPC error is a well-formed reply to this request, so the session stays
|
||||
# up: no reconnect, no retry, no quarantine. The streamable-HTTP client also
|
||||
# synthesizes one (status-less "Session terminated") for an HTTP 404, which some
|
||||
# providers return for a missing resource.
|
||||
error = McpError(ErrorData(code=32600, message="Session terminated"))
|
||||
builds = 0
|
||||
|
||||
def build(_config: Any) -> Any:
|
||||
nonlocal builds
|
||||
builds += 1
|
||||
return _built_server(_sequence_server("rpc-error", error))
|
||||
|
||||
monkeypatch.setattr(mcp_client, "_build_server", build)
|
||||
monkeypatch.setattr(mcp_session, "_retry_delay", _zero_delay)
|
||||
|
||||
session = mcp_session.SupervisedMcpSession(_config("rpc-error"))
|
||||
assert await session.start()
|
||||
result = await session.dispatch("read", {}, label="rpc_error_read")
|
||||
assert result["success"] is False
|
||||
assert "not the connection" in result["content"]
|
||||
assert "still available" in result["content"]
|
||||
assert session.is_dead is False
|
||||
assert session.is_unavailable is False
|
||||
assert session._quarantine_count == 0
|
||||
assert builds == 1
|
||||
await session.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_during_quarantine_reports_temporary_state(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(mcp_session, "_retry_delay", _zero_delay)
|
||||
monkeypatch.setattr(asyncio, "sleep", _no_sleep)
|
||||
clock = [100.0]
|
||||
monkeypatch.setattr("strix.tools.mcp.session.time.monotonic", lambda: clock[0])
|
||||
builds = iter([_sequence_server("cooldown", _http_error(500)) for _ in range(3)])
|
||||
monkeypatch.setattr(mcp_client, "_build_server", lambda _config: _built_server(next(builds)))
|
||||
session = mcp_session.SupervisedMcpSession(_config("cooldown"))
|
||||
assert await session.start()
|
||||
await session.dispatch("read", {}, label="cooldown_read")
|
||||
assert session.is_unavailable is True
|
||||
|
||||
with pytest.raises(mcp_session.McpConnectionUnavailableError) as excinfo:
|
||||
await session.list_tools()
|
||||
message = str(excinfo.value)
|
||||
assert "temporarily unavailable" in message
|
||||
assert "retrying in about 30 seconds" in message
|
||||
assert "rest of this run" not in message
|
||||
await session.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_http_403_during_list_tools_dies() -> None:
|
||||
server = _list_tools_error_server("connect-403", _http_error(403))
|
||||
|
||||
@@ -72,6 +72,13 @@ def test_recommended_models_are_matched_case_insensitively() -> None:
|
||||
"moonshot/kimi-k2.6",
|
||||
"kimi-k2.7-code",
|
||||
"moonshot/kimi-k3",
|
||||
"anthropic/claude-fable-5-1",
|
||||
"vertex_ai/claude-fable-5-1@default",
|
||||
"gemini/gemini-3.7-flash",
|
||||
"glm-5.3",
|
||||
"zai/glm-5.3-flash",
|
||||
"openrouter/z-ai/glm-5.3",
|
||||
"novita/zai-org/glm-5.2",
|
||||
],
|
||||
)
|
||||
def test_frontier_model_families_are_accepted(model_name: str) -> None:
|
||||
@@ -92,6 +99,9 @@ def test_frontier_model_families_are_accepted(model_name: str) -> None:
|
||||
"openrouter/x-ai/grok-4",
|
||||
"mistral/mistral-medium-3-5",
|
||||
"mistral/magistral-medium-latest",
|
||||
"zai/glm-4.7",
|
||||
"openrouter/z-ai/glm-5",
|
||||
"custom-provider/glm-5.3-local",
|
||||
],
|
||||
)
|
||||
def test_non_frontier_models_are_rejected(model_name: str) -> None:
|
||||
|
||||
@@ -11,7 +11,8 @@ PYPROJECT = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
||||
|
||||
def _optional_dependencies() -> dict[str, list[str]]:
|
||||
data = tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))
|
||||
return data["project"]["optional-dependencies"]
|
||||
extras: dict[str, list[str]] = data["project"]["optional-dependencies"]
|
||||
return extras
|
||||
|
||||
|
||||
def test_vertex_extra_pins_google_auth() -> None:
|
||||
|
||||
@@ -16,8 +16,10 @@ from strix.tools.finish.tool import finish_scan
|
||||
from strix.tools.reporting.tool import (
|
||||
_do_create,
|
||||
_do_create_dependency,
|
||||
_do_update,
|
||||
create_dependency_report,
|
||||
create_vulnerability_report,
|
||||
update_vulnerability_report,
|
||||
)
|
||||
|
||||
|
||||
@@ -1251,3 +1253,507 @@ async def test_dependency_report_rejects_contextual_breakdown_without_reasoning(
|
||||
assert result["success"] is False
|
||||
assert any("contextual_cvss_reasoning is required" in error for error in result["errors"])
|
||||
assert report_state.vulnerability_reports == []
|
||||
|
||||
|
||||
_CONFIRMED_KWARGS: dict[str, Any] = {
|
||||
"title": "Unauthenticated file write on /files/{id}",
|
||||
"description": "A multipart PATCH writes attacker content before the permission check.",
|
||||
"impact": "Any anonymous user overwrites stored files and serves attacker content.",
|
||||
"target": "https://cms.example.com",
|
||||
"technical_analysis": "disk.write runs before the authorization guard.",
|
||||
"poc_description": "1. PATCH /files/<uuid> with a multipart body as an anonymous user.",
|
||||
"poc_script_code": "PATCH /files/2f1c HTTP/1.1\n\n--x\nowned\n--x--",
|
||||
"remediation_steps": "Authorize before the write.",
|
||||
"evidence": "The stored file returns the injected payload after the 403 response.",
|
||||
"assumptions": "Assumes the uuid of one existing file is known.",
|
||||
"counterevidence": "The endpoint answers 403, yet the write already landed.",
|
||||
"confidence": "HIGH",
|
||||
"confidence_rationale": "The write was observed end to end against the live host.",
|
||||
"severity_change_conditions": "A guard before disk.write would remove the impact.",
|
||||
"fix_effort": "MEDIUM",
|
||||
"cvss_breakdown": _CVSS,
|
||||
"endpoint": "/files/{id}",
|
||||
"method": "PATCH",
|
||||
"cve": "CVE-2025-55746",
|
||||
"cwe": "CWE-863",
|
||||
"code_locations": None,
|
||||
}
|
||||
|
||||
|
||||
def _seed_weak_report(report_state: ReportState) -> None:
|
||||
"""A version-based, unproven entry for the same issue, as an earlier agent files it."""
|
||||
report_state.vulnerability_reports.append(
|
||||
{
|
||||
"id": "vuln-0009",
|
||||
"title": "Directus 11.5.1 exposed on public host (in scope for CVE-2025-55746)",
|
||||
"severity": "medium",
|
||||
"timestamp": "2026-01-01 00:00:00 UTC",
|
||||
"description": "The banner reports a version affected by CVE-2025-55746.",
|
||||
"target": "https://cms.example.com",
|
||||
"confidence": "low",
|
||||
"evidence": "The version banner only.",
|
||||
"cvss": 5.3,
|
||||
"finding_class": "dynamic",
|
||||
"agent_id": "aaaa1111",
|
||||
}
|
||||
)
|
||||
report_state._saved_vuln_ids.add("vuln-0009")
|
||||
|
||||
|
||||
async def test_duplicate_verdict_rejects_without_touching_the_existing_report(
|
||||
report_state: ReportState, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Deduplication only answers identity. A duplicate is rejected and points at the
|
||||
finding it matched; revising that finding is a separate, explicit operation."""
|
||||
_seed_weak_report(report_state)
|
||||
|
||||
async def fake_check_duplicate(
|
||||
_candidate: dict[str, Any], _existing: list[dict[str, Any]]
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"is_duplicate": True,
|
||||
"duplicate_id": "vuln-0009",
|
||||
"confidence": 0.9,
|
||||
"reason": "Same root cause on the same endpoint.",
|
||||
}
|
||||
|
||||
monkeypatch.setattr("strix.report.dedupe.check_duplicate", fake_check_duplicate)
|
||||
|
||||
result = await _do_create(**_CONFIRMED_KWARGS, agent_id="834f79fb", agent_name="Validation")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["duplicate_of"] == "vuln-0009"
|
||||
assert "action" not in result
|
||||
assert len(report_state.vulnerability_reports) == 1
|
||||
report = report_state.vulnerability_reports[0]
|
||||
assert report["severity"] == "medium", "a duplicate verdict never edits the matched finding"
|
||||
assert "poc_script_code" not in report
|
||||
assert "update_history" not in report
|
||||
|
||||
|
||||
def test_update_vulnerability_report_records_chained_impact(report_state: ReportState) -> None:
|
||||
"""Attack chaining raises the impact of a finding already on file."""
|
||||
_seed_weak_report(report_state)
|
||||
|
||||
updated = report_state.update_vulnerability_report(
|
||||
"vuln-0009",
|
||||
{
|
||||
"severity": "CRITICAL",
|
||||
"cvss": 9.8,
|
||||
"impact": "The overwritten file loads in an admin session and takes over the account.",
|
||||
"id": "vuln-9999",
|
||||
"finding_class": "static",
|
||||
},
|
||||
update_reason="A chained admin takeover follows the file write.",
|
||||
)
|
||||
|
||||
assert updated is not None
|
||||
assert updated["id"] == "vuln-0009", "identity fields are not updatable"
|
||||
assert updated["finding_class"] == "dynamic"
|
||||
assert updated["severity"] == "critical"
|
||||
assert updated["updated_at"]
|
||||
assert report_state.update_vulnerability_report("vuln-0404", {"severity": "high"}) is None
|
||||
|
||||
|
||||
def test_update_vulnerability_report_ignores_identical_content(report_state: ReportState) -> None:
|
||||
_seed_weak_report(report_state)
|
||||
assert report_state.update_vulnerability_report("vuln-0009", {"severity": "medium"}) is None
|
||||
assert "update_history" not in report_state.vulnerability_reports[0]
|
||||
|
||||
|
||||
def test_update_drops_reasoning_left_behind_by_the_field_it_describes(
|
||||
report_state: ReportState,
|
||||
) -> None:
|
||||
"""A rating the update replaces must not keep the rationale for the old one."""
|
||||
_seed_weak_report(report_state)
|
||||
report = report_state.vulnerability_reports[0]
|
||||
report["confidence_rationale"] = "Nothing was executed; the version banner is the only signal."
|
||||
report["cvss_breakdown"] = {"attack_vector": "network", "user_interaction": "required"}
|
||||
report["severity_change_conditions"] = "Confirming the write would raise this."
|
||||
|
||||
updated = report_state.update_vulnerability_report(
|
||||
"vuln-0009",
|
||||
{
|
||||
"confidence": "high",
|
||||
"severity": "critical",
|
||||
"cvss": 9.8,
|
||||
"severity_change_conditions": "A guard before the write would remove the impact.",
|
||||
},
|
||||
)
|
||||
|
||||
assert updated is not None
|
||||
assert "confidence_rationale" not in updated, "the superseded rationale must not survive"
|
||||
assert "cvss_breakdown" not in updated
|
||||
assert updated["severity_change_conditions"].startswith("A guard"), (
|
||||
"a replacement the update supplies is kept, not dropped"
|
||||
)
|
||||
assert updated["update_history"][0]["dropped_fields"] == [
|
||||
"confidence_rationale",
|
||||
"cvss_breakdown",
|
||||
]
|
||||
|
||||
run_dir = report_state._run_dir
|
||||
assert run_dir is not None
|
||||
markdown = (run_dir / "vulnerabilities" / "vuln-0009.md").read_text(encoding="utf-8")
|
||||
assert "version banner is the only signal" not in markdown
|
||||
assert "Dropped as superseded: confidence_rationale, cvss_breakdown" in markdown
|
||||
|
||||
|
||||
def test_agent_revises_its_own_report_without_a_duplicate_verdict(
|
||||
report_state: ReportState,
|
||||
) -> None:
|
||||
"""Editing a finding is its own operation: no dedupe verdict is involved."""
|
||||
_seed_weak_report(report_state)
|
||||
|
||||
result = _do_update(
|
||||
report_id="vuln-0009",
|
||||
update_reason="An unauthenticated PATCH wrote the file, so the finding is confirmed.",
|
||||
fields={
|
||||
"poc_script_code": "PATCH /files/2f1c HTTP/1.1",
|
||||
"confidence": "HIGH",
|
||||
"confidence_rationale": "The write was replayed twice.",
|
||||
"cvss_breakdown": _CVSS,
|
||||
"severity_change_conditions": "A guard before the write would remove the impact.",
|
||||
},
|
||||
agent_id="834f79fb",
|
||||
agent_name="Directus CVE-2025-55746 Validation Agent",
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["action"] == "updated"
|
||||
assert result["severity"] == "critical"
|
||||
assert result["cvss_score"] == pytest.approx(9.8)
|
||||
assert "cvss" in result["updated_fields"], "a new vector carries its own score"
|
||||
assert len(report_state.vulnerability_reports) == 1
|
||||
|
||||
report = report_state.vulnerability_reports[0]
|
||||
assert report["id"] == "vuln-0009"
|
||||
assert report["confidence"] == "high"
|
||||
assert report["agent_id"] == "aaaa1111", "the original reporter stays on the finding"
|
||||
history = report["update_history"]
|
||||
assert history[0]["agent_name"] == "Directus CVE-2025-55746 Validation Agent"
|
||||
assert history[0]["reason"].startswith("An unauthenticated PATCH")
|
||||
|
||||
run_dir = report_state._run_dir
|
||||
assert run_dir is not None
|
||||
markdown = (run_dir / "vulnerabilities" / "vuln-0009.md").read_text(encoding="utf-8")
|
||||
assert "PATCH /files/2f1c" in markdown
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("report_id", "update_reason", "fields", "expected"),
|
||||
[
|
||||
(" ", "reason", {"impact": "x"}, "report_id cannot be empty"),
|
||||
("vuln-0009", " ", {"impact": "x"}, "update_reason cannot be empty"),
|
||||
("vuln-0009", "reason", {}, "No fields to update"),
|
||||
("vuln-0404", "reason", {"impact": "x"}, "not found"),
|
||||
],
|
||||
)
|
||||
def test_update_rejects_a_call_it_cannot_act_on(
|
||||
report_state: ReportState,
|
||||
report_id: str,
|
||||
update_reason: str,
|
||||
fields: dict[str, Any],
|
||||
expected: str,
|
||||
) -> None:
|
||||
_seed_weak_report(report_state)
|
||||
|
||||
result = _do_update(report_id=report_id, update_reason=update_reason, fields=fields)
|
||||
|
||||
assert result["success"] is False
|
||||
assert expected in result["error"]
|
||||
assert "update_history" not in report_state.vulnerability_reports[0]
|
||||
|
||||
|
||||
def test_update_reports_every_invalid_field_at_once(report_state: ReportState) -> None:
|
||||
_seed_weak_report(report_state)
|
||||
|
||||
result = _do_update(
|
||||
report_id="vuln-0009",
|
||||
update_reason="Raising the rating.",
|
||||
fields={
|
||||
"confidence": "very high",
|
||||
"fix_effort": "weeks",
|
||||
"cvss_breakdown": {**_CVSS, "attack_vector": "X"},
|
||||
"cve": "CVE-BAD",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
joined = " ".join(result["errors"])
|
||||
assert "confidence" in joined
|
||||
assert "fix_effort" in joined
|
||||
assert "attack_vector" in joined
|
||||
assert "CVE" in joined
|
||||
assert report_state.vulnerability_reports[0]["confidence"] == "low", "nothing was applied"
|
||||
|
||||
|
||||
def test_update_wants_verification_for_a_fix_it_would_apply(
|
||||
report_state: ReportState,
|
||||
) -> None:
|
||||
_seed_weak_report(report_state)
|
||||
|
||||
result = _do_update(
|
||||
report_id="vuln-0009",
|
||||
update_reason="Adding the file the write lands in.",
|
||||
fields={
|
||||
"code_locations": [
|
||||
{
|
||||
"file": "api/src/controllers/files.ts",
|
||||
"start_line": 42,
|
||||
"fix_before": "await storage.write(id, body)",
|
||||
"fix_after": "await assertPermission(req); await storage.write(id, body)",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert any("fix_verification" in error for error in result["errors"])
|
||||
|
||||
|
||||
def test_update_says_so_when_the_report_already_carries_it(
|
||||
report_state: ReportState,
|
||||
) -> None:
|
||||
_seed_weak_report(report_state)
|
||||
|
||||
result = _do_update(
|
||||
report_id="vuln-0009",
|
||||
update_reason="Restating the severity.",
|
||||
fields={"confidence": "low"},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "already says this" in result["error"]
|
||||
assert result["report_id"] == "vuln-0009"
|
||||
|
||||
|
||||
def test_update_tool_asks_for_the_report_and_the_reason() -> None:
|
||||
schema = update_vulnerability_report.params_json_schema
|
||||
assert set(schema["required"]) >= {"report_id", "update_reason"}
|
||||
assert "cvss_breakdown" in schema["properties"]
|
||||
assert "id" not in schema["properties"], "identity fields are not editable"
|
||||
description = update_vulnerability_report.description
|
||||
assert "not deduplication" in description
|
||||
|
||||
|
||||
def test_update_keeps_an_exploit_out_of_a_dependency_finding(report_state: ReportState) -> None:
|
||||
"""A dependency record is rated from its advisory, so a revision must not write a
|
||||
PoC and a dynamic rating onto it. The proof belongs in its own finding."""
|
||||
_seed_weak_report(report_state)
|
||||
dependency_report = report_state.vulnerability_reports[0]
|
||||
dependency_report["finding_class"] = "dependency_cve"
|
||||
dependency_report["dependency_metadata"] = {
|
||||
"package_name": "directus",
|
||||
"installed_version": "11.5.1",
|
||||
}
|
||||
|
||||
result = _do_update(
|
||||
report_id="vuln-0009",
|
||||
update_reason="An unauthenticated PATCH wrote the file.",
|
||||
fields={
|
||||
"poc_script_code": "PATCH /files/2f1c HTTP/1.1",
|
||||
"endpoint": "/files/{uuid}",
|
||||
"cvss_breakdown": _CVSS,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "dependency_cve" in result["error"]
|
||||
assert set(result["rejected_fields"]) == {"endpoint", "poc_script_code"}
|
||||
assert dependency_report["severity"] == "medium"
|
||||
assert "poc_script_code" not in dependency_report
|
||||
assert "update_history" not in dependency_report
|
||||
|
||||
|
||||
def _seed_dependency_report(report_state: ReportState) -> dict[str, Any]:
|
||||
_seed_weak_report(report_state)
|
||||
dependency_report = report_state.vulnerability_reports[0]
|
||||
dependency_report["finding_class"] = "dependency_cve"
|
||||
dependency_report["dependency_metadata"] = {
|
||||
"package_name": "directus",
|
||||
"installed_version": "11.5.1",
|
||||
"manifest_path": "package-lock.json",
|
||||
"advisory_cvss": 9.8,
|
||||
"contextual_cvss_breakdown": {**_CVSS, "confidentiality": "L"},
|
||||
"contextual_cvss_score": 5.3,
|
||||
"contextual_cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N",
|
||||
"contextual_cvss_reasoning": "The vulnerable API is imported but never called.",
|
||||
}
|
||||
return dependency_report
|
||||
|
||||
|
||||
def test_update_re_rates_a_dependency_finding_through_its_contextual_cvss(
|
||||
report_state: ReportState,
|
||||
) -> None:
|
||||
"""A dependency finding is rated in the context of the codebase. A revised
|
||||
breakdown replaces that contextual rating, with the reasoning a reader can
|
||||
check, and leaves the package identity alone."""
|
||||
dependency_report = _seed_dependency_report(report_state)
|
||||
|
||||
result = _do_update(
|
||||
report_id="vuln-0009",
|
||||
update_reason="A call path from the upload handler to the vulnerable API was found.",
|
||||
fields={
|
||||
"cvss_breakdown": _CVSS,
|
||||
"contextual_cvss_reasoning": (
|
||||
"routes/upload.ts:88 reaches the affected parser with user input."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["severity"] == "critical"
|
||||
assert dependency_report["severity"] == "critical"
|
||||
assert dependency_report["cvss"] == 9.8
|
||||
assert "cvss_breakdown" not in dependency_report
|
||||
assert "contextual_cvss_reasoning" not in dependency_report
|
||||
metadata = dependency_report["dependency_metadata"]
|
||||
assert metadata["package_name"] == "directus"
|
||||
assert metadata["installed_version"] == "11.5.1"
|
||||
assert metadata["manifest_path"] == "package-lock.json"
|
||||
assert metadata["advisory_cvss"] == 9.8
|
||||
assert metadata["contextual_cvss_breakdown"] == _CVSS
|
||||
assert metadata["contextual_cvss_score"] == 9.8
|
||||
assert metadata["contextual_cvss_vector"] == "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"
|
||||
assert metadata["contextual_cvss_reasoning"].startswith("routes/upload.ts:88")
|
||||
assert dependency_report["finding_class"] == "dependency_cve"
|
||||
history = dependency_report["update_history"]
|
||||
assert history[-1]["previous_severity"] == "medium"
|
||||
assert set(history[-1]["fields"]) == {"cvss", "dependency_metadata", "severity"}
|
||||
|
||||
|
||||
def test_update_wants_the_reasoning_behind_a_dependency_re_rating(
|
||||
report_state: ReportState,
|
||||
) -> None:
|
||||
dependency_report = _seed_dependency_report(report_state)
|
||||
|
||||
result = _do_update(
|
||||
report_id="vuln-0009",
|
||||
update_reason="The parser is reachable.",
|
||||
fields={"cvss_breakdown": _CVSS},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert any("contextual_cvss_reasoning" in error for error in result["errors"])
|
||||
assert dependency_report["severity"] == "medium"
|
||||
assert dependency_report["dependency_metadata"]["contextual_cvss_score"] == 5.3
|
||||
assert "update_history" not in dependency_report
|
||||
|
||||
|
||||
def test_update_corrects_the_reasoning_behind_a_dependency_rating_alone(
|
||||
report_state: ReportState,
|
||||
) -> None:
|
||||
"""The rating on file stays; only its explanation is replaced."""
|
||||
dependency_report = _seed_dependency_report(report_state)
|
||||
|
||||
result = _do_update(
|
||||
report_id="vuln-0009",
|
||||
update_reason="The reasoning named the wrong module.",
|
||||
fields={"contextual_cvss_reasoning": "lib/parser.ts imports it; no call site reaches it."},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["updated_fields"] == ["dependency_metadata"]
|
||||
assert dependency_report["severity"] == "medium"
|
||||
assert dependency_report["cvss"] == 5.3
|
||||
metadata = dependency_report["dependency_metadata"]
|
||||
assert metadata["contextual_cvss_breakdown"] == {**_CVSS, "confidentiality": "L"}
|
||||
assert metadata["contextual_cvss_score"] == 5.3
|
||||
assert metadata["contextual_cvss_reasoning"].startswith("lib/parser.ts")
|
||||
assert metadata["package_name"] == "directus"
|
||||
|
||||
|
||||
def test_update_wants_a_rating_before_reasoning_about_one(
|
||||
report_state: ReportState,
|
||||
) -> None:
|
||||
_seed_weak_report(report_state)
|
||||
dependency_report = report_state.vulnerability_reports[0]
|
||||
dependency_report["finding_class"] = "dependency_cve"
|
||||
dependency_report["dependency_metadata"] = {
|
||||
"package_name": "directus",
|
||||
"installed_version": "11.5.1",
|
||||
}
|
||||
|
||||
result = _do_update(
|
||||
report_id="vuln-0009",
|
||||
update_reason="Explaining the rating.",
|
||||
fields={"contextual_cvss_reasoning": "Reachable."},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert any("cvss_breakdown is required" in error for error in result["errors"])
|
||||
assert "contextual_cvss_reasoning" not in dependency_report["dependency_metadata"]
|
||||
|
||||
|
||||
def test_update_keeps_contextual_reasoning_off_a_dynamic_finding(
|
||||
report_state: ReportState,
|
||||
) -> None:
|
||||
_seed_weak_report(report_state)
|
||||
|
||||
result = _do_update(
|
||||
report_id="vuln-0009",
|
||||
update_reason="Re-rating.",
|
||||
fields={"cvss_breakdown": _CVSS, "contextual_cvss_reasoning": "Reachable."},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["rejected_fields"] == ["contextual_cvss_reasoning"]
|
||||
assert report_state.vulnerability_reports[0]["severity"] == "medium"
|
||||
|
||||
|
||||
def test_update_reads_a_legacy_dependency_record_by_its_metadata(
|
||||
report_state: ReportState,
|
||||
) -> None:
|
||||
"""A dependency finding filed before finding_class was persisted still carries
|
||||
package metadata, so its class is read from that, not defaulted to dynamic."""
|
||||
_seed_weak_report(report_state)
|
||||
dependency_report = report_state.vulnerability_reports[0]
|
||||
dependency_report.pop("finding_class", None)
|
||||
dependency_report["dependency_metadata"] = {
|
||||
"package_name": "directus",
|
||||
"installed_version": "11.5.1",
|
||||
}
|
||||
|
||||
result = _do_update(
|
||||
report_id="vuln-0009",
|
||||
update_reason="An unauthenticated PATCH wrote the file.",
|
||||
fields={"poc_script_code": "PATCH /files/2f1c HTTP/1.1", "cvss_breakdown": _CVSS},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "dependency_cve" in result["error"]
|
||||
assert "poc_script_code" not in dependency_report
|
||||
|
||||
|
||||
def test_update_still_corrects_the_prose_of_a_dependency_finding(
|
||||
report_state: ReportState,
|
||||
) -> None:
|
||||
"""Fields every class carries stay editable on a dependency record."""
|
||||
_seed_weak_report(report_state)
|
||||
dependency_report = report_state.vulnerability_reports[0]
|
||||
dependency_report["finding_class"] = "dependency_cve"
|
||||
|
||||
result = _do_update(
|
||||
report_id="vuln-0009",
|
||||
update_reason="The advisory names a later fixed release than the report says.",
|
||||
fields={"remediation_steps": "Upgrade to 11.5.2 or later."},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert dependency_report["remediation_steps"] == "Upgrade to 11.5.2 or later."
|
||||
assert dependency_report["finding_class"] == "dependency_cve"
|
||||
|
||||
|
||||
def test_update_refuses_code_locations_it_cannot_use(report_state: ReportState) -> None:
|
||||
"""A location without a usable file and line is reported, not dropped in silence."""
|
||||
_seed_weak_report(report_state)
|
||||
|
||||
result = _do_update(
|
||||
report_id="vuln-0009",
|
||||
update_reason="Naming the vulnerable handler.",
|
||||
fields={"code_locations": [{"label": "the file write"}]},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert any("start_line" in error for error in result["errors"])
|
||||
|
||||
@@ -185,6 +185,4 @@ async def test_roster_is_persisted_even_without_a_status_sink(
|
||||
)
|
||||
|
||||
assert persisted, "roster must persist even when no status sink is attached"
|
||||
assert persisted[-1] == [
|
||||
{"name": "local_fs", "provider": None, "tool_count": 3, "dead": False}
|
||||
]
|
||||
assert persisted[-1] == [{"name": "local_fs", "provider": None, "tool_count": 3, "dead": False}]
|
||||
|
||||
@@ -34,7 +34,8 @@ def _finding(**overrides: Any) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _rule_tags(doc: dict[str, Any]) -> list[str]:
|
||||
return doc["runs"][0]["tool"]["driver"]["rules"][0]["properties"]["tags"]
|
||||
tags: list[str] = doc["runs"][0]["tool"]["driver"]["rules"][0]["properties"]["tags"]
|
||||
return tags
|
||||
|
||||
|
||||
def test_stride_tags_on_rule_for_known_cwe() -> None:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
@@ -9,13 +10,40 @@ import pytest
|
||||
from strix.core.sessions import open_agent_session
|
||||
|
||||
|
||||
def _count_open_fds() -> int | None:
|
||||
def _fd_dir() -> Path | None:
|
||||
for path in (Path("/proc/self/fd"), Path("/dev/fd")):
|
||||
if path.is_dir():
|
||||
return len(list(path.iterdir()))
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _count_open_fds() -> int | None:
|
||||
fd_dir = _fd_dir()
|
||||
return None if fd_dir is None else len(list(fd_dir.iterdir()))
|
||||
|
||||
|
||||
def _count_open_fds_to(files: list[Path]) -> int | None:
|
||||
"""Count the descriptors this process holds on exactly ``files``.
|
||||
|
||||
Matching on inode rather than on the process-wide total keeps the check
|
||||
immune to sockets and pipes that unrelated background threads open while
|
||||
the test runs.
|
||||
"""
|
||||
fd_dir = _fd_dir()
|
||||
if fd_dir is None:
|
||||
return None
|
||||
wanted = {(stat.st_dev, stat.st_ino) for stat in (path.stat() for path in files)}
|
||||
held = 0
|
||||
for entry in fd_dir.iterdir():
|
||||
try:
|
||||
stat = os.fstat(int(entry.name))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if (stat.st_dev, stat.st_ino) in wanted:
|
||||
held += 1
|
||||
return held
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sessions_hold_no_descriptors_while_parked(tmp_path: Path) -> None:
|
||||
"""Descriptor use must track live operations, not the number of sessions.
|
||||
@@ -25,21 +53,20 @@ async def test_sessions_hold_no_descriptors_while_parked(tmp_path: Path) -> None
|
||||
scan, and fan-out multiplies those handles until the process runs out of file
|
||||
descriptors (#1018). A session that is not mid-operation should hold none.
|
||||
"""
|
||||
baseline = _count_open_fds()
|
||||
if baseline is None:
|
||||
if _fd_dir() is None:
|
||||
pytest.skip("no /proc/self/fd or /dev/fd on this platform")
|
||||
|
||||
sessions = [open_agent_session(f"a{i}", tmp_path / f"s{i}.db") for i in range(60)]
|
||||
db_paths = [tmp_path / f"s{i}.db" for i in range(60)]
|
||||
sessions = [open_agent_session(f"a{i}", path) for i, path in enumerate(db_paths)]
|
||||
try:
|
||||
for _ in range(4):
|
||||
await asyncio.gather(
|
||||
*(s.add_items([{"role": "user", "content": "x"}]) for s in sessions)
|
||||
)
|
||||
await asyncio.gather(*(s.get_items() for s in sessions))
|
||||
parked = _count_open_fds()
|
||||
assert parked is not None
|
||||
# 60 parked sessions, yet descriptors are back at the baseline.
|
||||
assert parked - baseline <= 5, f"parked fds grew by {parked - baseline}"
|
||||
parked = _count_open_fds_to(db_paths)
|
||||
# 60 parked sessions, yet none of them holds its database open.
|
||||
assert parked == 0, f"parked sessions hold {parked} database descriptors"
|
||||
finally:
|
||||
for s in sessions:
|
||||
s.close()
|
||||
|
||||
125
tests/test_telemetry_errors.py
Normal file
125
tests/test_telemetry_errors.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Error beacons carry a category, phase, and exception class — never a message."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from strix.report.state import ReportState
|
||||
from strix.telemetry import posthog, report_error, scarf, set_scan_phase
|
||||
from strix.telemetry._common import exception_props
|
||||
|
||||
|
||||
PRIVATE_MESSAGE = "private message that must stay on the machine"
|
||||
|
||||
|
||||
def _capture(sent: list[dict[str, Any]], event: str, props: dict[str, Any]) -> bool:
|
||||
sent.append({"event": event, **props})
|
||||
return True
|
||||
|
||||
|
||||
def test_exception_props_uses_bare_name_for_builtins() -> None:
|
||||
assert exception_props(ValueError(PRIVATE_MESSAGE)) == {"exception_type": "ValueError"}
|
||||
|
||||
|
||||
def test_exception_props_prefixes_third_party_top_level_package() -> None:
|
||||
props = exception_props(requests.exceptions.ConnectTimeout(PRIVATE_MESSAGE))
|
||||
assert props == {"exception_type": "requests.ConnectTimeout"}
|
||||
|
||||
|
||||
def _chained(cause: BaseException | None, *, explicit: bool) -> RuntimeError:
|
||||
exc = RuntimeError("wrapped")
|
||||
if explicit:
|
||||
exc.__cause__ = cause
|
||||
exc.__suppress_context__ = True
|
||||
else:
|
||||
exc.__context__ = cause
|
||||
return exc
|
||||
|
||||
|
||||
def test_exception_props_reports_explicit_cause() -> None:
|
||||
props = exception_props(_chained(ConnectionError(PRIVATE_MESSAGE), explicit=True))
|
||||
assert props == {"exception_type": "RuntimeError", "exception_cause": "ConnectionError"}
|
||||
|
||||
|
||||
def test_exception_props_reports_implicit_context() -> None:
|
||||
props = exception_props(_chained(KeyError("k"), explicit=False))
|
||||
assert props["exception_cause"] == "KeyError"
|
||||
|
||||
|
||||
def test_exception_props_ignores_suppressed_context() -> None:
|
||||
exc = _chained(None, explicit=True)
|
||||
exc.__context__ = KeyError("k")
|
||||
assert exception_props(exc) == {"exception_type": "RuntimeError"}
|
||||
|
||||
|
||||
def test_exception_props_unwraps_exception_group() -> None:
|
||||
group = ExceptionGroup("tasks", [TimeoutError("t"), ValueError("v")])
|
||||
assert exception_props(group) == {"exception_type": "TimeoutError"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("telemetry", [posthog, scarf])
|
||||
def test_error_event_carries_phase_and_class_but_no_message(
|
||||
telemetry: Any,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
sent: list[dict[str, Any]] = []
|
||||
monkeypatch.setattr(telemetry, "_send", lambda event, props: _capture(sent, event, props))
|
||||
set_scan_phase("sandbox_init")
|
||||
|
||||
telemetry.error("scan_failed", RuntimeError(PRIVATE_MESSAGE))
|
||||
|
||||
assert len(sent) == 1
|
||||
event = sent[0]
|
||||
assert event["event"] == "error"
|
||||
assert event["error_type"] == "scan_failed"
|
||||
assert event["phase"] == "sandbox_init"
|
||||
assert event["exception_type"] == "RuntimeError"
|
||||
assert PRIVATE_MESSAGE not in repr(event)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("telemetry", [posthog, scarf])
|
||||
def test_error_event_without_exception_omits_exception_fields(
|
||||
telemetry: Any,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
sent: list[dict[str, Any]] = []
|
||||
monkeypatch.setattr(telemetry, "_send", lambda event, props: _capture(sent, event, props))
|
||||
set_scan_phase("startup")
|
||||
|
||||
telemetry.error("docker_not_installed")
|
||||
|
||||
assert sent[0]["error_type"] == "docker_not_installed"
|
||||
assert sent[0]["phase"] == "startup"
|
||||
assert "exception_type" not in sent[0]
|
||||
assert "exception_cause" not in sent[0]
|
||||
|
||||
|
||||
def test_report_error_fans_out_to_both_backends(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
sent: list[dict[str, Any]] = []
|
||||
monkeypatch.setattr(posthog, "_send", lambda event, props: _capture(sent, event, props))
|
||||
monkeypatch.setattr(scarf, "_send", lambda event, props: _capture(sent, event, props))
|
||||
|
||||
report_error("model_connection_failed", TimeoutError(PRIVATE_MESSAGE))
|
||||
|
||||
assert len(sent) == 2
|
||||
assert {e["error_type"] for e in sent} == {"model_connection_failed"}
|
||||
assert {e["exception_type"] for e in sent} == {"TimeoutError"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("telemetry", [posthog, scarf])
|
||||
def test_scan_ended_prefers_recorded_exit_reason(
|
||||
telemetry: Any,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
state = ReportState()
|
||||
state.scan_ended_exit_reason = "budget_exceeded"
|
||||
sent: list[dict[str, Any]] = []
|
||||
monkeypatch.setattr(telemetry, "_send", lambda event, props: _capture(sent, event, props))
|
||||
|
||||
telemetry.end(state, exit_reason="user_exit")
|
||||
|
||||
assert sent[0]["event"] == "scan_ended"
|
||||
assert sent[0]["exit_reason"] == "budget_exceeded"
|
||||
@@ -155,7 +155,7 @@ def test_setup_restores_prepared_cli_targets() -> None:
|
||||
async def test_start_validates_model_before_callback() -> None:
|
||||
started = False
|
||||
|
||||
async def start(_verify: bool = True) -> None:
|
||||
async def start() -> None:
|
||||
nonlocal started
|
||||
started = True
|
||||
|
||||
@@ -170,7 +170,7 @@ async def test_start_validates_model_before_callback() -> None:
|
||||
async def test_start_launches_with_a_configured_model() -> None:
|
||||
started = False
|
||||
|
||||
async def start(_verify: bool = True) -> None:
|
||||
async def start() -> None:
|
||||
nonlocal started
|
||||
started = True
|
||||
|
||||
@@ -189,7 +189,7 @@ async def test_start_launches_with_a_configured_model() -> None:
|
||||
async def test_start_without_target_requires_mount_consent() -> None:
|
||||
started = False
|
||||
|
||||
async def start(_verify: bool = True) -> None:
|
||||
async def start() -> None:
|
||||
nonlocal started
|
||||
started = True
|
||||
|
||||
@@ -200,7 +200,7 @@ async def test_start_without_target_requires_mount_consent() -> None:
|
||||
|
||||
# Mounting the working directory is never silent.
|
||||
with pytest.raises(ValueError, match="No target set"):
|
||||
await controller.handle("setup.start", {"verify": False})
|
||||
await controller.handle("setup.start", {})
|
||||
assert started is False
|
||||
assert controller.targets == []
|
||||
assert controller.workspace_mount is None
|
||||
@@ -211,7 +211,7 @@ async def test_target_less_start_enters_live_view_and_waits_for_the_mount() -> N
|
||||
"""Nothing is prepared until the live-view confirmation is answered."""
|
||||
started = False
|
||||
|
||||
async def start(_verify: bool = True) -> None:
|
||||
async def start() -> None:
|
||||
nonlocal started
|
||||
started = True
|
||||
|
||||
@@ -220,7 +220,7 @@ async def test_target_less_start_enters_live_view_and_waits_for_the_mount() -> N
|
||||
loader._cached = None
|
||||
controller = TuiController(args(), on_start=start)
|
||||
|
||||
result = await controller.handle("setup.start", {"verify": False, "mount_working_dir": True})
|
||||
result = await controller.handle("setup.start", {"mount_working_dir": True})
|
||||
|
||||
assert result == {"started": True}
|
||||
# The live view is up so the prompt can be shown there, but the scan has not
|
||||
@@ -236,26 +236,23 @@ async def test_target_less_start_enters_live_view_and_waits_for_the_mount() -> N
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirming_the_mount_starts_the_scan_without_a_target() -> None:
|
||||
started = False
|
||||
seen_verify: bool | None = None
|
||||
|
||||
async def start(verify: bool = True) -> None:
|
||||
nonlocal started, seen_verify
|
||||
async def start() -> None:
|
||||
nonlocal started
|
||||
started = True
|
||||
seen_verify = verify
|
||||
|
||||
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
|
||||
os.environ["ANTHROPIC_API_KEY"] = "test-key"
|
||||
loader._cached = None
|
||||
controller = TuiController(args(), on_start=start)
|
||||
await controller.handle("setup.start", {"verify": False, "mount_working_dir": True})
|
||||
await controller.handle("setup.start", {"mount_working_dir": True})
|
||||
|
||||
result = await controller.handle("setup.confirm_mount", {"approved": True})
|
||||
|
||||
assert result == {"approved": True}
|
||||
assert started is True
|
||||
# Launched optimistically, and mounted as a workspace: the scan genuinely
|
||||
# has no target, so the instruction is the only source of truth.
|
||||
assert seen_verify is False
|
||||
# Mounted as a workspace: the scan genuinely has no target, so the
|
||||
# instruction is the only source of truth.
|
||||
assert controller.workspace_mount == str(Path.cwd())
|
||||
assert controller.targets == []
|
||||
assert controller.scan_state == "running"
|
||||
@@ -264,22 +261,23 @@ async def test_confirming_the_mount_starts_the_scan_without_a_target() -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_declining_the_mount_runs_without_one() -> None:
|
||||
started: list[bool] = []
|
||||
started = 0
|
||||
|
||||
async def start(verify: bool = True) -> None:
|
||||
started.append(verify)
|
||||
async def start() -> None:
|
||||
nonlocal started
|
||||
started += 1
|
||||
|
||||
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
|
||||
os.environ["ANTHROPIC_API_KEY"] = "test-key"
|
||||
loader._cached = None
|
||||
controller = TuiController(args(), on_start=start)
|
||||
await controller.handle("setup.start", {"verify": False, "mount_working_dir": True})
|
||||
await controller.handle("setup.start", {"mount_working_dir": True})
|
||||
|
||||
result = await controller.handle("setup.confirm_mount", {"approved": False})
|
||||
|
||||
assert result == {"approved": False}
|
||||
# Declining skips the directory; it does not abandon the scan.
|
||||
assert started == [False]
|
||||
assert started == 1
|
||||
assert controller.workspace_mount is None
|
||||
assert controller.pending_workspace_mount is None
|
||||
assert controller.setup_mode is False
|
||||
@@ -289,21 +287,22 @@ async def test_declining_the_mount_runs_without_one() -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approving_the_mount_runs_with_it() -> None:
|
||||
started: list[bool] = []
|
||||
started = 0
|
||||
|
||||
async def start(verify: bool = True) -> None:
|
||||
started.append(verify)
|
||||
async def start() -> None:
|
||||
nonlocal started
|
||||
started += 1
|
||||
|
||||
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
|
||||
os.environ["ANTHROPIC_API_KEY"] = "test-key"
|
||||
loader._cached = None
|
||||
controller = TuiController(args(), on_start=start)
|
||||
await controller.handle("setup.start", {"verify": False, "mount_working_dir": True})
|
||||
await controller.handle("setup.start", {"mount_working_dir": True})
|
||||
|
||||
result = await controller.handle("setup.confirm_mount", {"approved": True})
|
||||
|
||||
assert result == {"approved": True}
|
||||
assert started == [False]
|
||||
assert started == 1
|
||||
assert controller.workspace_mount == str(Path.cwd())
|
||||
assert controller.scan_state == "running"
|
||||
|
||||
@@ -352,23 +351,91 @@ async def test_user_message_updates_live_agent_projection_immediately() -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_forwards_verify_flag_by_default() -> None:
|
||||
seen_verify: bool | None = None
|
||||
async def test_start_verifies_the_model_before_a_targeted_launch() -> None:
|
||||
order: list[str] = []
|
||||
|
||||
async def start(verify: bool = True) -> None:
|
||||
nonlocal seen_verify
|
||||
seen_verify = verify
|
||||
async def verify() -> None:
|
||||
order.append("verify")
|
||||
|
||||
async def start() -> None:
|
||||
order.append("start")
|
||||
|
||||
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
|
||||
os.environ["ANTHROPIC_API_KEY"] = "test-key"
|
||||
loader._cached = None
|
||||
controller = TuiController(args(), on_start=start, on_verify=verify)
|
||||
await controller.handle("setup.add_target", {"target": "https://example.com"})
|
||||
|
||||
await controller.handle("setup.start", {})
|
||||
|
||||
assert order == ["verify", "start"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_verifies_the_model_before_a_bare_prompt_leaves_setup() -> None:
|
||||
"""A bare prompt gets the same model check as a named target, while the
|
||||
setup log is still on screen to show the outcome."""
|
||||
verified = 0
|
||||
|
||||
async def verify() -> None:
|
||||
nonlocal verified
|
||||
verified += 1
|
||||
|
||||
async def start() -> None:
|
||||
return None
|
||||
|
||||
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
|
||||
os.environ["ANTHROPIC_API_KEY"] = "test-key"
|
||||
loader._cached = None
|
||||
controller = TuiController(args(), on_start=start, on_verify=verify)
|
||||
|
||||
await controller.handle("setup.start", {"mount_working_dir": True})
|
||||
|
||||
assert verified == 1
|
||||
assert controller.setup_mode is False
|
||||
assert controller.pending_workspace_mount == str(Path.cwd())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_model_check_keeps_the_start_screen() -> None:
|
||||
async def verify() -> None:
|
||||
raise RuntimeError("Model connection failed: timed out")
|
||||
|
||||
async def start() -> None:
|
||||
pytest.fail("the scan must not start when the model check fails")
|
||||
|
||||
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
|
||||
os.environ["ANTHROPIC_API_KEY"] = "test-key"
|
||||
loader._cached = None
|
||||
controller = TuiController(args(), on_start=start, on_verify=verify)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Model connection failed"):
|
||||
await controller.handle("setup.start", {"mount_working_dir": True})
|
||||
|
||||
# Still on the start screen, so the error lands in the setup log and the
|
||||
# user can retry; no run was prepared behind a stuck live view.
|
||||
assert controller.setup_mode is True
|
||||
assert controller.scan_started is False
|
||||
assert controller.scan_state == "setup"
|
||||
assert controller.pending_workspace_mount is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirmed_mount_launch_failure_is_reported_in_the_live_view() -> None:
|
||||
async def start() -> None:
|
||||
raise ValueError("Scan preparation failed")
|
||||
|
||||
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
|
||||
os.environ["ANTHROPIC_API_KEY"] = "test-key"
|
||||
loader._cached = None
|
||||
controller = TuiController(args(), on_start=start)
|
||||
await controller.handle("setup.add_target", {"target": "https://example.com"})
|
||||
await controller.handle("setup.start", {"mount_working_dir": True})
|
||||
|
||||
# A named target keeps the upfront model check.
|
||||
await controller.handle("setup.start", {})
|
||||
with pytest.raises(ValueError, match="Scan preparation failed"):
|
||||
await controller.handle("setup.confirm_mount", {"approved": True})
|
||||
|
||||
assert seen_verify is True
|
||||
assert controller.scan_state == "failed"
|
||||
assert controller.error == "Scan preparation failed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -376,7 +443,7 @@ async def test_start_rejects_concurrent_and_repeated_submissions() -> None:
|
||||
entered = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def start(_verify: bool = True) -> None:
|
||||
async def start() -> None:
|
||||
entered.set()
|
||||
await release.wait()
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from typing import TYPE_CHECKING
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from strix.core.paths import latest_run_dir, runs_base_dir
|
||||
from strix.interface.viewer.cli import run_view
|
||||
from strix.interface.viewer.cli import _state_label, run_view
|
||||
from strix.interface.viewer.server import serve
|
||||
from strix.interface.viewer.transcript import (
|
||||
build_run_state,
|
||||
@@ -115,6 +115,14 @@ def test_read_run_summary_surfaces_mcp_connection_status(tmp_path: Path) -> None
|
||||
assert read_run_summary(run_dir)["mcp_connection_status"] == roster
|
||||
|
||||
|
||||
def test_viewer_cli_labels_terminal_statuses() -> None:
|
||||
assert _state_label({"status": "completed", "finished": True}) == "[#22c55e]finished[/]"
|
||||
assert _state_label({"status": "stopped", "finished": True}) == "[#eab308]stopped[/]"
|
||||
assert _state_label({"status": "interrupted", "finished": True}) == "[#eab308]interrupted[/]"
|
||||
assert _state_label({"status": "failed", "finished": True}) == "[#ef4444]failed[/]"
|
||||
assert _state_label({"status": "running", "finished": False}) == "[#eab308]live[/]"
|
||||
|
||||
|
||||
def test_read_missing_artifacts_return_defaults(tmp_path: Path) -> None:
|
||||
run_dir = _make_run(tmp_path, "empty", status="running", end_time=None)
|
||||
assert read_vulnerabilities(run_dir) == []
|
||||
|
||||
@@ -41,6 +41,8 @@ def _fast_wait(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
async def _context() -> dict[str, Any]:
|
||||
coordinator = AgentCoordinator()
|
||||
await coordinator.register("root", "strix", parent_id=None)
|
||||
# A live child keeps the wait genuine: with nobody to hear from it returns at once.
|
||||
await coordinator.register("child", "recon", parent_id="root")
|
||||
return {"agent_id": "root", "coordinator": coordinator}
|
||||
|
||||
|
||||
|
||||
416
tests/test_web_search.py
Normal file
416
tests/test_web_search.py
Normal file
@@ -0,0 +1,416 @@
|
||||
"""Tests for web_search/web_get_contents provider selection and the Exa backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from strix.config.settings import IntegrationSettings
|
||||
from strix.interface.environment import _missing_web_search_vars
|
||||
from strix.tools.web_search import tool
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Self
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, body: dict[str, Any]) -> None:
|
||||
self._body = body
|
||||
self.headers: dict[str, str] = {}
|
||||
|
||||
def __enter__(self) -> Self:
|
||||
return self
|
||||
|
||||
def __exit__(self, *_exc: object) -> None:
|
||||
return None
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
def json(self) -> dict[str, Any]:
|
||||
return self._body
|
||||
|
||||
|
||||
def test_auto_prefers_exa_when_both_keys_set() -> None:
|
||||
integrations = IntegrationSettings(PERPLEXITY_API_KEY="pk", EXA_API_KEY="ek")
|
||||
assert tool._resolve_provider(integrations) == ("exa", "ek")
|
||||
|
||||
|
||||
def test_auto_falls_back_to_perplexity_when_only_perplexity_is_set() -> None:
|
||||
integrations = IntegrationSettings(PERPLEXITY_API_KEY="pk")
|
||||
assert tool._resolve_provider(integrations) == ("perplexity", "pk")
|
||||
|
||||
|
||||
def test_explicit_exa_ignores_a_configured_perplexity_key() -> None:
|
||||
integrations = IntegrationSettings(
|
||||
PERPLEXITY_API_KEY="pk",
|
||||
EXA_API_KEY="ek",
|
||||
STRIX_WEB_SEARCH_PROVIDER="exa",
|
||||
)
|
||||
assert tool._resolve_provider(integrations) == ("exa", "ek")
|
||||
|
||||
|
||||
def test_explicit_perplexity_ignores_a_configured_exa_key() -> None:
|
||||
integrations = IntegrationSettings(
|
||||
PERPLEXITY_API_KEY="pk",
|
||||
EXA_API_KEY="ek",
|
||||
STRIX_WEB_SEARCH_PROVIDER="perplexity",
|
||||
)
|
||||
assert tool._resolve_provider(integrations) == ("perplexity", "pk")
|
||||
|
||||
|
||||
def test_explicit_exa_without_a_key_names_only_exa() -> None:
|
||||
integrations = IntegrationSettings(
|
||||
PERPLEXITY_API_KEY="pk",
|
||||
STRIX_WEB_SEARCH_PROVIDER="exa",
|
||||
)
|
||||
resolved = tool._resolve_provider(integrations)
|
||||
assert isinstance(resolved, dict)
|
||||
assert resolved["success"] is False
|
||||
assert "EXA_API_KEY" in resolved["error"]
|
||||
assert "PERPLEXITY_API_KEY" not in resolved["error"]
|
||||
|
||||
|
||||
def test_no_keys_names_both_providers() -> None:
|
||||
resolved = tool._resolve_provider(IntegrationSettings())
|
||||
assert isinstance(resolved, dict)
|
||||
assert "EXA_API_KEY or PERPLEXITY_API_KEY" in resolved["error"]
|
||||
|
||||
|
||||
def test_exa_content_requests_summaries_and_renders_results(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
captured["url"] = url
|
||||
captured["headers"] = kwargs["headers"]
|
||||
captured["json"] = kwargs["json"]
|
||||
return _FakeResponse(
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"url": "https://nvd.example/cve",
|
||||
"title": "NVD entry",
|
||||
"summary": " CVE-2024-0001 is a heap overflow. ",
|
||||
},
|
||||
{"id": "https://blog.example/post"},
|
||||
"not-a-dict",
|
||||
{"title": "no url"},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(requests, "post", fake_post)
|
||||
|
||||
content = tool._exa_content("ek", "OpenSSH 7.4 RCE?", "auto", 5)
|
||||
|
||||
assert captured["url"] == "https://api.exa.ai/search"
|
||||
assert captured["headers"]["x-api-key"] == "ek"
|
||||
assert "OpenSSH 7.4 RCE?" in captured["json"]["query"]
|
||||
assert captured["json"]["type"] == "auto"
|
||||
assert captured["json"]["numResults"] == 5
|
||||
assert captured["json"]["contents"] == {"summary": {"query": tool._EXA_SUMMARY_PROMPT}}
|
||||
assert content == (
|
||||
"### NVD entry\nhttps://nvd.example/cve\nCVE-2024-0001 is a heap overflow.\n\n"
|
||||
"### https://blog.example/post\nhttps://blog.example/post"
|
||||
)
|
||||
|
||||
|
||||
def test_exa_content_renders_a_result_without_contents(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
requests,
|
||||
"post",
|
||||
lambda *_a, **_kw: _FakeResponse(
|
||||
{"results": [{"url": "https://ex.example", "title": "Ex"}]}
|
||||
),
|
||||
)
|
||||
assert tool._exa_content("ek", "q", "auto", 5) == "### Ex\nhttps://ex.example"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body", [{}, {"results": None}, {"results": []}, {"results": ["x"]}])
|
||||
def test_exa_content_rejects_empty_results(
|
||||
monkeypatch: pytest.MonkeyPatch, body: dict[str, Any]
|
||||
) -> None:
|
||||
monkeypatch.setattr(requests, "post", lambda *_a, **_kw: _FakeResponse(body))
|
||||
with pytest.raises(ValueError, match="no results"):
|
||||
tool._exa_content("ek", "q", "auto", 5)
|
||||
|
||||
|
||||
def test_do_search_reports_empty_exa_results_as_unexpected(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class _Settings:
|
||||
integrations = IntegrationSettings(EXA_API_KEY="ek")
|
||||
|
||||
monkeypatch.setattr(tool, "load_settings", _Settings)
|
||||
monkeypatch.setattr(requests, "post", lambda *_a, **_kw: _FakeResponse({}))
|
||||
|
||||
result = tool._do_search("q")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "unexpected response" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("env", "expected"),
|
||||
[
|
||||
({}, ["EXA_API_KEY", "PERPLEXITY_API_KEY"]),
|
||||
({"EXA_API_KEY": "ek"}, []),
|
||||
({"PERPLEXITY_API_KEY": "pk"}, []),
|
||||
({"STRIX_WEB_SEARCH_PROVIDER": "exa", "PERPLEXITY_API_KEY": "pk"}, ["EXA_API_KEY"]),
|
||||
({"STRIX_WEB_SEARCH_PROVIDER": "exa", "EXA_API_KEY": "ek"}, []),
|
||||
({"STRIX_WEB_SEARCH_PROVIDER": "perplexity", "EXA_API_KEY": "ek"}, ["PERPLEXITY_API_KEY"]),
|
||||
({"STRIX_WEB_SEARCH_PROVIDER": "perplexity", "PERPLEXITY_API_KEY": "pk"}, []),
|
||||
],
|
||||
)
|
||||
def test_environment_validation_follows_provider_rules(
|
||||
env: dict[str, str], expected: list[str]
|
||||
) -> None:
|
||||
integrations = IntegrationSettings.model_validate(env)
|
||||
assert _missing_web_search_vars(integrations) == expected
|
||||
|
||||
|
||||
def test_exa_search_type_and_num_results_are_configurable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
class _Settings:
|
||||
integrations = IntegrationSettings(
|
||||
EXA_API_KEY="ek",
|
||||
STRIX_EXA_SEARCH_TYPE="deep-reasoning",
|
||||
STRIX_EXA_NUM_RESULTS=3,
|
||||
)
|
||||
|
||||
def fake_post(_url: str, **kwargs: Any) -> _FakeResponse:
|
||||
captured["json"] = kwargs["json"]
|
||||
return _FakeResponse({"results": [{"url": "https://ex.example", "title": "Ex"}]})
|
||||
|
||||
monkeypatch.setattr(tool, "load_settings", _Settings)
|
||||
monkeypatch.setattr(requests, "post", fake_post)
|
||||
|
||||
assert tool._do_search("q")["success"] is True
|
||||
assert captured["json"]["type"] == "deep-reasoning"
|
||||
assert captured["json"]["numResults"] == 3
|
||||
|
||||
|
||||
def test_exa_page_text_requests_full_text_and_renders_pages(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
captured["url"] = url
|
||||
captured["headers"] = kwargs["headers"]
|
||||
captured["json"] = kwargs["json"]
|
||||
return _FakeResponse(
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"url": "https://nvd.example/cve",
|
||||
"title": "NVD entry",
|
||||
"text": " Full advisory body. ",
|
||||
},
|
||||
{"url": "https://empty.example", "text": " "},
|
||||
"not-a-dict",
|
||||
{"text": "no url"},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(requests, "post", fake_post)
|
||||
|
||||
content, fetched = tool._exa_page_text("ek", ["https://nvd.example/cve"])
|
||||
|
||||
assert captured["url"] == "https://api.exa.ai/contents"
|
||||
assert captured["headers"]["x-api-key"] == "ek"
|
||||
assert captured["json"] == {"urls": ["https://nvd.example/cve"], "text": True}
|
||||
assert content == "### NVD entry\nhttps://nvd.example/cve\n\nFull advisory body."
|
||||
assert fetched == {"https://nvd.example/cve"}
|
||||
|
||||
|
||||
def test_exa_page_text_truncates_a_long_page(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
body = "A" * (tool._EXA_PAGE_MAX_CHARS + 500)
|
||||
monkeypatch.setattr(
|
||||
requests,
|
||||
"post",
|
||||
lambda *_a, **_kw: _FakeResponse(
|
||||
{"results": [{"url": "https://ex.example", "text": body}]}
|
||||
),
|
||||
)
|
||||
content, _fetched = tool._exa_page_text("ek", ["https://ex.example"])
|
||||
assert "truncated at" in content
|
||||
assert content.count("A") == tool._EXA_PAGE_MAX_CHARS
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body", [{}, {"results": []}, {"results": [{"url": "u"}]}])
|
||||
def test_exa_page_text_rejects_pages_without_text(
|
||||
monkeypatch: pytest.MonkeyPatch, body: dict[str, Any]
|
||||
) -> None:
|
||||
monkeypatch.setattr(requests, "post", lambda *_a, **_kw: _FakeResponse(body))
|
||||
with pytest.raises(ValueError, match="no page contents"):
|
||||
tool._exa_page_text("ek", ["https://ex.example"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("urls", [[], ["", " "]])
|
||||
def test_do_get_contents_requires_a_url(urls: list[str]) -> None:
|
||||
result = tool._do_get_contents(urls)
|
||||
assert result["success"] is False
|
||||
assert "at least one URL" in result["error"]
|
||||
|
||||
|
||||
def test_do_get_contents_caps_the_url_count() -> None:
|
||||
urls = [f"https://ex{index}.example" for index in range(tool._EXA_MAX_CONTENT_URLS + 1)]
|
||||
result = tool._do_get_contents(urls)
|
||||
assert result["success"] is False
|
||||
assert "Too many URLs" in result["error"]
|
||||
|
||||
|
||||
def test_do_get_contents_needs_an_exa_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class _Settings:
|
||||
integrations = IntegrationSettings(PERPLEXITY_API_KEY="pk")
|
||||
|
||||
monkeypatch.setattr(tool, "load_settings", _Settings)
|
||||
|
||||
result = tool._do_get_contents(["https://ex.example"])
|
||||
|
||||
assert result["success"] is False
|
||||
assert "EXA_API_KEY" in result["error"]
|
||||
|
||||
|
||||
def test_do_get_contents_refuses_a_perplexity_pinned_provider(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class _Settings:
|
||||
integrations = IntegrationSettings(
|
||||
EXA_API_KEY="ek",
|
||||
PERPLEXITY_API_KEY="pk",
|
||||
STRIX_WEB_SEARCH_PROVIDER="perplexity",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(tool, "load_settings", _Settings)
|
||||
|
||||
result = tool._do_get_contents(["https://ex.example"])
|
||||
|
||||
assert result["success"] is False
|
||||
assert "web_search" in result["error"]
|
||||
|
||||
|
||||
def test_do_get_contents_returns_page_text(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class _Settings:
|
||||
integrations = IntegrationSettings(EXA_API_KEY="ek")
|
||||
|
||||
monkeypatch.setattr(tool, "load_settings", _Settings)
|
||||
monkeypatch.setattr(tool, "_exa_page_text", lambda *_a: ("page", {"https://ex.example"}))
|
||||
|
||||
result = tool._do_get_contents([" https://ex.example "])
|
||||
|
||||
assert result == {
|
||||
"success": True,
|
||||
"urls": ["https://ex.example"],
|
||||
"provider": "exa",
|
||||
"content": "page",
|
||||
}
|
||||
|
||||
|
||||
def test_do_get_contents_reports_urls_exa_did_not_return(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class _Settings:
|
||||
integrations = IntegrationSettings(EXA_API_KEY="ek")
|
||||
|
||||
monkeypatch.setattr(tool, "load_settings", _Settings)
|
||||
monkeypatch.setattr(
|
||||
requests,
|
||||
"post",
|
||||
lambda *_a, **_kw: _FakeResponse(
|
||||
{"results": [{"url": "https://ok.example/", "text": "Body."}]}
|
||||
),
|
||||
)
|
||||
|
||||
result = tool._do_get_contents(["https://ok.example", "https://blocked.example"])
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["urls"] == ["https://ok.example"]
|
||||
assert result["failed_urls"] == ["https://blocked.example"]
|
||||
assert "1 of 2" in result["warning"]
|
||||
assert "blocked.example" not in result["content"]
|
||||
|
||||
|
||||
def test_normalize_url_folds_only_scheme_and_host() -> None:
|
||||
assert tool._normalize_url("HTTPS://Ex.Example/Path/") == tool._normalize_url(
|
||||
"https://ex.example/Path"
|
||||
)
|
||||
assert tool._normalize_url("https://ex.example/Path") != tool._normalize_url(
|
||||
"https://ex.example/path"
|
||||
)
|
||||
assert tool._normalize_url("https://ex.example/p?Q=A") != tool._normalize_url(
|
||||
"https://ex.example/p?q=a"
|
||||
)
|
||||
|
||||
|
||||
def test_do_get_contents_omits_the_warning_when_every_page_returns(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class _Settings:
|
||||
integrations = IntegrationSettings(EXA_API_KEY="ek")
|
||||
|
||||
monkeypatch.setattr(tool, "load_settings", _Settings)
|
||||
monkeypatch.setattr(
|
||||
requests,
|
||||
"post",
|
||||
lambda *_a, **_kw: _FakeResponse(
|
||||
{
|
||||
"results": [
|
||||
{"url": "https://a.example", "text": "A."},
|
||||
{"url": "https://b.example", "text": "B."},
|
||||
]
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
result = tool._do_get_contents(["https://a.example", "https://b.example"])
|
||||
|
||||
assert result["urls"] == ["https://a.example", "https://b.example"]
|
||||
assert "failed_urls" not in result
|
||||
assert "warning" not in result
|
||||
|
||||
|
||||
def test_do_get_contents_sanitizes_a_network_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class _Settings:
|
||||
integrations = IntegrationSettings(EXA_API_KEY="ek")
|
||||
|
||||
def boom(*_args: Any, **_kwargs: Any) -> None:
|
||||
raise requests.exceptions.ConnectionError
|
||||
|
||||
monkeypatch.setattr(tool, "load_settings", _Settings)
|
||||
monkeypatch.setattr(requests, "post", boom)
|
||||
|
||||
result = tool._do_get_contents(["https://ex.example"])
|
||||
|
||||
assert result["success"] is False
|
||||
assert "network error" in result["error"]
|
||||
assert "ek" not in result["error"]
|
||||
|
||||
|
||||
def test_do_search_reports_the_provider_it_used(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class _Settings:
|
||||
integrations = IntegrationSettings(EXA_API_KEY="ek")
|
||||
|
||||
monkeypatch.setattr(tool, "load_settings", _Settings)
|
||||
monkeypatch.setattr(tool, "_exa_content", lambda *_a: "answer")
|
||||
|
||||
result = tool._do_search("OpenSSH 7.4 RCE?")
|
||||
|
||||
assert result == {
|
||||
"success": True,
|
||||
"query": "OpenSSH 7.4 RCE?",
|
||||
"provider": "exa",
|
||||
"content": "answer",
|
||||
}
|
||||
Reference in New Issue
Block a user