mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/usestrix/strix.git
synced 2026-09-20 16:13:44 +08:00
Compare commits
14 Commits
opencode-s
...
fix/tui-se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19ec5fbf25 | ||
|
|
b5c3807fef | ||
|
|
42baa7c09e | ||
|
|
8fdf6a5c09 | ||
|
|
46cf2f52f3 | ||
|
|
3de9471431 | ||
|
|
a071022182 | ||
|
|
d26b1ab0de | ||
|
|
de730119f0 | ||
|
|
608ef4a37b | ||
|
|
f901d2a8bf | ||
|
|
944274e12f | ||
|
|
eeca404716 | ||
|
|
1df67c52e2 |
22
AGENTS.md
22
AGENTS.md
@@ -38,13 +38,25 @@ Target-specific workflows built on the same engine:
|
||||
|
||||
- **Managed cloud (app.strix.ai):** no Docker, no LLM key, no local install; adds team dashboards, scheduling, PR reviews, and downloadable PDF/DOCX reports (Enterprise plan). Best in sandboxed/CI environments and for teams. Use it when local infra isn't available.
|
||||
```bash
|
||||
# token from Settings → API Access; register the target as an asset, then:
|
||||
curl -sS https://app.strix.ai/api/v1/scans -H "Authorization: Bearer $STRIX_API_TOKEN" \
|
||||
-H "Content-Type: application/json" -d '{"engagement_type":"live_test","domain_ids":["<uuid>"]}'
|
||||
strix cloud login --scopes scans:read scans:write uploads:write billing:read
|
||||
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 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 billing topup --credits 20 --yes # explicit approval after exit code 5
|
||||
```
|
||||
- API docs: https://docs.app.strix.ai (OpenAPI: https://docs.app.strix.ai/openapi.json).
|
||||
- Account setup runs from the CLI too: `strix cloud workspaces list|create|use` (`workspace` is an alias and `use` accepts a displayed number, name, or ID), `strix cloud session scopes|scopes set`, `strix cloud org members invite`, `strix cloud billing subscribe --plan strix_cloud`, `strix cloud billing portal`, `strix cloud integrations install github`, and `strix cloud domains verify <id>`. Workspace switching preserves the server-side profile and can never widen past the login ceiling; ordinary switches do not reprompt. The last four end at a person: the command prints a link or a DNS record for the user to open or add, and it never completes the payment, installation, or DNS change for them.
|
||||
- Every REST operation has a `strix cloud <resource> <verb>` command. Run `strix cloud` to list them. Output is JSON when stdout is not a terminal (or with `--json`), and there are no prompts without a TTY. Binary downloads are the exception: redirect raw bytes intentionally, or combine `--output FILE --json` for structured download metadata. Exit codes: `0` success, `1` error, `2` usage, `4` auth or plan limit, `5` payment required. `--token` or `STRIX_API_TOKEN` is a stateless override and never replaces stored auth; set `--workspace-id`/`STRIX_WORKSPACE_ID` for an override CLI session. `--data` adds extra request fields as JSON, and accepts `@file` or `-` for standard input.
|
||||
- Local source uploads require `uploads:write`. For an agent/CI handoff, review `scans start --source . --dry-run --show-files --json`, capture `source.archive_sha256`, then rerun with the same `--source`, `--exclude`, and `--include-*` selection flags plus `--approve-sha256 HASH`. A changed snapshot is rejected. `--yes` approves only the snapshot built in that invocation, so reserve it for a deliberate human or one-shot approval rather than a digest-bound two-step handoff.
|
||||
- Git ignores, hidden files, `.git`, symlinks, dependency/build output, secret-like filenames, and nested archives are excluded by default; `.strixignore` and `--exclude` narrow the manifest further (a trailing `/` excludes a directory subtree). Limits: 20,000 files, 25 MiB/file, 250 MiB expanded, 50 MiB compressed. Source-only infers `code_review`; source plus a domain infers `live_test`.
|
||||
- The temporary local archive is always removed. A staged upload is deleted after a definitive rejection, but retained when a network error, `5xx`, malformed success response, or interruption leaves the scan launch ambiguous. JSON reports its `upload_id` with `launch_outcome_unknown: true`, or with `cleanup_unknown: true` when automatic deletion cannot be confirmed. Check `scans list` before retrying; if no scan is linked, run `uploads delete UPLOAD_ID`.
|
||||
- Non-Enterprise scans consume the scope estimate (a default-tier source-only review currently starts at 60 credits); Enterprise scans are plan-included. A rejected launch does not consume credits.
|
||||
- Human output is compact and numbered; non-TTY output and `--json` retain full records. Enable tab completion with `source <(strix completions zsh)` (or `bash`), or `strix completions fish | source`.
|
||||
- The REST API works directly too: https://docs.app.strix.ai (OpenAPI: https://docs.app.strix.ai/openapi.json).
|
||||
|
||||
- CLI docs index for LLMs: https://docs.strix.ai/llms.txt (full: https://docs.strix.ai/llms-full.txt).
|
||||
- CLI docs index for LLMs: https://docs.strix.ai/llms.txt (full: https://docs.strix.ai/llms-full.txt). Managed API docs for LLMs: https://docs.app.strix.ai/llms.txt.
|
||||
- Only scan targets the user is authorized to test.
|
||||
|
||||
## Contributing to this repo
|
||||
|
||||
102
README.md
102
README.md
@@ -320,6 +320,108 @@ strix auth status # show the active sign-in
|
||||
strix auth logout # forget the sign-in
|
||||
```
|
||||
|
||||
#### 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`:
|
||||
|
||||
```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 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:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
#### 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:
|
||||
|
||||
@@ -35,6 +35,25 @@ Skip the setup. Run Strix in the cloud at [app.strix.ai](https://app.strix.ai).
|
||||
2. Connect your repository or enter a target URL
|
||||
3. Launch your first scan
|
||||
|
||||
## Scan Local Source
|
||||
|
||||
Send a local working tree to the managed white-box scanner without connecting a source-control provider:
|
||||
|
||||
```bash
|
||||
# Review the exact file manifest and capture source.archive_sha256. Nothing is uploaded.
|
||||
strix cloud scans start --source . --dry-run --show-files --json
|
||||
SOURCE_SHA256="<reviewed source.archive_sha256>"
|
||||
|
||||
# Repeat the same source-selection flags and approve that exact snapshot.
|
||||
strix cloud scans start --source . --approve-sha256 "$SOURCE_SHA256" --wait
|
||||
```
|
||||
|
||||
In a Git repository, Strix includes tracked files and untracked files that are not ignored. Hidden files, `.git`, symlinks, dependencies and build output, secret-like filenames, and nested archives are excluded by default. Use `.strixignore` or repeat `--exclude GLOB` for project-specific exclusions. `--include-hidden`, `--include-sensitive`, and `--include-archives` are explicit opt-ins.
|
||||
|
||||
The CLI limits individual files, total expanded bytes, archive bytes, and file count. For an agent or CI handoff, repeat the same `--source`, `--exclude`, and `--include-*` flags with `--approve-sha256`; Strix refuses the upload if the rebuilt archive differs from the reviewed digest. `--yes` is a one-invocation approval for the snapshot built at that moment, not a digest-bound two-step approval.
|
||||
|
||||
The temporary local archive is always removed. After a definitive launch rejection, Strix also deletes the staged remote upload. If a network error, server error, or interruption makes the launch outcome ambiguous, it retains the upload and reports its ID; check `strix cloud scans list` before retrying, then delete an unlinked upload with `strix cloud uploads delete UPLOAD_ID`.
|
||||
|
||||
<Card title="Try Strix Cloud" icon="rocket" href="https://app.strix.ai">
|
||||
Run your first pentest in minutes.
|
||||
</Card>
|
||||
|
||||
@@ -36,13 +36,14 @@ npx skills use usestrix/strix@penetration-testing-with-strix | claude
|
||||
Both use the same engine and produce the same validated findings and SARIF, so agents can pick per situation or combine them:
|
||||
|
||||
- **Open-source CLI (self-hosted)** — runs locally in a Docker sandbox with your own LLM key. Free, fully local, air-gap capable. Best for local dev loops and full control.
|
||||
- **Managed cloud** — runs on Strix's infrastructure via the [app.strix.ai REST API](https://docs.app.strix.ai). No Docker, no LLM key, no local install; adds team dashboards, scheduling, PR reviews, and downloadable PDF/DOCX reports (Enterprise plan). Best in sandboxed/CI environments and for teams. Create an API token under **Settings → API Access**; the `managed-pentesting-with-strix` skill has the full flow.
|
||||
- **Managed cloud** — runs on Strix's infrastructure. Drive it with the `strix cloud` CLI (every REST operation has a `strix cloud <resource> <verb>` command) or the [app.strix.ai REST API](https://docs.app.strix.ai) directly. No Docker, no LLM key; adds team dashboards, scheduling, PR reviews, and downloadable PDF/DOCX reports (Enterprise plan). Best in sandboxed/CI environments and for teams. Sign in with `strix cloud login` (browser device sign-in, account created on first use) or create a token in the dashboard under **Settings → API Access**. The `managed-pentesting-with-strix` skill has the full flow.
|
||||
|
||||
## Agent-Friendly Interfaces
|
||||
|
||||
Everything an agent needs is machine-readable:
|
||||
|
||||
- **Headless CLI** — `strix -n` runs without the TUI and exits with `0` (clean), `1` (error), or `2` (vulnerabilities found).
|
||||
- **Cloud CLI** — `strix cloud` prints JSON when stdout is not a terminal (or with `--json`), never prompts without a TTY, and exits with `0` (success), `1` (error), `2` (usage), `4` (authentication required), or `5` (payment required). Credit top-ups pay the Stripe machine-payment challenge with an agent wallet (`strix cloud billing topup --credits N --yes`). Account setup also runs from the CLI: `strix cloud workspaces list|create|use`, `strix cloud org members invite`, `strix cloud billing subscribe`, `strix cloud billing portal`, and `strix cloud integrations install github`. The last three print a hosted link the user opens to finish the payment or approve the installation.
|
||||
- **REST API** — the managed platform exposes a documented [OpenAPI](https://docs.app.strix.ai/openapi.json) at `https://app.strix.ai/api/v1` (scans, vulnerabilities, assets, PR reviews, schedules, webhooks) with bearer tokens and scopes.
|
||||
- **Structured results** — every run writes `vulnerabilities.json`, `vulnerabilities.csv`, `findings.sarif` (SARIF 2.1.0), and per-finding Markdown under `strix_runs/<run-name>/`; the cloud exposes the same as JSON plus SARIF export.
|
||||
- **Budget controls** — `--max-budget` and `--max-turns` give agents hard cost/time caps.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "strix-agent"
|
||||
version = "1.5.3"
|
||||
version = "1.6.0"
|
||||
description = "Open-source AI Hackers for your apps"
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
@@ -43,6 +43,7 @@ dependencies = [
|
||||
"requests>=2.32.0",
|
||||
"cvss>=3.2",
|
||||
"caido-sdk-client>=0.2.0",
|
||||
"markdown-it-py>=3.0.0",
|
||||
"reportlab>=4.0",
|
||||
"pypdf>=5.0",
|
||||
# Cap <49: 49.x drops the universal2 macOS wheel (arm64-only), which breaks
|
||||
@@ -229,6 +230,7 @@ ignore = [
|
||||
# Test doubles use fixture tokens/passwords and match a callee signature whose
|
||||
# args they intentionally ignore.
|
||||
"tests/test_viewer_auth.py" = ["S105", "S106", "ARG001"]
|
||||
"tests/test_cloud_cli.py" = ["S105", "ARG001"]
|
||||
"tests/test_codex_auth.py" = ["S105", "S106", "SLF001"]
|
||||
# Hatchling loads the build hook by path, not as an importable package.
|
||||
"scripts/tui_sidecar_hook.py" = ["INP001"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1,23 +1,59 @@
|
||||
---
|
||||
name: managed-pentesting-with-strix
|
||||
description: Run a managed pentest of a web app or API through the app.strix.ai REST API — no local Docker, LLM key, or install needed. Create an API token, register domain/repository assets, launch and poll scans, triage vulnerabilities, export SARIF, download PDF/DOCX pentest reports for SOC 2 and other compliance evidence (Enterprise plan), start PR reviews, and set up schedules and webhooks. Use when the user wants continuous or scheduled pentesting-as-a-service, an auditor-ready pentest report, scans tracked in a team dashboard, or security testing from a sandboxed agent/CI environment with no infrastructure.
|
||||
description: Run a managed pentest of a web app, API, repository, or local workspace on the app.strix.ai platform with the `strix cloud` CLI or REST API — no local Docker or LLM key needed. Safely review and upload local source, register assets, launch and poll scans, triage vulnerabilities, export SARIF, download compliance reports, start PR reviews, buy credits, and set up schedules or webhooks. Use for managed, continuous, scheduled, team-tracked, or sandboxed-agent security testing.
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: usestrix
|
||||
homepage: https://docs.app.strix.ai
|
||||
---
|
||||
|
||||
# Strix Cloud API (managed, no local infra)
|
||||
# Strix Cloud (managed, no local infra)
|
||||
|
||||
Use this when you want Strix's autonomous pentesting **without running Docker or an LLM yourself** — the scan runs on Strix's infrastructure and results are tracked in a team dashboard. This is the right choice in sandboxed/hosted agent and CI environments, for teams, and for scheduled/continuous testing (downloadable PDF/DOCX reports are an Enterprise-plan feature). For fully local, free, air-gapped, or BYO-LLM runs, use the open-source CLI in the **penetration-testing-with-strix** skill instead — both share the same engine and SARIF output, so you can mix them.
|
||||
|
||||
Full reference: **[docs.app.strix.ai](https://docs.app.strix.ai)** · OpenAPI: `https://docs.app.strix.ai/openapi.json`
|
||||
There are two equivalent interfaces. Prefer the CLI:
|
||||
|
||||
## Setup
|
||||
- **`strix cloud` CLI** — every REST operation has a command in the form `strix cloud <resource> <verb>`. Install with `curl -sSL https://strix.ai/install | bash`. Run `strix cloud` to list all resources and `strix cloud <resource> help` (or `-h`) to list a resource's verbs; a bare resource with a safe read operation runs its documented default.
|
||||
- **REST API** — base URL `https://app.strix.ai/api/v1`, `Authorization: Bearer <token>` on every request. Full reference: **[docs.app.strix.ai](https://docs.app.strix.ai)** · agent index: `https://docs.app.strix.ai/llms.txt` · OpenAPI: `https://docs.app.strix.ai/openapi.json`.
|
||||
|
||||
- **Base URL:** `https://app.strix.ai/api/v1`
|
||||
- **Auth:** every request sends `Authorization: Bearer <token>`. Tokens are **org-scoped**.
|
||||
- **Get a token:** the user creates one in the dashboard at **Settings → API Access** (app.strix.ai). Ask them for it; never hardcode, log, or commit it. Store it in an env var or the CI secret store.
|
||||
The CLI is equally usable by agents and people. Output is complete JSON when stdout is not a terminal, or when you pass `--json`; terminal tables favor names, branches, lifecycle states, and numbered selectors. 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 label credentials as active, expired, or revoked. Binary downloads are the exception: redirect raw bytes intentionally, or use `--output FILE --json` to write the file and receive structured metadata. There are no interactive prompts when stdin is not a terminal. Exit codes: `0` success, `1` request/runtime error, `2` invalid usage, `4` authentication or plan limit, `5` payment required.
|
||||
|
||||
Every resource group with a safe read operation has a useful default action, and `-h` or `help` always shows its verbs. Native tab completion includes resources, verbs, flags, workspace commands, and local paths:
|
||||
|
||||
```bash
|
||||
source <(strix completions zsh) # current zsh session
|
||||
source <(strix completions bash) # current bash session
|
||||
strix completions fish | source # current fish session
|
||||
```
|
||||
|
||||
Write commands take request fields as flags. Every write command also accepts one JSON object with `--data`, which is the way to send fields that have no flag:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
The platform enforces plan and role limits, and the CLI passes the platform message through. Report downloads need the Enterprise plan. Schedules need the Pro plan. Billing writes need an admin token. A blocked command exits with code `4`.
|
||||
|
||||
## Setup: sign in
|
||||
|
||||
Run the device sign-in. It creates the user's account and workspace on first use and stores a personal API token in `~/.strix/platform-auth.json`:
|
||||
|
||||
```bash
|
||||
strix cloud login
|
||||
# Non-interactive least-privilege example:
|
||||
strix cloud login --scopes scans:read scans:write uploads:write billing:read vulnerabilities:read assets:read assets:write
|
||||
# Or use a stable named profile:
|
||||
strix cloud login --scope-profile recommended
|
||||
```
|
||||
|
||||
The user approves the sign-in in the browser. With `--scopes` (and optionally `--workspace <name-or-id>`) there are no terminal prompts, so the command works from a non-interactive agent shell. In an interactive terminal without flags, the CLI offers a workspace picker and scope presets (Recommended, Full access, Minimal, Custom). Recommended covers ordinary scans, source uploads, workspace switching, and user-approved credit top-ups; it excludes `tokens:write`, which must be requested explicitly when credential management is required. Use explicit scopes for a narrower automation token.
|
||||
|
||||
- `strix cloud whoami` is the fast local status. `strix cloud session --json` verifies the remote device session; `strix cloud session scopes` shows both effective access and the immutable login ceiling.
|
||||
- `strix cloud logout` revokes the remote session before removing the local token. On a network or server failure it keeps the token so the user can retry; `--local-only` deliberately skips revocation.
|
||||
- Every other `strix cloud` command uses the stored token automatically. `--token <token>` or `STRIX_API_TOKEN` is a stateless per-command override and never overwrites the stored account. For an override that is itself a CLI session, also pass `--workspace-id` or set `STRIX_WORKSPACE_ID`.
|
||||
- Never hardcode, log, or commit the token. Store it in an env var or the CI secret store.
|
||||
- **Scopes (least-privilege):** assign only what the integration needs and rotate regularly:
|
||||
|
||||
| Scope | Grants |
|
||||
@@ -28,15 +64,105 @@ Full reference: **[docs.app.strix.ai](https://docs.app.strix.ai)** · OpenAPI: `
|
||||
| `schedules:read` / `:write` | read schedules · create/trigger recurring scans |
|
||||
| `pr_reviews:write` | trigger PR security reviews |
|
||||
| `webhooks:read` / `:write` | manage webhook subscriptions |
|
||||
| `tokens:write` | create/revoke API tokens |
|
||||
| `uploads:write` | upload local source or documents for a scan |
|
||||
| `organizations:read` | read organization details (listing/switching the signed-in user's workspaces needs no API scope) |
|
||||
| `organizations:write` | create/update workspaces (admin) |
|
||||
| `tokens:write` | create/revoke ordinary API tokens (not needed to manage the current CLI session) |
|
||||
| `knowledge:read` / `:write` | read/update organization knowledge |
|
||||
| `audit:read` | read/export the Enterprise audit log |
|
||||
| `billing:read` / `billing:write` | read credit balance & auto top-up settings · buy credits (admin) |
|
||||
|
||||
HTTP errors map to messages and exit codes: `401` bad/expired token (exit `4`), `402` out of credits (exit `5`), `403` scope/plan-tier limit (exit `4`), `422` validation error (exit `1`).
|
||||
|
||||
Create a time-limited automation token with `strix cloud tokens create`. Use
|
||||
`--rbac-scopes` to restrict it to target IDs, tags, or business units; the value is a
|
||||
JSON array of `{ "type": "target|tag|business_unit", "value": "..." }` objects:
|
||||
|
||||
```bash
|
||||
export STRIX_API_TOKEN="<token>"
|
||||
BASE=https://app.strix.ai/api/v1
|
||||
auth=(-H "Authorization: Bearer $STRIX_API_TOKEN")
|
||||
strix cloud tokens create --type service --name staging-ci \
|
||||
--expires-at 2026-12-31T23:59:59Z \
|
||||
--scopes scans:read scans:write \
|
||||
--rbac-scopes '[{"type":"tag","value":"staging"}]'
|
||||
```
|
||||
|
||||
All examples use `jq` to parse JSON. Handle HTTP errors: `401` bad/expired token, `402` out of credits, `403` scope/plan-tier limit, `422` validation error.
|
||||
The token secret is returned once. Store it directly in a secret manager and do not
|
||||
print or commit it. `--expires-at` and `--expires-in-days` are mutually exclusive.
|
||||
|
||||
## 0. Credits & top-ups
|
||||
|
||||
Non-Enterprise scans consume org credits. Enterprise engagements are plan-included and do not debit the wallet. Check the balance before a scan (`billing:read`):
|
||||
|
||||
```bash
|
||||
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 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.
|
||||
|
||||
```bash
|
||||
strix cloud billing topup --credits 20 --yes # explicit approval; skips the TTY prompt
|
||||
strix cloud billing topup --credits 20 --no-pay # print the 402 challenge without paying
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
If the user does not want a wallet, create a hosted checkout link with `strix cloud billing subscribe --plan strix_top_up` and give the link to the user. The user pays in the browser.
|
||||
|
||||
Automatic top-ups (admin): `strix cloud billing auto-topup` shows the setting. Enable it with:
|
||||
|
||||
```bash
|
||||
strix cloud billing auto-topup update --enabled --topup-credits 20 --monthly-cap-credits 200
|
||||
```
|
||||
|
||||
An omitted `--monthly-cap-credits` keeps the stored cap. Pass `--no-monthly-cap` to remove the cap.
|
||||
|
||||
### Workspaces and account setup
|
||||
|
||||
Manage workspaces with a personal token from `strix cloud login`:
|
||||
|
||||
```bash
|
||||
strix cloud workspaces list # numbered name/role/current list
|
||||
strix cloud workspaces create --name "My Team" # admin + organizations:write
|
||||
strix cloud workspaces use 2 # displayed number, exact name, or ID
|
||||
strix cloud workspace use "My Team" # singular `workspace` alias also works
|
||||
strix cloud session scopes # effective scopes + consent ceiling
|
||||
strix cloud session scopes set minimal # narrow the session
|
||||
strix cloud org members invite --email dev@example.com --role analyst
|
||||
```
|
||||
|
||||
`workspaces use` retargets the current personal token to a workspace the user already belongs to and stores the updated workspace metadata; the bearer secret and expiry stay unchanged. It does not reprompt during ordinary switches: the server preserves the chosen profile, enforces the immutable login ceiling, and caps effective scopes by the target role. Use `--scope-profile` or `--scopes` to narrow within that ceiling; broader consent requires `strix cloud login` again. The CLI pins each process to the workspace it started in, so concurrent shells fail with a recoverable conflict instead of silently crossing organizations.
|
||||
|
||||
### Handoffs a person must finish
|
||||
|
||||
Four steps end at the user. The command creates the link or the record and prints it. Strix opens the browser only in an interactive terminal. Pass `--no-browser` to print the URL only.
|
||||
|
||||
```bash
|
||||
strix cloud billing subscribe --plan strix_cloud # hosted checkout page for the Cloud plan
|
||||
strix cloud billing portal # billing portal for the card and the plan
|
||||
strix cloud integrations install github # GitHub App or Slack installation page
|
||||
strix cloud domains verify <domain-id> # DNS record to add, then run it again
|
||||
```
|
||||
|
||||
Give the printed URL or DNS record to the user and wait. Do not claim that the payment, the installation, or the DNS change is complete. Confirm the result afterwards with `strix cloud credits`, `strix cloud integrations list`, or `strix cloud domains list`. All four commands need an admin token, except `domains verify`, which needs `assets:write`.
|
||||
|
||||
### Organization knowledge
|
||||
|
||||
Agents can manage the organization knowledge base without the dashboard (`knowledge:read` / `knowledge:write`):
|
||||
|
||||
```bash
|
||||
strix cloud knowledge list --search authentication
|
||||
strix cloud knowledge add --title "Authentication" --content "Staging uses SSO."
|
||||
strix cloud knowledge update <document-id> --content "Staging uses SSO and TOTP."
|
||||
strix cloud knowledge delete <document-id>
|
||||
strix cloud knowledge policies add --key staging-only --content "Never test production."
|
||||
strix cloud knowledge policies delete staging-only
|
||||
strix cloud knowledge repos entries usestrix/strix
|
||||
```
|
||||
|
||||
Knowledge policy writes require an admin token. Repository names are passed as normal `owner/name` values; the CLI handles URL encoding. The `costs` and `llm-settings` commands target on-prem installations and return `404` on app.strix.ai.
|
||||
|
||||
## 1. Register the target as an asset
|
||||
|
||||
@@ -44,109 +170,152 @@ Scans run against **registered assets**, not raw URLs. Register once, then reuse
|
||||
|
||||
```bash
|
||||
# Domain (black-box / live target). Requires domain verification before external scanning.
|
||||
# asset_type must be one of: web_app | api | attack_surface.
|
||||
curl -sS "$BASE/domains" "${auth[@]}" -H "Content-Type: application/json" \
|
||||
-d '{"domain":"staging.example.com","asset_type":"web_app"}' | jq '{id:.domain.id, status, reachable, verification}'
|
||||
# --asset-type must be one of: web_app | api | attack_surface.
|
||||
strix cloud domains add --domain staging.example.com --asset-type web_app
|
||||
|
||||
# Repository (white-box / code review). `full_name` is "owner/name".
|
||||
# Send one repository object, or a bare JSON array for several — not an object
|
||||
# wrapping a "repositories" key (that is rejected with 400).
|
||||
curl -sS "$BASE/repositories" "${auth[@]}" -H "Content-Type: application/json" \
|
||||
-d '[{"full_name":"org/app","provider":"github"}]' | jq '.repositories[] | {id, full_name}'
|
||||
strix cloud repos add --data '{"full_name":"org/app","provider":"github"}'
|
||||
```
|
||||
|
||||
Look up existing assets instead of re-adding: `GET /domains`, `GET /repositories` (both `assets:read`, paginated with `?page=&limit=`).
|
||||
Look up existing assets instead of re-adding: `strix cloud domains list`, `strix cloud repos list` (both `assets:read`).
|
||||
|
||||
## 2. Launch a scan
|
||||
|
||||
`POST /scans` (`scans:write`). Provide at least one target via `domain_ids`, `repository_ids`, or `internal_targets` (internal infra needs a network connector — see docs).
|
||||
`strix cloud scans start` (`scans:write`). Provide at least one target with `--domain-ids`, `--repository-ids`, or `--internal-targets` (internal infra needs a network connector — see docs).
|
||||
|
||||
```bash
|
||||
scan_id=$(curl -sS "$BASE/scans" "${auth[@]}" -H "Content-Type: application/json" -d '{
|
||||
"engagement_type": "live_test",
|
||||
"domain_ids": ["<domain-uuid>"],
|
||||
"focus": "IDOR, auth bypass, SSRF",
|
||||
"context": "Staging. Test account creds are configured as a test user.",
|
||||
"notify_on_completion": true
|
||||
}' | jq -r .scan_id)
|
||||
echo "$scan_id"
|
||||
strix cloud scans start \
|
||||
--engagement-type live_test \
|
||||
--domain-ids <domain-uuid> \
|
||||
--focus "IDOR, auth bypass, SSRF" \
|
||||
--context "Staging. Test account creds are configured as a test user." \
|
||||
--notify-on-completion
|
||||
```
|
||||
|
||||
Useful `CreateScanRequest` fields:
|
||||
Useful flags (each maps to a `CreateScanRequest` field):
|
||||
|
||||
| Field | Purpose |
|
||||
| Flag | Purpose |
|
||||
|---|---|
|
||||
| `engagement_type` | `live_test` (default), `code_review`, `internal_infra`, `compliance_pentest` |
|
||||
| `domain_ids` / `repository_ids` / `internal_targets` | targets (at least one) |
|
||||
| `domain_paths` / `repository_branches` | narrow to specific paths / branches |
|
||||
| `credentials` | authenticated scanning, incl. `mfa_method` (`totp`/`email_otp`/…) + `totp_secret` |
|
||||
| `headers` | extra HTTP headers (API keys, for example) for the target |
|
||||
| `focus` / `concerns` / `context` | steer the agents |
|
||||
| `upload_ids` | attach uploaded source/docs archives for white-box context |
|
||||
| `notify_on_completion` / `notification_emails` | email when done |
|
||||
| `--engagement-type` | `live_test` (default), `code_review`, `internal_infra`, `compliance_pentest` |
|
||||
| `--domain-ids` / `--repository-ids` / `--internal-targets` | targets (at least one) |
|
||||
| `--domain-paths` / `--repository-branches` | narrow to specific paths / branches (JSON maps) |
|
||||
| `--credentials` | authenticated scanning, incl. `mfa_method` (`totp`/`email_otp`/…) + `totp_secret` (JSON list) |
|
||||
| `--headers` | extra target HTTP headers as a JSON array of header objects |
|
||||
| `--focus` / `--concerns` / `--context` | free-form strings that steer the agents |
|
||||
| `--upload-ids` | attach uploaded source/docs archives for white-box context |
|
||||
| `--notify-on-completion` / `--notification-emails` | email when done |
|
||||
|
||||
Response is `{ scan_id, title, status }` with `status` = `pending`.
|
||||
Without `--source`, the response is `{ scan_id, title, status }` with `status` = `pending`.
|
||||
Local-source success wraps that platform response as
|
||||
`{ source, upload_id, scan: { scan_id, title, status } }`, so automation can retain the exact
|
||||
approved manifest and staged-upload identifier alongside the created scan.
|
||||
|
||||
## 3. Poll to completion
|
||||
### Scan a local workspace in the cloud
|
||||
|
||||
`GET /scans/{scanId}` (`scans:read`). Status flow: `pending → running → completed` (or `failed` / `cancelled`). Poll on an interval — scans take minutes to hours. Do not block.
|
||||
For an agent or CI workflow, bind approval to the exact source snapshot that was reviewed. Run
|
||||
the dry run with the intended source-selection flags, review the manifest and selected paths,
|
||||
and capture `source.archive_sha256`. Then repeat the same `--source`, every `--exclude`, and
|
||||
any `--include-hidden`, `--include-sensitive`, or `--include-archives` flags with
|
||||
`--approve-sha256`:
|
||||
|
||||
```bash
|
||||
while :; do
|
||||
s=$(curl -sS "$BASE/scans/$scan_id" "${auth[@]}" | jq -r .status)
|
||||
echo "status=$s"; [[ "$s" =~ ^(completed|failed|cancelled)$ ]] && break
|
||||
sleep 60
|
||||
done
|
||||
strix cloud scans start --source . --exclude 'private/' --dry-run --show-files --json
|
||||
# After reviewing the output, capture its source.archive_sha256 value:
|
||||
SOURCE_SHA256="<reviewed source.archive_sha256>"
|
||||
# Repeat every source-selection flag unchanged; a source-only scan infers code_review.
|
||||
strix cloud scans start --source . --exclude 'private/' \
|
||||
--approve-sha256 "$SOURCE_SHA256" --wait
|
||||
```
|
||||
|
||||
The CLI rebuilds the archive and refuses the upload if its SHA-256 no longer matches. `--yes`
|
||||
has deliberately narrower semantics: it approves only the snapshot built during that one
|
||||
invocation. Use it for a deliberate human or one-shot approval, not as the second half of a
|
||||
digest-bound agent/CI review. Without a TTY, a source upload requires either matching
|
||||
`--approve-sha256` approval or `--yes`; an interactive terminal can instead show the summary,
|
||||
the selected filenames when `--show-files` is set, and a `[y/N]` confirmation for its current
|
||||
snapshot.
|
||||
|
||||
The default selection is privacy-conscious: in a Git worktree it includes tracked files plus untracked files that are not ignored; it honors `.gitignore`, excludes every hidden path component, always excludes `.git`, symlinks, dependencies/build output, secret-like filenames, and nested archives. Add project exclusions to `.strixignore` (one exclude glob per line) or repeat `--exclude GLOB`; a trailing slash such as `private/` excludes that directory subtree.
|
||||
|
||||
The client refuses more than 20,000 files, a file over 25 MiB, more than 250 MiB expanded, or a ZIP over 50 MiB. The service then stream-inflates the ZIP and independently rejects malformed or unsupported entries, unsafe paths, too many entries, oversized entries, excessive expanded data, and oversized compressed input, so an untrusted client cannot bypass the ZIP-bomb controls by forging metadata.
|
||||
|
||||
Only use `--include-hidden`, `--include-sensitive`, or `--include-archives` after the dry-run manifest shows that the scan needs them. Hidden and sensitive files are separate opt-ins: for example, including `.env` requires both `--include-hidden` and `--include-sensitive`.
|
||||
|
||||
The CLI removes its private temporary local archive after every invocation. Once a remote
|
||||
upload is staged, a definitive scan rejection causes the CLI to delete it. A network failure,
|
||||
`5xx` response, malformed success response, or interruption after scan launch begins is
|
||||
ambiguous—the platform may have accepted the scan—so the CLI retains the upload and returns
|
||||
its `upload_id` with `launch_outcome_unknown: true`. If an automatic deletion attempt cannot
|
||||
be confirmed, it instead returns the retained `upload_id` with `cleanup_unknown: true`.
|
||||
Before retrying, run `strix cloud scans list` to avoid a duplicate scan or charge. If no scan
|
||||
is linked to the retained upload, remove it with `strix cloud uploads delete UPLOAD_ID`;
|
||||
linked uploads cannot be deleted.
|
||||
|
||||
With no explicit type, source alone infers `code_review`. Any domain target wins and infers `live_test`, so source plus a deployed domain is the normal white-box live-test workflow. Pass `--engagement-type` when you need to override the inference.
|
||||
|
||||
## 3. Wait for completion
|
||||
|
||||
Pass `--wait` to `scans start` to poll until the scan reaches a final state, or poll yourself with `strix cloud scans get <scan-id>` (`scans:read`). Bound automation with `--wait-timeout SECONDS`; timeout exits cleanly without cancelling the remote scan. Status flow: `pending → running → completed` (or `failed` / `cancelled`). Scans take minutes to hours — poll on an interval, do not block indefinitely.
|
||||
|
||||
## 4. Read findings
|
||||
|
||||
The scan-detail response includes `executive_summary`, `methodology`, `recommendations`, a `findings` severity roll-up, and a `vulnerabilities[]` array. Each vulnerability carries `title, severity, status, cvss, cwe, endpoint, method, impact, technical_analysis, poc_description, poc_script_code`, and (for code findings) `code_file`/`code_diff`/`code_before`/`code_after`.
|
||||
|
||||
```bash
|
||||
curl -sS "$BASE/scans/$scan_id" "${auth[@]}" \
|
||||
strix cloud scans get <scan-id> --json \
|
||||
| jq '["critical","high","medium","low","info"] as $order
|
||||
| .vulnerabilities
|
||||
| sort_by(.severity as $s | $order | index($s))
|
||||
| .[] | {title, severity, endpoint, cwe}'
|
||||
```
|
||||
|
||||
Cloud severities are `critical | high | medium | low` and statuses are `open | in_progress | fixed | ignored`. Sort by an explicit severity order rather than `sort_by(.severity)`, which sorts alphabetically (critical, high, low, medium).
|
||||
Cloud severities are `critical | high | medium | low` and statuses are `open | in_progress | snoozed | fixed | ignored | not_affected`. Sort by an explicit severity order rather than `sort_by(.severity)`, which sorts alphabetically (critical, high, low, medium).
|
||||
|
||||
Org-wide triage across scans: `GET /vulnerabilities` (`vulnerabilities:read`; filter by severity/status). Update triage state with the vulnerabilities `:write` endpoints. To remediate, hand off to the **fix-security-vulnerabilities-with-strix** skill.
|
||||
Org-wide triage across scans: `strix cloud vulns list --severity critical` (`vulnerabilities:read`, and it also filters by `--status`, `--scan-id`, and more). Update triage state with `strix cloud vulns update <id> --status fixed`. To remediate, hand off to the **fix-security-vulnerabilities-with-strix** skill.
|
||||
|
||||
## 5. Export & report
|
||||
|
||||
```bash
|
||||
# SARIF 2.1.0 for GitHub code scanning / ASPM ingestion
|
||||
curl -sS "$BASE/scans/$scan_id/sarif" "${auth[@]}" -o findings.sarif
|
||||
strix cloud scans sarif <scan-id> --output findings.sarif
|
||||
|
||||
# Report. The format and file type are query params (`Accept` is ignored):
|
||||
# format=technical (default) | retest | attestation | executive_summary
|
||||
# type=pdf (default) | docx
|
||||
# Any report download requires the Enterprise plan; formats beyond `technical`,
|
||||
# Report. Formats: technical (default) | retest | attestation | executive_summary
|
||||
# Types: pdf (default) | docx
|
||||
# Any report download requires the Enterprise plan. Formats beyond `technical`,
|
||||
# DOCX, and white-label branding are Enterprise-only too. Scan must be completed.
|
||||
curl -sS "$BASE/scans/$scan_id/report?format=technical&type=pdf" "${auth[@]}" -o strix-report.pdf
|
||||
strix cloud scans report <scan-id> --format technical --type pdf --output strix-report.pdf
|
||||
```
|
||||
|
||||
Downloads refuse to replace a file unless `--force` is explicit. Enterprise audit logs can be streamed as JSON or exported without trying to JSON-decode the body:
|
||||
|
||||
```bash
|
||||
strix cloud audit list --format csv --all --output audit.csv
|
||||
strix cloud audit list --format ndjson --all --output audit.ndjson
|
||||
```
|
||||
|
||||
## 6. PR reviews
|
||||
|
||||
Trigger an automated security review of a pull request (`pr_reviews:write`); results appear as PR comments and in the dashboard:
|
||||
Trigger an automated security review of a pull request (`pr_reviews:write`). Read the repository's `provider` and `installation_id` with `strix cloud repos list`; both identify the installed source-control integration. The results appear as PR comments and in the dashboard:
|
||||
|
||||
```bash
|
||||
curl -sS "$BASE/pr-reviews/start" "${auth[@]}" -H "Content-Type: application/json" \
|
||||
-d '{"repository_full_name":"org/app","pr_number":123}'
|
||||
strix cloud pr-reviews start \
|
||||
--provider github \
|
||||
--installation-id <installation-id> \
|
||||
--repository-full-name org/app \
|
||||
--pr-number 123
|
||||
```
|
||||
|
||||
List/inspect via `GET /pr-reviews` and `GET /pr-reviews/{id}`. Repo-level PR-review behavior is configured with the repository-settings endpoint.
|
||||
List/inspect with `strix cloud pr-reviews list` and `strix cloud pr-reviews get <id>`. Repo-level PR-review behavior is configured with `strix cloud pr-reviews settings`.
|
||||
|
||||
## 7. Continuous testing (schedules & webhooks)
|
||||
|
||||
- **Schedules** (`schedules:write`, Pro plan): create recurring scans and trigger them on demand — the managed equivalent of a cron-driven CLI loop.
|
||||
- **Webhooks** (`webhooks:write`): subscribe to pentest/vulnerability lifecycle events such as `scan.completed` and `vulnerability.created` to push results into Slack, ticketing, or your own pipeline instead of polling.
|
||||
- **Schedules** (`schedules:write`, Pro plan): `strix cloud schedules create` makes recurring scans, and `strix cloud schedules trigger <id>` runs one on demand — the managed equivalent of a cron-driven CLI loop.
|
||||
- **Webhooks** (`webhooks:write`): `strix cloud webhooks create` subscribes to pentest/vulnerability lifecycle events such as `scan.completed` and `vulnerability.created` to push results into Slack, ticketing, or your own pipeline instead of polling.
|
||||
|
||||
See the schedules and webhooks sections at [docs.app.strix.ai](https://docs.app.strix.ai) for payloads.
|
||||
|
||||
Network connectors are Enterprise-only. `strix cloud connectors create` may return a one-time enrollment command containing credentials; do not paste it into logs, and request it with `--include-command` only when the user is ready to install it. Browser checkout, source-control installation, DNS verification, connector installation, chat sharing, and publishing SARIF to an external provider are user handoffs or explicit external mutations—prepare the command/link, then obtain the appropriate approval before completing them.
|
||||
|
||||
## Safety
|
||||
|
||||
Only scan assets the user's organization owns or is authorized to test. External domain scans require verification (DNS/file/meta-tag) enforced by the platform — do not try to bypass it.
|
||||
|
||||
@@ -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
|
||||
@@ -580,6 +581,7 @@ _BASE_TOOLS: tuple[Tool, ...] = (
|
||||
web_search,
|
||||
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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
169
strix/interface/cloud/__init__.py
Normal file
169
strix/interface/cloud/__init__.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""`strix cloud` — the managed Strix platform (app.strix.ai) from the terminal.
|
||||
|
||||
Every command maps to one operation of the public REST API. Output is JSON
|
||||
when stdout is not a terminal, so agents can parse every result. Exit codes:
|
||||
0 success, 1 error, 2 invalid usage, 4 authentication required, 5 payment
|
||||
required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
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.render import json_mode
|
||||
from strix.interface.cloud.runner import resolve, run
|
||||
from strix.interface.cloud.session import run_session
|
||||
from strix.interface.cloud.spec import DEFAULT_VERBS, GROUP_HELP, SPEC
|
||||
from strix.interface.cloud.workspaces import run_workspace_use
|
||||
from strix.interface.platform_cli import run_login
|
||||
from strix.interface.terminal_text import sanitize_terminal_text
|
||||
|
||||
|
||||
_USAGE_HEADER = """[bold]Usage:[/] strix cloud <command> [arguments]
|
||||
|
||||
[bold]Session commands:[/]
|
||||
login Sign in to the managed platform and store an API token
|
||||
logout Remove the stored API token
|
||||
whoami Show the stored account, workspace, and token state
|
||||
session Inspect or narrow the remote CLI session
|
||||
credits Show the credit balance of the workspace
|
||||
|
||||
[bold]Resource commands:[/]"""
|
||||
|
||||
_USAGE_FOOTER = """
|
||||
Run [bold]strix cloud <command> help[/] to list its verbs. Common read-only
|
||||
commands may also run their default verb when no verb is given.
|
||||
Every REST resource command accepts [bold]--json[/] and [bold]--token[/]. Write
|
||||
commands accept [bold]--data[/] with a JSON object of extra request fields.
|
||||
Login is an interactive device flow; [bold]whoami[/] and [bold]logout[/] also
|
||||
produce JSON automatically when output is redirected.
|
||||
API reference: https://docs.app.strix.ai"""
|
||||
|
||||
_HELP_TOKENS = frozenset({"-h", "--help", "help"})
|
||||
|
||||
|
||||
def _is_help_request(argv: list[str]) -> bool:
|
||||
"""Recognize a help token with an optional JSON-output flag in either order."""
|
||||
return sum(argument in _HELP_TOKENS for argument in argv) == 1 and all(
|
||||
argument in _HELP_TOKENS or argument == "--json" for argument in argv
|
||||
)
|
||||
|
||||
|
||||
def run_cloud(argv: list[str]) -> int:
|
||||
"""Run a managed-cloud command without ever leaking a Ctrl-C traceback."""
|
||||
try:
|
||||
return _run_cloud(argv)
|
||||
except KeyboardInterrupt:
|
||||
if json_mode(flag="--json" in argv):
|
||||
sys.stdout.write(json.dumps({"error": "Interrupted.", "interrupted": True}) + "\n")
|
||||
else:
|
||||
Console(stderr=True).print("[yellow]Interrupted.[/]")
|
||||
return 130
|
||||
|
||||
|
||||
def _run_cloud(argv: list[str]) -> int: # noqa: PLR0911, PLR0912
|
||||
"""Entry point for ``strix cloud …``. Returns a process exit code."""
|
||||
console = Console()
|
||||
as_json = json_mode(flag="--json" in argv)
|
||||
if not argv or _is_help_request(argv):
|
||||
if as_json:
|
||||
_print_usage_json()
|
||||
else:
|
||||
_print_usage(console)
|
||||
return 0
|
||||
if argv == ["--json"]:
|
||||
_print_usage_json()
|
||||
return 0
|
||||
|
||||
group, rest = argv[0], argv[1:]
|
||||
if group == "workspace":
|
||||
group = "workspaces"
|
||||
if group in ("login", "logout", "whoami"):
|
||||
return _run_session(console, group, rest)
|
||||
if group == "session":
|
||||
return run_session(rest)
|
||||
if group == "credits":
|
||||
group, rest = "billing", ["credits", *rest]
|
||||
if group == "workspaces" and rest and rest[0] == "use":
|
||||
try:
|
||||
return run_workspace_use(rest[1:])
|
||||
except http.CloudError as exc:
|
||||
if "--json" in rest:
|
||||
sys.stdout.write(json.dumps({"error": str(exc)}) + "\n")
|
||||
else:
|
||||
console.print(f"[red]Error:[/] {escape(sanitize_terminal_text(exc))}")
|
||||
return exc.exit_code
|
||||
|
||||
if group not in SPEC:
|
||||
if as_json:
|
||||
sys.stdout.write(json.dumps({"error": f"unknown command: {group}"}) + "\n")
|
||||
return 2
|
||||
console.print(f"[red]Unknown command:[/] {escape(sanitize_terminal_text(group))}")
|
||||
_print_usage(console)
|
||||
return 2
|
||||
group_help = _is_help_request(rest)
|
||||
resolved = None if group_help else resolve(group, rest)
|
||||
if resolved is None:
|
||||
help_tokens: set[str] = set(_HELP_TOKENS) if group_help else set()
|
||||
invalid = [arg for arg in rest if arg != "--json" and arg not in help_tokens]
|
||||
_print_verbs(console, group, as_json=as_json, error="unknown verb" if invalid else None)
|
||||
return 2 if invalid else 0
|
||||
cmd, remaining = resolved
|
||||
verb_label = " ".join(rest[: len(rest) - len(remaining)]) or DEFAULT_VERBS.get(group, "")
|
||||
return run(group, verb_label, cmd, remaining)
|
||||
|
||||
|
||||
def _run_session(_console: Console, group: str, rest: list[str]) -> int:
|
||||
if rest and rest[0] == "help":
|
||||
rest = ["--help", *rest[1:]]
|
||||
session_argv = {
|
||||
"login": rest,
|
||||
"logout": ["logout", *rest],
|
||||
"whoami": ["status", *rest],
|
||||
}
|
||||
return run_login(session_argv[group])
|
||||
|
||||
|
||||
def _print_usage(console: Console) -> None:
|
||||
console.print(_USAGE_HEADER)
|
||||
for group in SPEC:
|
||||
console.print(f" {group:<14}{GROUP_HELP.get(group, '')}")
|
||||
console.print(_USAGE_FOOTER)
|
||||
|
||||
|
||||
def _print_verbs(
|
||||
console: Console, group: str, *, as_json: bool = False, error: str | None = None
|
||||
) -> None:
|
||||
if as_json:
|
||||
verbs: list[dict[str, str]] = [
|
||||
{"name": verb, "help": command.help} for verb, command in SPEC[group].items()
|
||||
]
|
||||
if group == "workspaces":
|
||||
verbs.append({"name": "use", "help": "Switch the stored token to another workspace."})
|
||||
payload: dict[str, object] = {
|
||||
"command": f"strix cloud {group}",
|
||||
"verbs": verbs,
|
||||
}
|
||||
if error:
|
||||
payload["error"] = error
|
||||
sys.stdout.write(json.dumps(payload, indent=2) + "\n")
|
||||
return
|
||||
console.print(f"[bold]strix cloud {group}[/] verbs:")
|
||||
for verb, cmd in SPEC[group].items():
|
||||
console.print(f" {verb:<28}{cmd.help}")
|
||||
if group == "workspaces":
|
||||
console.print(f" {'use':<28}Switch the stored token to another workspace.")
|
||||
|
||||
|
||||
def _print_usage_json() -> None:
|
||||
payload = {
|
||||
"command": "strix cloud",
|
||||
"session_commands": ["login", "logout", "whoami", "session", "credits"],
|
||||
"resource_commands": [{"name": group, "help": GROUP_HELP.get(group, "")} for group in SPEC],
|
||||
}
|
||||
sys.stdout.write(json.dumps(payload, indent=2) + "\n")
|
||||
18
strix/interface/cloud/arguments.py
Normal file
18
strix/interface/cloud/arguments.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Argument parsing that reports managed-cloud usage errors through one contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from typing import NoReturn
|
||||
|
||||
import strix.interface.cloud.http as http # noqa: PLR0402
|
||||
|
||||
|
||||
class CloudArgumentParser(argparse.ArgumentParser):
|
||||
"""Raise a typed usage error instead of printing argparse prose and exiting."""
|
||||
|
||||
def error(self, message: str) -> NoReturn:
|
||||
raise http.CloudError(
|
||||
f"invalid arguments for {self.prog}: {message}",
|
||||
exit_code=http.EXIT_USAGE,
|
||||
)
|
||||
718
strix/interface/cloud/billing.py
Normal file
718
strix/interface/cloud/billing.py
Normal file
@@ -0,0 +1,718 @@
|
||||
"""Billing top-up and agent-wallet execution for ``strix cloud``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
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
|
||||
|
||||
import strix.interface.cloud.http as http # noqa: PLR0402
|
||||
from strix.interface.cloud.payment_proxy import WalletUpstreamResponse, wallet_payment_bridge
|
||||
from strix.interface.cloud.render import emit
|
||||
from strix.interface.terminal_text import sanitize_terminal_text
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import argparse
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
|
||||
_MAX_WALLET_DETAIL_CHARS = 2_000
|
||||
# Keep the wallet client on the exact protocol implementation used by the
|
||||
# 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(
|
||||
{
|
||||
"ALL_PROXY",
|
||||
"APPDATA",
|
||||
"COLORTERM",
|
||||
"COMSPEC",
|
||||
"FORCE_COLOR",
|
||||
"HOME",
|
||||
"HTTPS_PROXY",
|
||||
"HTTP_PROXY",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"LC_CTYPE",
|
||||
"LOCALAPPDATA",
|
||||
"NO_COLOR",
|
||||
"NO_PROXY",
|
||||
"PATH",
|
||||
"PATHEXT",
|
||||
"SSL_CERT_DIR",
|
||||
"SSL_CERT_FILE",
|
||||
"SYSTEMROOT",
|
||||
"TEMP",
|
||||
"TERM",
|
||||
"TMP",
|
||||
"TMPDIR",
|
||||
"USERPROFILE",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_DATA_HOME",
|
||||
"XDG_STATE_HOME",
|
||||
"all_proxy",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"no_proxy",
|
||||
}
|
||||
)
|
||||
_AUTHORIZATION_SECRET = re.compile(r"(?i)((?:bearer|payment)\s+)[^\s\"']+")
|
||||
_LOOPBACK_NO_PROXY = ("127.0.0.1", "localhost", "::1")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _WalletClientResult:
|
||||
process: subprocess.CompletedProcess[str]
|
||||
upstream_responses: tuple[WalletUpstreamResponse, ...]
|
||||
|
||||
|
||||
def run_topup( # noqa: PLR0911, PLR0912, PLR0915
|
||||
console: Console,
|
||||
args: argparse.Namespace,
|
||||
body: dict[str, Any],
|
||||
*,
|
||||
as_json: bool,
|
||||
token: str | None,
|
||||
) -> int:
|
||||
"""Handle the HTTP 402 challenge and optional agent-wallet payment."""
|
||||
response = http.request("POST", "/billing/topup", token=token, body=body)
|
||||
if response.status_code != 402:
|
||||
emit(console, http.check(response), as_json=as_json)
|
||||
return http.EXIT_OK
|
||||
|
||||
challenge = http.parsed(response)
|
||||
if getattr(args, "no_pay", False):
|
||||
emit(
|
||||
console,
|
||||
{"error": "Payment required", "challenge": challenge},
|
||||
as_json=as_json,
|
||||
)
|
||||
return http.EXIT_PAYMENT
|
||||
|
||||
credit_count = body.get("credits")
|
||||
if not getattr(args, "yes", False):
|
||||
if as_json or not (sys.stdin.isatty() and sys.stdout.isatty()):
|
||||
emit(
|
||||
console,
|
||||
{
|
||||
"error": (
|
||||
"Payment requires explicit approval in non-interactive mode. "
|
||||
"Review the challenge, then re-run with --yes to authorize payment."
|
||||
),
|
||||
"challenge": challenge,
|
||||
},
|
||||
as_json=as_json,
|
||||
)
|
||||
return http.EXIT_PAYMENT
|
||||
answer = console.input(f"Buy {credit_count} credit(s) now? [y/N]: ").strip().lower()
|
||||
if answer not in ("y", "yes"):
|
||||
console.print("[yellow]Payment cancelled.[/]")
|
||||
return http.EXIT_PAYMENT
|
||||
|
||||
npx = shutil.which("npx")
|
||||
if npx is None:
|
||||
message = (
|
||||
"Payment requires a wallet client. Install Node.js and run the command again, "
|
||||
"or pay the challenge with an MPP wallet client."
|
||||
)
|
||||
if as_json:
|
||||
emit(
|
||||
console,
|
||||
{"error": message, "challenge": challenge},
|
||||
as_json=True,
|
||||
)
|
||||
else:
|
||||
emit(console, challenge, as_json=False)
|
||||
console.print(f"[yellow]Payment required.[/] {message}")
|
||||
return http.EXIT_PAYMENT
|
||||
|
||||
payment_method = getattr(args, "payment_method", None) or os.environ.get(
|
||||
"MPPX_STRIPE_PAYMENT_METHOD"
|
||||
)
|
||||
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:
|
||||
emit(
|
||||
console,
|
||||
{
|
||||
"error": (
|
||||
"Payment was interrupted after the wallet started. The outcome is unknown; "
|
||||
"run `strix cloud billing credits` and check the balance before retrying."
|
||||
),
|
||||
"interrupted": True,
|
||||
"payment_outcome_unknown": True,
|
||||
},
|
||||
as_json=as_json,
|
||||
)
|
||||
return 130
|
||||
except OSError:
|
||||
emit(
|
||||
console,
|
||||
{
|
||||
"error": "Could not start the wallet client securely.",
|
||||
"challenge": challenge,
|
||||
},
|
||||
as_json=as_json,
|
||||
)
|
||||
return http.EXIT_PAYMENT
|
||||
|
||||
result = wallet_result.process
|
||||
confirmed_receipt = _confirmed_topup_receipt(wallet_result.upstream_responses)
|
||||
if confirmed_receipt is not None:
|
||||
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
|
||||
if result.returncode == 0:
|
||||
try:
|
||||
receipt = json.loads(stdout)
|
||||
except (TypeError, ValueError):
|
||||
emit(
|
||||
console,
|
||||
{
|
||||
"error": (
|
||||
"The wallet reported success but did not return JSON. Check the credit "
|
||||
"balance before retrying payment."
|
||||
),
|
||||
"detail": _wallet_detail(stdout or stderr or "No wallet output was returned."),
|
||||
"payment_outcome_unknown": True,
|
||||
},
|
||||
as_json=True,
|
||||
)
|
||||
return http.EXIT_PAYMENT
|
||||
if not _valid_topup_receipt(receipt):
|
||||
emit(
|
||||
console,
|
||||
{
|
||||
"error": (
|
||||
"The wallet returned an invalid top-up receipt. Check the credit balance "
|
||||
"before retrying payment."
|
||||
),
|
||||
"detail": _wallet_detail(stdout),
|
||||
"payment_outcome_unknown": True,
|
||||
},
|
||||
as_json=True,
|
||||
)
|
||||
return http.EXIT_PAYMENT
|
||||
emit(
|
||||
console,
|
||||
{
|
||||
"error": (
|
||||
"The wallet returned a receipt, but the Strix billing endpoint did not "
|
||||
"confirm it. Check the credit balance before retrying payment."
|
||||
),
|
||||
"detail": _wallet_detail(stdout),
|
||||
"payment_outcome_unknown": True,
|
||||
},
|
||||
as_json=True,
|
||||
)
|
||||
return http.EXIT_PAYMENT
|
||||
|
||||
emit(
|
||||
console,
|
||||
{
|
||||
"error": (
|
||||
"The wallet exited without a confirmed receipt. The payment outcome is unknown; "
|
||||
"run `strix cloud billing credits` and check the balance before retrying."
|
||||
),
|
||||
"detail": _wallet_detail(
|
||||
stderr or stdout or f"Wallet client exited with status {result.returncode}."
|
||||
),
|
||||
"wallet_exit_code": result.returncode,
|
||||
"payment_outcome_unknown": True,
|
||||
},
|
||||
as_json=True,
|
||||
)
|
||||
return http.EXIT_PAYMENT
|
||||
|
||||
|
||||
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 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()
|
||||
upstream_responses: list[WalletUpstreamResponse] = []
|
||||
with tempfile.TemporaryDirectory(prefix="strix-wallet-") as wallet_cwd:
|
||||
wallet_root = Path(wallet_cwd)
|
||||
user_config = wallet_root / "user.npmrc"
|
||||
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:
|
||||
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(("LINK_", "MPPX_"))
|
||||
}
|
||||
for name in ("NO_PROXY", "no_proxy"):
|
||||
entries = [entry.strip() for entry in environment.get(name, "").split(",") if entry.strip()]
|
||||
normalized = {entry.lower().strip("[]") for entry in entries}
|
||||
entries.extend(host for host in _LOOPBACK_NO_PROXY if host not in normalized)
|
||||
environment[name] = ",".join(entries)
|
||||
return environment
|
||||
|
||||
|
||||
def _wallet_detail(value: str) -> str:
|
||||
"""Bound and redact third-party wallet diagnostics before returning JSON."""
|
||||
redacted = _AUTHORIZATION_SECRET.sub(r"\1[redacted]", sanitize_terminal_text(value))
|
||||
if len(redacted) <= _MAX_WALLET_DETAIL_CHARS:
|
||||
return redacted
|
||||
return redacted[: _MAX_WALLET_DETAIL_CHARS - 1] + "…"
|
||||
|
||||
|
||||
def _valid_topup_receipt(value: Any) -> bool:
|
||||
"""Require the documented success shape before reporting a paid top-up."""
|
||||
if not isinstance(value, dict):
|
||||
return False
|
||||
fields = cast("dict[str, Any]", value)
|
||||
credits_granted = fields.get("credits_granted")
|
||||
balance = fields.get("balance")
|
||||
return (
|
||||
isinstance(credits_granted, int)
|
||||
and not isinstance(credits_granted, bool)
|
||||
and credits_granted >= 0
|
||||
and isinstance(fields.get("duplicate"), bool)
|
||||
and isinstance(fields.get("reference"), str)
|
||||
and bool(fields["reference"])
|
||||
and isinstance(balance, int)
|
||||
and not isinstance(balance, bool)
|
||||
and balance >= 0
|
||||
)
|
||||
|
||||
|
||||
def _confirmed_topup_receipt(
|
||||
responses: tuple[WalletUpstreamResponse, ...],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return a receipt only when the trusted bridge observed its successful response."""
|
||||
for response in reversed(responses):
|
||||
if not 200 <= response.status_code < 300:
|
||||
continue
|
||||
try:
|
||||
receipt = json.loads(response.body)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if _valid_topup_receipt(receipt):
|
||||
return cast("dict[str, Any]", receipt)
|
||||
return None
|
||||
361
strix/interface/cloud/http.py
Normal file
361
strix/interface/cloud/http.py
Normal file
@@ -0,0 +1,361 @@
|
||||
"""HTTP client for the managed Strix platform API (app.strix.ai)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from urllib.parse import SplitResult, urlsplit
|
||||
|
||||
import requests
|
||||
|
||||
from strix.config import load_settings
|
||||
from strix.interface.platform_cli import read_record
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_DEFAULT_TIMEOUT_S = 120
|
||||
_SUPABASE_STORAGE_HOST = re.compile(r"^[a-z0-9-]+\.supabase\.co$")
|
||||
_STORAGE_PATH_PREFIX = "/storage/v1/"
|
||||
_app_url_override: str | None = None
|
||||
_token_override_active = False
|
||||
_workspace_id_override: str | None = None
|
||||
_timeout_s: float = _DEFAULT_TIMEOUT_S
|
||||
|
||||
EXIT_OK = 0
|
||||
EXIT_ERROR = 1
|
||||
EXIT_USAGE = 2
|
||||
EXIT_AUTH = 4
|
||||
EXIT_PAYMENT = 5
|
||||
|
||||
|
||||
class CloudError(Exception):
|
||||
"""A failed cloud command. Carries the process exit code."""
|
||||
|
||||
def __init__(self, message: str, *, exit_code: int = EXIT_ERROR, payload: Any = None) -> None:
|
||||
super().__init__(message)
|
||||
self.exit_code = exit_code
|
||||
self.payload = payload
|
||||
|
||||
|
||||
class CloudTransportError(CloudError):
|
||||
"""A request may have reached the platform, but no response was received."""
|
||||
|
||||
|
||||
def configure(
|
||||
*,
|
||||
base_url: str | None = None,
|
||||
timeout: float | None = None,
|
||||
token_override: bool = False,
|
||||
workspace_id: str | None = None,
|
||||
) -> None:
|
||||
"""Set the platform URL and the request timeout for this process."""
|
||||
global _app_url_override, _timeout_s, _token_override_active # noqa: PLW0603
|
||||
global _workspace_id_override # noqa: PLW0603
|
||||
_app_url_override = base_url.rstrip("/") if base_url else None
|
||||
_token_override_active = token_override
|
||||
explicit_workspace = workspace_id or os.environ.get("STRIX_WORKSPACE_ID")
|
||||
if explicit_workspace:
|
||||
_workspace_id_override = explicit_workspace.strip()
|
||||
elif not token_override and not os.environ.get("STRIX_API_TOKEN"):
|
||||
record = read_record()
|
||||
stored_workspace = record.get("organization_id") if record is not None else None
|
||||
_workspace_id_override = (
|
||||
stored_workspace.strip()
|
||||
if isinstance(stored_workspace, str) and stored_workspace.strip()
|
||||
else None
|
||||
)
|
||||
else:
|
||||
_workspace_id_override = None
|
||||
if timeout is not None:
|
||||
if not math.isfinite(timeout) or timeout <= 0:
|
||||
raise CloudError(
|
||||
"request timeout must be a finite number greater than 0.",
|
||||
exit_code=EXIT_USAGE,
|
||||
)
|
||||
_timeout_s = timeout
|
||||
|
||||
|
||||
def app_url() -> str:
|
||||
if _app_url_override:
|
||||
return _app_url_override
|
||||
viewer = load_settings().viewer
|
||||
configured = viewer.app_url.rstrip("/")
|
||||
explicitly_configured = bool(os.environ.get("STRIX_APP_URL")) or "app_url" in getattr(
|
||||
viewer, "model_fields_set", set[str]()
|
||||
)
|
||||
if explicitly_configured or _token_override_active or os.environ.get("STRIX_API_TOKEN"):
|
||||
return configured
|
||||
record = read_record()
|
||||
stored = record.get("app_url") if record is not None else None
|
||||
if isinstance(stored, str) and stored:
|
||||
try:
|
||||
_parse_origin_url(stored, label="stored platform URL")
|
||||
except CloudError:
|
||||
pass
|
||||
else:
|
||||
return stored.rstrip("/")
|
||||
return configured
|
||||
|
||||
|
||||
def api_token(override: str | None = None) -> str:
|
||||
token = override or os.environ.get("STRIX_API_TOKEN")
|
||||
if not token:
|
||||
record = read_record()
|
||||
if record is not None:
|
||||
stored = record.get("api_token")
|
||||
if isinstance(stored, str):
|
||||
_validate_stored_token_origin(record)
|
||||
token = stored
|
||||
if not token or not token.strip():
|
||||
raise CloudError(
|
||||
"not signed in. Run `strix cloud login`, or set STRIX_API_TOKEN.",
|
||||
exit_code=EXIT_AUTH,
|
||||
)
|
||||
return token.strip()
|
||||
|
||||
|
||||
def _validate_stored_token_origin(record: dict[str, Any]) -> None:
|
||||
"""Never send a stored bearer token to an origin other than its issuer."""
|
||||
stored_url = record.get("app_url")
|
||||
if not isinstance(stored_url, str) or not stored_url:
|
||||
raise CloudError(
|
||||
"the stored sign-in is not bound to a trusted platform. Run `strix cloud login` "
|
||||
"again before using it.",
|
||||
exit_code=EXIT_AUTH,
|
||||
)
|
||||
try:
|
||||
stored_origin = _origin(_parse_origin_url(stored_url, label="stored platform URL"))
|
||||
active_origin = _origin(_parse_origin_url(app_url(), label="configured platform URL"))
|
||||
except CloudError as exc:
|
||||
raise CloudError(
|
||||
"the stored sign-in has an invalid platform binding. Run `strix cloud login` again.",
|
||||
exit_code=EXIT_AUTH,
|
||||
) from exc
|
||||
if stored_origin != active_origin:
|
||||
raise CloudError(
|
||||
"the stored sign-in belongs to a different platform. Refusing to send its token; "
|
||||
"run `strix cloud login` for the configured platform or supply an explicit token.",
|
||||
exit_code=EXIT_AUTH,
|
||||
)
|
||||
|
||||
|
||||
def request(
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
token: str | None = None,
|
||||
query: dict[str, Any] | None = None,
|
||||
body: dict[str, Any] | None = None,
|
||||
stream: bool = False,
|
||||
idempotency_key: str | None = None,
|
||||
) -> requests.Response:
|
||||
url = f"{app_url()}/api/v1{path}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_token(token)}",
|
||||
}
|
||||
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:
|
||||
headers["Idempotency-Key"] = idempotency_key
|
||||
try:
|
||||
response = requests.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
params={
|
||||
key: ("true" if value else "false") if isinstance(value, bool) else value
|
||||
for key, value in (query or {}).items()
|
||||
if value is not None
|
||||
}
|
||||
or None,
|
||||
json=body,
|
||||
timeout=_timeout_s,
|
||||
stream=stream,
|
||||
allow_redirects=False,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise CloudTransportError(f"could not reach {app_url()}: {exc}") from exc
|
||||
return response
|
||||
|
||||
|
||||
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
|
||||
if token_override or _token_override_active or os.environ.get("STRIX_API_TOKEN"):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def upload_file(signed_url: str, upload_token: str, path: Path) -> None:
|
||||
"""Stream a file to a platform-issued storage URL."""
|
||||
_validate_upload_url(signed_url)
|
||||
response: requests.Response | None = None
|
||||
try:
|
||||
with path.open("rb") as stream:
|
||||
response = requests.put(
|
||||
signed_url,
|
||||
data=stream,
|
||||
headers={
|
||||
"Authorization": f"Bearer {upload_token}",
|
||||
"Content-Type": "application/zip",
|
||||
},
|
||||
timeout=_timeout_s,
|
||||
allow_redirects=False,
|
||||
)
|
||||
except (OSError, requests.RequestException) as exc:
|
||||
raise CloudError(f"source upload failed: {exc}") from exc
|
||||
try:
|
||||
if 300 <= response.status_code < 400:
|
||||
raise CloudError("source upload refused an unexpected redirect")
|
||||
if not response.ok:
|
||||
detail = ""
|
||||
try:
|
||||
payload = response.json()
|
||||
if isinstance(payload, dict):
|
||||
fields = cast("dict[str, Any]", payload)
|
||||
detail = str(fields.get("message") or fields.get("error") or "")
|
||||
except ValueError:
|
||||
pass
|
||||
raise CloudError(detail or f"source upload failed (HTTP {response.status_code})")
|
||||
finally:
|
||||
response.close()
|
||||
|
||||
|
||||
def _validate_upload_url(signed_url: str) -> None:
|
||||
"""Allow uploads only to the trusted app origin or managed Supabase storage."""
|
||||
# Supabase signed upload URLs carry their signature in the query string.
|
||||
# Keep every origin/path restriction below, but allow that opaque query on
|
||||
# this one platform-issued URL type.
|
||||
target = _parse_origin_url(
|
||||
signed_url,
|
||||
label="source upload URL",
|
||||
allow_query=True,
|
||||
)
|
||||
if not target.path.startswith(_STORAGE_PATH_PREFIX):
|
||||
raise CloudError("source upload refused a URL outside the storage API")
|
||||
|
||||
configured_app = _parse_origin_url(app_url(), label="configured platform URL")
|
||||
if _origin(target) == _origin(configured_app):
|
||||
return
|
||||
if _is_loopback_host(configured_app.hostname or "") and _is_loopback_host(
|
||||
target.hostname or ""
|
||||
):
|
||||
return
|
||||
|
||||
hostname = target.hostname or ""
|
||||
if (
|
||||
target.scheme == "https"
|
||||
and target.port in (None, 443)
|
||||
and _SUPABASE_STORAGE_HOST.fullmatch(hostname)
|
||||
):
|
||||
return
|
||||
raise CloudError(
|
||||
"source upload refused an untrusted storage origin; only the configured platform "
|
||||
"origin and managed Supabase storage are allowed"
|
||||
)
|
||||
|
||||
|
||||
def _parse_origin_url(
|
||||
value: str,
|
||||
*,
|
||||
label: str,
|
||||
allow_query: bool = False,
|
||||
) -> SplitResult:
|
||||
try:
|
||||
parsed = urlsplit(value)
|
||||
port = parsed.port
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise CloudError(f"{label} is invalid") from exc
|
||||
hostname = parsed.hostname
|
||||
if (
|
||||
parsed.scheme not in {"http", "https"}
|
||||
or not hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or (parsed.query and not allow_query)
|
||||
or parsed.fragment
|
||||
or "\\" in value
|
||||
or any(character.isspace() for character in value)
|
||||
or "%" in parsed.netloc
|
||||
):
|
||||
raise CloudError(f"{label} is invalid")
|
||||
try:
|
||||
hostname.encode("ascii")
|
||||
except UnicodeEncodeError as exc:
|
||||
raise CloudError(f"{label} contains a non-ASCII hostname") from exc
|
||||
if port is not None and not 1 <= port <= 65535:
|
||||
raise CloudError(f"{label} is invalid")
|
||||
return parsed
|
||||
|
||||
|
||||
def _origin(parsed: SplitResult) -> tuple[str, str, int]:
|
||||
default_port = 443 if parsed.scheme == "https" else 80
|
||||
return parsed.scheme, (parsed.hostname or "").lower(), parsed.port or default_port
|
||||
|
||||
|
||||
def _is_loopback_host(hostname: str) -> bool:
|
||||
normalized = hostname.lower().rstrip(".")
|
||||
if normalized == "localhost" or normalized.endswith(".localhost"):
|
||||
return True
|
||||
try:
|
||||
return ipaddress.ip_address(normalized).is_loopback
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def parsed(response: requests.Response) -> Any:
|
||||
content_type = response.headers.get("content-type", "")
|
||||
if "application/json" in content_type:
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError:
|
||||
return response.text
|
||||
return response.text
|
||||
|
||||
|
||||
def check(response: requests.Response) -> Any:
|
||||
data = parsed(response)
|
||||
if 200 <= response.status_code < 300:
|
||||
content_type = response.headers.get("content-type", "").lower()
|
||||
if "application/json" not in content_type:
|
||||
raise CloudError(
|
||||
"the server returned a non-JSON response. Check STRIX_APP_URL and preview "
|
||||
"access, then retry."
|
||||
)
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
raise CloudError(
|
||||
"the server returned malformed JSON. Check STRIX_APP_URL and preview "
|
||||
"access, then retry."
|
||||
) from exc
|
||||
detail = ""
|
||||
error_code = ""
|
||||
if isinstance(data, dict):
|
||||
raw = cast("dict[str, Any]", data)
|
||||
detail = str(raw.get("detail") or raw.get("error") or "")
|
||||
error_code = str(raw.get("code") or raw.get("error_code") or "")
|
||||
nested_error = raw.get("error")
|
||||
if isinstance(nested_error, dict):
|
||||
nested = cast("dict[str, Any]", nested_error)
|
||||
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 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)
|
||||
286
strix/interface/cloud/payment_proxy.py
Normal file
286
strix/interface/cloud/payment_proxy.py
Normal file
@@ -0,0 +1,286 @@
|
||||
"""Loopback bridge for wallet clients that only accept secrets in argv.
|
||||
|
||||
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 few
|
||||
requests (challenge probes and the paid retry) to the fixed billing endpoint.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import threading
|
||||
from contextlib import contextmanager, suppress
|
||||
from dataclasses import dataclass, field
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Generator
|
||||
|
||||
|
||||
_DEFAULT_REQUEST_TIMEOUT_S = 120.0
|
||||
_MAX_REQUEST_BODY_BYTES = 64 * 1024
|
||||
_MAX_UPSTREAM_RESPONSE_BYTES = 1024 * 1024
|
||||
_MAX_WALLET_REQUESTS = 3
|
||||
_HOP_BY_HOP_HEADERS = frozenset(
|
||||
{
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"proxy-connection",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _BridgeState:
|
||||
upstream_url: str
|
||||
authorization: str
|
||||
workspace_id: str | None
|
||||
expected_body: bytes
|
||||
path: str
|
||||
timeout: float
|
||||
response_observer: Callable[[WalletUpstreamResponse], None] | None = None
|
||||
request_count: int = 0
|
||||
lock: threading.Lock = field(default_factory=threading.Lock)
|
||||
|
||||
def claim_request(self) -> bool:
|
||||
"""Allow only the challenge probes and the one paid retry."""
|
||||
with self.lock:
|
||||
if self.request_count >= _MAX_WALLET_REQUESTS:
|
||||
return False
|
||||
self.request_count += 1
|
||||
return True
|
||||
|
||||
|
||||
class _ResponseTooLargeError(Exception):
|
||||
"""The fixed billing endpoint returned more data than a wallet needs."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WalletUpstreamResponse:
|
||||
"""A bounded upstream response observed by the trusted loopback bridge."""
|
||||
|
||||
status_code: int
|
||||
body: bytes
|
||||
|
||||
|
||||
def _bounded_response_body(response: requests.Response) -> bytes:
|
||||
content_length = response.headers.get("Content-Length")
|
||||
if content_length:
|
||||
try:
|
||||
if int(content_length) > _MAX_UPSTREAM_RESPONSE_BYTES:
|
||||
raise _ResponseTooLargeError
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
for chunk in response.iter_content(chunk_size=64 * 1024):
|
||||
if not chunk:
|
||||
continue
|
||||
total += len(chunk)
|
||||
if total > _MAX_UPSTREAM_RESPONSE_BYTES:
|
||||
raise _ResponseTooLargeError
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def _connection_header_names(handler: BaseHTTPRequestHandler) -> set[str]:
|
||||
value = handler.headers.get("Connection", "")
|
||||
return {item.strip().lower() for item in value.split(",") if item.strip()}
|
||||
|
||||
|
||||
def _forward_request_headers(handler: BaseHTTPRequestHandler) -> dict[str, str]:
|
||||
blocked = {
|
||||
*_HOP_BY_HOP_HEADERS,
|
||||
*_connection_header_names(handler),
|
||||
"content-length",
|
||||
"forwarded",
|
||||
"host",
|
||||
"true-client-ip",
|
||||
"x-forwarded-for",
|
||||
"x-forwarded-host",
|
||||
"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}
|
||||
|
||||
|
||||
def _send_json_error(handler: BaseHTTPRequestHandler, status: int, message: str) -> None:
|
||||
body = f'{{"error": "{message}"}}'.encode()
|
||||
handler.close_connection = True
|
||||
handler.send_response(status)
|
||||
handler.send_header("Content-Type", "application/json")
|
||||
handler.send_header("Content-Length", str(len(body)))
|
||||
handler.send_header("Cache-Control", "no-store")
|
||||
handler.send_header("Connection", "close")
|
||||
handler.end_headers()
|
||||
with suppress(BrokenPipeError, ConnectionResetError):
|
||||
handler.wfile.write(body)
|
||||
|
||||
|
||||
def _make_handler(state: _BridgeState) -> type[BaseHTTPRequestHandler]:
|
||||
class WalletBridgeHandler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
|
||||
"""Do not write wallet request metadata to stderr."""
|
||||
del format, args
|
||||
|
||||
def do_POST(self) -> None: # noqa: PLR0911, PLR0912
|
||||
if self.path != state.path:
|
||||
_send_json_error(self, 404, "Not found")
|
||||
return
|
||||
if self.headers.get("Transfer-Encoding"):
|
||||
_send_json_error(self, 400, "Chunked request bodies are not supported")
|
||||
return
|
||||
try:
|
||||
content_length = int(self.headers.get("Content-Length", ""))
|
||||
except ValueError:
|
||||
_send_json_error(self, 411, "A valid Content-Length is required")
|
||||
return
|
||||
if content_length < 0 or content_length > _MAX_REQUEST_BODY_BYTES:
|
||||
_send_json_error(self, 413, "Request body is too large")
|
||||
return
|
||||
body = self.rfile.read(content_length)
|
||||
if body != state.expected_body:
|
||||
_send_json_error(self, 403, "Request body did not match the approved top-up")
|
||||
return
|
||||
if not state.claim_request():
|
||||
_send_json_error(self, 429, "Wallet request limit reached")
|
||||
return
|
||||
|
||||
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",
|
||||
state.upstream_url,
|
||||
headers=headers,
|
||||
data=body,
|
||||
timeout=state.timeout,
|
||||
allow_redirects=False,
|
||||
stream=True,
|
||||
)
|
||||
try:
|
||||
response_body = _bounded_response_body(response)
|
||||
response_status = response.status_code
|
||||
response_headers = dict(response.headers)
|
||||
finally:
|
||||
response.close()
|
||||
except _ResponseTooLargeError:
|
||||
_send_json_error(self, 502, "Strix billing response was too large")
|
||||
return
|
||||
except requests.RequestException:
|
||||
_send_json_error(self, 502, "Could not reach the Strix billing endpoint")
|
||||
return
|
||||
|
||||
if state.response_observer is not None:
|
||||
with suppress(Exception):
|
||||
state.response_observer(
|
||||
WalletUpstreamResponse(status_code=response_status, body=response_body)
|
||||
)
|
||||
|
||||
if 300 <= response_status < 400:
|
||||
_send_json_error(self, 502, "Strix billing refused an unexpected redirect")
|
||||
return
|
||||
|
||||
self.send_response(response_status)
|
||||
response_connection_headers = {
|
||||
item.strip().lower()
|
||||
for item in response_headers.get("Connection", "").split(",")
|
||||
if item.strip()
|
||||
}
|
||||
blocked_response_headers = {
|
||||
*_HOP_BY_HOP_HEADERS,
|
||||
*response_connection_headers,
|
||||
"cache-control",
|
||||
"content-encoding",
|
||||
"content-length",
|
||||
"location",
|
||||
}
|
||||
for name, value in response_headers.items():
|
||||
if (
|
||||
name.lower() not in blocked_response_headers
|
||||
and "\r" not in value
|
||||
and "\n" not in value
|
||||
):
|
||||
self.send_header(name, value)
|
||||
self.send_header("Content-Length", str(len(response_body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
with suppress(BrokenPipeError, ConnectionResetError):
|
||||
self.wfile.write(response_body)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
_send_json_error(self, 405, "Method not allowed")
|
||||
|
||||
def do_PUT(self) -> None:
|
||||
_send_json_error(self, 405, "Method not allowed")
|
||||
|
||||
def do_PATCH(self) -> None:
|
||||
_send_json_error(self, 405, "Method not allowed")
|
||||
|
||||
def do_DELETE(self) -> None:
|
||||
_send_json_error(self, 405, "Method not allowed")
|
||||
|
||||
return WalletBridgeHandler
|
||||
|
||||
|
||||
@contextmanager
|
||||
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,
|
||||
) -> Generator[str]:
|
||||
"""Yield a one-run loopback URL that injects the Strix API token upstream.
|
||||
|
||||
The random path prevents accidental cross-process requests and limits local
|
||||
denial-of-service races. It is not an authentication boundary against a
|
||||
same-user process that can inspect another process's argv.
|
||||
"""
|
||||
capability = secrets.token_urlsafe(32)
|
||||
path = f"/topup/{capability}"
|
||||
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,
|
||||
response_observer=response_observer,
|
||||
)
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), _make_handler(state))
|
||||
server.daemon_threads = True
|
||||
thread = threading.Thread(
|
||||
target=server.serve_forever,
|
||||
kwargs={"poll_interval": 0.05},
|
||||
name="strix-wallet-bridge",
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
try:
|
||||
yield f"http://127.0.0.1:{server.server_port}{path}"
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=1)
|
||||
1759
strix/interface/cloud/render.py
Normal file
1759
strix/interface/cloud/render.py
Normal file
File diff suppressed because it is too large
Load Diff
1128
strix/interface/cloud/runner.py
Normal file
1128
strix/interface/cloud/runner.py
Normal file
File diff suppressed because it is too large
Load Diff
167
strix/interface/cloud/session.py
Normal file
167
strix/interface/cloud/session.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""Inspect and safely narrow a managed Strix CLI session."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
|
||||
import strix.interface.cloud.http as http # noqa: PLR0402
|
||||
from strix.interface.cloud.arguments import CloudArgumentParser
|
||||
from strix.interface.cloud.render import emit, json_mode
|
||||
from strix.interface.platform_cli import read_record, save_record
|
||||
from strix.interface.terminal_text import sanitize_terminal_text
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import argparse
|
||||
|
||||
|
||||
def run_session(argv: list[str]) -> int:
|
||||
console = Console()
|
||||
normalized = ["show", *argv] if not argv or argv[0].startswith("-") else list(argv)
|
||||
if normalized[0] == "help":
|
||||
normalized = ["--help", *normalized[1:]]
|
||||
if normalized[0] in {"-h", "--help"}:
|
||||
_print_help(console)
|
||||
return 0
|
||||
verb = normalized.pop(0)
|
||||
if verb == "scopes" and normalized and normalized[0] == "set":
|
||||
normalized.pop(0)
|
||||
return _run_scopes_set(console, normalized)
|
||||
if verb not in {"show", "scopes"}:
|
||||
console.print(f"[red]Unknown session command:[/] {escape(sanitize_terminal_text(verb))}")
|
||||
_print_help(console)
|
||||
return http.EXIT_USAGE
|
||||
return _run_show(console, normalized, scopes_only=verb == "scopes")
|
||||
|
||||
|
||||
def _common(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--json", action="store_true", help="Print the raw JSON response.")
|
||||
parser.add_argument("--show-scopes", action="store_true", help="Print every granted scope.")
|
||||
parser.add_argument("--token", default=None, help="API token override.")
|
||||
parser.add_argument("--workspace-id", default=None, metavar="ORG_ID")
|
||||
parser.add_argument("--app-url", default=None, metavar="URL")
|
||||
parser.add_argument("--timeout", default=None, type=float, metavar="SECONDS")
|
||||
|
||||
|
||||
def _configure(args: argparse.Namespace) -> bool:
|
||||
external = args.token is not None or bool(os.environ.get("STRIX_API_TOKEN", "").strip())
|
||||
http.configure(
|
||||
base_url=args.app_url,
|
||||
timeout=args.timeout,
|
||||
token_override=bool(args.token),
|
||||
workspace_id=args.workspace_id,
|
||||
)
|
||||
return external
|
||||
|
||||
|
||||
def _run_show(console: Console, argv: list[str], *, scopes_only: bool) -> int:
|
||||
parser = CloudArgumentParser(prog=f"strix cloud session {'scopes' if scopes_only else 'show'}")
|
||||
_common(parser)
|
||||
as_json = json_mode(flag="--json" in argv)
|
||||
try:
|
||||
args = parser.parse_args(argv)
|
||||
_configure(args)
|
||||
payload = http.check(http.request("GET", "/cli/session", token=args.token))
|
||||
except SystemExit as exc:
|
||||
return int(exc.code or 0)
|
||||
except http.CloudError as exc:
|
||||
return _error(console, exc, as_json=as_json)
|
||||
if not isinstance(payload, dict):
|
||||
return _error(console, http.CloudError("invalid CLI session response"), as_json=as_json)
|
||||
record = cast("dict[str, Any]", payload)
|
||||
if as_json:
|
||||
emit(console, record, as_json=True)
|
||||
return http.EXIT_OK
|
||||
scopes = _string_list(record.get("scopes"))
|
||||
ceiling = _string_list(record.get("scope_ceiling"))
|
||||
profile = str(record.get("scope_profile") or "custom").title()
|
||||
if not scopes_only:
|
||||
device_name = escape(str(record.get("device_name") or "this device"))
|
||||
console.print(f"[green]Active CLI session[/] on [bold]{device_name}[/]")
|
||||
console.print(f" Workspace: {escape(str(record.get('organization_id') or 'unknown'))}")
|
||||
console.print(f" Access: {profile} · {len(scopes)} scopes granted · {len(ceiling)} maximum")
|
||||
if args.show_scopes or scopes_only:
|
||||
console.print(f" Granted: [dim]{escape(' '.join(scopes))}[/]")
|
||||
console.print(f" Ceiling: [dim]{escape(' '.join(ceiling))}[/]")
|
||||
return http.EXIT_OK
|
||||
|
||||
|
||||
def _run_scopes_set(console: Console, argv: list[str]) -> int:
|
||||
parser = CloudArgumentParser(
|
||||
prog="strix cloud session scopes set",
|
||||
description="Change scopes within the access approved at browser sign-in.",
|
||||
)
|
||||
mode = parser.add_mutually_exclusive_group(required=True)
|
||||
mode.add_argument("profile", nargs="?", choices=("minimal", "recommended", "full"))
|
||||
mode.add_argument("--scopes", nargs="+", metavar="SCOPE")
|
||||
_common(parser)
|
||||
as_json = json_mode(flag="--json" in argv)
|
||||
try:
|
||||
args = parser.parse_args(argv)
|
||||
external = _configure(args)
|
||||
body = (
|
||||
{"scope_profile": args.profile}
|
||||
if args.profile
|
||||
else {"scope_profile": "custom", "scopes": args.scopes}
|
||||
)
|
||||
payload = http.check(http.request("PATCH", "/cli/session", token=args.token, body=body))
|
||||
except SystemExit as exc:
|
||||
return int(exc.code or 0)
|
||||
except http.CloudError as exc:
|
||||
return _error(console, exc, as_json=as_json)
|
||||
if not isinstance(payload, dict):
|
||||
return _error(console, http.CloudError("invalid CLI session response"), as_json=as_json)
|
||||
result = cast("dict[str, Any]", payload)
|
||||
if not external:
|
||||
stored = read_record()
|
||||
if stored is not None:
|
||||
stored.update(
|
||||
{
|
||||
key: result[key]
|
||||
for key in ("scopes", "requested_scopes", "scope_ceiling", "scope_profile")
|
||||
if key in result
|
||||
}
|
||||
)
|
||||
save_record(stored)
|
||||
if as_json:
|
||||
emit(console, result, as_json=True)
|
||||
else:
|
||||
scopes = _string_list(result.get("scopes"))
|
||||
profile = str(result.get("scope_profile") or "custom").title()
|
||||
console.print(f"[green]✓ CLI access updated.[/] {profile} · {len(scopes)} scopes granted")
|
||||
if args.show_scopes:
|
||||
console.print(f" Scopes: [dim]{escape(' '.join(scopes))}[/]")
|
||||
return http.EXIT_OK
|
||||
|
||||
|
||||
def _string_list(value: Any) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
items = cast("list[Any]", cast("Any", value))
|
||||
return [str(item) for item in items]
|
||||
|
||||
|
||||
def _error(console: Console, error: http.CloudError, *, as_json: bool) -> int:
|
||||
if as_json:
|
||||
raw_payload: Any = error.payload
|
||||
error_payload = cast("dict[str, Any]", raw_payload)
|
||||
payload = dict(error_payload) if isinstance(raw_payload, dict) else {}
|
||||
payload["error"] = str(error)
|
||||
if payload.get("detail") == payload.get("error"):
|
||||
payload.pop("detail", None)
|
||||
emit(console, payload, as_json=True)
|
||||
else:
|
||||
console.print(f"[red]Error:[/] {escape(sanitize_terminal_text(error))}")
|
||||
return error.exit_code
|
||||
|
||||
|
||||
def _print_help(console: Console) -> None:
|
||||
console.print("[bold]strix cloud session[/] commands:")
|
||||
console.print(" show Show the remote CLI session (default).")
|
||||
console.print(" scopes Show granted scopes and consent ceiling.")
|
||||
console.print(" scopes set PROFILE Use minimal, recommended, or full.")
|
||||
console.print(" scopes set --scopes SCOPE… Use a custom set within the ceiling.")
|
||||
403
strix/interface/cloud/source_scan.py
Normal file
403
strix/interface/cloud/source_scan.py
Normal file
@@ -0,0 +1,403 @@
|
||||
"""Local-source approval, upload, and scan-launch lifecycle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from urllib.parse import quote
|
||||
|
||||
from rich.markup import escape
|
||||
|
||||
import strix.interface.cloud.http as http # noqa: PLR0402
|
||||
from strix.interface.cloud.render import emit
|
||||
from strix.interface.cloud.source_upload import prepare_source, remove_bundle
|
||||
from strix.interface.terminal_text import sanitize_terminal_text
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import argparse
|
||||
from typing import NoReturn
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from strix.interface.cloud.source_upload import SourceBundle
|
||||
|
||||
|
||||
_SHA256 = re.compile(r"^[0-9a-fA-F]{64}$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocalSourceScan:
|
||||
"""Own one local bundle and its staged upload through a scan launch."""
|
||||
|
||||
bundle: SourceBundle | None = None
|
||||
upload_id: str | None = None
|
||||
idempotency_key: str | None = None
|
||||
_launch_started: bool = False
|
||||
|
||||
def prepare_and_attach(
|
||||
self,
|
||||
console: Console,
|
||||
args: argparse.Namespace,
|
||||
body: dict[str, Any],
|
||||
*,
|
||||
as_json: bool,
|
||||
token: str | None,
|
||||
) -> bool:
|
||||
"""Prepare source, emit a dry run, or upload and attach it to ``body``.
|
||||
|
||||
Returns ``True`` when a dry run was emitted and request execution should stop.
|
||||
"""
|
||||
self.bundle = prepare_scan_source(console, args, as_json=as_json)
|
||||
if self.bundle is None:
|
||||
return False
|
||||
if getattr(args, "dry_run", False):
|
||||
emit(
|
||||
console,
|
||||
{"source": self.bundle.summary(show_files=getattr(args, "show_files", False))},
|
||||
as_json=as_json,
|
||||
view="source_manifest",
|
||||
)
|
||||
return True
|
||||
self.upload_id = _upload_scan_source(self.bundle, token=token)
|
||||
existing = body.get("upload_ids")
|
||||
body["upload_ids"] = [
|
||||
*(existing if isinstance(existing, list) else []),
|
||||
self.upload_id,
|
||||
]
|
||||
return False
|
||||
|
||||
def mark_launch_started(self) -> None:
|
||||
"""Record that the scan-creation request may have reached the platform."""
|
||||
self._launch_started = self.upload_id is not None
|
||||
|
||||
def handle_request_failure(self, error: BaseException, *, token: str | None) -> None:
|
||||
"""Clean or retain a staged upload according to request ambiguity."""
|
||||
if self.upload_id is None:
|
||||
return
|
||||
if self._launch_started:
|
||||
if isinstance(error, KeyboardInterrupt):
|
||||
raise _interrupted_source_upload_error(
|
||||
self.upload_id, self.idempotency_key
|
||||
) from None
|
||||
if isinstance(error, Exception):
|
||||
raise _retained_source_upload_error(
|
||||
self.upload_id, error, self.idempotency_key
|
||||
) from error
|
||||
return
|
||||
try:
|
||||
_delete_upload(self.upload_id, token=token)
|
||||
except (http.CloudError, KeyboardInterrupt) as cleanup_error:
|
||||
if isinstance(error, Exception):
|
||||
raise _source_cleanup_error(self.upload_id, error, cleanup_error) from error
|
||||
interrupted = http.CloudError("source upload interrupted.", exit_code=130)
|
||||
raise _source_cleanup_error(self.upload_id, interrupted, cleanup_error) from None
|
||||
|
||||
def handle_response_failure(
|
||||
self,
|
||||
error: BaseException,
|
||||
*,
|
||||
definitive: bool,
|
||||
token: str | None,
|
||||
) -> None:
|
||||
"""Clean a rejected upload or retain one whose scan result is ambiguous."""
|
||||
if self.upload_id is None:
|
||||
return
|
||||
if definitive:
|
||||
try:
|
||||
_delete_upload(self.upload_id, token=token)
|
||||
except (http.CloudError, KeyboardInterrupt) as cleanup_error:
|
||||
if isinstance(error, Exception):
|
||||
raise _source_cleanup_error(self.upload_id, error, cleanup_error) from error
|
||||
interrupted = http.CloudError("source upload interrupted.", exit_code=130)
|
||||
raise _source_cleanup_error(self.upload_id, interrupted, cleanup_error) from None
|
||||
return
|
||||
if isinstance(error, Exception):
|
||||
raise _retained_source_upload_error(
|
||||
self.upload_id, error, self.idempotency_key
|
||||
) from error
|
||||
|
||||
def wrap_result(self, result: Any, args: argparse.Namespace) -> Any:
|
||||
"""Attach the approved source manifest to a successful scan response."""
|
||||
if self.bundle is None:
|
||||
return result
|
||||
return {
|
||||
"source": self.bundle.summary(show_files=getattr(args, "show_files", False)),
|
||||
"upload_id": self.upload_id,
|
||||
"scan": result,
|
||||
}
|
||||
|
||||
def close(self) -> None:
|
||||
"""Remove the private temporary bundle, if one was built."""
|
||||
if self.bundle is not None:
|
||||
remove_bundle(self.bundle)
|
||||
|
||||
|
||||
def prepare_scan_source(
|
||||
console: Console, args: argparse.Namespace, *, as_json: bool
|
||||
) -> SourceBundle | None:
|
||||
"""Build and approve the exact local-source snapshot for one invocation."""
|
||||
source = getattr(args, "source", None)
|
||||
source_flags = (
|
||||
"dry_run",
|
||||
"show_files",
|
||||
"include_hidden",
|
||||
"include_sensitive",
|
||||
"include_archives",
|
||||
"approve_sha256",
|
||||
)
|
||||
if source is None:
|
||||
if any(getattr(args, name, False) for name in source_flags) or getattr(args, "exclude", []):
|
||||
raise http.CloudError("source upload options require --source DIRECTORY.")
|
||||
return None
|
||||
bundle = prepare_source(
|
||||
source,
|
||||
include_hidden=bool(getattr(args, "include_hidden", False)),
|
||||
include_sensitive=bool(getattr(args, "include_sensitive", False)),
|
||||
include_archives=bool(getattr(args, "include_archives", False)),
|
||||
exclude=cast("list[str]", getattr(args, "exclude", [])),
|
||||
)
|
||||
keep_bundle = False
|
||||
try:
|
||||
approved_digest = _validate_source_digest_approval(args, bundle)
|
||||
if getattr(args, "dry_run", False):
|
||||
keep_bundle = True
|
||||
return bundle
|
||||
if getattr(args, "yes", False) or approved_digest is not None:
|
||||
keep_bundle = True
|
||||
return bundle
|
||||
if as_json or not (sys.stdin.isatty() and sys.stdout.isatty()):
|
||||
_source_approval_error(
|
||||
"source upload requires explicit approval in non-interactive mode. "
|
||||
"Review with --dry-run --show-files, then rerun with "
|
||||
"--approve-sha256 <reviewed hash>; use --yes only for a deliberate "
|
||||
"one-shot approval of the snapshot built by that invocation."
|
||||
)
|
||||
console.print(
|
||||
"[bold]Local source upload[/]\n"
|
||||
f" {len(bundle.manifest.files):,} file(s), "
|
||||
f"{_format_bytes(bundle.manifest.total_bytes)} "
|
||||
f"({_format_bytes(bundle.archive_bytes)} compressed)\n"
|
||||
f" {sum(bundle.manifest.excluded.values()):,} path(s) excluded\n"
|
||||
" Only the selected files will be sent to Strix Cloud."
|
||||
)
|
||||
if getattr(args, "show_files", False):
|
||||
console.print(f"\n[bold]Selected files ({len(bundle.manifest.files):,})[/]")
|
||||
for selected in bundle.manifest.files:
|
||||
console.print(
|
||||
f" {escape(sanitize_terminal_text(selected.archive_name))}", soft_wrap=True
|
||||
)
|
||||
answer = (
|
||||
console.input("Upload this source and start the scan? [y/N]: ", markup=False)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
if answer not in ("y", "yes"):
|
||||
_source_approval_error("source upload cancelled.")
|
||||
keep_bundle = True
|
||||
return bundle
|
||||
finally:
|
||||
if not keep_bundle:
|
||||
remove_bundle(bundle)
|
||||
|
||||
|
||||
def _validate_source_digest_approval(args: argparse.Namespace, bundle: SourceBundle) -> str | None:
|
||||
approved_digest = getattr(args, "approve_sha256", None)
|
||||
if approved_digest is None:
|
||||
return None
|
||||
if not isinstance(approved_digest, str) or not _SHA256.fullmatch(approved_digest):
|
||||
_source_approval_error("--approve-sha256 must be exactly 64 hexadecimal characters.")
|
||||
if bundle.archive_sha256 != approved_digest.lower():
|
||||
_source_approval_error(
|
||||
"source archive SHA-256 does not match --approve-sha256; review a fresh "
|
||||
"--dry-run before uploading."
|
||||
)
|
||||
return approved_digest
|
||||
|
||||
|
||||
def _source_approval_error(message: str) -> NoReturn:
|
||||
raise http.CloudError(message)
|
||||
|
||||
|
||||
def _upload_scan_source(bundle: SourceBundle, *, token: str | None) -> str:
|
||||
file_name = f"strix-source-{bundle.archive_sha256[:12]}.zip"
|
||||
requested = http.check(
|
||||
http.request(
|
||||
"POST",
|
||||
"/uploads/request",
|
||||
token=token,
|
||||
body={
|
||||
"file_name": file_name,
|
||||
"file_size": bundle.archive_bytes,
|
||||
"category": "repository",
|
||||
},
|
||||
)
|
||||
)
|
||||
if not isinstance(requested, dict):
|
||||
raise http.CloudError("the platform returned an invalid source upload response.")
|
||||
fields = cast("dict[str, Any]", requested)
|
||||
upload_id = fields.get("upload_id")
|
||||
signed_url = fields.get("signed_url")
|
||||
upload_token = fields.get("token")
|
||||
if not all(isinstance(value, str) and value for value in (upload_id, signed_url, upload_token)):
|
||||
error = http.CloudError("the platform did not return complete source upload credentials.")
|
||||
if isinstance(upload_id, str) and upload_id:
|
||||
try:
|
||||
_delete_upload(upload_id, token=token)
|
||||
except (http.CloudError, KeyboardInterrupt) as cleanup_error:
|
||||
raise _source_cleanup_error(upload_id, error, cleanup_error) from error
|
||||
raise error
|
||||
try:
|
||||
http.upload_file(cast("str", signed_url), cast("str", upload_token), bundle.archive_path)
|
||||
completed = http.check(
|
||||
http.request(
|
||||
"POST",
|
||||
"/uploads/complete",
|
||||
token=token,
|
||||
body={"upload_id": upload_id},
|
||||
)
|
||||
)
|
||||
_validate_completed_upload(completed, expected_id=cast("str", upload_id))
|
||||
except BaseException as error:
|
||||
try:
|
||||
_delete_upload(cast("str", upload_id), token=token)
|
||||
except (http.CloudError, KeyboardInterrupt) as cleanup_error:
|
||||
if isinstance(error, Exception):
|
||||
raise _source_cleanup_error(cast("str", upload_id), error, cleanup_error) from error
|
||||
interrupted = http.CloudError("source upload interrupted.", exit_code=130)
|
||||
raise _source_cleanup_error(
|
||||
cast("str", upload_id), interrupted, cleanup_error
|
||||
) from None
|
||||
raise
|
||||
return cast("str", upload_id)
|
||||
|
||||
|
||||
def _validate_completed_upload(completed: Any, *, expected_id: str) -> None:
|
||||
fields = cast("dict[str, Any]", completed) if isinstance(completed, dict) else {}
|
||||
if fields.get("id") != expected_id:
|
||||
raise http.CloudError("the platform returned an invalid source upload completion response.")
|
||||
|
||||
|
||||
def _delete_upload(upload_id: str, *, token: str | None) -> None:
|
||||
response = http.request("DELETE", f"/uploads/{quote(upload_id, safe='')}", token=token)
|
||||
if response.status_code == 404 or 200 <= response.status_code < 300:
|
||||
return
|
||||
http.check(response)
|
||||
|
||||
|
||||
def _source_cleanup_note(upload_id: str, cleanup_error: BaseException) -> str:
|
||||
return (
|
||||
f"Cleanup of source upload {upload_id} could not be confirmed: {cleanup_error}. "
|
||||
f"Retry with `strix cloud uploads delete {upload_id}`."
|
||||
)
|
||||
|
||||
|
||||
def _source_cleanup_error(
|
||||
upload_id: str, error: Exception, cleanup_error: BaseException
|
||||
) -> http.CloudError:
|
||||
"""Report a staged source object whenever automatic deletion is uncertain."""
|
||||
message = f"{error} {_source_cleanup_note(upload_id, cleanup_error)}"
|
||||
payload: dict[str, Any] = {}
|
||||
exit_code = http.EXIT_ERROR
|
||||
if isinstance(error, http.CloudError):
|
||||
exit_code = error.exit_code
|
||||
raw_payload: Any = error.payload
|
||||
if isinstance(raw_payload, dict):
|
||||
payload.update(cast("dict[str, Any]", raw_payload))
|
||||
elif raw_payload is not None:
|
||||
payload["detail"] = raw_payload
|
||||
payload.update(
|
||||
{
|
||||
"error": message,
|
||||
"upload_id": upload_id,
|
||||
"upload_retained": True,
|
||||
"cleanup_unknown": True,
|
||||
}
|
||||
)
|
||||
return http.CloudError(message, exit_code=exit_code, payload=payload)
|
||||
|
||||
|
||||
def _interrupted_source_upload_error(
|
||||
upload_id: str, idempotency_key: str | None = None
|
||||
) -> http.CloudError:
|
||||
retry_note = _idempotency_retry_note(idempotency_key)
|
||||
message = (
|
||||
"Interrupted while starting the scan. The launch outcome is unknown, so source upload "
|
||||
f"{upload_id} was retained. Check `strix cloud scans list` before retrying; if no scan "
|
||||
f"was created, run `strix cloud uploads delete {upload_id}`.{retry_note}"
|
||||
)
|
||||
payload: dict[str, Any] = {
|
||||
"error": message,
|
||||
"interrupted": True,
|
||||
"upload_id": upload_id,
|
||||
"upload_retained": True,
|
||||
"launch_outcome_unknown": True,
|
||||
}
|
||||
_attach_idempotency_recovery(payload, idempotency_key)
|
||||
return http.CloudError(message, exit_code=130, payload=payload)
|
||||
|
||||
|
||||
def _retained_source_upload_error(
|
||||
upload_id: str,
|
||||
error: Exception,
|
||||
idempotency_key: str | None = None,
|
||||
) -> http.CloudError:
|
||||
"""Preserve source when the platform may already have accepted its scan."""
|
||||
retry_note = _idempotency_retry_note(idempotency_key)
|
||||
message = (
|
||||
f"{error} The scan launch outcome is unknown, so source upload {upload_id} was retained. "
|
||||
"Check `strix cloud scans list` before retrying; if no scan was created, clean it up "
|
||||
f"with `strix cloud uploads delete {upload_id}`. Linked uploads cannot be deleted."
|
||||
f"{retry_note}"
|
||||
)
|
||||
payload: dict[str, Any] = {}
|
||||
exit_code = http.EXIT_ERROR
|
||||
if isinstance(error, http.CloudError):
|
||||
exit_code = error.exit_code
|
||||
raw_payload: Any = error.payload
|
||||
error_payload = cast("dict[str, Any]", raw_payload)
|
||||
if isinstance(raw_payload, dict):
|
||||
payload.update(error_payload)
|
||||
elif raw_payload is not None:
|
||||
payload["detail"] = raw_payload
|
||||
payload.update(
|
||||
{
|
||||
"error": message,
|
||||
"upload_id": upload_id,
|
||||
"upload_retained": True,
|
||||
"launch_outcome_unknown": True,
|
||||
}
|
||||
)
|
||||
_attach_idempotency_recovery(payload, idempotency_key)
|
||||
return http.CloudError(message, exit_code=exit_code, payload=payload)
|
||||
|
||||
|
||||
def _idempotency_retry_note(idempotency_key: str | None) -> str:
|
||||
if not idempotency_key:
|
||||
return ""
|
||||
return (
|
||||
" An exact retry is safe only with the same request body and "
|
||||
f"`--idempotency-key {idempotency_key}`."
|
||||
)
|
||||
|
||||
|
||||
def _attach_idempotency_recovery(payload: dict[str, Any], idempotency_key: str | None) -> None:
|
||||
if not idempotency_key:
|
||||
return
|
||||
payload.update(
|
||||
{
|
||||
"idempotency_key": idempotency_key,
|
||||
"retry_safe": True,
|
||||
"retry_same_request": True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _format_bytes(value: int) -> str:
|
||||
if value < 1024:
|
||||
return f"{value} B"
|
||||
if value < 1024 * 1024:
|
||||
return f"{value / 1024:.1f} KB"
|
||||
return f"{value / (1024 * 1024):.1f} MB"
|
||||
703
strix/interface/cloud/source_upload.py
Normal file
703
strix/interface/cloud/source_upload.py
Normal file
@@ -0,0 +1,703 @@
|
||||
"""Privacy-conscious local source packaging for managed scans."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess # nosec B404
|
||||
import tempfile
|
||||
import zipfile
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import strix.interface.cloud.http as http # noqa: PLR0402
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from typing import Protocol
|
||||
|
||||
class _ScandirIterator(Iterator[os.DirEntry[str]], Protocol):
|
||||
def close(self) -> None: ...
|
||||
|
||||
|
||||
MAX_FILES = 20_000
|
||||
MAX_FILE_BYTES = 25 * 1024 * 1024
|
||||
MAX_TOTAL_BYTES = 250 * 1024 * 1024
|
||||
MAX_ARCHIVE_BYTES = 50 * 1024 * 1024
|
||||
MAX_CANDIDATE_PATHS = 200_000
|
||||
MAX_IGNORE_BYTES = 64 * 1024
|
||||
MAX_IGNORE_PATTERNS = 1_000
|
||||
MAX_IGNORE_PATTERN_CHARS = 1_024
|
||||
|
||||
_ALWAYS_EXCLUDED_DIRS = frozenset(
|
||||
{
|
||||
".git",
|
||||
".hg",
|
||||
".svn",
|
||||
"node_modules",
|
||||
"vendor",
|
||||
"venv",
|
||||
".venv",
|
||||
"env",
|
||||
"__pycache__",
|
||||
".tox",
|
||||
".pytest_cache",
|
||||
".mypy_cache",
|
||||
".ruff_cache",
|
||||
"dist",
|
||||
"build",
|
||||
"coverage",
|
||||
"target",
|
||||
".next",
|
||||
".nuxt",
|
||||
".gradle",
|
||||
}
|
||||
)
|
||||
_SENSITIVE_NAMES = frozenset(
|
||||
{
|
||||
"id_rsa",
|
||||
"id_dsa",
|
||||
"id_ecdsa",
|
||||
"id_ed25519",
|
||||
"credentials.json",
|
||||
"service-account.json",
|
||||
"service_account.json",
|
||||
".env",
|
||||
".npmrc",
|
||||
".pypirc",
|
||||
".netrc",
|
||||
".git-credentials",
|
||||
"application_default_credentials.json",
|
||||
}
|
||||
)
|
||||
_SENSITIVE_PATTERNS = (
|
||||
"*.pem",
|
||||
"*.key",
|
||||
"*.p12",
|
||||
"*.pfx",
|
||||
"*.keystore",
|
||||
"*.jks",
|
||||
"secrets.*",
|
||||
"secret.*",
|
||||
".env.*",
|
||||
)
|
||||
_SENSITIVE_PATH_SUFFIXES = (
|
||||
(".aws", "credentials"),
|
||||
(".aws", "config"),
|
||||
(".docker", "config.json"),
|
||||
(".config", "gcloud", "credentials.db"),
|
||||
(".azure", "accesstokens.json"),
|
||||
(".azure", "azureprofile.json"),
|
||||
(".kube", "config"),
|
||||
)
|
||||
_ARCHIVE_SUFFIXES = (
|
||||
".zip",
|
||||
".tar",
|
||||
".tgz",
|
||||
".tar.gz",
|
||||
".tar.bz2",
|
||||
".tar.xz",
|
||||
".7z",
|
||||
".rar",
|
||||
".gz",
|
||||
".bz2",
|
||||
".xz",
|
||||
".jar",
|
||||
".war",
|
||||
".whl",
|
||||
".nupkg",
|
||||
".apk",
|
||||
".ipa",
|
||||
)
|
||||
_ARCHIVE_MAGIC_PREFIXES = (
|
||||
b"PK\x03\x04",
|
||||
b"PK\x05\x06",
|
||||
b"PK\x07\x08",
|
||||
b"\x1f\x8b",
|
||||
b"BZh",
|
||||
b"\xfd7zXZ\x00",
|
||||
b"7z\xbc\xaf\x27\x1c",
|
||||
b"Rar!\x1a\x07",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SelectedFile:
|
||||
path: Path
|
||||
archive_name: str
|
||||
size: int
|
||||
device: int
|
||||
inode: int
|
||||
mtime_ns: int
|
||||
ctime_ns: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceManifest:
|
||||
source: Path
|
||||
files: tuple[SelectedFile, ...]
|
||||
excluded: Counter[str]
|
||||
include_hidden: bool
|
||||
include_sensitive: bool
|
||||
include_archives: bool
|
||||
|
||||
@property
|
||||
def total_bytes(self) -> int:
|
||||
return sum(item.size for item in self.files)
|
||||
|
||||
def as_dict(
|
||||
self,
|
||||
*,
|
||||
show_files: bool,
|
||||
archive_bytes: int | None = None,
|
||||
archive_sha256: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
result: dict[str, object] = {
|
||||
"source": str(self.source),
|
||||
"file_count": len(self.files),
|
||||
"uncompressed_bytes": self.total_bytes,
|
||||
"excluded_count": sum(self.excluded.values()),
|
||||
"excluded_by_reason": dict(sorted(self.excluded.items())),
|
||||
"include_hidden": self.include_hidden,
|
||||
"include_sensitive": self.include_sensitive,
|
||||
"include_archives": self.include_archives,
|
||||
}
|
||||
if archive_bytes is not None:
|
||||
result["archive_bytes"] = archive_bytes
|
||||
if archive_sha256 is not None:
|
||||
result["archive_sha256"] = archive_sha256
|
||||
if show_files:
|
||||
result["files"] = [item.archive_name for item in self.files]
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceBundle:
|
||||
manifest: SourceManifest
|
||||
archive_path: Path
|
||||
archive_bytes: int
|
||||
archive_sha256: str
|
||||
|
||||
def summary(self, *, show_files: bool) -> dict[str, object]:
|
||||
return self.manifest.as_dict(
|
||||
show_files=show_files,
|
||||
archive_bytes=self.archive_bytes,
|
||||
archive_sha256=self.archive_sha256,
|
||||
)
|
||||
|
||||
|
||||
def prepare_source(
|
||||
value: str,
|
||||
*,
|
||||
include_hidden: bool,
|
||||
include_sensitive: bool,
|
||||
include_archives: bool,
|
||||
exclude: list[str],
|
||||
) -> SourceBundle:
|
||||
"""Select safe source files and build a bounded temporary ZIP archive."""
|
||||
source = Path(value).expanduser().resolve()
|
||||
if not source.is_dir():
|
||||
raise http.CloudError(f"--source must be a directory: {source}")
|
||||
manifest = select_source(
|
||||
source,
|
||||
include_hidden=include_hidden,
|
||||
include_sensitive=include_sensitive,
|
||||
include_archives=include_archives,
|
||||
exclude=exclude,
|
||||
)
|
||||
if not manifest.files:
|
||||
raise http.CloudError("no files remain after applying source upload exclusions.")
|
||||
|
||||
with tempfile.NamedTemporaryFile(prefix="strix-source-", suffix=".zip", delete=False) as handle:
|
||||
archive_path = Path(handle.name)
|
||||
try:
|
||||
_write_archive(archive_path, manifest.files)
|
||||
except BaseException:
|
||||
archive_path.unlink(missing_ok=True)
|
||||
raise
|
||||
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."
|
||||
)
|
||||
digest = _sha256(archive_path)
|
||||
return SourceBundle(manifest, archive_path, archive_bytes, digest)
|
||||
|
||||
|
||||
def select_source(
|
||||
source: Path,
|
||||
*,
|
||||
include_hidden: bool = False,
|
||||
include_sensitive: bool = False,
|
||||
include_archives: bool = False,
|
||||
exclude: list[str] | None = None,
|
||||
) -> SourceManifest:
|
||||
excluded: Counter[str] = Counter()
|
||||
selected: list[SelectedFile] = []
|
||||
patterns = [*_load_ignore_patterns(source), *(exclude or [])]
|
||||
_validate_patterns(patterns)
|
||||
total_bytes = 0
|
||||
for relative in _candidate_paths(
|
||||
source,
|
||||
include_hidden=include_hidden,
|
||||
patterns=patterns,
|
||||
excluded=excluded,
|
||||
):
|
||||
archive_name = relative.as_posix()
|
||||
reason = _exclusion_reason(
|
||||
relative,
|
||||
include_hidden=include_hidden,
|
||||
include_sensitive=include_sensitive,
|
||||
include_archives=include_archives,
|
||||
patterns=patterns,
|
||||
)
|
||||
if reason:
|
||||
excluded[reason] += 1
|
||||
continue
|
||||
path = source / relative
|
||||
try:
|
||||
info = path.lstat()
|
||||
except OSError:
|
||||
excluded["unreadable"] += 1
|
||||
continue
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
excluded["symlink_or_non_file"] += 1
|
||||
continue
|
||||
if not include_archives and _has_archive_magic(path):
|
||||
excluded["nested_archive"] += 1
|
||||
continue
|
||||
if info.st_size > MAX_FILE_BYTES:
|
||||
raise http.CloudError(
|
||||
f"{archive_name} is larger than the 25 MB per-file limit; exclude it explicitly."
|
||||
)
|
||||
selected.append(
|
||||
SelectedFile(
|
||||
path=path,
|
||||
archive_name=archive_name,
|
||||
size=info.st_size,
|
||||
device=info.st_dev,
|
||||
inode=info.st_ino,
|
||||
mtime_ns=info.st_mtime_ns,
|
||||
ctime_ns=info.st_ctime_ns,
|
||||
)
|
||||
)
|
||||
total_bytes += info.st_size
|
||||
if len(selected) > MAX_FILES:
|
||||
raise http.CloudError(
|
||||
f"source contains more than {MAX_FILES:,} files; narrow --source or add exclusions."
|
||||
)
|
||||
if total_bytes > MAX_TOTAL_BYTES:
|
||||
raise http.CloudError(
|
||||
"selected source is larger than the 250 MB expanded-size limit; narrow --source "
|
||||
"or add --exclude patterns."
|
||||
)
|
||||
selected.sort(key=lambda item: item.archive_name)
|
||||
return SourceManifest(
|
||||
source,
|
||||
tuple(selected),
|
||||
excluded,
|
||||
include_hidden,
|
||||
include_sensitive,
|
||||
include_archives,
|
||||
)
|
||||
|
||||
|
||||
def remove_bundle(bundle: SourceBundle) -> None:
|
||||
bundle.archive_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _candidate_paths(
|
||||
source: Path,
|
||||
*,
|
||||
include_hidden: bool,
|
||||
patterns: list[str],
|
||||
excluded: Counter[str],
|
||||
) -> Iterator[Path]:
|
||||
git_root = _git_root(source)
|
||||
if git_root is not None:
|
||||
git = shutil.which("git")
|
||||
if git is not None:
|
||||
yield from _git_candidate_paths(git, git_root, source)
|
||||
return
|
||||
yield from _walk_candidate_paths(
|
||||
source,
|
||||
include_hidden=include_hidden,
|
||||
patterns=patterns,
|
||||
excluded=excluded,
|
||||
)
|
||||
|
||||
|
||||
def _git_candidate_paths(git: str, git_root: Path, source: Path) -> Iterator[Path]:
|
||||
"""Stream Git's NUL-delimited manifest without buffering an unbounded repository."""
|
||||
relative_source = source.relative_to(git_root)
|
||||
command = [
|
||||
git,
|
||||
"-C",
|
||||
str(git_root),
|
||||
"ls-files",
|
||||
"-z",
|
||||
"--cached",
|
||||
"--others",
|
||||
"--exclude-standard",
|
||||
"--",
|
||||
]
|
||||
if relative_source != Path():
|
||||
command.append(relative_source.as_posix())
|
||||
try:
|
||||
process = subprocess.Popen( # noqa: S603 # nosec B603
|
||||
command,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise http.CloudError(f"could not enumerate Git source files: {exc}") from exc
|
||||
assert process.stdout is not None
|
||||
buffer = b""
|
||||
count = 0
|
||||
try:
|
||||
while chunk := process.stdout.read(64 * 1024):
|
||||
buffer += chunk
|
||||
records = buffer.split(b"\0")
|
||||
buffer = records.pop()
|
||||
for raw in records:
|
||||
relative = _git_relative_path(raw, relative_source)
|
||||
if relative is None:
|
||||
continue
|
||||
count += 1
|
||||
_check_candidate_limit(count)
|
||||
yield relative
|
||||
if buffer:
|
||||
raise http.CloudError("Git returned a malformed source file manifest.")
|
||||
if process.wait() != 0:
|
||||
raise http.CloudError("Git could not enumerate the source directory.")
|
||||
finally:
|
||||
process.stdout.close()
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=1)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
|
||||
|
||||
def _git_relative_path(raw: bytes, relative_source: Path) -> Path | None:
|
||||
repo_relative = Path(os.fsdecode(raw))
|
||||
try:
|
||||
relative = repo_relative.relative_to(relative_source)
|
||||
except ValueError:
|
||||
return None
|
||||
if relative.is_absolute() or ".." in relative.parts:
|
||||
raise http.CloudError("Git returned an unsafe source path.")
|
||||
return relative
|
||||
|
||||
|
||||
def _walk_candidate_paths(
|
||||
source: Path,
|
||||
*,
|
||||
include_hidden: bool,
|
||||
patterns: list[str],
|
||||
excluded: Counter[str],
|
||||
) -> Iterator[Path]:
|
||||
"""Walk top-down so excluded dependency, VCS, and hidden trees are never traversed."""
|
||||
count = 0
|
||||
stack: list[tuple[Path, _ScandirIterator]] = []
|
||||
try:
|
||||
stack.append((source, os.scandir(source)))
|
||||
while stack:
|
||||
root_path, entries = stack[-1]
|
||||
try:
|
||||
entry = next(entries)
|
||||
except StopIteration:
|
||||
entries.close()
|
||||
stack.pop()
|
||||
continue
|
||||
count += 1
|
||||
_check_candidate_limit(count)
|
||||
path = root_path / entry.name
|
||||
relative = path.relative_to(source)
|
||||
try:
|
||||
is_directory = entry.is_dir(follow_symlinks=False)
|
||||
is_symlink = entry.is_symlink()
|
||||
except OSError:
|
||||
excluded["unreadable"] += 1
|
||||
continue
|
||||
if is_directory:
|
||||
reason = _pruned_directory_reason(
|
||||
relative,
|
||||
include_hidden=include_hidden,
|
||||
patterns=patterns,
|
||||
)
|
||||
if reason:
|
||||
excluded[reason] += 1
|
||||
continue
|
||||
try:
|
||||
stack.append((path, os.scandir(path)))
|
||||
except OSError:
|
||||
excluded["unreadable"] += 1
|
||||
continue
|
||||
if is_symlink:
|
||||
excluded["symlink_or_non_file"] += 1
|
||||
continue
|
||||
yield relative
|
||||
except OSError as exc:
|
||||
raise http.CloudError(f"could not enumerate source directory {source}: {exc}") from exc
|
||||
finally:
|
||||
for _, entries in stack:
|
||||
entries.close()
|
||||
|
||||
|
||||
def _pruned_directory_reason(
|
||||
relative: Path,
|
||||
*,
|
||||
include_hidden: bool,
|
||||
patterns: list[str],
|
||||
) -> str | None:
|
||||
lower_parts = tuple(part.lower() for part in relative.parts)
|
||||
if any(part == ".git" for part in lower_parts):
|
||||
return "git_metadata"
|
||||
if any(part in _ALWAYS_EXCLUDED_DIRS for part in lower_parts):
|
||||
return "dependency_or_build_output"
|
||||
if not include_hidden and any(part.startswith(".") for part in relative.parts):
|
||||
return "hidden"
|
||||
if any(_matches_user_pattern(relative, pattern) for pattern in patterns):
|
||||
return "user_pattern"
|
||||
return None
|
||||
|
||||
|
||||
def _check_candidate_limit(count: int) -> None:
|
||||
if count > MAX_CANDIDATE_PATHS:
|
||||
raise http.CloudError(
|
||||
f"source enumeration exceeded {MAX_CANDIDATE_PATHS:,} paths before filtering; "
|
||||
"narrow --source or add directory exclusions."
|
||||
)
|
||||
|
||||
|
||||
def _git_root(source: Path) -> Path | None:
|
||||
git = shutil.which("git")
|
||||
if git is None:
|
||||
return None
|
||||
result = subprocess.run( # noqa: S603 # nosec B603
|
||||
[git, "-C", str(source), "rev-parse", "--show-toplevel"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
try:
|
||||
return Path(result.stdout.strip()).resolve()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _exclusion_reason( # noqa: PLR0911
|
||||
relative: Path,
|
||||
*,
|
||||
include_hidden: bool,
|
||||
include_sensitive: bool,
|
||||
include_archives: bool,
|
||||
patterns: list[str],
|
||||
) -> str | None:
|
||||
parts = relative.parts
|
||||
lower_parts = tuple(part.lower() for part in parts)
|
||||
if any(part == ".git" for part in lower_parts):
|
||||
return "git_metadata"
|
||||
if any(part in _ALWAYS_EXCLUDED_DIRS for part in lower_parts[:-1]):
|
||||
return "dependency_or_build_output"
|
||||
if not include_hidden and any(part.startswith(".") for part in parts):
|
||||
return "hidden"
|
||||
if any(_matches_user_pattern(relative, pattern) for pattern in patterns):
|
||||
return "user_pattern"
|
||||
name = relative.name.lower()
|
||||
if not include_sensitive and (
|
||||
name in _SENSITIVE_NAMES
|
||||
or any(fnmatch.fnmatch(name, pattern) for pattern in _SENSITIVE_PATTERNS)
|
||||
or any(
|
||||
lower_parts[-len(suffix) :] == suffix
|
||||
for suffix in _SENSITIVE_PATH_SUFFIXES
|
||||
if len(lower_parts) >= len(suffix)
|
||||
)
|
||||
):
|
||||
return "sensitive_filename"
|
||||
if not include_archives and name.endswith(_ARCHIVE_SUFFIXES):
|
||||
return "nested_archive"
|
||||
return None
|
||||
|
||||
|
||||
def _matches_user_pattern(relative: Path, pattern: str) -> bool:
|
||||
"""Match exclude globs, including intuitive trailing-slash directory rules."""
|
||||
relative_posix = relative.as_posix()
|
||||
posix = PurePosixPath(relative_posix)
|
||||
if pattern.endswith("/"):
|
||||
directory_pattern = pattern.rstrip("/")
|
||||
if not directory_pattern:
|
||||
return False
|
||||
return (
|
||||
posix.match(directory_pattern)
|
||||
or fnmatch.fnmatch(relative_posix, directory_pattern)
|
||||
or any(
|
||||
PurePosixPath(parent.as_posix()).match(directory_pattern)
|
||||
or fnmatch.fnmatch(parent.as_posix(), directory_pattern)
|
||||
for parent in posix.parents
|
||||
if parent != PurePosixPath(".")
|
||||
)
|
||||
)
|
||||
return posix.match(pattern) or fnmatch.fnmatch(relative_posix, pattern)
|
||||
|
||||
|
||||
def _write_archive(destination: Path, files: tuple[SelectedFile, ...]) -> None:
|
||||
with zipfile.ZipFile(
|
||||
destination, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6
|
||||
) as archive:
|
||||
for item in files:
|
||||
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||
try:
|
||||
descriptor = os.open(item.path, flags)
|
||||
except OSError as exc:
|
||||
raise http.CloudError(f"could not safely read {item.archive_name}: {exc}") from exc
|
||||
with os.fdopen(descriptor, "rb") as source_file:
|
||||
current = os.fstat(source_file.fileno())
|
||||
if (
|
||||
not stat.S_ISREG(current.st_mode)
|
||||
or current.st_size != item.size
|
||||
or current.st_dev != item.device
|
||||
or current.st_ino != item.inode
|
||||
or current.st_mtime_ns != item.mtime_ns
|
||||
or current.st_ctime_ns != item.ctime_ns
|
||||
):
|
||||
raise http.CloudError(
|
||||
f"{item.archive_name} changed while the source archive was being built; "
|
||||
"retry."
|
||||
)
|
||||
info = zipfile.ZipInfo(item.archive_name)
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
info.external_attr = 0o100644 << 16
|
||||
with archive.open(info, "w", force_zip64=True) as target:
|
||||
remaining = item.size
|
||||
while remaining:
|
||||
chunk = source_file.read(min(1024 * 1024, remaining))
|
||||
if not chunk:
|
||||
raise http.CloudError(
|
||||
f"{item.archive_name} changed while the source archive was being "
|
||||
"built; retry."
|
||||
)
|
||||
target.write(chunk)
|
||||
remaining -= len(chunk)
|
||||
final = os.fstat(source_file.fileno())
|
||||
if (
|
||||
source_file.read(1)
|
||||
or not stat.S_ISREG(final.st_mode)
|
||||
or final.st_size != item.size
|
||||
or final.st_dev != item.device
|
||||
or final.st_ino != item.inode
|
||||
or final.st_mtime_ns != item.mtime_ns
|
||||
or final.st_ctime_ns != item.ctime_ns
|
||||
):
|
||||
raise http.CloudError(
|
||||
f"{item.archive_name} changed while the source archive was being "
|
||||
"built; retry."
|
||||
)
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _has_archive_magic(path: Path) -> bool:
|
||||
"""Recognize common archive containers even when their suffix is disguised."""
|
||||
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||
try:
|
||||
descriptor = os.open(path, flags)
|
||||
with os.fdopen(descriptor, "rb") as stream:
|
||||
header = stream.read(512)
|
||||
except OSError:
|
||||
return False
|
||||
return header.startswith(_ARCHIVE_MAGIC_PREFIXES) or header[257:262] == b"ustar"
|
||||
|
||||
|
||||
def _load_ignore_patterns(source: Path) -> list[str]:
|
||||
path = source / ".strixignore"
|
||||
raw_text = _read_ignore_file(path)
|
||||
if raw_text is None:
|
||||
return []
|
||||
if len(raw_text) > MAX_IGNORE_BYTES:
|
||||
raise http.CloudError(f"{path} is larger than the {MAX_IGNORE_BYTES:,}-byte limit.")
|
||||
try:
|
||||
lines = raw_text.decode("utf-8").splitlines()
|
||||
except UnicodeDecodeError as exc:
|
||||
raise http.CloudError(f"{path} must be UTF-8 text.") from exc
|
||||
patterns: list[str] = []
|
||||
for line_number, raw in enumerate(lines, start=1):
|
||||
value = raw.strip()
|
||||
if not value or value.startswith("#"):
|
||||
continue
|
||||
if value.startswith("!"):
|
||||
raise http.CloudError(
|
||||
f"{path}:{line_number}: negated patterns are not supported; use exclude-only globs."
|
||||
)
|
||||
patterns.append(value)
|
||||
if len(patterns) > MAX_IGNORE_PATTERNS:
|
||||
raise http.CloudError(
|
||||
f"{path} contains more than {MAX_IGNORE_PATTERNS:,} exclusion patterns."
|
||||
)
|
||||
return patterns
|
||||
|
||||
|
||||
def _read_ignore_file(path: Path) -> bytes | None:
|
||||
"""Read a bounded regular ignore file without blocking on a FIFO or device."""
|
||||
try:
|
||||
descriptor = os.open(
|
||||
path,
|
||||
os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0),
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as exc:
|
||||
raise http.CloudError(f"could not read {path}: {exc}") from exc
|
||||
try:
|
||||
info = os.fstat(descriptor)
|
||||
except OSError as exc:
|
||||
os.close(descriptor)
|
||||
raise http.CloudError(f"could not inspect {path}: {exc}") from exc
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
os.close(descriptor)
|
||||
raise http.CloudError(f"{path} must be a regular file.")
|
||||
try:
|
||||
stream = os.fdopen(descriptor, "rb")
|
||||
except OSError as exc:
|
||||
os.close(descriptor)
|
||||
raise http.CloudError(f"could not read {path}: {exc}") from exc
|
||||
try:
|
||||
return stream.read(MAX_IGNORE_BYTES + 1)
|
||||
except OSError as exc:
|
||||
raise http.CloudError(f"could not read {path}: {exc}") from exc
|
||||
finally:
|
||||
stream.close()
|
||||
|
||||
|
||||
def _validate_patterns(patterns: list[str]) -> None:
|
||||
if len(patterns) > MAX_IGNORE_PATTERNS:
|
||||
raise http.CloudError(
|
||||
f"source upload accepts at most {MAX_IGNORE_PATTERNS:,} exclusion patterns."
|
||||
)
|
||||
for pattern in patterns:
|
||||
if len(pattern) > MAX_IGNORE_PATTERN_CHARS:
|
||||
raise http.CloudError(
|
||||
"source exclusion patterns must be at most "
|
||||
f"{MAX_IGNORE_PATTERN_CHARS:,} characters each."
|
||||
)
|
||||
if "\x00" in pattern:
|
||||
raise http.CloudError("source exclusion patterns cannot contain NUL bytes.")
|
||||
1147
strix/interface/cloud/spec.py
Normal file
1147
strix/interface/cloud/spec.py
Normal file
File diff suppressed because it is too large
Load Diff
291
strix/interface/cloud/workspaces.py
Normal file
291
strix/interface/cloud/workspaces.py
Normal file
@@ -0,0 +1,291 @@
|
||||
"""`strix cloud workspaces use` — switch the stored token to another workspace.
|
||||
|
||||
The command lists the workspaces of the account, finds the requested one by
|
||||
ID or by exact name, asks the platform to rotate that token in place, and
|
||||
stores the returned workspace metadata. The bearer secret and expiry stay the
|
||||
same; the account's role in the target workspace limits the granted scopes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
|
||||
import strix.interface.cloud.http as http # noqa: PLR0402
|
||||
from strix.interface.cloud.arguments import CloudArgumentParser
|
||||
from strix.interface.cloud.render import emit, json_mode
|
||||
from strix.interface.platform_cli import AUTH_PATH, read_record, save_record
|
||||
from strix.interface.platform_identity import read_or_create_identity
|
||||
from strix.interface.terminal_text import sanitize_terminal_text
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import argparse
|
||||
|
||||
|
||||
def run_workspace_use(argv: list[str]) -> int:
|
||||
"""Entry point for ``strix cloud workspaces use``. Returns an exit code."""
|
||||
console = Console()
|
||||
parser = CloudArgumentParser(
|
||||
prog="strix cloud workspaces use",
|
||||
description="Switch the stored API token to another workspace.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"workspace",
|
||||
metavar="WORKSPACE",
|
||||
help="Workspace number from `workspaces list`, ID, or exact name.",
|
||||
)
|
||||
scope_mode = parser.add_mutually_exclusive_group()
|
||||
scope_mode.add_argument(
|
||||
"--scopes",
|
||||
nargs="+",
|
||||
metavar="SCOPE",
|
||||
default=None,
|
||||
help=(
|
||||
"Use a custom scope set within the login-approved ceiling. "
|
||||
"Without this option, preserve the server-side scope preference."
|
||||
),
|
||||
)
|
||||
scope_mode.add_argument(
|
||||
"--scope-profile",
|
||||
choices=("minimal", "recommended", "full"),
|
||||
default=None,
|
||||
help="Change to a profile within the authority approved at login.",
|
||||
)
|
||||
parser.add_argument("--show-scopes", action="store_true", help="Print every granted scope.")
|
||||
parser.add_argument("--json", action="store_true", help="Print the raw JSON response.")
|
||||
parser.add_argument("--token", default=None, help="API token override.")
|
||||
parser.add_argument(
|
||||
"--workspace-id",
|
||||
default=None,
|
||||
metavar="ORG_ID",
|
||||
help="Expected workspace for an override CLI token.",
|
||||
)
|
||||
parser.add_argument("--app-url", default=None, metavar="URL", help="Platform URL override.")
|
||||
parser.add_argument(
|
||||
"--timeout", default=None, type=float, metavar="SECONDS", help="Request timeout in seconds."
|
||||
)
|
||||
as_json = json_mode(flag="--json" in argv)
|
||||
try:
|
||||
args = parser.parse_args(argv)
|
||||
except SystemExit as exc:
|
||||
return exc.code if isinstance(exc.code, int) else 2
|
||||
except http.CloudError as exc:
|
||||
_emit_cloud_error(console, exc, as_json=as_json)
|
||||
return exc.exit_code
|
||||
|
||||
as_json = json_mode(flag=bool(args.json))
|
||||
try:
|
||||
http.configure(
|
||||
base_url=args.app_url,
|
||||
timeout=args.timeout,
|
||||
token_override=bool(args.token),
|
||||
workspace_id=args.workspace_id,
|
||||
)
|
||||
return _use(console, args, as_json=as_json)
|
||||
except http.CloudError as exc:
|
||||
_emit_cloud_error(console, exc, as_json=as_json)
|
||||
return exc.exit_code
|
||||
|
||||
|
||||
def _use( # noqa: PLR0912, PLR0915
|
||||
console: Console, args: argparse.Namespace, *, as_json: bool
|
||||
) -> int:
|
||||
workspace = _find_workspace(args.workspace, token=args.token)
|
||||
stored_record: dict[str, Any] = read_record() or {}
|
||||
# An override token may belong to a different account. Never mix its new
|
||||
# workspace state with identity or scope preferences from the stored sign-in.
|
||||
external_token = args.token is not None or bool(os.environ.get("STRIX_API_TOKEN", "").strip())
|
||||
record: dict[str, Any] = {} if external_token else dict(stored_record)
|
||||
body: dict[str, Any] = {}
|
||||
if args.scopes:
|
||||
body["scopes"] = args.scopes
|
||||
body["scope_profile"] = "custom"
|
||||
elif args.scope_profile:
|
||||
body["scope_profile"] = args.scope_profile
|
||||
if not external_token:
|
||||
try:
|
||||
body.update(read_or_create_identity())
|
||||
except (OSError, ValueError) as exc:
|
||||
raise http.CloudError(f"could not load the CLI device identity: {exc}") from exc
|
||||
switched = _switch_workspace_token(
|
||||
str(workspace["id"]),
|
||||
token=args.token,
|
||||
body=body or None,
|
||||
)
|
||||
if not isinstance(switched, dict):
|
||||
raise _workspace_switch_unknown("the platform returned an invalid response")
|
||||
switched_record = cast("dict[str, Any]", switched)
|
||||
switched_token = switched_record.get("api_token")
|
||||
if not isinstance(switched_token, str) or not switched_token.strip():
|
||||
raise _workspace_switch_unknown("the platform response omitted the token")
|
||||
switched_scopes = switched_record.get("scopes")
|
||||
switched_scope_items = cast("list[Any]", cast("Any", switched_scopes))
|
||||
if not isinstance(switched_scopes, list) or not all(
|
||||
isinstance(scope, str) for scope in switched_scope_items
|
||||
):
|
||||
raise _workspace_switch_unknown("the platform response contained invalid scopes")
|
||||
validated_scopes = cast("list[str]", switched_scope_items)
|
||||
|
||||
record.update(
|
||||
{
|
||||
"api_token": switched_token,
|
||||
"organization_id": switched_record.get("organization_id", workspace["id"]),
|
||||
"organization_name": switched_record.get(
|
||||
"organization_name", workspace.get("name", "")
|
||||
),
|
||||
"expires_at": switched_record.get("expires_at") or stored_record.get("expires_at"),
|
||||
"scopes": validated_scopes,
|
||||
"requested_scopes": switched_record.get("requested_scopes", validated_scopes),
|
||||
"scope_ceiling": switched_record.get("scope_ceiling", []),
|
||||
"scope_profile": switched_record.get("scope_profile", "custom"),
|
||||
"token_id": switched_record.get("token_id"),
|
||||
"credential_source": switched_record.get("credential_source", "api"),
|
||||
"device_name": switched_record.get("device_name"),
|
||||
"app_url": http.app_url(),
|
||||
}
|
||||
)
|
||||
if switched_record.get("email"):
|
||||
record["email"] = switched_record["email"]
|
||||
if not external_token:
|
||||
try:
|
||||
save_record(record)
|
||||
except OSError as exc:
|
||||
raise http.CloudError(
|
||||
"the platform switched the token, but the local workspace metadata could not be "
|
||||
f"stored in {AUTH_PATH}: {exc}. The bearer is still valid; fix the file and safely "
|
||||
"rerun the same workspace use command.",
|
||||
payload={
|
||||
"workspace_switched": True,
|
||||
"local_record_updated": False,
|
||||
"retry_safe": True,
|
||||
},
|
||||
) from exc
|
||||
|
||||
result = {
|
||||
"workspace_id": record["organization_id"],
|
||||
"workspace_name": record["organization_name"],
|
||||
"scopes": record["scopes"],
|
||||
"requested_scopes": record.get("requested_scopes", record["scopes"]),
|
||||
"scope_ceiling": record.get("scope_ceiling", []),
|
||||
"scope_profile": record.get("scope_profile", "custom"),
|
||||
"expires_at": record.get("expires_at"),
|
||||
"token_id": record.get("token_id"),
|
||||
"credential_source": record.get("credential_source", "api"),
|
||||
"device_name": record.get("device_name"),
|
||||
"stored": not external_token,
|
||||
}
|
||||
if as_json:
|
||||
emit(console, result, as_json=True)
|
||||
return http.EXIT_OK
|
||||
workspace_name = escape(sanitize_terminal_text(record["organization_name"]))
|
||||
console.print(f"[green]✓ Switched to workspace [bold]{workspace_name}[/].[/]")
|
||||
scopes = record.get("scopes")
|
||||
if isinstance(scopes, list) and scopes:
|
||||
scope_items = cast("list[Any]", cast("Any", scopes))
|
||||
scope_names = [scope for scope in scope_items if isinstance(scope, str)]
|
||||
if scope_names and args.show_scopes:
|
||||
rendered_scopes = escape(sanitize_terminal_text(" ".join(scope_names)))
|
||||
console.print(f" Scopes: [dim]{rendered_scopes}[/]")
|
||||
elif scope_names:
|
||||
profile = str(record.get("scope_profile") or "custom").title()
|
||||
console.print(f" Access: [dim]{profile} · {len(scope_names)} scopes granted[/]")
|
||||
if external_token:
|
||||
console.print(" Token: [dim]override used for this command only; not stored[/]")
|
||||
else:
|
||||
console.print(f" Token: stored in [dim]{escape(sanitize_terminal_text(AUTH_PATH))}[/]")
|
||||
return http.EXIT_OK
|
||||
|
||||
|
||||
def _switch_workspace_token(
|
||||
workspace_id: str,
|
||||
*,
|
||||
token: str | None,
|
||||
body: dict[str, Any] | None,
|
||||
) -> Any:
|
||||
"""Switch in place, distinguishing definitive rejections from lost outcomes."""
|
||||
try:
|
||||
response = http.request(
|
||||
"POST",
|
||||
f"/workspaces/{workspace_id}/token",
|
||||
token=token,
|
||||
body=body,
|
||||
)
|
||||
except http.CloudError as exc:
|
||||
raise _workspace_switch_unknown(str(exc)) from exc
|
||||
|
||||
# Client/auth/conflict responses prove the rotation did not return success.
|
||||
# A 5xx or malformed success may arrive after the database commit, but the
|
||||
# server preserves the bearer so replaying this exact command is safe.
|
||||
if response.status_code in {400, 401, 403, 404, 409, 422}:
|
||||
return http.check(response)
|
||||
try:
|
||||
return http.check(response)
|
||||
except http.CloudError as exc:
|
||||
raise _workspace_switch_unknown(str(exc)) from exc
|
||||
|
||||
|
||||
def _workspace_switch_unknown(detail: str) -> http.CloudError:
|
||||
return http.CloudError(
|
||||
"workspace switch outcome is unknown: "
|
||||
f"{sanitize_terminal_text(detail)}. The bearer secret is unchanged; safely rerun the "
|
||||
"same workspace use command, or list workspaces to check the current one.",
|
||||
payload={
|
||||
"switch_outcome_unknown": True,
|
||||
"retry_safe": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _emit_cloud_error(console: Console, error: http.CloudError, *, as_json: bool) -> None:
|
||||
if as_json:
|
||||
raw_payload: Any = error.payload
|
||||
error_payload = cast("dict[str, Any]", raw_payload)
|
||||
payload = dict(error_payload) if isinstance(raw_payload, dict) else {}
|
||||
payload["error"] = str(error)
|
||||
emit(console, payload, as_json=True)
|
||||
return
|
||||
console.print(f"[red]Error:[/] {escape(sanitize_terminal_text(error))}")
|
||||
|
||||
|
||||
def _find_workspace(selector: str, *, token: str | None) -> dict[str, Any]:
|
||||
listed = http.check(http.request("GET", "/workspaces", token=token))
|
||||
listed_record = cast("dict[str, Any]", listed) if isinstance(listed, dict) else {}
|
||||
items = listed_record.get("workspaces")
|
||||
item_values = cast("list[Any]", cast("Any", items)) if isinstance(items, list) else []
|
||||
workspaces = [
|
||||
cast("dict[str, Any]", cast("Any", item)) for item in item_values if isinstance(item, dict)
|
||||
]
|
||||
if not workspaces:
|
||||
raise http.CloudError("no workspaces found for this account.")
|
||||
wanted = selector.strip()
|
||||
if wanted.isdigit():
|
||||
index = int(wanted)
|
||||
if 1 <= index <= len(workspaces):
|
||||
return workspaces[index - 1]
|
||||
raise http.CloudError(
|
||||
f"workspace number must be between 1 and {len(workspaces)}. "
|
||||
"Run `strix cloud workspaces` to see the numbered list."
|
||||
)
|
||||
by_id = [w for w in workspaces if w.get("id") == wanted]
|
||||
if by_id:
|
||||
return by_id[0]
|
||||
by_name = [w for w in workspaces if str(w.get("name", "")).casefold() == wanted.casefold()]
|
||||
if len(by_name) == 1:
|
||||
return by_name[0]
|
||||
if len(by_name) > 1:
|
||||
numbers = ", ".join(
|
||||
str(index)
|
||||
for index, workspace in enumerate(workspaces, start=1)
|
||||
if workspace in by_name
|
||||
)
|
||||
raise http.CloudError(
|
||||
f"multiple workspaces are named {wanted!r}. Use its list number: {numbers}"
|
||||
)
|
||||
names = ", ".join(
|
||||
f"{index}: {workspace.get('name')}" for index, workspace in enumerate(workspaces, start=1)
|
||||
)
|
||||
raise http.CloudError(f"no workspace matches {wanted!r}. Your workspaces: {names}")
|
||||
373
strix/interface/completions.py
Normal file
373
strix/interface/completions.py
Normal file
@@ -0,0 +1,373 @@
|
||||
"""Shell completion scripts and candidates for the Strix CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from strix.interface.cloud.spec import DEFAULT_VERBS, SPEC, Cmd
|
||||
from strix.interface.terminal_text import has_terminal_control, sanitize_terminal_text
|
||||
|
||||
|
||||
_ROOT_COMMANDS = ("cloud", "auth", "view", "completions", "completion")
|
||||
_SESSION_COMMANDS = ("login", "logout", "whoami", "session", "credits")
|
||||
_COMMON_FLAGS = (
|
||||
"--json",
|
||||
"--token",
|
||||
"--workspace-id",
|
||||
"--app-url",
|
||||
"--timeout",
|
||||
"-h",
|
||||
"--help",
|
||||
)
|
||||
_COMMON_VALUE_FLAGS = frozenset({"--token", "--workspace-id", "--app-url", "--timeout"})
|
||||
_WORKSPACE_USE_FLAGS = (*_COMMON_FLAGS, "--scopes", "--scope-profile", "--show-scopes")
|
||||
|
||||
|
||||
def run_completions(argv: list[str]) -> int:
|
||||
"""Print a shell integration script or hidden completion candidates."""
|
||||
if argv and argv[0] == "--candidates":
|
||||
for candidate in completion_candidates(argv[1:]):
|
||||
sys.stdout.write(candidate + "\n")
|
||||
return 0
|
||||
if not argv or argv[0] in ("-h", "--help", "help"):
|
||||
sys.stdout.write(
|
||||
"Usage: strix completions <zsh|bash|fish>\n\n"
|
||||
"Enable tab completion for the current shell:\n"
|
||||
" zsh: source <(strix completions zsh)\n"
|
||||
" bash: source <(strix completions bash)\n"
|
||||
" fish: strix completions fish | source\n"
|
||||
)
|
||||
return 0
|
||||
shell = argv[0].lower()
|
||||
scripts = {"zsh": _zsh_script, "bash": _bash_script, "fish": _fish_script}
|
||||
generator = scripts.get(shell)
|
||||
if generator is None:
|
||||
sys.stderr.write(
|
||||
f"Unknown shell: {sanitize_terminal_text(shell)}. Choose zsh, bash, or fish.\n"
|
||||
)
|
||||
return 2
|
||||
sys.stdout.write(generator())
|
||||
return 0
|
||||
|
||||
|
||||
def completion_candidates(words: list[str]) -> list[str]:
|
||||
"""Return candidates for words after the ``strix`` executable."""
|
||||
prior, current = _split_cursor(words)
|
||||
if not prior:
|
||||
candidates = _matching(_ROOT_COMMANDS, current)
|
||||
elif prior[0] != "cloud":
|
||||
candidates = []
|
||||
else:
|
||||
candidates = _cloud_candidates(prior[1:], current)
|
||||
# The line-oriented shell protocol cannot represent these names safely.
|
||||
# Omitting them is preferable to returning a sanitized path that does not exist.
|
||||
return [candidate for candidate in candidates if not has_terminal_control(candidate)]
|
||||
|
||||
|
||||
def _split_cursor(words: list[str]) -> tuple[list[str], str]:
|
||||
if not words:
|
||||
return [], ""
|
||||
return words[:-1], words[-1]
|
||||
|
||||
|
||||
def _cloud_candidates(prior: list[str], current: str) -> list[str]: # noqa: PLR0911
|
||||
groups = (*_SESSION_COMMANDS, *SPEC, "workspace")
|
||||
if not prior:
|
||||
return _matching(groups, current)
|
||||
group = "workspaces" if prior[0] == "workspace" else prior[0]
|
||||
rest = prior[1:]
|
||||
if group in _SESSION_COMMANDS:
|
||||
return _session_candidates(group, rest, current)
|
||||
commands = SPEC.get(group)
|
||||
if commands is None:
|
||||
return _matching(groups, current)
|
||||
default_verb = DEFAULT_VERBS.get(group)
|
||||
default_is_active = (rest and rest[0].startswith("-")) or (not rest and current.startswith("-"))
|
||||
if default_verb is not None and default_is_active:
|
||||
return _command_candidates(commands[default_verb], rest, current)
|
||||
|
||||
command_paths = sorted(
|
||||
((verb.split(), cmd) for verb, cmd in commands.items()),
|
||||
key=lambda item: len(item[0]),
|
||||
reverse=True,
|
||||
)
|
||||
for path, cmd in command_paths:
|
||||
if rest[: len(path)] == path:
|
||||
command_candidates = _command_candidates(cmd, rest[len(path) :], current)
|
||||
if rest == path:
|
||||
nested_words = {
|
||||
candidate_path[len(path)]
|
||||
for candidate_path, _candidate_cmd in command_paths
|
||||
if len(candidate_path) > len(path) and candidate_path[: len(path)] == path
|
||||
}
|
||||
return sorted({*command_candidates, *_matching(nested_words, current)})
|
||||
return command_candidates
|
||||
if group == "workspaces" and rest[:1] == ["use"]:
|
||||
return _flag_candidates(
|
||||
_WORKSPACE_USE_FLAGS,
|
||||
rest[1:],
|
||||
current,
|
||||
value_flags=_COMMON_VALUE_FLAGS | {"--scopes"},
|
||||
)
|
||||
|
||||
verb_paths = [path for path, _cmd in command_paths]
|
||||
if group == "workspaces":
|
||||
verb_paths.append(["use"])
|
||||
matching_paths = [path for path in verb_paths if path[: len(rest)] == rest]
|
||||
if not matching_paths:
|
||||
return []
|
||||
next_words = sorted({path[len(rest)] for path in matching_paths if len(path) > len(rest)})
|
||||
return _matching(next_words, current)
|
||||
|
||||
|
||||
def _session_candidates(group: str, prior: list[str], current: str) -> list[str]:
|
||||
if group == "session":
|
||||
if not prior:
|
||||
return _matching(("show", "scopes", "help", *_COMMON_FLAGS, "--show-scopes"), current)
|
||||
if prior[:1] == ["scopes"] and len(prior) == 1:
|
||||
return _matching(("set", *_COMMON_FLAGS, "--show-scopes"), current)
|
||||
if prior[:2] == ["scopes", "set"]:
|
||||
return _matching(
|
||||
("minimal", "recommended", "full", "--scopes", *_COMMON_FLAGS, "--show-scopes"),
|
||||
current,
|
||||
)
|
||||
return _flag_candidates(
|
||||
(*_COMMON_FLAGS, "--show-scopes"),
|
||||
prior,
|
||||
current,
|
||||
value_flags=_COMMON_VALUE_FLAGS | {"--scopes"},
|
||||
)
|
||||
flags = _session_flags(group)
|
||||
value_flags: frozenset[str] = frozenset()
|
||||
if group == "login":
|
||||
value_flags = frozenset({"--scopes", "--scope-profile", "--workspace", "--device-name"})
|
||||
elif group == "credits":
|
||||
value_flags = _COMMON_VALUE_FLAGS
|
||||
return _flag_candidates(flags, prior, current, value_flags=value_flags)
|
||||
|
||||
|
||||
def _session_flags(group: str) -> tuple[str, ...]:
|
||||
if group == "login":
|
||||
return (
|
||||
"--no-browser",
|
||||
"--scopes",
|
||||
"--scope-profile",
|
||||
"--workspace",
|
||||
"--device-name",
|
||||
"-h",
|
||||
"--help",
|
||||
)
|
||||
if group == "whoami":
|
||||
return ("--json", "--show-scopes", "-h", "--help")
|
||||
if group == "logout":
|
||||
return ("--json", "--local-only", "-h", "--help")
|
||||
if group == "credits":
|
||||
return _COMMON_FLAGS
|
||||
return ("-h", "--help")
|
||||
|
||||
|
||||
def _command_candidates(cmd: Cmd, prior: list[str], current: str) -> list[str]:
|
||||
filesystem = _filesystem_candidates(cmd, prior, current)
|
||||
if filesystem is not None:
|
||||
return filesystem
|
||||
return _flag_candidates(
|
||||
_command_flags(cmd),
|
||||
prior,
|
||||
current,
|
||||
value_flags=_command_value_flags(cmd),
|
||||
)
|
||||
|
||||
|
||||
def _flag_candidates(
|
||||
flags: tuple[str, ...],
|
||||
prior: list[str],
|
||||
current: str,
|
||||
*,
|
||||
value_flags: frozenset[str],
|
||||
) -> list[str]:
|
||||
if prior and prior[-1] in value_flags and not current.startswith("-"):
|
||||
return []
|
||||
return _matching(flags, current)
|
||||
|
||||
|
||||
def _command_flags(cmd: Cmd) -> tuple[str, ...]:
|
||||
flags: list[str] = list(_COMMON_FLAGS)
|
||||
for param in cmd.query + cmd.body:
|
||||
flag = "--" + (param.flag or _kebab(param.name))
|
||||
flags.append(flag)
|
||||
if param.kind == "bool":
|
||||
flags.append("--no-" + flag.removeprefix("--"))
|
||||
if cmd.method in ("POST", "PUT", "PATCH"):
|
||||
flags.append("--data")
|
||||
if cmd.idempotent:
|
||||
flags.append("--idempotency-key")
|
||||
if cmd.binary or cmd.path == "/audit":
|
||||
flags.extend(("--output", "--force"))
|
||||
if cmd.link:
|
||||
flags.append("--no-browser")
|
||||
if cmd.wait_path or cmd.wait_self:
|
||||
flags.extend(("--wait", "--wait-timeout"))
|
||||
if cmd.path == "/billing/topup":
|
||||
flags.extend(("--yes", "--no-pay", "--payment-method"))
|
||||
if cmd.path == "/scans" and cmd.method == "POST":
|
||||
flags.extend(
|
||||
(
|
||||
"--source",
|
||||
"--approve-sha256",
|
||||
"--dry-run",
|
||||
"--yes",
|
||||
"--show-files",
|
||||
"--exclude",
|
||||
"--include-hidden",
|
||||
"--include-sensitive",
|
||||
"--include-archives",
|
||||
)
|
||||
)
|
||||
if cmd.path == "/billing/auto-topup" and cmd.method == "PUT":
|
||||
flags.append("--no-monthly-cap")
|
||||
return tuple(dict.fromkeys(flags))
|
||||
|
||||
|
||||
def _command_value_flags(cmd: Cmd) -> frozenset[str]:
|
||||
flags = set(_COMMON_VALUE_FLAGS)
|
||||
for param in cmd.query + cmd.body:
|
||||
if param.kind != "bool":
|
||||
flags.add("--" + (param.flag or _kebab(param.name)))
|
||||
if cmd.method in ("POST", "PUT", "PATCH"):
|
||||
flags.add("--data")
|
||||
if cmd.idempotent:
|
||||
flags.add("--idempotency-key")
|
||||
if cmd.binary or cmd.path == "/audit":
|
||||
flags.add("--output")
|
||||
if cmd.wait_path or cmd.wait_self:
|
||||
flags.add("--wait-timeout")
|
||||
if cmd.path == "/billing/topup":
|
||||
flags.add("--payment-method")
|
||||
if cmd.path == "/scans" and cmd.method == "POST":
|
||||
flags.update(("--source", "--approve-sha256", "--exclude"))
|
||||
return frozenset(flags)
|
||||
|
||||
|
||||
def _filesystem_candidates( # noqa: PLR0911
|
||||
cmd: Cmd, prior: list[str], current: str
|
||||
) -> list[str] | None:
|
||||
inline = (
|
||||
("--source=", True, ""),
|
||||
("--output=", False, ""),
|
||||
("--data=@", False, "@"),
|
||||
)
|
||||
for option, directories_only, marker in inline:
|
||||
if current.startswith(option):
|
||||
value = current.removeprefix(option)
|
||||
return [
|
||||
option + candidate.removeprefix(marker)
|
||||
for candidate in _path_candidates(
|
||||
marker + value,
|
||||
directories_only=directories_only,
|
||||
marker=marker,
|
||||
)
|
||||
]
|
||||
|
||||
if not prior or current.startswith("-"):
|
||||
return None
|
||||
option = prior[-1]
|
||||
if option == "--source" and cmd.path == "/scans" and cmd.method == "POST":
|
||||
return _path_candidates(current, directories_only=True)
|
||||
if option == "--output" and (cmd.binary or cmd.path == "/audit"):
|
||||
return _path_candidates(current)
|
||||
if option == "--data" and cmd.method in ("POST", "PUT", "PATCH"):
|
||||
if not current:
|
||||
return ["@"]
|
||||
if current.startswith("@"):
|
||||
return _path_candidates(current, marker="@")
|
||||
return []
|
||||
return None
|
||||
|
||||
|
||||
def _path_candidates(
|
||||
value: str,
|
||||
*,
|
||||
directories_only: bool = False,
|
||||
marker: str = "",
|
||||
) -> list[str]:
|
||||
raw = value.removeprefix(marker) if marker else value
|
||||
ends_with_separator = raw.endswith(("/", "\\"))
|
||||
expanded = Path(raw or ".").expanduser()
|
||||
directory = expanded if ends_with_separator else expanded.parent
|
||||
name_prefix = "" if ends_with_separator else expanded.name
|
||||
raw_base = raw if ends_with_separator else raw[: len(raw) - len(name_prefix)]
|
||||
try:
|
||||
entries = directory.iterdir()
|
||||
matches = [
|
||||
entry
|
||||
for entry in entries
|
||||
if entry.name.startswith(name_prefix) and (not directories_only or entry.is_dir())
|
||||
]
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
candidates: list[str] = []
|
||||
for entry in sorted(matches, key=lambda item: item.name.casefold()):
|
||||
candidate = marker + raw_base + entry.name
|
||||
if entry.is_dir():
|
||||
candidate += "/"
|
||||
candidates.append(candidate)
|
||||
return candidates
|
||||
|
||||
|
||||
def _kebab(value: str) -> str:
|
||||
output: list[str] = []
|
||||
for char in value:
|
||||
if char.isupper():
|
||||
output.extend(("-", char.lower()))
|
||||
else:
|
||||
output.append("-" if char == "_" else char)
|
||||
return "".join(output)
|
||||
|
||||
|
||||
def _matching(candidates: Any, prefix: str) -> list[str]:
|
||||
return sorted({str(candidate) for candidate in candidates if str(candidate).startswith(prefix)})
|
||||
|
||||
|
||||
def _zsh_script() -> str:
|
||||
return r"""#compdef strix
|
||||
_strix() {
|
||||
local -a candidates
|
||||
candidates=("${(@f)$($words[1] completions --candidates "${words[@]:2}")}")
|
||||
_describe 'strix' candidates
|
||||
}
|
||||
compdef _strix strix
|
||||
"""
|
||||
|
||||
|
||||
def _bash_script() -> str:
|
||||
return r"""_strix_completion() {
|
||||
local -a candidates
|
||||
local candidate
|
||||
while IFS= read -r candidate; do
|
||||
candidates+=("$candidate")
|
||||
done < <(strix completions --candidates "${COMP_WORDS[@]:1:$COMP_CWORD}")
|
||||
COMPREPLY=("${candidates[@]}")
|
||||
for candidate in "${COMPREPLY[@]}"; do
|
||||
if [[ $candidate == */ ]]; then
|
||||
if type compopt >/dev/null 2>&1; then
|
||||
compopt -o nospace
|
||||
fi
|
||||
break
|
||||
fi
|
||||
done
|
||||
}
|
||||
complete -F _strix_completion strix
|
||||
"""
|
||||
|
||||
|
||||
def _fish_script() -> str:
|
||||
return r"""function __strix_candidates
|
||||
set -l words (commandline -opc)
|
||||
set -e words[1]
|
||||
command strix completions --candidates $words (commandline -ct)
|
||||
end
|
||||
complete -c strix -f -a '(__strix_candidates)'
|
||||
"""
|
||||
@@ -62,6 +62,14 @@ import logging # noqa: E402
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ROOT_SUBCOMMAND_HELP = """
|
||||
Additional commands:
|
||||
strix cloud ... Use the managed Strix platform
|
||||
strix auth ... Manage model-subscription sign-in
|
||||
strix view [RUN] View a completed or running scan
|
||||
strix completions SHELL Generate zsh, bash, or fish tab completion
|
||||
"""
|
||||
|
||||
|
||||
def _exception_messages(exc: BaseException) -> tuple[str, ...]:
|
||||
messages: list[str] = []
|
||||
@@ -383,13 +391,10 @@ 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
|
||||
try:
|
||||
asyncio.run(warm_up_llm(show_model_warning=True))
|
||||
except ModelConnectionError as exc:
|
||||
@@ -410,6 +415,13 @@ def main() -> None:
|
||||
if sys.platform == "win32":
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
|
||||
if len(sys.argv) == 2 and sys.argv[1] in ("-h", "--help"):
|
||||
try:
|
||||
parse_arguments()
|
||||
except SystemExit as exc:
|
||||
Console().print(_ROOT_SUBCOMMAND_HELP.strip(), markup=False)
|
||||
raise SystemExit(exc.code) from None
|
||||
|
||||
# `strix view [<run>]` is a viewer-only subcommand, dispatched before the
|
||||
# scan argument parser (which requires a target) and before any scan setup.
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "view":
|
||||
@@ -425,6 +437,19 @@ def main() -> None:
|
||||
|
||||
sys.exit(run_auth(sys.argv[2:]))
|
||||
|
||||
# Generate native shell completion scripts before scan argument parsing.
|
||||
if len(sys.argv) > 1 and sys.argv[1] in ("completion", "completions"):
|
||||
from strix.interface.completions import run_completions
|
||||
|
||||
sys.exit(run_completions(sys.argv[2:]))
|
||||
|
||||
# `strix cloud …` drives the managed platform (app.strix.ai) and exits;
|
||||
# it needs no target, Docker, or scan setup.
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "cloud":
|
||||
from strix.interface.cloud import run_cloud
|
||||
|
||||
sys.exit(run_cloud(sys.argv[2:]))
|
||||
|
||||
from strix.llm.warmup import start_import_warmup
|
||||
|
||||
start_import_warmup()
|
||||
@@ -439,10 +464,9 @@ 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:
|
||||
if args.non_interactive:
|
||||
_bootstrap_scan(args)
|
||||
|
||||
from strix.report.state import get_global_report_state
|
||||
@@ -483,6 +507,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)
|
||||
|
||||
798
strix/interface/platform_cli.py
Normal file
798
strix/interface/platform_cli.py
Normal file
@@ -0,0 +1,798 @@
|
||||
"""`strix cloud login` — managed platform sign-in (app.strix.ai).
|
||||
|
||||
Signing in runs an OAuth 2.0 device authorization flow in the browser, creates
|
||||
the Strix account and workspace when they do not exist yet, and stores a
|
||||
personal API token in ``~/.strix/platform-auth.json``. The token drives the
|
||||
managed REST API (scans, credits, top-ups) without a dashboard visit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
from typing import Any, NoReturn, cast
|
||||
from urllib.parse import urlparse, urlsplit, urlunsplit
|
||||
|
||||
import requests
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
|
||||
from strix.config import load_settings
|
||||
from strix.interface.platform_identity import read_or_create_identity
|
||||
from strix.interface.terminal_text import sanitize_terminal_text
|
||||
from strix.interface.url_safety import is_safe_web_url
|
||||
from strix.utils.secret_files import write_secret_text
|
||||
|
||||
|
||||
AUTH_PATH = Path.home() / ".strix" / "platform-auth.json"
|
||||
|
||||
_HTTP_TIMEOUT_S = 30
|
||||
_DEFAULT_POLL_INTERVAL_S = 5
|
||||
_MAX_POLL_INTERVAL_S = 60
|
||||
_MAX_EXPIRES_IN_S = 30 * 60
|
||||
|
||||
_ROLE_RANK = {"viewer": 0, "analyst": 1, "admin": 2}
|
||||
|
||||
|
||||
class PlatformAuthError(Exception):
|
||||
"""Raised when the device authorization flow fails."""
|
||||
|
||||
|
||||
class _SessionUsageError(Exception):
|
||||
"""A session subcommand received invalid arguments."""
|
||||
|
||||
|
||||
class _SessionArgumentParser(argparse.ArgumentParser):
|
||||
def error(self, message: str) -> NoReturn:
|
||||
raise _SessionUsageError(f"invalid arguments for {self.prog}: {message}")
|
||||
|
||||
|
||||
def _terminal_markup(value: object) -> str:
|
||||
return escape(sanitize_terminal_text(value))
|
||||
|
||||
|
||||
def _app_url() -> str:
|
||||
return load_settings().viewer.app_url.rstrip("/")
|
||||
|
||||
|
||||
def read_record() -> dict[str, Any] | None:
|
||||
try:
|
||||
data = json.loads(AUTH_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
record = cast("dict[str, Any]", data)
|
||||
if not record.get("api_token"):
|
||||
return None
|
||||
return record
|
||||
|
||||
|
||||
def save_record(record: dict[str, Any]) -> None:
|
||||
write_secret_text(AUTH_PATH, json.dumps(record, indent=2))
|
||||
|
||||
|
||||
def logout() -> bool:
|
||||
try:
|
||||
AUTH_PATH.unlink()
|
||||
except FileNotFoundError:
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def run_login(argv: list[str]) -> int:
|
||||
"""Entry point for ``strix cloud login``. Returns a process exit code."""
|
||||
console = Console()
|
||||
subcommand = argv[0] if argv else None
|
||||
|
||||
if subcommand == "status":
|
||||
return _status(console, argv[1:])
|
||||
if subcommand == "logout":
|
||||
return _logout(console, argv[1:])
|
||||
return _login(console, argv)
|
||||
|
||||
|
||||
def _login(console: Console, argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(prog="strix cloud login", add_help=True)
|
||||
parser.add_argument(
|
||||
"--no-browser",
|
||||
action="store_true",
|
||||
help="Do not open the browser. Print the verification URL instead.",
|
||||
)
|
||||
scope_mode = parser.add_mutually_exclusive_group()
|
||||
scope_mode.add_argument(
|
||||
"--scopes",
|
||||
nargs="+",
|
||||
metavar="SCOPE",
|
||||
default=None,
|
||||
help=(
|
||||
"API scopes for the token, for example scans:read billing:write. "
|
||||
"The server always includes a minimum scope set. "
|
||||
"Without this option, an interactive picker opens after the browser step."
|
||||
),
|
||||
)
|
||||
scope_mode.add_argument(
|
||||
"--scope-profile",
|
||||
choices=("minimal", "recommended", "full"),
|
||||
default=None,
|
||||
help="Scope profile to approve. Defaults to an interactive choice in a TTY.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--device-name",
|
||||
default=None,
|
||||
metavar="NAME",
|
||||
help="Privacy-safe label shown for this CLI session in the dashboard.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--workspace",
|
||||
metavar="WORKSPACE",
|
||||
default=None,
|
||||
help=(
|
||||
"Workspace that receives the token, by ID or by exact name. "
|
||||
"Without this option, an interactive picker opens when you have "
|
||||
"more than one workspace."
|
||||
),
|
||||
)
|
||||
previous_record = read_record()
|
||||
try:
|
||||
args = parser.parse_args(argv)
|
||||
except SystemExit as exc: # argparse already printed the message
|
||||
return exc.code if isinstance(exc.code, int) else 2
|
||||
|
||||
console.print()
|
||||
host = urlparse(_app_url()).netloc or _app_url()
|
||||
console.print(f"[bold]Signing in to the Strix platform[/] [dim]({_terminal_markup(host)})[/]")
|
||||
console.print(
|
||||
"[dim]This creates your account and workspace when needed, and stores an API token.[/]"
|
||||
)
|
||||
console.print()
|
||||
|
||||
try:
|
||||
record = _run_device_flow(
|
||||
console,
|
||||
open_browser=not args.no_browser,
|
||||
scopes=args.scopes,
|
||||
scope_profile=args.scope_profile,
|
||||
workspace=args.workspace,
|
||||
device_name=args.device_name,
|
||||
)
|
||||
except PlatformAuthError as exc:
|
||||
console.print(f"[red]Sign-in failed:[/] {_terminal_markup(exc)}")
|
||||
return 1
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[yellow]Sign-in cancelled.[/]")
|
||||
return 130
|
||||
|
||||
try:
|
||||
save_record(record)
|
||||
except OSError as exc:
|
||||
console.print(
|
||||
f"[red]Sign-in succeeded, but the token could not be stored:[/] {_terminal_markup(exc)}"
|
||||
)
|
||||
console.print(
|
||||
f"[dim]Check that {_terminal_markup(AUTH_PATH.parent)} is writable, "
|
||||
"then run `strix cloud login` again.[/]"
|
||||
)
|
||||
return 1
|
||||
_revoke_replaced_legacy_session(previous_record, record)
|
||||
_print_success(console, record)
|
||||
return 0
|
||||
|
||||
|
||||
def _run_device_flow( # noqa: PLR0912, PLR0915
|
||||
console: Console,
|
||||
*,
|
||||
open_browser: bool,
|
||||
scopes: list[str] | None = None,
|
||||
scope_profile: str | None = None,
|
||||
workspace: str | None = None,
|
||||
device_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
app_url = _app_url()
|
||||
interactive = workspace is not None or (
|
||||
sys.stdin.isatty() and scopes is None and scope_profile is None
|
||||
)
|
||||
try:
|
||||
identity = read_or_create_identity(device_name=device_name)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise PlatformAuthError(f"could not prepare the CLI device identity: {exc}") from exc
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{app_url}/api/v1/cli/login",
|
||||
timeout=_HTTP_TIMEOUT_S,
|
||||
allow_redirects=False,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise PlatformAuthError(f"could not reach {app_url}: {exc}") from exc
|
||||
if not 200 <= response.status_code < 300:
|
||||
raise PlatformAuthError(_error_detail(response))
|
||||
authorization = _json_object(response)
|
||||
|
||||
user_code = str(authorization.get("user_code") or "")
|
||||
verification_uri = str(
|
||||
authorization.get("verification_uri_complete")
|
||||
or authorization.get("verification_uri")
|
||||
or ""
|
||||
)
|
||||
device_code = str(authorization.get("device_code") or "")
|
||||
expires_in = _as_positive_int(
|
||||
authorization.get("expires_in"), default=300, maximum=_MAX_EXPIRES_IN_S
|
||||
)
|
||||
interval = _as_positive_int(
|
||||
authorization.get("interval"),
|
||||
default=_DEFAULT_POLL_INTERVAL_S,
|
||||
maximum=_MAX_POLL_INTERVAL_S,
|
||||
)
|
||||
if not device_code or not verification_uri:
|
||||
raise PlatformAuthError("the server returned an incomplete device authorization")
|
||||
if not is_safe_web_url(verification_uri, trusted_origin=app_url):
|
||||
raise PlatformAuthError("the server returned an invalid verification URL")
|
||||
|
||||
console.print(
|
||||
Panel.fit(
|
||||
Text.assemble(
|
||||
("Confirmation code: ", "dim"),
|
||||
(sanitize_terminal_text(user_code), "bold cyan"),
|
||||
),
|
||||
title="Verify this device",
|
||||
)
|
||||
)
|
||||
console.print("Open this URL in your browser and confirm the code:")
|
||||
console.print(sanitize_terminal_text(verification_uri), markup=False, soft_wrap=True)
|
||||
|
||||
if open_browser:
|
||||
with contextlib.suppress(Exception):
|
||||
webbrowser.open(verification_uri)
|
||||
|
||||
console.print("[dim]Waiting for browser confirmation…[/]")
|
||||
|
||||
poll_body: dict[str, Any] = {"device_code": device_code, **identity}
|
||||
if interactive:
|
||||
poll_body["interactive"] = True
|
||||
elif scopes:
|
||||
poll_body["scopes"] = scopes
|
||||
elif scope_profile:
|
||||
poll_body["scope_profile"] = scope_profile
|
||||
|
||||
deadline = time.monotonic() + expires_in
|
||||
while time.monotonic() < deadline:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
time.sleep(min(interval, remaining))
|
||||
try:
|
||||
poll = requests.post(
|
||||
f"{app_url}/api/v1/cli/login/poll",
|
||||
json=poll_body,
|
||||
timeout=_HTTP_TIMEOUT_S,
|
||||
allow_redirects=False,
|
||||
)
|
||||
except requests.RequestException:
|
||||
continue
|
||||
if 200 <= poll.status_code < 300:
|
||||
return _finish_login(
|
||||
console,
|
||||
app_url,
|
||||
poll,
|
||||
scopes=scopes,
|
||||
scope_profile=scope_profile,
|
||||
workspace=workspace,
|
||||
)
|
||||
delta = _handle_poll_error(poll)
|
||||
if delta is None:
|
||||
break
|
||||
interval = min(interval + delta, _MAX_POLL_INTERVAL_S)
|
||||
|
||||
raise PlatformAuthError("the sign-in request expired. Run `strix cloud login` again.")
|
||||
|
||||
|
||||
def _handle_poll_error(poll: requests.Response) -> int | None:
|
||||
"""Return the interval increase, or None when the device code expired."""
|
||||
error = ""
|
||||
with contextlib.suppress(ValueError, AttributeError):
|
||||
error = str(poll.json().get("error", ""))
|
||||
if error == "authorization_pending":
|
||||
return 0
|
||||
if error == "slow_down":
|
||||
return 5
|
||||
if error == "access_denied":
|
||||
raise PlatformAuthError("the sign-in request was denied in the browser")
|
||||
if error == "expired_token":
|
||||
return None
|
||||
raise PlatformAuthError(_error_detail(poll))
|
||||
|
||||
|
||||
def _finish_login(
|
||||
console: Console,
|
||||
app_url: str,
|
||||
poll: requests.Response,
|
||||
*,
|
||||
scopes: list[str] | None,
|
||||
scope_profile: str | None,
|
||||
workspace: str | None,
|
||||
) -> dict[str, Any]:
|
||||
result = _json_object(poll)
|
||||
if result.get("selection_required"):
|
||||
return _complete_selection(
|
||||
console,
|
||||
app_url,
|
||||
result,
|
||||
scopes=scopes,
|
||||
scope_profile=scope_profile,
|
||||
workspace=workspace,
|
||||
)
|
||||
return _bind_login_record(_require_api_token(result), app_url)
|
||||
|
||||
|
||||
def _signed_in_record(
|
||||
response: requests.Response,
|
||||
*,
|
||||
app_url: str,
|
||||
) -> dict[str, Any]:
|
||||
return _bind_login_record(
|
||||
_require_api_token(_json_object(response)),
|
||||
app_url,
|
||||
)
|
||||
|
||||
|
||||
def _require_api_token(record: dict[str, Any]) -> dict[str, Any]:
|
||||
api_token = record.get("api_token")
|
||||
if not isinstance(api_token, str) or not api_token.strip():
|
||||
raise PlatformAuthError("the server returned a sign-in response without an API token")
|
||||
return record
|
||||
|
||||
|
||||
def _bind_login_record(record: dict[str, Any], app_url: str) -> dict[str, Any]:
|
||||
"""Bind a stored credential to its issuer and preserve its scope preference."""
|
||||
parsed = urlsplit(app_url)
|
||||
if (
|
||||
parsed.scheme not in {"http", "https"}
|
||||
or not parsed.netloc
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
or "\\" in app_url
|
||||
or any(character.isspace() for character in app_url)
|
||||
or "%" in parsed.netloc
|
||||
):
|
||||
raise PlatformAuthError("the configured platform URL is invalid")
|
||||
bound = dict(record)
|
||||
bound["app_url"] = urlunsplit(
|
||||
(parsed.scheme.lower(), parsed.netloc.lower(), parsed.path.rstrip("/"), "", "")
|
||||
)
|
||||
preference: Any = record.get("requested_scopes", record.get("scopes"))
|
||||
preference_items = cast("list[Any]", preference)
|
||||
if isinstance(preference, list) and all(isinstance(scope, str) for scope in preference_items):
|
||||
bound["requested_scopes"] = list(dict.fromkeys(cast("list[str]", preference_items)))
|
||||
return bound
|
||||
|
||||
|
||||
def _complete_selection(
|
||||
console: Console,
|
||||
app_url: str,
|
||||
selection: dict[str, Any],
|
||||
*,
|
||||
scopes: list[str] | None,
|
||||
scope_profile: str | None,
|
||||
workspace: str | None,
|
||||
) -> dict[str, Any]:
|
||||
organizations = _dict_items(selection.get("organizations"))
|
||||
catalog = _dict_items(selection.get("scopes"))
|
||||
selection_token = str(selection.get("selection_token") or "")
|
||||
if not selection_token or not organizations:
|
||||
raise PlatformAuthError("the server returned an incomplete selection response")
|
||||
|
||||
chosen_org = _choose_workspace(console, organizations, workspace)
|
||||
role = str(chosen_org.get("role") or "admin")
|
||||
chosen_scopes = scopes
|
||||
chosen_profile = scope_profile
|
||||
if chosen_scopes is None and chosen_profile is None and sys.stdin.isatty():
|
||||
chosen_profile, chosen_scopes = _choose_scopes(console, catalog, role)
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"selection_token": selection_token,
|
||||
"organization_id": chosen_org.get("id"),
|
||||
}
|
||||
if chosen_scopes is not None:
|
||||
body["scopes"] = chosen_scopes
|
||||
body["scope_profile"] = "custom"
|
||||
elif chosen_profile is not None:
|
||||
body["scope_profile"] = chosen_profile
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{app_url}/api/v1/cli/login/complete",
|
||||
json=body,
|
||||
timeout=_HTTP_TIMEOUT_S,
|
||||
allow_redirects=False,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise PlatformAuthError(f"could not reach {app_url}: {exc}") from exc
|
||||
if not 200 <= response.status_code < 300:
|
||||
raise PlatformAuthError(_error_detail(response))
|
||||
return _signed_in_record(
|
||||
response,
|
||||
app_url=app_url,
|
||||
)
|
||||
|
||||
|
||||
def _dict_items(value: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
items = cast("list[Any]", cast("Any", value))
|
||||
return [cast("dict[str, Any]", cast("Any", item)) for item in items if isinstance(item, dict)]
|
||||
|
||||
|
||||
def _choose_workspace(
|
||||
console: Console, organizations: list[dict[str, Any]], workspace: str | None
|
||||
) -> dict[str, Any]:
|
||||
if workspace is not None:
|
||||
wanted = workspace.strip().casefold()
|
||||
by_id = [org for org in organizations if str(org.get("id", "")).casefold() == wanted]
|
||||
if by_id:
|
||||
return by_id[0]
|
||||
by_name = [
|
||||
org for org in organizations if str(org.get("name", "")).strip().casefold() == wanted
|
||||
]
|
||||
if len(by_name) == 1:
|
||||
return by_name[0]
|
||||
if len(by_name) > 1:
|
||||
matching_ids = ", ".join(str(org.get("id", "")) for org in by_name)
|
||||
raise PlatformAuthError(
|
||||
f"multiple workspaces are named {workspace!r}; use an exact workspace ID: "
|
||||
f"{matching_ids}"
|
||||
)
|
||||
names = ", ".join(str(org.get("name", "")) for org in organizations)
|
||||
raise PlatformAuthError(f"no workspace matches {workspace!r}. Your workspaces: {names}")
|
||||
if len(organizations) == 1:
|
||||
return organizations[0]
|
||||
if not sys.stdin.isatty():
|
||||
choices = ", ".join(f"{org.get('name', '')} ({org.get('id', '')})" for org in organizations)
|
||||
raise PlatformAuthError(
|
||||
"more than one workspace is available; rerun with --workspace NAME_OR_ID. "
|
||||
f"Available workspaces: {choices}"
|
||||
)
|
||||
|
||||
console.print()
|
||||
console.print("[bold]Select a workspace for the API token:[/]")
|
||||
for index, org in enumerate(organizations, start=1):
|
||||
name = _terminal_markup(org.get("name", ""))
|
||||
org_role = _terminal_markup(org.get("role", ""))
|
||||
console.print(f" [cyan]{index}[/]. {name} [dim]({org_role})[/]")
|
||||
while True:
|
||||
answer = console.input(f"Workspace [1-{len(organizations)}] (1): ").strip() or "1"
|
||||
if answer.isdigit() and 1 <= int(answer) <= len(organizations):
|
||||
return organizations[int(answer) - 1]
|
||||
console.print("[yellow]Enter a number from the list.[/]")
|
||||
|
||||
|
||||
def _choose_scopes(
|
||||
console: Console, catalog: list[dict[str, Any]], role: str
|
||||
) -> tuple[str, list[str] | None]:
|
||||
"""Prompt for a named scope profile or a custom scope list."""
|
||||
rank = _ROLE_RANK.get(role, 2)
|
||||
allowed = [
|
||||
item for item in catalog if _ROLE_RANK.get(str(item.get("min_role", "viewer")), 0) <= rank
|
||||
]
|
||||
if not allowed:
|
||||
return "recommended", None
|
||||
|
||||
console.print()
|
||||
console.print("[bold]Select token scopes:[/]")
|
||||
console.print(
|
||||
" [cyan]1[/]. Recommended [dim](scans, findings, schedules, assets, uploads, "
|
||||
"workspace switching, billing/top-ups; no token creation)[/]"
|
||||
)
|
||||
console.print(" [cyan]2[/]. Full access [dim](every scope your role allows)[/]")
|
||||
console.print(" [cyan]3[/]. Minimal [dim](scan read/write and billing read)[/]")
|
||||
console.print(" [cyan]4[/]. Custom [dim](pick individual scopes)[/]")
|
||||
while True:
|
||||
answer = console.input("Scopes [1-4] (1): ").strip() or "1"
|
||||
if answer == "1":
|
||||
return "recommended", None
|
||||
if answer == "2":
|
||||
return "full", None
|
||||
if answer == "3":
|
||||
return "minimal", None
|
||||
if answer == "4":
|
||||
return "custom", _choose_custom_scopes(console, allowed)
|
||||
console.print("[yellow]Enter a number from 1 to 4.[/]")
|
||||
|
||||
|
||||
def _choose_custom_scopes(console: Console, allowed: list[dict[str, Any]]) -> list[str]:
|
||||
selected = {
|
||||
str(item["scope"])
|
||||
for item in allowed
|
||||
if item.get("scope") and (item.get("default") or item.get("minimum"))
|
||||
}
|
||||
while True:
|
||||
console.print()
|
||||
for index, item in enumerate(allowed, start=1):
|
||||
scope = str(item.get("scope", ""))
|
||||
mark = "[green]x[/]" if scope in selected else " "
|
||||
required = " [dim](always included)[/]" if item.get("minimum") else ""
|
||||
rendered_scope = _terminal_markup(scope)
|
||||
description = _terminal_markup(item.get("description", ""))
|
||||
console.print(
|
||||
f" [{mark}] [cyan]{index:>2}[/]. {rendered_scope}{required}"
|
||||
f"\n [dim]{description}[/]"
|
||||
)
|
||||
answer = console.input(
|
||||
"Toggle scopes by number (comma separated), or press Enter to confirm: "
|
||||
).strip()
|
||||
if not answer:
|
||||
return sorted(selected)
|
||||
for part in answer.replace(",", " ").split():
|
||||
if not part.isdigit() or not 1 <= int(part) <= len(allowed):
|
||||
console.print(
|
||||
f"[yellow]Ignored {_terminal_markup(part)!r}: not a number from the list.[/]"
|
||||
)
|
||||
continue
|
||||
item = allowed[int(part) - 1]
|
||||
scope = str(item.get("scope", ""))
|
||||
if item.get("minimum"):
|
||||
console.print(f"[yellow]{_terminal_markup(scope)} is always included.[/]")
|
||||
continue
|
||||
if scope in selected:
|
||||
selected.discard(scope)
|
||||
else:
|
||||
selected.add(scope)
|
||||
|
||||
|
||||
def _json_object(response: requests.Response) -> dict[str, Any]:
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError as exc:
|
||||
raise PlatformAuthError("the server returned a response that is not JSON") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise PlatformAuthError("the server returned an unexpected response shape")
|
||||
return cast("dict[str, Any]", data)
|
||||
|
||||
|
||||
def _as_positive_int(value: Any, *, default: int, maximum: int) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return default
|
||||
if parsed <= 0:
|
||||
return default
|
||||
return min(parsed, maximum)
|
||||
|
||||
|
||||
def _error_detail(response: requests.Response) -> str:
|
||||
with contextlib.suppress(ValueError, AttributeError):
|
||||
detail = response.json().get("detail")
|
||||
if detail:
|
||||
return str(detail)
|
||||
return f"HTTP {response.status_code}"
|
||||
|
||||
|
||||
def _session_headers(record: dict[str, Any]) -> dict[str, str]:
|
||||
headers = {"Authorization": f"Bearer {record['api_token']}"}
|
||||
workspace_id = record.get("organization_id")
|
||||
if isinstance(workspace_id, str) and workspace_id:
|
||||
headers["X-Strix-Workspace"] = workspace_id
|
||||
return headers
|
||||
|
||||
|
||||
def _revoke_stored_session(record: dict[str, Any]) -> tuple[bool, str | None]:
|
||||
"""Revoke one server session; return (definitively_inactive, error)."""
|
||||
app_url = record.get("app_url")
|
||||
if not isinstance(app_url, str) or not app_url:
|
||||
return False, (
|
||||
"the stored sign-in has no trusted platform URL; use --local-only to remove it"
|
||||
)
|
||||
try:
|
||||
response = requests.delete(
|
||||
f"{app_url.rstrip('/')}/api/v1/cli/session",
|
||||
headers=_session_headers(record),
|
||||
timeout=_HTTP_TIMEOUT_S,
|
||||
allow_redirects=False,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
return False, f"could not revoke the remote CLI session: {exc}"
|
||||
if response.status_code in {200, 204, 401}:
|
||||
return True, None
|
||||
return False, f"could not revoke the remote CLI session: {_error_detail(response)}"
|
||||
|
||||
|
||||
def _print_logout_failure(console: Console, message: str, *, as_json: bool) -> int:
|
||||
if as_json:
|
||||
sys.stdout.write(json.dumps({"error": message, "removed": False}) + "\n")
|
||||
else:
|
||||
console.print(f"[red]Sign-out failed:[/] {_terminal_markup(message)}")
|
||||
console.print("[dim]The local token was kept so you can safely retry.[/]")
|
||||
return 1
|
||||
|
||||
|
||||
def _revoke_replaced_legacy_session(
|
||||
previous: dict[str, Any] | None, current: dict[str, Any]
|
||||
) -> None:
|
||||
"""Best-effort cleanup when the first device-aware login replaces a legacy token."""
|
||||
if not previous or previous.get("api_token") == current.get("api_token"):
|
||||
return
|
||||
if previous.get("app_url") != current.get("app_url"):
|
||||
return
|
||||
with contextlib.suppress(KeyError, requests.RequestException):
|
||||
requests.delete(
|
||||
f"{previous['app_url']}/api/v1/cli/session",
|
||||
headers=_session_headers(previous),
|
||||
timeout=_HTTP_TIMEOUT_S,
|
||||
allow_redirects=False,
|
||||
)
|
||||
|
||||
|
||||
def _print_success(console: Console, record: dict[str, Any]) -> None:
|
||||
email = record.get("email", "")
|
||||
organization = record.get("organization_name") or record.get("organization_id", "")
|
||||
console.print()
|
||||
console.print("[green]✓ Signed in to the Strix platform.[/]")
|
||||
if email:
|
||||
console.print(f" Account: [bold]{_terminal_markup(email)}[/]")
|
||||
if organization:
|
||||
console.print(f" Workspace: [bold]{_terminal_markup(organization)}[/]")
|
||||
scopes = record.get("scopes")
|
||||
if isinstance(scopes, list) and scopes:
|
||||
console.print(f" Access: [dim]{_terminal_markup(_scope_summary(record))}[/]")
|
||||
console.print(f" Token: stored in [dim]{_terminal_markup(AUTH_PATH)}[/]")
|
||||
console.print()
|
||||
console.print(
|
||||
"[dim]The managed platform is ready. Run `strix cloud` to list the commands. "
|
||||
"See https://docs.app.strix.ai for the API reference.[/]"
|
||||
)
|
||||
|
||||
|
||||
def _status(console: Console, argv: list[str]) -> int: # noqa: PLR0912
|
||||
parser = _SessionArgumentParser(
|
||||
prog="strix cloud whoami",
|
||||
description="Show the stored managed-platform account, workspace, scopes, and expiry.",
|
||||
)
|
||||
parser.add_argument("--json", action="store_true", help="Print the session as JSON.")
|
||||
parser.add_argument("--show-scopes", action="store_true", help="Print every granted scope.")
|
||||
as_json = "--json" in argv or not sys.stdout.isatty()
|
||||
try:
|
||||
args = parser.parse_args(argv)
|
||||
except _SessionUsageError as exc:
|
||||
if as_json:
|
||||
sys.stdout.write(json.dumps({"error": str(exc)}) + "\n")
|
||||
else:
|
||||
console.print(f"[red]Error:[/] {_terminal_markup(exc)}")
|
||||
return 2
|
||||
except SystemExit as exc:
|
||||
return exc.code if isinstance(exc.code, int) else 2
|
||||
|
||||
as_json = bool(args.json) or not sys.stdout.isatty()
|
||||
record = read_record()
|
||||
if record is None:
|
||||
if as_json:
|
||||
sys.stdout.write(json.dumps({"signed_in": False, "error": "Not signed in"}) + "\n")
|
||||
return 1
|
||||
console.print("[yellow]Not signed in.[/] Run [bold]strix cloud login[/] to sign in.")
|
||||
return 1
|
||||
email = record.get("email", "unknown")
|
||||
organization = record.get("organization_name") or record.get("organization_id", "")
|
||||
expires_at = record.get("expires_at", "")
|
||||
if as_json:
|
||||
payload = {
|
||||
"signed_in": True,
|
||||
"email": email,
|
||||
"organization_id": record.get("organization_id"),
|
||||
"organization_name": record.get("organization_name"),
|
||||
"scopes": record.get("scopes", []),
|
||||
"expires_at": expires_at or None,
|
||||
**({"app_url": record["app_url"]} if record.get("app_url") else {}),
|
||||
}
|
||||
sys.stdout.write(json.dumps(payload, indent=2, default=str) + "\n")
|
||||
return 0
|
||||
console.print(f"[green]Signed in[/] as [bold]{_terminal_markup(email)}[/]")
|
||||
if organization:
|
||||
console.print(f" Workspace: {_terminal_markup(organization)}")
|
||||
if expires_at:
|
||||
console.print(f" Token expires: {_terminal_markup(expires_at)}")
|
||||
if record.get("app_url"):
|
||||
console.print(f" Platform: {_terminal_markup(record['app_url'])}")
|
||||
scopes = record.get("scopes")
|
||||
if isinstance(scopes, list) and scopes:
|
||||
scope_items = cast("list[Any]", cast("Any", scopes))
|
||||
if args.show_scopes:
|
||||
console.print(
|
||||
f" Scopes: {_terminal_markup(' '.join(str(scope) for scope in scope_items))}"
|
||||
)
|
||||
else:
|
||||
console.print(f" Access: {_terminal_markup(_scope_summary(record))}")
|
||||
return 0
|
||||
|
||||
|
||||
def _scope_summary(record: dict[str, Any]) -> str:
|
||||
scopes = record.get("scopes")
|
||||
scope_items = cast("list[Any]", cast("Any", scopes)) if isinstance(scopes, list) else []
|
||||
count = len(scope_items)
|
||||
profile = str(record.get("scope_profile") or "custom").replace("_", " ").title()
|
||||
return f"{profile} · {count} scope{'s' if count != 1 else ''} granted"
|
||||
|
||||
|
||||
def _logout(console: Console, argv: list[str]) -> int: # noqa: PLR0911, PLR0912
|
||||
parser = _SessionArgumentParser(
|
||||
prog="strix cloud logout",
|
||||
description="Revoke this CLI session and remove its token from this machine.",
|
||||
)
|
||||
parser.add_argument("--json", action="store_true", help="Print the result as JSON.")
|
||||
parser.add_argument(
|
||||
"--local-only",
|
||||
action="store_true",
|
||||
help="Remove only the local token, leaving the remote session active.",
|
||||
)
|
||||
as_json = "--json" in argv or not sys.stdout.isatty()
|
||||
try:
|
||||
args = parser.parse_args(argv)
|
||||
except _SessionUsageError as exc:
|
||||
if as_json:
|
||||
sys.stdout.write(json.dumps({"error": str(exc)}) + "\n")
|
||||
else:
|
||||
console.print(f"[red]Error:[/] {_terminal_markup(exc)}")
|
||||
return 2
|
||||
except SystemExit as exc:
|
||||
return exc.code if isinstance(exc.code, int) else 2
|
||||
as_json = bool(args.json) or not sys.stdout.isatty()
|
||||
if read_record() is None and not AUTH_PATH.exists():
|
||||
if as_json:
|
||||
sys.stdout.write(json.dumps({"signed_in": False, "removed": False}) + "\n")
|
||||
return 0
|
||||
console.print("[yellow]Not signed in.[/]")
|
||||
return 0
|
||||
record = read_record()
|
||||
remotely_revoked = False
|
||||
if record is not None and not args.local_only:
|
||||
remotely_revoked, revoke_error = _revoke_stored_session(record)
|
||||
if revoke_error:
|
||||
return _print_logout_failure(console, revoke_error, as_json=as_json)
|
||||
|
||||
if not logout():
|
||||
if as_json:
|
||||
sys.stdout.write(
|
||||
json.dumps(
|
||||
{
|
||||
"error": "Could not remove the stored API token",
|
||||
"signed_in": True,
|
||||
"removed": False,
|
||||
}
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
return 1
|
||||
console.print(
|
||||
f"[red]Could not remove the stored API token.[/] Delete "
|
||||
f"{_terminal_markup(AUTH_PATH)} manually."
|
||||
)
|
||||
return 1
|
||||
if as_json:
|
||||
sys.stdout.write(
|
||||
json.dumps(
|
||||
{
|
||||
"signed_in": False,
|
||||
"removed": True,
|
||||
"remotely_revoked": remotely_revoked,
|
||||
"local_only": bool(args.local_only),
|
||||
}
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
return 0
|
||||
if args.local_only:
|
||||
console.print(
|
||||
"[yellow]Local sign-out only.[/] The remote CLI session is still active; "
|
||||
"revoke it from API Access if needed."
|
||||
)
|
||||
else:
|
||||
console.print("[green]Signed out.[/] The CLI session was revoked and removed locally.")
|
||||
return 0
|
||||
46
strix/interface/platform_identity.py
Normal file
46
strix/interface/platform_identity.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Stable, privacy-safe identity for this Strix CLI installation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import platform
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from strix.utils.secret_files import write_secret_text
|
||||
|
||||
|
||||
IDENTITY_PATH = Path.home() / ".strix" / "cli-identity.json"
|
||||
|
||||
|
||||
def _default_device_name(instance_id: str) -> str:
|
||||
system = {"Darwin": "macOS", "Windows": "Windows", "Linux": "Linux"}.get(
|
||||
platform.system(), "Computer"
|
||||
)
|
||||
return f"{system} CLI · {instance_id[:8]}"
|
||||
|
||||
|
||||
def read_or_create_identity(*, device_name: str | None = None) -> dict[str, str]:
|
||||
"""Return one installation ID, optionally updating its user-facing label."""
|
||||
record: dict[str, Any] = {}
|
||||
try:
|
||||
raw = json.loads(IDENTITY_PATH.read_text(encoding="utf-8"))
|
||||
if isinstance(raw, dict):
|
||||
record = cast("dict[str, Any]", raw)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
instance_id = record.get("client_instance_id")
|
||||
if not isinstance(instance_id, str) or len(instance_id) < 8:
|
||||
instance_id = str(uuid4())
|
||||
label = device_name.strip() if device_name is not None else record.get("device_name")
|
||||
if not isinstance(label, str) or not label.strip():
|
||||
label = _default_device_name(instance_id)
|
||||
label = " ".join(label.split())
|
||||
if not 1 <= len(label) <= 80:
|
||||
raise ValueError("device name must be 1-80 printable characters")
|
||||
|
||||
identity = {"client_instance_id": instance_id, "device_name": label}
|
||||
write_secret_text(IDENTITY_PATH, json.dumps(identity, indent=2))
|
||||
return identity
|
||||
21
strix/interface/terminal_text.py
Normal file
21
strix/interface/terminal_text.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Safe rendering of untrusted text in a terminal."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
|
||||
_TERMINAL_CONTROL = re.compile(r"[\x00-\x1f\x7f-\x9f]")
|
||||
|
||||
|
||||
def has_terminal_control(value: object) -> bool:
|
||||
"""Return whether text contains bytes that can alter terminal state/protocols."""
|
||||
return _TERMINAL_CONTROL.search(str(value)) is not None
|
||||
|
||||
|
||||
def sanitize_terminal_text(value: object) -> str:
|
||||
"""Make C0/C1 control bytes visible so they cannot operate a terminal."""
|
||||
return _TERMINAL_CONTROL.sub(
|
||||
lambda match: f"\\x{ord(match.group()):02x}",
|
||||
str(value),
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -63,11 +63,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 +105,56 @@ 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")
|
||||
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...")
|
||||
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 +171,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
|
||||
@@ -373,9 +414,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)
|
||||
|
||||
85
strix/interface/url_safety.py
Normal file
85
strix/interface/url_safety.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Validation for URLs printed or opened on behalf of a remote service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
from urllib.parse import SplitResult, urlsplit
|
||||
|
||||
from strix.interface.terminal_text import has_terminal_control
|
||||
|
||||
|
||||
def is_safe_web_url(
|
||||
value: object,
|
||||
*,
|
||||
trusted_origin: str | None = None,
|
||||
require_trusted_origin: bool = False,
|
||||
) -> bool:
|
||||
"""Accept a strict HTTP(S) URL, optionally only on a pre-trusted origin."""
|
||||
parsed = _parse(value)
|
||||
if parsed is None:
|
||||
return False
|
||||
trusted = _parse(trusted_origin) if trusted_origin is not None else None
|
||||
same_origin = trusted is not None and _origin(parsed) == _origin(trusted)
|
||||
if require_trusted_origin:
|
||||
return same_origin
|
||||
if same_origin:
|
||||
return True
|
||||
return _is_safe_external_https(parsed)
|
||||
|
||||
|
||||
def _is_safe_external_https(parsed: SplitResult) -> bool:
|
||||
"""Reject local, numeric-looking, or otherwise ambiguous external hosts."""
|
||||
hostname = (parsed.hostname or "").lower().rstrip(".")
|
||||
if (
|
||||
parsed.scheme != "https"
|
||||
or hostname == "localhost"
|
||||
or hostname.endswith((".localhost", ".local"))
|
||||
):
|
||||
return False
|
||||
try:
|
||||
return ipaddress.ip_address(hostname).is_global
|
||||
except ValueError:
|
||||
pass
|
||||
labels = hostname.split(".")
|
||||
return len(labels) >= 2 and not all(_looks_numeric(label) for label in labels)
|
||||
|
||||
|
||||
def _parse(value: object) -> SplitResult | None:
|
||||
if not isinstance(value, str) or not value or has_terminal_control(value):
|
||||
return None
|
||||
if "\\" in value or any(character.isspace() for character in value):
|
||||
return None
|
||||
try:
|
||||
parsed = urlsplit(value)
|
||||
port = parsed.port
|
||||
except ValueError:
|
||||
return None
|
||||
hostname = parsed.hostname
|
||||
if (
|
||||
parsed.scheme not in {"http", "https"}
|
||||
or not hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.fragment
|
||||
or "%" in parsed.netloc
|
||||
):
|
||||
return None
|
||||
try:
|
||||
hostname.encode("ascii")
|
||||
except UnicodeEncodeError:
|
||||
return None
|
||||
return parsed if port is None or 1 <= port <= 65535 else None
|
||||
|
||||
|
||||
def _origin(parsed: SplitResult) -> tuple[str, str, int]:
|
||||
default_port = 443 if parsed.scheme == "https" else 80
|
||||
return parsed.scheme, (parsed.hostname or "").lower().rstrip("."), parsed.port or default_port
|
||||
|
||||
|
||||
def _looks_numeric(label: str) -> bool:
|
||||
lowered = label.lower()
|
||||
if lowered.startswith("0x"):
|
||||
return len(lowered) > 2 and all(
|
||||
character in "0123456789abcdef" for character in lowered[2:]
|
||||
)
|
||||
return bool(lowered) and all(character.isdigit() for character in lowered)
|
||||
@@ -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"],
|
||||
|
||||
@@ -20,6 +20,7 @@ from datetime import datetime
|
||||
from io import BytesIO
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from markdown_it import MarkdownIt
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.enums import TA_CENTER
|
||||
@@ -49,6 +50,8 @@ from strix.interface.viewer.transcript import (
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from markdown_it.token import Token
|
||||
|
||||
|
||||
# Palette lifted from the cloud report theme (styles/base.ts, docx/theme.ts).
|
||||
_INK = colors.HexColor("#000000")
|
||||
@@ -72,11 +75,21 @@ _SANS_BOLD = "Helvetica-Bold"
|
||||
_MONO = "Courier"
|
||||
|
||||
_PAGE_W, _PAGE_H = A4
|
||||
_INLINE_MD = MarkdownIt("commonmark", {"html": False, "linkify": False}).disable(
|
||||
["autolink", "image", "link"]
|
||||
)
|
||||
_UNSAFE_TEXT_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f\ud800-\udfff\ufffe\uffff]")
|
||||
|
||||
|
||||
def _normalize_text(value: Any) -> str:
|
||||
"""Normalize characters that ReportLab cannot safely serialize."""
|
||||
text = str(value).replace("\r\n", "\n").replace("\r", "\n")
|
||||
return _UNSAFE_TEXT_RE.sub("\ufffd", text)
|
||||
|
||||
|
||||
def _esc(value: Any) -> str:
|
||||
"""Escape a value for reportlab's Paragraph markup."""
|
||||
return html.escape(str(value)).replace("\n", "<br/>")
|
||||
return html.escape(_normalize_text(value)).replace("\n", "<br/>")
|
||||
|
||||
|
||||
class _NumberedCanvas(pdfcanvas.Canvas): # type: ignore[misc] # reportlab base is untyped
|
||||
@@ -253,7 +266,10 @@ def _duration(start: Any, end: Any) -> str:
|
||||
end_dt = _parse_time(end)
|
||||
if not start_dt or not end_dt:
|
||||
return "n/a"
|
||||
seconds = int((end_dt - start_dt).total_seconds())
|
||||
try:
|
||||
seconds = int((end_dt - start_dt).total_seconds())
|
||||
except (OverflowError, TypeError):
|
||||
return "n/a"
|
||||
if seconds < 0:
|
||||
return "n/a"
|
||||
hours, remainder = divmod(seconds, 3600)
|
||||
@@ -265,10 +281,18 @@ def _duration(start: Any, end: Any) -> str:
|
||||
return f"{secs}s"
|
||||
|
||||
|
||||
def _severity_badge(styles: dict[str, ParagraphStyle], severity: str) -> Table:
|
||||
def _normalize_severity(value: Any) -> str:
|
||||
severity = str(value or "").lower().strip()
|
||||
if severity == "informational":
|
||||
return "info"
|
||||
return severity if severity in {*_SEVERITY_COLORS, "info"} else "low"
|
||||
|
||||
|
||||
def _severity_badge(styles: dict[str, ParagraphStyle], severity: Any) -> Table:
|
||||
"""A colored pill matching .severity-badge in the cloud report."""
|
||||
severity = _normalize_severity(severity)
|
||||
color = _SEVERITY_COLORS.get(severity, _MUTED)
|
||||
cell = Paragraph(severity.upper(), styles["badge"])
|
||||
cell = Paragraph(_esc(severity.upper()), styles["badge"])
|
||||
table = Table([[cell]], colWidths=[len(severity) * 6.5 + 20])
|
||||
table.setStyle(
|
||||
TableStyle(
|
||||
@@ -406,27 +430,36 @@ def _cover(
|
||||
|
||||
|
||||
def _inline_md(text: str) -> str:
|
||||
"""Convert inline markdown (bold, italic, `code`) to reportlab markup.
|
||||
"""Render a safe subset of inline Markdown as ReportLab markup."""
|
||||
tokens = _INLINE_MD.parseInline(_normalize_text(text))[0].children or []
|
||||
return "".join(_inline_token_markup(token) for token in tokens)
|
||||
|
||||
Code spans are stashed as placeholders before bold/italic run, so bold that
|
||||
wraps a code span (``**`x`**``) works and code contents are never mangled.
|
||||
"""
|
||||
codes: list[str] = []
|
||||
|
||||
def _stash(match: re.Match[str]) -> str:
|
||||
codes.append(match.group(1))
|
||||
return f"\x00{len(codes) - 1}\x00"
|
||||
def _inline_token_markup(token: Token) -> str:
|
||||
fixed_markup = {
|
||||
"strong_open": "<b>",
|
||||
"strong_close": "</b>",
|
||||
"em_open": "<i>",
|
||||
"em_close": "</i>",
|
||||
"hardbreak": "<br/>",
|
||||
"softbreak": " ",
|
||||
}.get(token.type)
|
||||
if fixed_markup is not None:
|
||||
return fixed_markup
|
||||
if token.type == "code_inline":
|
||||
return f'<font face="{_MONO}" color="#b31d28">{html.escape(token.content)}</font>'
|
||||
# Unsupported token content remains escaped so parser extensions cannot
|
||||
# expose ReportLab tags.
|
||||
return html.escape(token.content)
|
||||
|
||||
seg = html.escape(re.sub(r"`([^`]+)`", _stash, text))
|
||||
seg = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", seg)
|
||||
seg = re.sub(r"__(.+?)__", r"<b>\1</b>", seg)
|
||||
seg = re.sub(r"\*(.+?)\*", r"<i>\1</i>", seg)
|
||||
|
||||
def _restore(match: re.Match[str]) -> str:
|
||||
inner = html.escape(codes[int(match.group(1))])
|
||||
return f'<font face="{_MONO}" color="#b31d28">{inner}</font>'
|
||||
|
||||
return re.sub(r"\x00(\d+)\x00", _restore, seg)
|
||||
def _markdown_paragraph(text: str, style: ParagraphStyle) -> Paragraph:
|
||||
"""Build a Markdown paragraph, falling back to escaped source text."""
|
||||
source = _normalize_text(text)
|
||||
try:
|
||||
return Paragraph(_inline_md(source), style)
|
||||
except ValueError:
|
||||
return Paragraph(_esc(source), style)
|
||||
|
||||
|
||||
def _strip_leading_heading(md: str) -> str:
|
||||
@@ -447,12 +480,12 @@ def _markdown_flowables( # noqa: PLR0915 - cohesive block parser, splitting hur
|
||||
|
||||
def flush_para() -> None:
|
||||
if para:
|
||||
flow.append(Paragraph(_inline_md(" ".join(para)), styles["body"]))
|
||||
flow.append(_markdown_paragraph(" ".join(para), styles["body"]))
|
||||
para.clear()
|
||||
|
||||
def flush_bullets() -> None:
|
||||
for marker, item in bullets:
|
||||
flow.append(Paragraph(f"{marker} {_inline_md(item)}", styles["bullet"]))
|
||||
flow.append(_markdown_paragraph(f"{marker}\u00a0{item}", styles["bullet"]))
|
||||
bullets.clear()
|
||||
|
||||
lines = md.replace("\r\n", "\n").split("\n")
|
||||
@@ -479,7 +512,7 @@ def _markdown_flowables( # noqa: PLR0915 - cohesive block parser, splitting hur
|
||||
if heading:
|
||||
flush_para()
|
||||
flush_bullets()
|
||||
flow.append(Paragraph(_inline_md(heading.group(2)), styles["md_heading"]))
|
||||
flow.append(_markdown_paragraph(heading.group(2), styles["md_heading"]))
|
||||
i += 1
|
||||
continue
|
||||
ordered = re.match(r"^(\d+)\.\s+(.*)$", stripped)
|
||||
@@ -532,7 +565,7 @@ def _finding_flowables(
|
||||
styles: dict[str, ParagraphStyle], index: int, vuln: dict[str, Any]
|
||||
) -> list[Flowable]:
|
||||
title = vuln.get("title") or "Untitled finding"
|
||||
severity = str(vuln.get("severity") or "").lower().strip() or "low"
|
||||
severity = _normalize_severity(vuln.get("severity"))
|
||||
|
||||
meta_bits = []
|
||||
if vuln.get("cvss") is not None:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,9 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -13,7 +15,6 @@ from agents.sandbox.entries import BaseEntry, File, LocalDir
|
||||
from agents.sandbox.manifest import Environment, Manifest
|
||||
|
||||
from strix.config import load_settings
|
||||
from strix.core.paths import run_dir_for, runtime_state_dir
|
||||
from strix.runtime.backends import backend_supports_bind_mounts, get_backend
|
||||
from strix.runtime.caido_bootstrap import bootstrap_caido
|
||||
from strix.runtime.caido_handle import CaidoBootstrapHandle
|
||||
@@ -168,6 +169,17 @@ def build_extra_file_entries(
|
||||
return entries
|
||||
|
||||
|
||||
def extra_file_staging_dir(scan_id: str) -> Path:
|
||||
"""A fresh host staging directory for a scan's extra-file bind mounts.
|
||||
|
||||
The docker daemon resolves bind sources in its own filesystem. With a
|
||||
remote daemon (e.g. a dind sidecar) the run directory is not shared, so
|
||||
staging lives under the temp dir like every other bind-mount source.
|
||||
"""
|
||||
safe = "".join(c if c.isalnum() or c in "-_." else "-" for c in scan_id)
|
||||
return Path(tempfile.mkdtemp(prefix=f"strix-extra-files-{safe}-"))
|
||||
|
||||
|
||||
def build_extra_file_bind_mounts(
|
||||
extra_files: list[dict[str, Any]],
|
||||
staging_dir: Path,
|
||||
@@ -280,11 +292,12 @@ async def create_or_reuse(
|
||||
backend_name = load_settings().runtime.backend
|
||||
backend = get_backend(backend_name)
|
||||
|
||||
staging_dir: Path | None = None
|
||||
if backend_supports_bind_mounts(backend_name):
|
||||
bind_mounts = build_bind_mounts(local_sources)
|
||||
entries: dict[str | Path, BaseEntry] = {}
|
||||
if extra_files:
|
||||
staging_dir = runtime_state_dir(run_dir_for(scan_id)) / "extra_files"
|
||||
staging_dir = extra_file_staging_dir(scan_id)
|
||||
bind_mounts.extend(
|
||||
build_extra_file_bind_mounts(extra_files, staging_dir, local_sources)
|
||||
)
|
||||
@@ -322,44 +335,56 @@ async def create_or_reuse(
|
||||
image,
|
||||
)
|
||||
report("Starting sandbox container")
|
||||
client, session = await backend(
|
||||
image=image,
|
||||
manifest=manifest,
|
||||
exposed_ports=(_CONTAINER_CAIDO_PORT,),
|
||||
bind_mounts=bind_mounts,
|
||||
)
|
||||
|
||||
report("Setting up the proxy")
|
||||
caido_endpoint = await session.resolve_exposed_port(_CONTAINER_CAIDO_PORT)
|
||||
scheme = "https" if caido_endpoint.tls else "http"
|
||||
host_caido_url = f"{scheme}://{caido_endpoint.host}:{caido_endpoint.port}"
|
||||
logger.debug("Caido host endpoint resolved: %s", host_caido_url)
|
||||
|
||||
# The Caido login + project setup polls the guest for a couple of seconds
|
||||
# and nothing needs the client before the first proxy tool call, so it
|
||||
# runs concurrently with the rest of scan start; consumers resolve the
|
||||
# handle at first use (see CaidoBootstrapHandle).
|
||||
caido_client = CaidoBootstrapHandle(
|
||||
asyncio.create_task(
|
||||
bootstrap_caido(
|
||||
session,
|
||||
host_url=host_caido_url,
|
||||
container_url=container_caido_url,
|
||||
),
|
||||
name=f"caido-bootstrap-{scan_id}",
|
||||
try:
|
||||
client, session = await backend(
|
||||
image=image,
|
||||
manifest=manifest,
|
||||
exposed_ports=(_CONTAINER_CAIDO_PORT,),
|
||||
bind_mounts=bind_mounts,
|
||||
)
|
||||
)
|
||||
|
||||
bundle = {
|
||||
"client": client,
|
||||
"session": session,
|
||||
"caido_client": caido_client,
|
||||
}
|
||||
_SESSION_CACHE[scan_id] = bundle
|
||||
report("Setting up the proxy")
|
||||
caido_endpoint = await session.resolve_exposed_port(_CONTAINER_CAIDO_PORT)
|
||||
scheme = "https" if caido_endpoint.tls else "http"
|
||||
host_caido_url = f"{scheme}://{caido_endpoint.host}:{caido_endpoint.port}"
|
||||
logger.debug("Caido host endpoint resolved: %s", host_caido_url)
|
||||
|
||||
# The Caido login + project setup polls the guest for a couple of seconds
|
||||
# and nothing needs the client before the first proxy tool call, so it
|
||||
# runs concurrently with the rest of scan start; consumers resolve the
|
||||
# handle at first use (see CaidoBootstrapHandle).
|
||||
caido_client = CaidoBootstrapHandle(
|
||||
asyncio.create_task(
|
||||
bootstrap_caido(
|
||||
session,
|
||||
host_url=host_caido_url,
|
||||
container_url=container_caido_url,
|
||||
),
|
||||
name=f"caido-bootstrap-{scan_id}",
|
||||
)
|
||||
)
|
||||
|
||||
bundle = {
|
||||
"client": client,
|
||||
"session": session,
|
||||
"caido_client": caido_client,
|
||||
"extra_file_staging_dir": staging_dir,
|
||||
}
|
||||
_SESSION_CACHE[scan_id] = bundle
|
||||
except BaseException:
|
||||
# Until the bundle is cached, cleanup(scan_id) cannot find the
|
||||
# staging dir, so it is removed here.
|
||||
_remove_staging_dir(staging_dir)
|
||||
raise
|
||||
logger.info("Sandbox session for scan %s ready and cached", scan_id)
|
||||
return bundle
|
||||
|
||||
|
||||
def _remove_staging_dir(staging_dir: Path | None) -> None:
|
||||
if staging_dir is not None:
|
||||
shutil.rmtree(staging_dir, ignore_errors=True)
|
||||
|
||||
|
||||
async def cleanup(scan_id: str) -> None:
|
||||
"""Tear down ``scan_id``'s container and drop its cache entry.
|
||||
|
||||
@@ -373,6 +398,8 @@ async def cleanup(scan_id: str) -> None:
|
||||
logger.debug("cleanup(%s): no cached session", scan_id)
|
||||
return
|
||||
|
||||
_remove_staging_dir(bundle.get("extra_file_staging_dir"))
|
||||
|
||||
caido_client = bundle.get("caido_client")
|
||||
if caido_client is not None:
|
||||
try:
|
||||
|
||||
@@ -13,6 +13,7 @@ from strix.tools.mcp.config import (
|
||||
McpAuth,
|
||||
McpConnectionConfig,
|
||||
)
|
||||
from strix.tools.mcp.failures import FailureInfo, HttpStatusRecorder, classify
|
||||
from strix.tools.mcp.loader import load_user_mcp_configs
|
||||
from strix.tools.mcp.naming import namespaced_tool_name
|
||||
from strix.tools.mcp.registry import (
|
||||
@@ -38,6 +39,8 @@ __all__ = [
|
||||
"MCP_REGISTRY_CONTEXT_KEY",
|
||||
"BearerAuth",
|
||||
"ConnectedMcpServer",
|
||||
"FailureInfo",
|
||||
"HttpStatusRecorder",
|
||||
"McpAuth",
|
||||
"McpCallInfo",
|
||||
"McpConnectionConfig",
|
||||
@@ -50,6 +53,7 @@ __all__ = [
|
||||
"SupervisedMcpSession",
|
||||
"attach_mcp_requests",
|
||||
"call_mcp",
|
||||
"classify",
|
||||
"connect_mcp_servers",
|
||||
"describe_mcp",
|
||||
"list_mcps",
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -30,13 +30,17 @@ from agents.mcp import (
|
||||
create_static_tool_filter,
|
||||
)
|
||||
from mcp.client.stdio import stdio_client
|
||||
from mcp.shared._httpx_utils import create_mcp_http_client
|
||||
|
||||
from strix.tools.mcp.failures import HttpStatusRecorder
|
||||
from strix.tools.mcp.session import McpConnectionUnavailableError, SupervisedMcpSession
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
import httpx
|
||||
|
||||
from strix.tools.mcp.config import McpConnectionConfig
|
||||
from strix.tools.mcp.registry import McpConnectionRequest, McpRegistry
|
||||
|
||||
@@ -71,6 +75,13 @@ class ConnectedMcpServer(NamedTuple):
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class BuiltMcpServer(NamedTuple):
|
||||
"""A constructed SDK server and its optional HTTP failure recorder."""
|
||||
|
||||
server: MCPServer
|
||||
recorder: HttpStatusRecorder | None
|
||||
|
||||
|
||||
def _auth_headers(config: McpConnectionConfig) -> dict[str, str]:
|
||||
"""Build the per-server request headers from the connection's auth."""
|
||||
auth = config.auth
|
||||
@@ -108,9 +119,12 @@ class _QuietMCPServerStdio(MCPServerStdio):
|
||||
return _quiet_stdio_streams(self.params)
|
||||
|
||||
|
||||
def _build_server(config: McpConnectionConfig) -> MCPServer:
|
||||
def _build_server(config: McpConnectionConfig) -> BuiltMcpServer:
|
||||
"""Construct (but do not connect) the SDK server for one connection.
|
||||
|
||||
The returned tuple carries the server and, for HTTP connections, a recorder
|
||||
that retains sanitized response metadata for the owning session.
|
||||
|
||||
When ``allowed_tools`` is a list the static filter means the server will not
|
||||
even list tools outside it, so it is the authoritative gate on what
|
||||
``describe_mcp`` and ``call_mcp`` can see. When it is ``None`` no filter is
|
||||
@@ -128,22 +142,43 @@ def _build_server(config: McpConnectionConfig) -> MCPServer:
|
||||
"args": config.args,
|
||||
"env": config.env,
|
||||
}
|
||||
return _QuietMCPServerStdio(
|
||||
params=stdio_params,
|
||||
name=config.name,
|
||||
tool_filter=tool_filter,
|
||||
cache_tools_list=True,
|
||||
return BuiltMcpServer(
|
||||
_QuietMCPServerStdio(
|
||||
params=stdio_params,
|
||||
name=config.name,
|
||||
tool_filter=tool_filter,
|
||||
cache_tools_list=True,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
recorder = HttpStatusRecorder()
|
||||
|
||||
def httpx_client_factory(
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: httpx.Timeout | None = None,
|
||||
auth: httpx.Auth | None = None,
|
||||
) -> httpx.AsyncClient:
|
||||
client = create_mcp_http_client(headers=headers, timeout=timeout, auth=auth)
|
||||
client.event_hooks.setdefault("response", []).append(recorder)
|
||||
return client
|
||||
|
||||
http_params: MCPServerStreamableHttpParams = {
|
||||
"url": cast("str", config.url),
|
||||
"headers": _auth_headers(config),
|
||||
"timeout": config.http_timeout_seconds,
|
||||
"sse_read_timeout": config.sse_read_timeout_seconds,
|
||||
"httpx_client_factory": httpx_client_factory,
|
||||
}
|
||||
return MCPServerStreamableHttp(
|
||||
params=http_params,
|
||||
name=config.name,
|
||||
tool_filter=tool_filter,
|
||||
cache_tools_list=True,
|
||||
return BuiltMcpServer(
|
||||
MCPServerStreamableHttp(
|
||||
params=http_params,
|
||||
name=config.name,
|
||||
tool_filter=tool_filter,
|
||||
cache_tools_list=True,
|
||||
client_session_timeout_seconds=config.session_timeout_seconds,
|
||||
),
|
||||
recorder,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ from typing import Annotated, Literal
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
DEFAULT_MAX_CONCURRENT_CALLS = 4
|
||||
|
||||
|
||||
class BearerAuth(BaseModel):
|
||||
"""Header-token auth, sent as ``Authorization: Bearer <token>``."""
|
||||
|
||||
@@ -65,6 +68,18 @@ class McpConnectionConfig(BaseModel):
|
||||
MCP inventory every agent renders in its prompt, so it describes the
|
||||
connection once rather than being repeated onto each of its tools."""
|
||||
|
||||
http_timeout_seconds: float = Field(default=30.0, gt=0)
|
||||
"""HTTP request timeout; the SDK's 5-second default is below tool p95s."""
|
||||
|
||||
sse_read_timeout_seconds: float = Field(default=300.0, gt=0)
|
||||
"""Stream read timeout; the SDK's 5-second default is below tool p95s."""
|
||||
|
||||
session_timeout_seconds: float = Field(default=60.0, gt=0)
|
||||
"""MCP operation timeout for SQL queries and cloud describe fan-outs."""
|
||||
|
||||
max_concurrent_calls: int = Field(default=DEFAULT_MAX_CONCURRENT_CALLS, ge=1)
|
||||
"""Maximum concurrent calls for this connection name across sessions."""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_transport_fields(self) -> McpConnectionConfig:
|
||||
if self.transport == "http" and not self.url:
|
||||
|
||||
150
strix/tools/mcp/failures.py
Normal file
150
strix/tools/mcp/failures.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""Classify MCP connection failures without retaining sensitive request data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from email.utils import parsedate_to_datetime
|
||||
from typing import Literal, cast
|
||||
|
||||
import httpx
|
||||
from agents.exceptions import UserError
|
||||
from mcp.shared.exceptions import McpError
|
||||
|
||||
|
||||
FailureKind = Literal[
|
||||
"auth", "permission", "rate_limit", "server", "transport", "timeout", "protocol", "unknown"
|
||||
]
|
||||
|
||||
_PRIORITY: dict[FailureKind, int] = {
|
||||
"auth": 0,
|
||||
"permission": 1,
|
||||
"rate_limit": 2,
|
||||
"server": 3,
|
||||
"protocol": 4,
|
||||
"timeout": 5,
|
||||
"transport": 6,
|
||||
"unknown": 7,
|
||||
}
|
||||
_HTTP_ERROR_RE = re.compile(r"\bHTTP error\s+(\d{3})\b", re.IGNORECASE)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FailureInfo:
|
||||
"""A non-sensitive description of one connection failure."""
|
||||
|
||||
kind: FailureKind
|
||||
status: int | None = None
|
||||
reason: str | None = None
|
||||
retry_after: float | None = None
|
||||
request_method: str | None = None
|
||||
request_path: str | None = None
|
||||
|
||||
@property
|
||||
def retryable(self) -> bool:
|
||||
return self.kind not in {"auth", "permission"}
|
||||
|
||||
|
||||
def _retry_after(value: str | None) -> float | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return max(0.0, float(value))
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
date = parsedate_to_datetime(value)
|
||||
if date.tzinfo is None:
|
||||
date = date.replace(tzinfo=UTC)
|
||||
return max(0.0, (date - datetime.now(UTC)).total_seconds())
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
|
||||
|
||||
def _from_status(
|
||||
status: int,
|
||||
reason: str | None = None,
|
||||
retry_after: float | None = None,
|
||||
*,
|
||||
request_method: str | None = None,
|
||||
request_path: str | None = None,
|
||||
) -> FailureInfo:
|
||||
if status == 401:
|
||||
kind: FailureKind = "auth"
|
||||
elif status == 403:
|
||||
kind = "permission"
|
||||
elif status == 429:
|
||||
kind = "rate_limit"
|
||||
elif 500 <= status <= 599:
|
||||
kind = "server"
|
||||
elif 400 <= status <= 499:
|
||||
kind = "protocol"
|
||||
else:
|
||||
kind = "unknown"
|
||||
return FailureInfo(
|
||||
kind,
|
||||
status,
|
||||
reason,
|
||||
retry_after,
|
||||
request_method,
|
||||
request_path,
|
||||
)
|
||||
|
||||
|
||||
def _direct(exc: BaseException) -> FailureInfo | None:
|
||||
if isinstance(exc, httpx.HTTPStatusError):
|
||||
response = exc.response
|
||||
request = response.request
|
||||
return _from_status(
|
||||
response.status_code,
|
||||
response.reason_phrase,
|
||||
_retry_after(response.headers.get("Retry-After")),
|
||||
request_method=request.method,
|
||||
request_path=request.url.path,
|
||||
)
|
||||
if isinstance(exc, httpx.TimeoutException):
|
||||
return FailureInfo("timeout", reason="request timed out")
|
||||
if isinstance(exc, httpx.TransportError):
|
||||
return FailureInfo("transport", reason="transport error")
|
||||
if isinstance(exc, McpError):
|
||||
return FailureInfo("protocol", reason="MCP protocol error")
|
||||
if isinstance(exc, UserError):
|
||||
match = _HTTP_ERROR_RE.search(str(exc))
|
||||
if match:
|
||||
return _from_status(int(match.group(1)))
|
||||
return None
|
||||
|
||||
|
||||
def classify(exc: BaseException) -> FailureInfo:
|
||||
"""Return the most specific non-sensitive classification in an exception tree."""
|
||||
direct = _direct(exc)
|
||||
matches: list[FailureInfo] = [direct] if direct is not None else []
|
||||
if isinstance(exc, BaseExceptionGroup):
|
||||
group = cast("BaseExceptionGroup[BaseException]", exc)
|
||||
matches.extend(classify(child) for child in group.exceptions)
|
||||
if matches:
|
||||
return min(matches, key=lambda info: _PRIORITY[info.kind])
|
||||
return FailureInfo("unknown", reason="unknown failure")
|
||||
|
||||
|
||||
class HttpStatusRecorder:
|
||||
"""Capture the last non-success response from one HTTP connection."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._failure: FailureInfo | None = None
|
||||
|
||||
async def __call__(self, response: httpx.Response) -> None:
|
||||
if not 200 <= response.status_code < 300:
|
||||
request = response.request
|
||||
self._failure = _from_status(
|
||||
response.status_code,
|
||||
response.reason_phrase,
|
||||
_retry_after(response.headers.get("Retry-After")),
|
||||
request_method=request.method,
|
||||
request_path=request.url.path,
|
||||
)
|
||||
|
||||
def take(self) -> FailureInfo | None:
|
||||
failure, self._failure = self._failure, None
|
||||
return failure
|
||||
@@ -28,10 +28,13 @@ lifetime, and ``cleanup()``. Three consequences:
|
||||
"connection unavailable" value instead of a cancellation propagating into the
|
||||
agent loop.
|
||||
|
||||
When a call fails the supervisor rebuilds and reconnects the session once (reusing
|
||||
the same config, so the same bearer token, never re-fetching credentials) and
|
||||
re-runs the one failed call once. If that still fails, the connection is marked
|
||||
dead: every later call returns the standard failed-tool output.
|
||||
Failure handling follows connection-pool discipline: discard on error, rebuild on
|
||||
next use. A failure while connecting or rebuilding describes the session. A
|
||||
non-2xx response from a tool call describes that request, not the session. Permission
|
||||
and protocol failures from a call return a failed tool output while the connection
|
||||
stays usable. Other classified failures are retried on the rebuilt session and then,
|
||||
if they keep failing, temporarily quarantine the connection. Authentication failures
|
||||
and repeated transient exhaustion permanently retire a connection.
|
||||
|
||||
Security: the connection's :class:`~strix.tools.mcp.config.McpConnectionConfig`
|
||||
holds a live bearer credential and is kept here in memory only, on the same
|
||||
@@ -46,7 +49,13 @@ import asyncio
|
||||
import contextlib
|
||||
import dataclasses
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
import secrets
|
||||
import time
|
||||
import weakref
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
|
||||
from strix.tools.mcp.config import DEFAULT_MAX_CONCURRENT_CALLS
|
||||
from strix.tools.mcp.failures import FailureInfo, HttpStatusRecorder, classify
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -63,6 +72,8 @@ if TYPE_CHECKING:
|
||||
# sessions), and its return value becomes the caller's result.
|
||||
Job = Callable[[MCPServer], Awaitable[Any]]
|
||||
|
||||
_Phase = Literal["connect", "call"]
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -70,13 +81,40 @@ logger = logging.getLogger(__name__)
|
||||
# before the supervising task is cancelled instead. Bounds teardown so a slow or
|
||||
# hung in-flight call cannot stall it forever.
|
||||
_SHUTDOWN_TIMEOUT = 10.0
|
||||
_MAX_ATTEMPTS = 3
|
||||
_SETTLE_DELAY = 0.05
|
||||
_SEMAPHORES: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, dict[str, asyncio.Semaphore]] = (
|
||||
weakref.WeakKeyDictionary()
|
||||
)
|
||||
_JITTER = secrets.SystemRandom()
|
||||
|
||||
# Everything the SDK can surface for a failed call: ordinary errors plus the
|
||||
# transport's task-group ``BaseExceptionGroup``. Caught wholesale and handed to
|
||||
# ``classify``; ``asyncio.CancelledError`` is always handled separately first,
|
||||
# so shutdown and genuine cancellation still propagate.
|
||||
_CLASSIFIABLE: tuple[type[BaseException], ...] = (BaseExceptionGroup, Exception)
|
||||
|
||||
|
||||
def _retry_delay(attempt: int, retry_after: float | None) -> float:
|
||||
if retry_after is not None:
|
||||
return retry_after
|
||||
base = min(8.0, 0.5 * (2 ** (attempt - 1)))
|
||||
return base + _JITTER.uniform(0.0, base * 0.1) # type: ignore[no-any-return]
|
||||
|
||||
|
||||
def _call_semaphore(name: str, limit: int) -> asyncio.Semaphore:
|
||||
loop = asyncio.get_running_loop()
|
||||
semaphores = _SEMAPHORES.setdefault(loop, {})
|
||||
return semaphores.setdefault(name, asyncio.Semaphore(limit))
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
@@ -84,10 +122,11 @@ class McpConnectionUnavailableError(RuntimeError):
|
||||
|
||||
@dataclasses.dataclass
|
||||
class _Outcome:
|
||||
"""What running one job resolved to: a value, or the connection being dead."""
|
||||
"""What running one job resolved to: a value, a call failure, or a dead connection."""
|
||||
|
||||
value: Any = None
|
||||
dead: bool = False
|
||||
call_failure: FailureInfo | None = None
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
@@ -96,6 +135,7 @@ class _Request:
|
||||
|
||||
job: Job
|
||||
future: asyncio.Future[_Outcome]
|
||||
phase: _Phase
|
||||
|
||||
|
||||
class SupervisedMcpSession:
|
||||
@@ -129,11 +169,12 @@ class SupervisedMcpSession:
|
||||
self._dead = False
|
||||
self._closing = False
|
||||
self._on_dead: Callable[[], None] | None = None
|
||||
# Guards the idle-death self-heal against a flapping server: set after an
|
||||
# idle reconnect, cleared once a real call runs. If the session dies idle
|
||||
# again before serving anything, we give up instead of reconnecting in a
|
||||
# tight loop.
|
||||
self._healed_without_progress = False
|
||||
self._recorder: HttpStatusRecorder | None = None
|
||||
self._unavailable_until: float | None = None
|
||||
self._quarantine_count = 0
|
||||
self._last_failure = FailureInfo("unknown", reason="connection unavailable")
|
||||
self._reconnect_lock = asyncio.Lock()
|
||||
self._call_semaphore: asyncio.Semaphore | None = None
|
||||
|
||||
@classmethod
|
||||
def adopt(
|
||||
@@ -147,7 +188,7 @@ class SupervisedMcpSession:
|
||||
|
||||
Calls run inline against ``server`` on the caller's task, matching the old
|
||||
direct-dispatch behavior. Reconnect is available only when ``config`` is
|
||||
given; otherwise a failed call marks the connection dead.
|
||||
given; otherwise a failed call can be quarantined but cannot be revived.
|
||||
"""
|
||||
self = cls.__new__(cls)
|
||||
self._name = name
|
||||
@@ -161,7 +202,12 @@ class SupervisedMcpSession:
|
||||
self._dead = False
|
||||
self._closing = False
|
||||
self._on_dead = None
|
||||
self._healed_without_progress = False
|
||||
self._recorder = None
|
||||
self._unavailable_until = None
|
||||
self._quarantine_count = 0
|
||||
self._last_failure = FailureInfo("unknown", reason="connection unavailable")
|
||||
self._reconnect_lock = asyncio.Lock()
|
||||
self._call_semaphore = None
|
||||
return self
|
||||
|
||||
# -- read-only accessors --------------------------------------------------
|
||||
@@ -185,6 +231,15 @@ class SupervisedMcpSession:
|
||||
def is_dead(self) -> bool:
|
||||
return self._dead
|
||||
|
||||
@property
|
||||
def is_unavailable(self) -> bool:
|
||||
"""Whether the connection is temporarily quarantined."""
|
||||
return (
|
||||
not self._dead
|
||||
and self._unavailable_until is not None
|
||||
and time.monotonic() < self._unavailable_until
|
||||
)
|
||||
|
||||
def set_on_dead(self, callback: Callable[[], None] | None) -> None:
|
||||
"""Register a one-shot callback fired when the connection transitions to dead.
|
||||
|
||||
@@ -198,11 +253,22 @@ class SupervisedMcpSession:
|
||||
"""
|
||||
self._on_dead = callback
|
||||
|
||||
def _mark_dead(self) -> None:
|
||||
def _mark_dead(self, failure: FailureInfo | None = None, *, attempt: int = 1) -> None:
|
||||
"""Flip the connection to dead and fire ``on_dead`` once on the transition."""
|
||||
if self._dead:
|
||||
return
|
||||
failure = failure or self._last_failure
|
||||
self._dead = True
|
||||
self._unavailable_until = None
|
||||
logger.error(
|
||||
"MCP connection %r permanently unavailable kind=%s status=%s reason=%s "
|
||||
"attempt=%d delay=0",
|
||||
self._name,
|
||||
failure.kind,
|
||||
failure.status,
|
||||
failure.reason,
|
||||
attempt,
|
||||
)
|
||||
callback = self._on_dead
|
||||
if callback is None:
|
||||
return
|
||||
@@ -280,13 +346,16 @@ class SupervisedMcpSession:
|
||||
# -- caller-facing operations --------------------------------------------
|
||||
|
||||
async def list_tools(self) -> list[MCPTool]:
|
||||
"""List the connection's tools, reconnecting once if the session died.
|
||||
"""List the connection's tools, retrying transient session failures.
|
||||
|
||||
Raises :class:`McpConnectionUnavailableError` when the connection is dead.
|
||||
Raises :class:`McpConnectionUnavailableError` when the connection is dead
|
||||
and never returns a call failure.
|
||||
"""
|
||||
outcome = await self._run_job(lambda server: server.list_tools())
|
||||
outcome = await self._run_job(lambda server: server.list_tools(), phase="connect")
|
||||
if outcome.dead:
|
||||
raise McpConnectionUnavailableError(self._unavailable_message())
|
||||
if outcome.call_failure is not None:
|
||||
raise RuntimeError("MCP list_tools returned a call failure")
|
||||
return cast("list[MCPTool]", outcome.value)
|
||||
|
||||
async def dispatch(
|
||||
@@ -297,11 +366,12 @@ class SupervisedMcpSession:
|
||||
label: str,
|
||||
result_transform: ResultTransform | None = None,
|
||||
) -> Any:
|
||||
"""Run one tool call, reconnecting once and retrying once on session death.
|
||||
"""Run one tool call with bounded retries for transient session failures.
|
||||
|
||||
Returns the tool output on success, or the standard failed-tool output
|
||||
(``success: False``) with a "connection unavailable" message when the
|
||||
connection is dead.
|
||||
(``success: False``) when the provider rejects the call or the connection
|
||||
is unavailable. A call rejection keeps the connection usable because the
|
||||
provider rejected the request, not the session.
|
||||
"""
|
||||
from strix.tools.mcp.client import dispatch_mcp_call
|
||||
|
||||
@@ -314,7 +384,11 @@ class SupervisedMcpSession:
|
||||
result_transform=result_transform,
|
||||
)
|
||||
|
||||
outcome = await self._run_job(job)
|
||||
outcome = await self._run_job(job, phase="call")
|
||||
if outcome.call_failure is not None:
|
||||
from strix.tools.mcp.client import _errored_tool_output
|
||||
|
||||
return _errored_tool_output(self._call_rejected_message(outcome.call_failure))
|
||||
if outcome.dead:
|
||||
from strix.tools.mcp.client import _errored_tool_output
|
||||
|
||||
@@ -323,13 +397,13 @@ class SupervisedMcpSession:
|
||||
|
||||
# -- job routing ----------------------------------------------------------
|
||||
|
||||
async def _run_job(self, job: Job) -> _Outcome:
|
||||
async def _run_job(self, job: Job, *, phase: _Phase) -> _Outcome:
|
||||
"""Route one job to the owning task (supervised) or run it inline (adopted)."""
|
||||
if self._supervised:
|
||||
return await self._submit(job)
|
||||
return await self._execute(job)
|
||||
return await self._submit(job, phase)
|
||||
return await self._execute(job, phase)
|
||||
|
||||
async def _submit(self, job: Job) -> _Outcome:
|
||||
async def _submit(self, job: Job, phase: _Phase) -> _Outcome:
|
||||
"""Hand a job to the supervising task and await its result as a value."""
|
||||
if self._dead or self._closing or self._task is None or self._task.done():
|
||||
return _Outcome(dead=True)
|
||||
@@ -339,7 +413,7 @@ class SupervisedMcpSession:
|
||||
if self._queue is None:
|
||||
self._pending.discard(future)
|
||||
return _Outcome(dead=True)
|
||||
await self._queue.put(_Request(job=job, future=future))
|
||||
await self._queue.put(_Request(job=job, future=future, phase=phase))
|
||||
# The task may have ended between the guard above and the put; ``_fail_pending``
|
||||
# would then never see this future, so resolve it here.
|
||||
if self._task.done() and not future.done():
|
||||
@@ -361,8 +435,15 @@ class SupervisedMcpSession:
|
||||
await self._safe_cleanup()
|
||||
self._fail_pending()
|
||||
return
|
||||
except Exception:
|
||||
logger.exception("Skipping MCP connection %r", self._name)
|
||||
except _CLASSIFIABLE as exc:
|
||||
failure = classify(exc)
|
||||
logger.warning(
|
||||
"Skipping MCP connection %r kind=%s status=%s attempt=1 delay=0",
|
||||
self._name,
|
||||
failure.kind,
|
||||
failure.status,
|
||||
exc_info=True,
|
||||
)
|
||||
self._report_ready(value=False)
|
||||
await self._safe_cleanup()
|
||||
self._fail_pending()
|
||||
@@ -384,109 +465,228 @@ class SupervisedMcpSession:
|
||||
# A cancellation while idle is the transport's task group cancelling
|
||||
# this supervising task because a background session task failed.
|
||||
# Contained here. If we are closing, this is an ordinary shutdown,
|
||||
# so let it propagate. Otherwise try to self-heal once: reconnect a
|
||||
# fresh session and keep serving. The flag stops a flapping server
|
||||
# (one that dies again before serving any call) from reconnecting in
|
||||
# a tight loop; there we give up and mark the connection dead. Later
|
||||
# calls then short-circuit to the dead output without this task.
|
||||
# so let it propagate. Otherwise quarantine the failed session and
|
||||
# keep serving requests so a later call can revive it.
|
||||
if self._closing:
|
||||
raise
|
||||
if not self._healed_without_progress:
|
||||
logger.warning(
|
||||
"MCP connection %r session died while idle; reconnecting once",
|
||||
self._name,
|
||||
)
|
||||
if await self._reconnect():
|
||||
logger.info(
|
||||
"MCP connection %r reconnected after an idle death", self._name
|
||||
)
|
||||
self._healed_without_progress = True
|
||||
continue
|
||||
else:
|
||||
logger.warning(
|
||||
"MCP connection %r died again before serving a call; "
|
||||
"marking it unavailable",
|
||||
self._name,
|
||||
)
|
||||
self._mark_dead()
|
||||
await self._safe_cleanup()
|
||||
return
|
||||
failure = self._recorder.take() if self._recorder is not None else None
|
||||
failure = failure or FailureInfo("transport", reason="session cancelled")
|
||||
self._last_failure = failure
|
||||
if failure.kind in {"auth", "permission"}:
|
||||
self._mark_dead(failure, attempt=1)
|
||||
return
|
||||
await self._quarantine(failure, attempt=1)
|
||||
if self._dead:
|
||||
return
|
||||
continue
|
||||
if request is None: # shutdown sentinel
|
||||
return
|
||||
outcome = await self._execute(request.job)
|
||||
# A served call is real progress: clear the idle-heal guard so a future
|
||||
# idle death is again allowed one reconnect.
|
||||
self._healed_without_progress = False
|
||||
outcome = await self._execute(request.job, request.phase)
|
||||
if not request.future.done():
|
||||
request.future.set_result(outcome)
|
||||
self._pending.discard(request.future)
|
||||
if self._dead:
|
||||
return
|
||||
|
||||
# -- run one job with reconnect-once + retry-once -------------------------
|
||||
# -- run one job with bounded classified retries --------------------------
|
||||
|
||||
async def _execute(self, job: Job) -> _Outcome:
|
||||
"""Run one job; on a session failure reconnect once and retry it once."""
|
||||
if self._dead or self._server is None:
|
||||
async def _execute(self, job: Job, phase: _Phase) -> _Outcome: # noqa: PLR0912
|
||||
"""Run one job on a healthy session, disposing it the instant it errors.
|
||||
|
||||
Discard-on-error, rebuild-on-next-use is the whole discipline here, and it
|
||||
rests on one invariant: **a session object is only ever awaited while
|
||||
healthy.** The moment a call fails, the very next thing this method does,
|
||||
before any other ``await`` including the backoff sleep inside
|
||||
:meth:`_handle_failure`, is dispose that session on this task
|
||||
(:meth:`_safe_cleanup` runs the transport teardown and clears ``_server``).
|
||||
|
||||
Why the ordering is the crux, not a nicety: when a provider returns a non-2xx
|
||||
status mid-call, the streamable-HTTP transport's task group cancels its scope,
|
||||
which cancels this supervising task; the failure surfaces as a
|
||||
``CancelledError`` and the scope keeps firing (re-raising on every subsequent
|
||||
``await``) until the session is torn down. Disposing closes the transport's
|
||||
AsyncExitStack, which exits that firing scope. If instead we slept for backoff
|
||||
first, the sleep would re-raise the firing ``CancelledError``, escape this
|
||||
method, and kill the supervising task, leaving the slot wedged with
|
||||
``is_dead`` False forever. Disposing first is what turns a failure into a
|
||||
returned value and keeps the task alive to rebuild on the next attempt.
|
||||
|
||||
The rebuild itself happens lazily at the top of the loop: once a failure has
|
||||
set ``_server`` to None, the next iteration builds a fresh session (guarded by
|
||||
:meth:`_reconnect`) and retries the operation on it. A permission or protocol
|
||||
failure from a call returns immediately after disposal because it describes
|
||||
that request, not the session. A genuine shutdown (``_closing``) and a real
|
||||
external cancellation still propagate; only the transport's teardown
|
||||
cancellation is contained.
|
||||
"""
|
||||
if self._dead:
|
||||
return _Outcome(dead=True)
|
||||
try:
|
||||
return _Outcome(value=await job(self._server))
|
||||
except asyncio.CancelledError:
|
||||
# For a supervised session a cancellation here is the transport scope
|
||||
# dying under an in-flight call: a session death, not a real cancel
|
||||
# (shutdown never cancels the task, it uses the sentinel). For an
|
||||
# adopted session there is no such scope, so a cancel is real.
|
||||
if not self._supervised or self._closing:
|
||||
raise
|
||||
logger.warning(
|
||||
"MCP connection %r was cancelled mid-call (session died); reconnecting once",
|
||||
if self._unavailable_until is not None:
|
||||
remaining = self._unavailable_until - time.monotonic()
|
||||
if remaining > 0:
|
||||
return _Outcome(dead=True)
|
||||
self._unavailable_until = None
|
||||
logger.info(
|
||||
"MCP connection %r revive started kind=%s status=%s attempt=1",
|
||||
self._name,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - any call failure is treated as a session death
|
||||
logger.warning(
|
||||
"MCP connection %r failed mid-call; reconnecting once", self._name
|
||||
self._last_failure.kind,
|
||||
self._last_failure.status,
|
||||
)
|
||||
|
||||
if not await self._reconnect():
|
||||
self._mark_dead()
|
||||
return _Outcome(dead=True)
|
||||
|
||||
try:
|
||||
return _Outcome(value=await job(self._server))
|
||||
except asyncio.CancelledError:
|
||||
if not self._supervised or self._closing:
|
||||
raise
|
||||
logger.warning(
|
||||
"MCP connection %r was cancelled again after reconnect; marking it unavailable",
|
||||
if self._call_semaphore is None:
|
||||
self._call_semaphore = _call_semaphore(
|
||||
self._name,
|
||||
(
|
||||
self._config.max_concurrent_calls
|
||||
if self._config is not None
|
||||
else DEFAULT_MAX_CONCURRENT_CALLS
|
||||
),
|
||||
)
|
||||
self._mark_dead()
|
||||
return _Outcome(dead=True)
|
||||
except Exception: # noqa: BLE001 - any retry failure means the connection is dead
|
||||
logger.warning(
|
||||
"MCP connection %r failed again after reconnect; marking it unavailable",
|
||||
self._name,
|
||||
)
|
||||
self._mark_dead()
|
||||
return _Outcome(dead=True)
|
||||
failure: FailureInfo | None = None
|
||||
for attempt in range(1, _MAX_ATTEMPTS + 1):
|
||||
# Lazy, atomic rebuild: a prior failure disposed the session, so build a
|
||||
# fresh one here. The rebuild lock lets concurrent callers (adopted
|
||||
# sessions dispatched from several agent tasks) share one rebuild rather
|
||||
# than each building their own.
|
||||
if self._server is None:
|
||||
reconnected, reconnect_failure = await self._reconnect()
|
||||
if not reconnected:
|
||||
failure = reconnect_failure or FailureInfo(
|
||||
"transport", reason="reconnect failed"
|
||||
)
|
||||
outcome = await self._handle_failure(failure, attempt, phase="connect")
|
||||
if outcome is not None:
|
||||
return outcome
|
||||
continue
|
||||
assert self._server is not None
|
||||
call_semaphore = self._call_semaphore
|
||||
assert call_semaphore is not None
|
||||
try:
|
||||
async with call_semaphore:
|
||||
result = await job(self._server)
|
||||
# A success clears the quarantine strikes. A connection that
|
||||
# recovered and served a call is healthy again, so transient
|
||||
# failure bursts separated by successful revivals must not
|
||||
# accumulate toward permanent retirement; only sustained failure
|
||||
# with no success in between should retire the connection.
|
||||
self._quarantine_count = 0
|
||||
return _Outcome(value=result)
|
||||
except asyncio.CancelledError:
|
||||
if not self._supervised or self._closing:
|
||||
raise
|
||||
failure = (
|
||||
self._recorder.take() if self._recorder is not None else None
|
||||
) or FailureInfo("transport", reason="session cancelled")
|
||||
# Dispose BEFORE any other await. The transport's cancel scope may be
|
||||
# firing right now; _safe_cleanup exits it so the backoff sleep below
|
||||
# cannot re-raise the cancellation and kill this task. See the
|
||||
# method docstring for why this ordering is load-bearing.
|
||||
await self._safe_cleanup()
|
||||
except _CLASSIFIABLE as exc:
|
||||
failure = classify(exc)
|
||||
if failure.kind == "unknown" and self._recorder is not None:
|
||||
failure = self._recorder.take() or failure
|
||||
# Dispose BEFORE any other await, same reason as the branch above:
|
||||
# never await on a session that has already errored.
|
||||
await self._safe_cleanup()
|
||||
|
||||
async def _reconnect(self) -> bool:
|
||||
"""Rebuild and reconnect the session once, reusing the stored config/token."""
|
||||
# Session is disposed and _server is None; _handle_failure may sleep for
|
||||
# backoff safely, and the next loop iteration rebuilds and retries.
|
||||
outcome = await self._handle_failure(failure, attempt, phase=phase)
|
||||
if outcome is not None:
|
||||
return outcome
|
||||
return _Outcome(dead=True)
|
||||
|
||||
async def _handle_failure(
|
||||
self, failure: FailureInfo, attempt: int, *, phase: _Phase
|
||||
) -> _Outcome | None:
|
||||
self._last_failure = failure
|
||||
if failure.kind == "auth":
|
||||
self._mark_dead(failure, attempt=attempt)
|
||||
return _Outcome(dead=True)
|
||||
if failure.kind == "permission":
|
||||
if phase == "call":
|
||||
return _Outcome(call_failure=failure)
|
||||
self._mark_dead(failure, attempt=attempt)
|
||||
return _Outcome(dead=True)
|
||||
if phase == "call" and failure.kind == "protocol":
|
||||
return _Outcome(call_failure=failure)
|
||||
if attempt == _MAX_ATTEMPTS:
|
||||
await self._quarantine(failure, attempt=attempt)
|
||||
return _Outcome(dead=True)
|
||||
delay = _retry_delay(attempt, failure.retry_after)
|
||||
self._log_retry(failure, attempt, delay)
|
||||
await asyncio.sleep(delay)
|
||||
return None
|
||||
|
||||
def _log_retry(self, failure: FailureInfo, attempt: int, delay: float) -> None:
|
||||
logger.warning(
|
||||
"MCP connection %r retryable failure kind=%s status=%s attempt=%d delay=%.2f",
|
||||
self._name,
|
||||
failure.kind,
|
||||
failure.status,
|
||||
attempt,
|
||||
delay,
|
||||
)
|
||||
|
||||
async def _quarantine(self, failure: FailureInfo, *, attempt: int) -> None:
|
||||
await self._safe_cleanup()
|
||||
if self._config is None:
|
||||
return False
|
||||
try:
|
||||
self._server = await self._open()
|
||||
except asyncio.CancelledError:
|
||||
if self._closing:
|
||||
raise
|
||||
logger.warning("MCP reconnect for %r was cancelled; giving up", self._name)
|
||||
self._server = None
|
||||
return False
|
||||
except Exception:
|
||||
logger.exception("MCP reconnect for %r failed", self._name)
|
||||
self._server = None
|
||||
return False
|
||||
logger.info("MCP connection %r reconnected", self._name)
|
||||
return True
|
||||
self._quarantine_count += 1
|
||||
if self._quarantine_count >= 3:
|
||||
self._mark_dead(failure, attempt=attempt)
|
||||
return
|
||||
cooldown = 30.0 * (2 ** (self._quarantine_count - 1))
|
||||
self._unavailable_until = time.monotonic() + cooldown
|
||||
logger.warning(
|
||||
"MCP connection %r quarantined kind=%s status=%s attempt=%d delay=%.2f",
|
||||
self._name,
|
||||
failure.kind,
|
||||
failure.status,
|
||||
attempt,
|
||||
cooldown,
|
||||
)
|
||||
|
||||
async def _reconnect(self) -> tuple[bool, FailureInfo | None]:
|
||||
"""Build a fresh session under the rebuild lock, so concurrent callers share one.
|
||||
|
||||
Called only when ``_server`` is None (a prior failure already disposed the old
|
||||
session). The lock serializes rebuilds; a caller that finds the session already
|
||||
rebuilt by whoever held the lock first reuses it instead of building a second
|
||||
one. There is deliberately no cleanup of an existing ``_server`` here: this
|
||||
method never runs against a live session, because the failure path disposes
|
||||
before it ever reaches a rebuild.
|
||||
"""
|
||||
async with self._reconnect_lock:
|
||||
if self._server is not None:
|
||||
# Another caller rebuilt while we waited for the lock; share it.
|
||||
return True, None
|
||||
if self._config is None:
|
||||
return False, FailureInfo("transport", reason="no reconnect config")
|
||||
try:
|
||||
server = await self._open()
|
||||
except asyncio.CancelledError:
|
||||
if self._closing:
|
||||
raise
|
||||
self._server = None
|
||||
return False, FailureInfo("transport", reason="reconnect cancelled")
|
||||
except _CLASSIFIABLE as exc:
|
||||
self._server = None
|
||||
failure = classify(exc)
|
||||
if failure.kind == "unknown" and self._recorder is not None:
|
||||
failure = self._recorder.take() or failure
|
||||
return False, failure
|
||||
# connect() is the only readiness surface exposed by the SDK.
|
||||
self._server = server
|
||||
try:
|
||||
await asyncio.sleep(_SETTLE_DELAY)
|
||||
except asyncio.CancelledError:
|
||||
# Dispose the just-built session before returning; _safe_cleanup
|
||||
# re-raises when we are shutting down and absorbs otherwise.
|
||||
await self._safe_cleanup()
|
||||
if self._closing:
|
||||
raise
|
||||
return False, FailureInfo("transport", reason="reconnect cancelled")
|
||||
return True, None
|
||||
|
||||
async def _open(self) -> MCPServer:
|
||||
"""Build and connect the SDK server, reusing the existing setup steps.
|
||||
@@ -499,10 +699,16 @@ class SupervisedMcpSession:
|
||||
|
||||
if self._config is None:
|
||||
raise RuntimeError(f"MCP connection {self._name!r} has no config to connect")
|
||||
server = _build_server(self._config)
|
||||
built = _build_server(self._config)
|
||||
server = built.server
|
||||
self._recorder = built.recorder
|
||||
try:
|
||||
await server.connect() # type: ignore[no-untyped-call]
|
||||
except BaseException:
|
||||
except asyncio.CancelledError:
|
||||
with contextlib.suppress(Exception):
|
||||
await server.cleanup() # type: ignore[no-untyped-call]
|
||||
raise
|
||||
except _CLASSIFIABLE:
|
||||
with contextlib.suppress(Exception):
|
||||
await server.cleanup() # type: ignore[no-untyped-call]
|
||||
raise
|
||||
@@ -510,13 +716,74 @@ class SupervisedMcpSession:
|
||||
|
||||
# -- helpers --------------------------------------------------------------
|
||||
|
||||
def _call_rejected_message(self, failure: FailureInfo) -> str:
|
||||
if failure.kind == "permission":
|
||||
return (
|
||||
f"MCP connection {self._name!r} rejected this call (status={failure.status}): "
|
||||
"the provider denied this specific request, not the connection. The connection "
|
||||
"is still available. Check the arguments — resource and project identifiers, "
|
||||
"and required fields — and whether the configured credential is allowed to read "
|
||||
"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 "
|
||||
"connection. The connection is still available. Check the tool's required "
|
||||
"arguments and value formats with describe_mcp, then retry."
|
||||
)
|
||||
raise AssertionError(f"Unexpected call failure kind: {failure.kind}")
|
||||
|
||||
async def _safe_cleanup(self) -> None:
|
||||
"""Dispose the live session on this task, completing teardown even under a
|
||||
firing cancel scope.
|
||||
|
||||
Why this is delicate: the streamable-HTTP transport holds an anyio task group
|
||||
whose cancel scope was entered on this supervising task. When a background POST
|
||||
got a non-2xx status the SDK cancelled that scope, and until the scope is
|
||||
exited every ``await`` on this task re-raises ``CancelledError``.
|
||||
``server.cleanup()`` closes the AsyncExitStack that runs the task group's
|
||||
``__aexit__``, and that ``__aexit__`` is exactly what exits the scope and stops
|
||||
the firing; it also absorbs the scope's own cancellation internally, so the
|
||||
common case returns cleanly. A stray ``CancelledError`` can still surface,
|
||||
though, and ``contextlib.suppress(Exception)`` would let it through because
|
||||
``CancelledError`` is a ``BaseException``, not an ``Exception``.
|
||||
|
||||
So we catch ``CancelledError`` explicitly. During a real shutdown
|
||||
(``_closing``) that cancellation is the run going down and must propagate, so
|
||||
we re-raise it. Otherwise we absorb it and retry the close a bounded number of
|
||||
times: if a cleanup was interrupted before the exit stack finished unwinding,
|
||||
closing again continues from where it left off (the stack pops one callback at
|
||||
a time), so the scope still ends up exited and this task stays runnable for the
|
||||
next rebuild.
|
||||
"""
|
||||
server = self._server
|
||||
self._server = None
|
||||
if server is None:
|
||||
return
|
||||
with contextlib.suppress(Exception):
|
||||
await server.cleanup() # type: ignore[no-untyped-call]
|
||||
for _ in range(_MAX_ATTEMPTS):
|
||||
try:
|
||||
# suppress(Exception) absorbs an ordinary cleanup error but lets a
|
||||
# CancelledError through, because it is a BaseException; the outer
|
||||
# handler below is what decides whether to propagate or retry it.
|
||||
with contextlib.suppress(Exception):
|
||||
await server.cleanup() # type: ignore[no-untyped-call]
|
||||
except asyncio.CancelledError:
|
||||
if self._closing:
|
||||
raise
|
||||
# Firing scope hit the cleanup await before the stack finished
|
||||
# unwinding; swallow this cancellation and close again to complete
|
||||
# the teardown. A fully-closed stack makes the retry a clean no-op.
|
||||
continue
|
||||
else:
|
||||
return
|
||||
|
||||
def _report_ready(self, value: bool) -> None:
|
||||
if self._ready is not None and not self._ready.done():
|
||||
@@ -529,8 +796,15 @@ class SupervisedMcpSession:
|
||||
self._pending.clear()
|
||||
|
||||
def _unavailable_message(self) -> str:
|
||||
if self._unavailable_until is not None:
|
||||
remaining = max(0.0, self._unavailable_until - time.monotonic())
|
||||
return (
|
||||
f"MCP connection {self._name!r} is temporarily unavailable "
|
||||
f"(kind={self._last_failure.kind}, status={self._last_failure.status}); "
|
||||
f"retrying in about {remaining:.0f} seconds."
|
||||
)
|
||||
return (
|
||||
f"MCP connection {self._name!r} is unavailable: its live session could "
|
||||
"not be reached and a reconnect attempt failed. It is marked unavailable "
|
||||
"for the rest of this run."
|
||||
f"MCP connection {self._name!r} is unavailable "
|
||||
f"(kind={self._last_failure.kind}, status={self._last_failure.status}); "
|
||||
"it will not be retried."
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -23,9 +23,26 @@ def write_secret_text(path: Path, text: str) -> None:
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(text)
|
||||
except BaseException:
|
||||
with contextlib.suppress(OSError):
|
||||
tmp.unlink()
|
||||
except BaseException as exc:
|
||||
_cleanup_tmp(tmp, exc)
|
||||
raise
|
||||
|
||||
tmp.replace(path)
|
||||
try:
|
||||
tmp.replace(path)
|
||||
except BaseException as exc:
|
||||
_cleanup_tmp(tmp, exc)
|
||||
raise
|
||||
|
||||
|
||||
def _cleanup_tmp(tmp: Path, cause: BaseException) -> None:
|
||||
"""Delete the temporary secret file. A failed delete must not stay silent."""
|
||||
try:
|
||||
tmp.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError:
|
||||
message = (
|
||||
f"could not store the secret, and the temporary file {tmp} "
|
||||
f"still holds it. Delete the file manually."
|
||||
)
|
||||
raise OSError(message) from cause
|
||||
|
||||
@@ -228,7 +228,10 @@ def test_resume_still_requires_targets_or_a_workspace(
|
||||
|
||||
assert "has no targets_info" in capsys.readouterr().err
|
||||
|
||||
def test_resume_non_object_run_json_exits(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
|
||||
def test_resume_non_object_run_json_exits(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
run_dir = tmp_path / "strix_runs" / "pentest_abcd"
|
||||
run_dir.mkdir(parents=True)
|
||||
|
||||
3569
tests/test_cloud_cli.py
Normal file
3569
tests/test_cloud_cli.py
Normal file
File diff suppressed because it is too large
Load Diff
1119
tests/test_cloud_cli_runtime.py
Normal file
1119
tests/test_cloud_cli_runtime.py
Normal file
File diff suppressed because it is too large
Load Diff
203
tests/test_cloud_idempotency.py
Normal file
203
tests/test_cloud_idempotency.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""Durable retry behavior for managed scan-launch commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from strix.interface import cloud
|
||||
from strix.interface.cloud import http, runner
|
||||
from strix.interface.completions import completion_candidates
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload: Any, *, status_code: int = 200) -> None:
|
||||
self._payload = payload
|
||||
self.status_code = status_code
|
||||
self.headers = {"content-type": "application/json"}
|
||||
self.text = json.dumps(payload)
|
||||
self.closed = False
|
||||
|
||||
def json(self) -> Any:
|
||||
return self._payload
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
def test_scan_start_generates_and_sends_one_stable_key(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: Any,
|
||||
) -> None:
|
||||
seen: list[dict[str, Any]] = []
|
||||
monkeypatch.setattr(runner, "uuid4", lambda: "generated-key")
|
||||
|
||||
def request(_method: str, _path: str, **kwargs: Any) -> FakeResponse:
|
||||
seen.append(kwargs)
|
||||
return FakeResponse({"scan_id": "scan-1", "status": "running"})
|
||||
|
||||
monkeypatch.setattr(http, "request", request)
|
||||
assert cloud.run_cloud(["scans", "start", "--domain-ids", "domain-1", "--json"]) == 0
|
||||
assert json.loads(capsys.readouterr().out)["scan_id"] == "scan-1"
|
||||
assert len(seen) == 1
|
||||
assert seen[0]["idempotency_key"] == "generated-key"
|
||||
assert seen[0]["body"]["engagement_type"] == "live_test"
|
||||
|
||||
|
||||
def test_exact_transport_retry_reuses_key_and_body(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
seen: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
def request(_method: str, _path: str, **kwargs: Any) -> FakeResponse:
|
||||
seen.append((kwargs["idempotency_key"], kwargs["body"]))
|
||||
if len(seen) == 1:
|
||||
raise http.CloudTransportError("response lost")
|
||||
return FakeResponse({"scan_id": "scan-1", "status": "running"})
|
||||
|
||||
monkeypatch.setattr(http, "request", request)
|
||||
command = [
|
||||
"scans",
|
||||
"start",
|
||||
"--domain-ids",
|
||||
"domain-1",
|
||||
"--idempotency-key",
|
||||
"retry-key",
|
||||
"--json",
|
||||
]
|
||||
assert cloud.run_cloud(command) == 0
|
||||
assert len(seen) == 2
|
||||
assert seen[0] == seen[1]
|
||||
assert seen[0][0] == "retry-key"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload,status",
|
||||
[
|
||||
({"code": "idempotency_request_in_progress", "retry_safe": True}, 409),
|
||||
({"code": "idempotency_outcome_unknown", "retry_safe": True}, 503),
|
||||
({"detail": "gateway unavailable"}, 502),
|
||||
({"detail": "rate limited"}, 429),
|
||||
],
|
||||
)
|
||||
def test_retryable_responses_are_closed_and_replayed(
|
||||
payload: dict[str, Any],
|
||||
status: int,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
first = FakeResponse(payload, status_code=status)
|
||||
responses = iter((first, FakeResponse({"scan_id": "scan-1", "status": "running"})))
|
||||
keys: list[str] = []
|
||||
|
||||
def request(_method: str, _path: str, **kwargs: Any) -> FakeResponse:
|
||||
keys.append(kwargs["idempotency_key"])
|
||||
return next(responses)
|
||||
|
||||
monkeypatch.setattr(http, "request", request)
|
||||
assert (
|
||||
cloud.run_cloud(
|
||||
[
|
||||
"scans",
|
||||
"rerun",
|
||||
"scan-old",
|
||||
"--idempotency-key",
|
||||
"same-key",
|
||||
"--json",
|
||||
]
|
||||
)
|
||||
== 0
|
||||
)
|
||||
assert keys == ["same-key", "same-key"]
|
||||
assert first.closed is True
|
||||
|
||||
|
||||
def test_terminal_key_conflict_is_not_retried(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: Any,
|
||||
) -> None:
|
||||
calls = 0
|
||||
|
||||
def request(_method: str, _path: str, **_kwargs: Any) -> FakeResponse:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return FakeResponse(
|
||||
{
|
||||
"detail": "key belongs to another request",
|
||||
"code": "idempotency_key_conflict",
|
||||
"terminal": True,
|
||||
},
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(http, "request", request)
|
||||
assert (
|
||||
cloud.run_cloud(["scans", "rerun", "scan-old", "--idempotency-key", "conflict", "--json"])
|
||||
== http.EXIT_ERROR
|
||||
)
|
||||
assert calls == 1
|
||||
assert json.loads(capsys.readouterr().out)["code"] == "idempotency_key_conflict"
|
||||
|
||||
|
||||
def test_exhausted_ambiguous_launch_reports_safe_recovery_key(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: Any,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
http,
|
||||
"request",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(http.CloudTransportError("response lost")),
|
||||
)
|
||||
assert (
|
||||
cloud.run_cloud(["scans", "rerun", "scan-old", "--idempotency-key", "recover-me", "--json"])
|
||||
== http.EXIT_ERROR
|
||||
)
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert payload["idempotency_key"] == "recover-me"
|
||||
assert payload["retry_safe"] is True
|
||||
assert payload["retry_same_request"] is True
|
||||
assert "--idempotency-key recover-me" in payload["error"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["", " white", "bad key", "x\nheader", "x" * 201])
|
||||
def test_invalid_idempotency_key_is_usage_error_before_request(
|
||||
key: str,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(http, "request", lambda *_a, **_k: pytest.fail("must not request"))
|
||||
assert (
|
||||
cloud.run_cloud(["scans", "rerun", "scan-old", "--idempotency-key", key, "--json"])
|
||||
== http.EXIT_USAGE
|
||||
)
|
||||
|
||||
|
||||
def test_idempotency_flag_is_completed_only_for_keyed_commands() -> None:
|
||||
assert "--idempotency-key" in completion_candidates(["cloud", "scans", "start", "--idemp"])
|
||||
assert "--idempotency-key" in completion_candidates(
|
||||
["cloud", "scans", "rerun", "scan-1", "--idemp"]
|
||||
)
|
||||
assert "--idempotency-key" not in completion_candidates(["cloud", "scans", "list", "--idemp"])
|
||||
assert "--idempotency-key" in completion_candidates(["cloud", "schedules", "create", "--idemp"])
|
||||
assert "--idempotency-key" in completion_candidates(
|
||||
["cloud", "schedules", "trigger", "schedule-1", "--idemp"]
|
||||
)
|
||||
|
||||
|
||||
def test_http_client_places_key_in_the_header(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
seen: dict[str, Any] = {}
|
||||
|
||||
def request(_method: str, _url: str, **kwargs: Any) -> FakeResponse:
|
||||
seen.update(kwargs)
|
||||
return FakeResponse({"ok": True})
|
||||
|
||||
monkeypatch.setattr(http.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"
|
||||
140
tests/test_cloud_payment_proxy.py
Normal file
140
tests/test_cloud_payment_proxy.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Security tests for the wallet payment loopback bridge."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
|
||||
from strix.interface.cloud import payment_proxy
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
|
||||
class _StreamingResponse:
|
||||
status_code = 200
|
||||
|
||||
def __init__(self, chunks: list[bytes]) -> None:
|
||||
self.chunks = chunks
|
||||
self.closed = False
|
||||
self.headers = {"Content-Type": "application/json"}
|
||||
|
||||
def iter_content(self, *, chunk_size: int) -> Iterator[bytes]:
|
||||
assert chunk_size > 0
|
||||
yield from self.chunks
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _post(url: str, body: bytes, headers: dict[str, str] | None = None) -> bytes:
|
||||
request = urllib.request.Request( # noqa: S310
|
||||
url,
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json", **(headers or {})},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=2) as response: # noqa: S310
|
||||
return response.read()
|
||||
|
||||
|
||||
def test_bridge_bounds_decompressed_upstream_response(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
response = _StreamingResponse([b"1234", b"5"])
|
||||
|
||||
def fake_request(*_args: Any, **kwargs: Any) -> _StreamingResponse:
|
||||
assert kwargs["stream"] is True
|
||||
return response
|
||||
|
||||
monkeypatch.setattr(payment_proxy, "_MAX_UPSTREAM_RESPONSE_BYTES", 4)
|
||||
monkeypatch.setattr(payment_proxy.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
|
||||
expected_body=b"{}",
|
||||
) as wallet_url:
|
||||
request = urllib.request.Request( # noqa: S310
|
||||
wallet_url,
|
||||
data=b"{}",
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
||||
urllib.request.urlopen(request, timeout=2) # noqa: S310
|
||||
|
||||
assert exc_info.value.code == 502
|
||||
assert response.closed is True
|
||||
|
||||
|
||||
def test_bridge_forwards_only_the_approved_request_and_protected_headers(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: list[dict[str, Any]] = []
|
||||
observed: list[payment_proxy.WalletUpstreamResponse] = []
|
||||
|
||||
def fake_request(*_args: Any, **kwargs: Any) -> _StreamingResponse:
|
||||
captured.append(kwargs)
|
||||
return _StreamingResponse([b'{"ok":true}'])
|
||||
|
||||
monkeypatch.setattr(payment_proxy.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:
|
||||
result = _post(
|
||||
wallet_url,
|
||||
b'{"credits":5}',
|
||||
{
|
||||
"Authorization": "Payment wallet-proof",
|
||||
"Proxy-Authorization": "Basic drop-me",
|
||||
"X-Strix-Authorization": "Bearer attacker",
|
||||
"X-Strix-Workspace": "org_attacker",
|
||||
},
|
||||
)
|
||||
|
||||
assert result == b'{"ok":true}'
|
||||
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 == [
|
||||
payment_proxy.WalletUpstreamResponse(status_code=200, body=b'{"ok":true}')
|
||||
]
|
||||
|
||||
with pytest.raises(urllib.error.HTTPError) as wrong_body:
|
||||
_post(wallet_url, b'{"credits":500}')
|
||||
assert wrong_body.value.code == 403
|
||||
assert len(captured) == 1
|
||||
|
||||
|
||||
def test_bridge_limits_valid_wallet_attempts(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls = 0
|
||||
|
||||
def fake_request(*_args: Any, **_kwargs: Any) -> _StreamingResponse:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return _StreamingResponse([b"{}"])
|
||||
|
||||
monkeypatch.setattr(payment_proxy.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
|
||||
expected_body=b"{}",
|
||||
) as wallet_url:
|
||||
assert _post(wallet_url, b"{}") == b"{}"
|
||||
assert _post(wallet_url, b"{}") == b"{}"
|
||||
assert _post(wallet_url, b"{}") == b"{}"
|
||||
with pytest.raises(urllib.error.HTTPError) as extra_request:
|
||||
_post(wallet_url, b"{}")
|
||||
|
||||
assert extra_request.value.code == 429
|
||||
assert calls == payment_proxy._MAX_WALLET_REQUESTS
|
||||
165
tests/test_cloud_session.py
Normal file
165
tests/test_cloud_session.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""CLI-session lifecycle, scope, and workspace-race behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
from strix.interface import cloud, platform_cli, platform_identity
|
||||
from strix.interface.cloud import http
|
||||
from strix.interface.cloud import session as cloud_session
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class Response:
|
||||
def __init__(self, payload: Any = None, status_code: int = 200) -> None:
|
||||
self._payload = payload
|
||||
self.status_code = status_code
|
||||
self.ok = 200 <= status_code < 400
|
||||
self.text = json.dumps(payload) if payload is not None else ""
|
||||
self.headers = {"content-type": "application/json"}
|
||||
|
||||
def json(self) -> Any:
|
||||
return self._payload
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
path = tmp_path / "platform-auth.json"
|
||||
monkeypatch.setattr(platform_cli, "AUTH_PATH", path)
|
||||
monkeypatch.delenv("STRIX_API_TOKEN", raising=False)
|
||||
monkeypatch.delenv("STRIX_WORKSPACE_ID", raising=False)
|
||||
return path
|
||||
|
||||
|
||||
def test_http_workspace_pin_is_captured_once(
|
||||
auth_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
platform_cli.save_record(
|
||||
{
|
||||
"api_token": "secret",
|
||||
"organization_id": "org_start",
|
||||
"app_url": "https://app.example.test",
|
||||
}
|
||||
)
|
||||
assert auth_path.exists()
|
||||
sent: list[dict[str, str]] = []
|
||||
|
||||
def fake_request(*_args: Any, **kwargs: Any) -> Response:
|
||||
sent.append(dict(kwargs["headers"]))
|
||||
return Response({})
|
||||
|
||||
monkeypatch.setattr(http.requests, "request", fake_request)
|
||||
http.configure()
|
||||
platform_cli.save_record(
|
||||
{
|
||||
"api_token": "secret",
|
||||
"organization_id": "org_changed_elsewhere",
|
||||
"app_url": "https://app.example.test",
|
||||
}
|
||||
)
|
||||
http.request("GET", "/scans")
|
||||
assert sent[0]["X-Strix-Workspace"] == "org_start"
|
||||
|
||||
|
||||
def test_session_scope_update_persists_only_for_stored_session(
|
||||
auth_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
platform_cli.save_record(
|
||||
{
|
||||
"api_token": "secret",
|
||||
"organization_id": "org_1",
|
||||
"app_url": "https://app.example.test",
|
||||
"scopes": ["scans:read"],
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
http,
|
||||
"request",
|
||||
lambda *_args, **_kwargs: Response(
|
||||
{
|
||||
"scopes": ["scans:read", "scans:write", "billing:read"],
|
||||
"requested_scopes": ["scans:read", "scans:write", "billing:read"],
|
||||
"scope_ceiling": ["scans:read", "scans:write", "billing:read"],
|
||||
"scope_profile": "minimal",
|
||||
}
|
||||
),
|
||||
)
|
||||
assert cloud.run_cloud(["session", "scopes", "set", "minimal", "--json"]) == 0
|
||||
stored = platform_cli.read_record()
|
||||
assert stored is not None
|
||||
assert stored["scope_profile"] == "minimal"
|
||||
assert json.loads(capsys.readouterr().out)["scope_profile"] == "minimal"
|
||||
|
||||
before = auth_path.read_text(encoding="utf-8")
|
||||
assert (
|
||||
cloud.run_cloud(["session", "scopes", "set", "minimal", "--token", "override", "--json"])
|
||||
== 0
|
||||
)
|
||||
assert auth_path.read_text(encoding="utf-8") == before
|
||||
|
||||
|
||||
def test_logout_keeps_local_token_when_remote_outcome_is_not_definitive(
|
||||
auth_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
platform_cli.save_record(
|
||||
{
|
||||
"api_token": "secret",
|
||||
"organization_id": "org_1",
|
||||
"app_url": "https://app.example.test",
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
platform_cli.requests,
|
||||
"delete",
|
||||
lambda *_args, **_kwargs: Response({"detail": "unavailable"}, 503),
|
||||
)
|
||||
assert cloud.run_cloud(["logout", "--json"]) == 1
|
||||
assert auth_path.exists()
|
||||
assert json.loads(capsys.readouterr().out)["removed"] is False
|
||||
|
||||
|
||||
def test_local_only_logout_is_explicit_and_recoverable(auth_path: Path, capsys: Any) -> None:
|
||||
platform_cli.save_record({"api_token": "secret"})
|
||||
assert cloud.run_cloud(["logout", "--local-only", "--json"]) == 0
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert payload["local_only"] is True
|
||||
assert payload["remotely_revoked"] is False
|
||||
assert not auth_path.exists()
|
||||
|
||||
|
||||
def test_session_json_errors_preserve_machine_readable_server_details(capsys: Any) -> None:
|
||||
error = http.CloudError(
|
||||
"workspace changed",
|
||||
payload={
|
||||
"detail": "workspace changed",
|
||||
"code": "workspace_session_changed",
|
||||
"current_organization_id": "org_current",
|
||||
},
|
||||
)
|
||||
|
||||
assert cloud_session._error(Console(), error, as_json=True) == http.EXIT_ERROR
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert payload == {
|
||||
"code": "workspace_session_changed",
|
||||
"current_organization_id": "org_current",
|
||||
"error": "workspace changed",
|
||||
}
|
||||
|
||||
|
||||
def test_cli_device_identity_is_stable_and_privacy_safe(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
path = tmp_path / "cli-identity.json"
|
||||
monkeypatch.setattr(platform_identity, "IDENTITY_PATH", path)
|
||||
first = platform_identity.read_or_create_identity()
|
||||
second = platform_identity.read_or_create_identity(device_name=" Build laptop ")
|
||||
assert second["client_instance_id"] == first["client_instance_id"]
|
||||
assert second["device_name"] == "Build laptop"
|
||||
assert path.stat().st_mode & 0o777 == 0o600
|
||||
657
tests/test_cloud_source_upload.py
Normal file
657
tests/test_cloud_source_upload.py
Normal file
@@ -0,0 +1,657 @@
|
||||
"""Local-source packaging and scan upload tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import zipfile
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
|
||||
from strix.interface import cloud
|
||||
from strix.interface.cloud import http, source_upload
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload: Any, status_code: int = 200) -> None:
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
self.text = json.dumps(payload)
|
||||
self.content = b""
|
||||
self.ok = 200 <= status_code < 400
|
||||
self.headers = {"content-type": "application/json"}
|
||||
|
||||
def json(self) -> Any:
|
||||
return self._payload
|
||||
|
||||
|
||||
class MalformedJsonResponse(FakeResponse):
|
||||
def json(self) -> Any:
|
||||
raise ValueError("malformed JSON")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _token_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("STRIX_API_TOKEN", "test-token")
|
||||
|
||||
|
||||
def _git_source(tmp_path: Path) -> Path:
|
||||
git = shutil.which("git")
|
||||
assert git is not None
|
||||
subprocess.run([git, "init", "-q", str(tmp_path)], check=True) # noqa: S603
|
||||
(tmp_path / "app.py").write_text("print('hello')\n", encoding="utf-8")
|
||||
(tmp_path / "README.md").write_text("hello\n", encoding="utf-8")
|
||||
(tmp_path / ".gitignore").write_text("ignored.log\n", encoding="utf-8")
|
||||
(tmp_path / "ignored.log").write_text("ignored\n", encoding="utf-8")
|
||||
subprocess.run( # noqa: S603
|
||||
[git, "-C", str(tmp_path), "add", "app.py", ".gitignore"], check=True
|
||||
)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_source_defaults_are_private_and_git_aware(tmp_path: Path) -> None:
|
||||
source = _git_source(tmp_path)
|
||||
(source / ".hidden.py").write_text("hidden\n", encoding="utf-8")
|
||||
(source / ".env").write_text("TOKEN=secret\n", encoding="utf-8")
|
||||
(source / "private.pem").write_text("secret\n", encoding="utf-8")
|
||||
(source / "fixture.zip").write_bytes(b"not really a zip")
|
||||
(source / "node_modules").mkdir()
|
||||
(source / "node_modules" / "dep.js").write_text("dep\n", encoding="utf-8")
|
||||
(source / "linked.py").symlink_to(source / "app.py")
|
||||
|
||||
bundle = source_upload.prepare_source(
|
||||
str(source),
|
||||
include_hidden=False,
|
||||
include_sensitive=False,
|
||||
include_archives=False,
|
||||
exclude=[],
|
||||
)
|
||||
try:
|
||||
names = [item.archive_name for item in bundle.manifest.files]
|
||||
assert names == ["README.md", "app.py"]
|
||||
assert bundle.manifest.total_bytes > 0
|
||||
assert bundle.archive_bytes <= source_upload.MAX_ARCHIVE_BYTES
|
||||
assert bundle.manifest.excluded["hidden"] == 3
|
||||
assert bundle.manifest.excluded["sensitive_filename"] == 1
|
||||
assert bundle.manifest.excluded["nested_archive"] == 1
|
||||
assert bundle.manifest.excluded["dependency_or_build_output"] == 1
|
||||
assert bundle.manifest.excluded["symlink_or_non_file"] == 1
|
||||
with zipfile.ZipFile(bundle.archive_path) as archive:
|
||||
assert archive.namelist() == names
|
||||
finally:
|
||||
source_upload.remove_bundle(bundle)
|
||||
|
||||
|
||||
def test_hidden_and_sensitive_files_need_separate_opt_ins(tmp_path: Path) -> None:
|
||||
source = _git_source(tmp_path)
|
||||
(source / ".env").write_text("TOKEN=secret\n", encoding="utf-8")
|
||||
(source / ".github").mkdir()
|
||||
(source / ".github" / "workflow.yml").write_text("name: test\n", encoding="utf-8")
|
||||
|
||||
hidden = source_upload.select_source(source, include_hidden=True)
|
||||
hidden_names = {item.archive_name for item in hidden.files}
|
||||
assert ".github/workflow.yml" in hidden_names
|
||||
assert ".env" not in hidden_names
|
||||
|
||||
sensitive = source_upload.select_source(source, include_hidden=True, include_sensitive=True)
|
||||
assert ".env" in {item.archive_name for item in sensitive.files}
|
||||
assert all(
|
||||
not name.startswith(".git/") for name in (item.archive_name for item in sensitive.files)
|
||||
)
|
||||
|
||||
|
||||
def test_hidden_opt_in_still_excludes_common_credential_paths(tmp_path: Path) -> None:
|
||||
paths = [
|
||||
".aws/credentials",
|
||||
".git-credentials",
|
||||
".docker/config.json",
|
||||
".config/gcloud/application_default_credentials.json",
|
||||
".config/gcloud/credentials.db",
|
||||
".azure/accessTokens.json",
|
||||
".kube/config",
|
||||
]
|
||||
for relative in paths:
|
||||
path = tmp_path / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("credential material\n", encoding="utf-8")
|
||||
|
||||
hidden_only = source_upload.select_source(tmp_path, include_hidden=True)
|
||||
assert not ({item.archive_name for item in hidden_only.files} & set(paths))
|
||||
assert hidden_only.excluded["sensitive_filename"] == len(paths)
|
||||
|
||||
explicitly_sensitive = source_upload.select_source(
|
||||
tmp_path, include_hidden=True, include_sensitive=True
|
||||
)
|
||||
assert set(paths) <= {item.archive_name for item in explicitly_sensitive.files}
|
||||
|
||||
|
||||
def test_hidden_opt_in_cannot_reenable_dependency_cache_or_build_dirs(tmp_path: Path) -> None:
|
||||
excluded_dirs = [
|
||||
".venv",
|
||||
"env",
|
||||
".tox",
|
||||
".pytest_cache",
|
||||
".mypy_cache",
|
||||
".ruff_cache",
|
||||
".next",
|
||||
".nuxt",
|
||||
".gradle",
|
||||
]
|
||||
for directory in excluded_dirs:
|
||||
path = tmp_path / directory / "artifact.txt"
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text("generated\n", encoding="utf-8")
|
||||
(tmp_path / ".github" / "workflow.yml").parent.mkdir()
|
||||
(tmp_path / ".github" / "workflow.yml").write_text("name: test\n", encoding="utf-8")
|
||||
|
||||
manifest = source_upload.select_source(tmp_path, include_hidden=True)
|
||||
|
||||
names = {item.archive_name for item in manifest.files}
|
||||
assert ".github/workflow.yml" in names
|
||||
assert not any(name.split("/", 1)[0] in excluded_dirs for name in names)
|
||||
assert manifest.excluded["dependency_or_build_output"] == len(excluded_dirs)
|
||||
|
||||
|
||||
def test_strixignore_and_cli_excludes_are_applied(tmp_path: Path) -> None:
|
||||
(tmp_path / "keep.py").write_text("keep\n", encoding="utf-8")
|
||||
(tmp_path / "generated.py").write_text("generated\n", encoding="utf-8")
|
||||
(tmp_path / "test_app.py").write_text("test\n", encoding="utf-8")
|
||||
(tmp_path / ".strixignore").write_text("generated.py\n", encoding="utf-8")
|
||||
|
||||
manifest = source_upload.select_source(tmp_path, exclude=["test_*.py"])
|
||||
assert [item.archive_name for item in manifest.files] == ["keep.py"]
|
||||
assert manifest.excluded["user_pattern"] == 2
|
||||
|
||||
|
||||
def test_strixignore_trailing_slash_excludes_the_whole_directory(tmp_path: Path) -> None:
|
||||
(tmp_path / "keep.py").write_text("keep\n", encoding="utf-8")
|
||||
private = tmp_path / "private" / "nested"
|
||||
private.mkdir(parents=True)
|
||||
(private / "secret.txt").write_text("do not upload\n", encoding="utf-8")
|
||||
cache = tmp_path / "packages" / "cache"
|
||||
cache.mkdir(parents=True)
|
||||
(cache / "artifact.txt").write_text("do not upload\n", encoding="utf-8")
|
||||
(tmp_path / ".strixignore").write_text("private/\n", encoding="utf-8")
|
||||
|
||||
manifest = source_upload.select_source(tmp_path, exclude=["cache/"])
|
||||
|
||||
assert [item.archive_name for item in manifest.files] == ["keep.py"]
|
||||
assert manifest.excluded["user_pattern"] >= 2
|
||||
|
||||
|
||||
def test_source_limits_expanded_bytes_before_compression(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(source_upload, "MAX_TOTAL_BYTES", 5)
|
||||
(tmp_path / "large.py").write_bytes(b"a" * 6)
|
||||
with pytest.raises(http.CloudError, match="expanded-size limit"):
|
||||
source_upload.select_source(tmp_path)
|
||||
|
||||
|
||||
def test_source_rejects_archives_by_suffix_and_actual_bytes(tmp_path: Path) -> None:
|
||||
(tmp_path / "app.py").write_text("print('safe')\n", encoding="utf-8")
|
||||
(tmp_path / "dependency.jar").write_bytes(b"not-even-a-valid-archive")
|
||||
(tmp_path / "renamed-source.txt").write_bytes(b"PK\x03\x04" + b"x" * 32)
|
||||
tar_header = bytearray(512)
|
||||
tar_header[257:262] = b"ustar"
|
||||
(tmp_path / "renamed-tar.bin").write_bytes(tar_header)
|
||||
|
||||
manifest = source_upload.select_source(tmp_path)
|
||||
|
||||
assert [item.archive_name for item in manifest.files] == ["app.py"]
|
||||
assert manifest.excluded["nested_archive"] == 3
|
||||
|
||||
|
||||
def test_source_enumeration_is_bounded_before_filtering(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(source_upload, "MAX_CANDIDATE_PATHS", 2)
|
||||
for index in range(3):
|
||||
(tmp_path / f"file-{index}.py").write_text("safe\n", encoding="utf-8")
|
||||
|
||||
with pytest.raises(http.CloudError, match="enumeration exceeded 2 paths"):
|
||||
source_upload.select_source(tmp_path)
|
||||
|
||||
|
||||
def test_strixignore_size_and_pattern_counts_are_bounded(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
ignore = tmp_path / ".strixignore"
|
||||
monkeypatch.setattr(source_upload, "MAX_IGNORE_BYTES", 4)
|
||||
ignore.write_text("12345", encoding="utf-8")
|
||||
with pytest.raises(http.CloudError, match="larger than the 4-byte limit"):
|
||||
source_upload.select_source(tmp_path)
|
||||
|
||||
monkeypatch.setattr(source_upload, "MAX_IGNORE_BYTES", 1_000)
|
||||
monkeypatch.setattr(source_upload, "MAX_IGNORE_PATTERNS", 1)
|
||||
ignore.write_text("one\ntwo\n", encoding="utf-8")
|
||||
with pytest.raises(http.CloudError, match="more than 1 exclusion patterns"):
|
||||
source_upload.select_source(tmp_path)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="named pipes are not supported")
|
||||
def test_strixignore_must_be_a_nonblocking_regular_file(tmp_path: Path) -> None:
|
||||
(tmp_path / "app.py").write_text("print('ok')\n", encoding="utf-8")
|
||||
os.mkfifo(tmp_path / ".strixignore")
|
||||
|
||||
with pytest.raises(http.CloudError, match="must be a regular file"):
|
||||
source_upload.select_source(tmp_path)
|
||||
|
||||
|
||||
def test_source_archive_rejects_a_path_swapped_after_manifest_review(tmp_path: Path) -> None:
|
||||
source_path = tmp_path / "app.py"
|
||||
source_path.write_bytes(b"safe")
|
||||
manifest = source_upload.select_source(tmp_path)
|
||||
|
||||
replacement = tmp_path / "replacement"
|
||||
replacement.write_bytes(b"oops")
|
||||
replacement.replace(source_path)
|
||||
|
||||
with pytest.raises(http.CloudError, match="changed while the source archive was being built"):
|
||||
source_upload._write_archive(tmp_path / "source.zip", manifest.files)
|
||||
|
||||
|
||||
def test_source_archive_rejects_same_inode_same_size_change_after_review(tmp_path: Path) -> None:
|
||||
source_path = tmp_path / "app.py"
|
||||
source_path.write_bytes(b"safe")
|
||||
manifest = source_upload.select_source(tmp_path)
|
||||
|
||||
source_path.write_bytes(b"evil")
|
||||
selected = manifest.files[0]
|
||||
os.utime(
|
||||
source_path,
|
||||
ns=(selected.mtime_ns + 1_000_000, selected.mtime_ns + 1_000_000),
|
||||
)
|
||||
|
||||
with pytest.raises(http.CloudError, match="changed while the source archive was being built"):
|
||||
source_upload._write_archive(tmp_path / "source.zip", manifest.files)
|
||||
|
||||
|
||||
def test_source_dry_run_never_calls_the_api(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
(tmp_path / "app.py").write_text("print('safe')\n", encoding="utf-8")
|
||||
|
||||
def fail_request(*_args: Any, **_kwargs: Any) -> Any:
|
||||
raise AssertionError("dry-run must not make an API request")
|
||||
|
||||
monkeypatch.setattr(http, "request", fail_request)
|
||||
assert (
|
||||
cloud.run_cloud(
|
||||
["scans", "start", "--source", str(tmp_path), "--dry-run", "--show-files", "--json"]
|
||||
)
|
||||
== 0
|
||||
)
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert payload["source"]["files"] == ["app.py"]
|
||||
assert payload["source"]["archive_sha256"]
|
||||
|
||||
|
||||
def test_noninteractive_source_upload_requires_yes(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
(tmp_path / "app.py").write_text("print('safe')\n", encoding="utf-8")
|
||||
monkeypatch.setattr(
|
||||
http,
|
||||
"request",
|
||||
lambda *_args, **_kwargs: pytest.fail("approval must happen before any API request"),
|
||||
)
|
||||
assert cloud.run_cloud(["scans", "start", "--source", str(tmp_path), "--json"]) == 1
|
||||
output = capsys.readouterr().out
|
||||
assert "requires explicit approval" in output
|
||||
assert "--approve-sha256 <reviewed hash>" in output
|
||||
assert "--yes" in output
|
||||
assert "one-shot approval" in output
|
||||
|
||||
|
||||
def test_source_digest_approval_rejects_a_changed_snapshot(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
source = tmp_path / "app.py"
|
||||
source.write_text("print('reviewed')\n", encoding="utf-8")
|
||||
assert (
|
||||
cloud.run_cloud(["scans", "start", "--source", str(tmp_path), "--dry-run", "--json"]) == 0
|
||||
)
|
||||
approved = json.loads(capsys.readouterr().out)["source"]["archive_sha256"]
|
||||
source.write_text("print('changed')\n", encoding="utf-8")
|
||||
monkeypatch.setattr(
|
||||
http,
|
||||
"request",
|
||||
lambda *_args, **_kwargs: pytest.fail("a changed snapshot must not reach the API"),
|
||||
)
|
||||
|
||||
assert (
|
||||
cloud.run_cloud(
|
||||
[
|
||||
"scans",
|
||||
"start",
|
||||
"--source",
|
||||
str(tmp_path),
|
||||
"--approve-sha256",
|
||||
approved,
|
||||
"--json",
|
||||
]
|
||||
)
|
||||
== http.EXIT_ERROR
|
||||
)
|
||||
assert "does not match" in json.loads(capsys.readouterr().out)["error"]
|
||||
|
||||
|
||||
def test_source_upload_is_completed_and_attached_to_scan(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
(tmp_path / "app.py").write_text("print('safe')\n", encoding="utf-8")
|
||||
calls: list[tuple[str, str, dict[str, Any]]] = []
|
||||
uploaded_path: Path | None = None
|
||||
|
||||
def fake_request(method: str, path: str, **kwargs: Any) -> FakeResponse:
|
||||
calls.append((method, path, kwargs))
|
||||
if path == "/uploads/request":
|
||||
return FakeResponse(
|
||||
{
|
||||
"upload_id": "upload-1",
|
||||
"signed_url": "https://storage.test/object",
|
||||
"token": "signed",
|
||||
}
|
||||
)
|
||||
if path == "/uploads/complete":
|
||||
return FakeResponse({"id": "upload-1"})
|
||||
if path == "/scans":
|
||||
return FakeResponse({"scan_id": "scan-1", "status": "pending"})
|
||||
raise AssertionError(path)
|
||||
|
||||
def fake_upload(_url: str, _token: str, path: Path) -> None:
|
||||
nonlocal uploaded_path
|
||||
uploaded_path = path
|
||||
assert path.exists()
|
||||
|
||||
monkeypatch.setattr(http, "request", fake_request)
|
||||
monkeypatch.setattr(http, "upload_file", fake_upload)
|
||||
|
||||
assert (
|
||||
cloud.run_cloud(
|
||||
["scans", "start", "--source", str(tmp_path), "--yes", "--show-files", "--json"]
|
||||
)
|
||||
== 0
|
||||
)
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert payload["upload_id"] == "upload-1"
|
||||
assert payload["scan"]["scan_id"] == "scan-1"
|
||||
assert payload["source"]["files"] == ["app.py"]
|
||||
scan_call = next(call for call in calls if call[1] == "/scans")
|
||||
assert scan_call[2]["body"] == {
|
||||
"engagement_type": "code_review",
|
||||
"upload_ids": ["upload-1"],
|
||||
}
|
||||
assert uploaded_path is not None and not uploaded_path.exists()
|
||||
|
||||
|
||||
def test_source_upload_with_domain_is_a_live_test(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
(tmp_path / "app.py").write_text("print('safe')\n", encoding="utf-8")
|
||||
calls: list[tuple[str, str, dict[str, Any]]] = []
|
||||
|
||||
def fake_request(method: str, path: str, **kwargs: Any) -> FakeResponse:
|
||||
calls.append((method, path, kwargs))
|
||||
if path == "/uploads/request":
|
||||
return FakeResponse(
|
||||
{
|
||||
"upload_id": "upload-1",
|
||||
"signed_url": "https://storage.test/object",
|
||||
"token": "signed",
|
||||
}
|
||||
)
|
||||
if path == "/uploads/complete":
|
||||
return FakeResponse({"id": "upload-1"})
|
||||
if path == "/scans":
|
||||
return FakeResponse({"scan_id": "scan-1", "status": "pending"})
|
||||
raise AssertionError(path)
|
||||
|
||||
monkeypatch.setattr(http, "request", fake_request)
|
||||
monkeypatch.setattr(http, "upload_file", lambda *_args, **_kwargs: None)
|
||||
|
||||
assert (
|
||||
cloud.run_cloud(
|
||||
[
|
||||
"scans",
|
||||
"start",
|
||||
"--source",
|
||||
str(tmp_path),
|
||||
"--domain-ids",
|
||||
"domain-1",
|
||||
"--yes",
|
||||
"--json",
|
||||
]
|
||||
)
|
||||
== 0
|
||||
)
|
||||
scan_call = next(call for call in calls if call[1] == "/scans")
|
||||
assert scan_call[2]["body"] == {
|
||||
"engagement_type": "live_test",
|
||||
"domain_ids": ["domain-1"],
|
||||
"upload_ids": ["upload-1"],
|
||||
}
|
||||
assert json.loads(capsys.readouterr().out)["scan"]["scan_id"] == "scan-1"
|
||||
|
||||
|
||||
def test_failed_scan_deletes_completed_source_upload(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
(tmp_path / "app.py").write_text("print('safe')\n", encoding="utf-8")
|
||||
paths: list[tuple[str, str]] = []
|
||||
|
||||
def fake_request(method: str, path: str, **_kwargs: Any) -> FakeResponse:
|
||||
paths.append((method, path))
|
||||
if path == "/uploads/request":
|
||||
return FakeResponse(
|
||||
{
|
||||
"upload_id": "upload-1",
|
||||
"signed_url": "https://storage.test/object",
|
||||
"token": "signed",
|
||||
}
|
||||
)
|
||||
if path == "/uploads/complete":
|
||||
return FakeResponse({"id": "upload-1"})
|
||||
if path == "/scans":
|
||||
return FakeResponse({"detail": "not enough credits"}, status_code=402)
|
||||
if path == "/uploads/upload-1":
|
||||
return FakeResponse({"ok": True})
|
||||
raise AssertionError(path)
|
||||
|
||||
monkeypatch.setattr(http, "request", fake_request)
|
||||
monkeypatch.setattr(http, "upload_file", lambda *_args, **_kwargs: None)
|
||||
assert cloud.run_cloud(["scans", "start", "--source", str(tmp_path), "--yes", "--json"]) == 5
|
||||
assert ("DELETE", "/uploads/upload-1") in paths
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"failure",
|
||||
["network", "server", "malformed_success", "malformed_json_success", "wrong_shape_success"],
|
||||
)
|
||||
def test_ambiguous_scan_launch_retains_completed_source_upload(
|
||||
failure: str,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: Any,
|
||||
) -> None:
|
||||
(tmp_path / "app.py").write_text("print('safe')\n", encoding="utf-8")
|
||||
paths: list[tuple[str, str]] = []
|
||||
|
||||
def fake_request(method: str, path: str, **_kwargs: Any) -> FakeResponse:
|
||||
paths.append((method, path))
|
||||
if path == "/uploads/request":
|
||||
return FakeResponse(
|
||||
{
|
||||
"upload_id": "upload-ambiguous",
|
||||
"signed_url": "https://storage.test/object",
|
||||
"token": "signed",
|
||||
}
|
||||
)
|
||||
if path == "/uploads/complete":
|
||||
return FakeResponse({"id": "upload-ambiguous"})
|
||||
if path == "/scans":
|
||||
if failure == "network":
|
||||
raise http.CloudError("connection closed before a response")
|
||||
if failure == "server":
|
||||
return FakeResponse({"detail": "temporary failure"}, status_code=500)
|
||||
if failure == "malformed_json_success":
|
||||
return MalformedJsonResponse("accepted")
|
||||
if failure == "wrong_shape_success":
|
||||
return FakeResponse({})
|
||||
response = FakeResponse("accepted")
|
||||
response.headers = {"content-type": "text/html"}
|
||||
return response
|
||||
if path == "/uploads/upload-ambiguous":
|
||||
pytest.fail("an upload with an ambiguous launch must not be deleted")
|
||||
raise AssertionError(path)
|
||||
|
||||
monkeypatch.setattr(http, "request", fake_request)
|
||||
monkeypatch.setattr(http, "upload_file", lambda *_args, **_kwargs: None)
|
||||
|
||||
assert (
|
||||
cloud.run_cloud(["scans", "start", "--source", str(tmp_path), "--yes", "--json"])
|
||||
== http.EXIT_ERROR
|
||||
)
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert payload["upload_id"] == "upload-ambiguous"
|
||||
assert payload["upload_retained"] is True
|
||||
assert payload["launch_outcome_unknown"] is True
|
||||
assert "outcome is unknown" in payload["error"]
|
||||
assert ("DELETE", "/uploads/upload-ambiguous") not in paths
|
||||
|
||||
|
||||
def test_mismatched_upload_completion_response_is_cleaned_before_launch(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
(tmp_path / "app.py").write_text("print('safe')\n", encoding="utf-8")
|
||||
paths: list[tuple[str, str]] = []
|
||||
|
||||
def fake_request(method: str, path: str, **_kwargs: Any) -> FakeResponse:
|
||||
paths.append((method, path))
|
||||
if path == "/uploads/request":
|
||||
return FakeResponse(
|
||||
{
|
||||
"upload_id": "upload-expected",
|
||||
"signed_url": "https://storage.test/object",
|
||||
"token": "signed",
|
||||
}
|
||||
)
|
||||
if path == "/uploads/complete":
|
||||
return FakeResponse({"id": "upload-different"})
|
||||
if path == "/uploads/upload-expected":
|
||||
return FakeResponse({"ok": True})
|
||||
if path == "/scans":
|
||||
pytest.fail("a scan must not launch before upload completion is confirmed")
|
||||
raise AssertionError(path)
|
||||
|
||||
monkeypatch.setattr(http, "request", fake_request)
|
||||
monkeypatch.setattr(http, "upload_file", lambda *_args, **_kwargs: None)
|
||||
|
||||
assert cloud.run_cloud(["scans", "start", "--source", str(tmp_path), "--yes"]) == 1
|
||||
assert ("DELETE", "/uploads/upload-expected") in paths
|
||||
|
||||
|
||||
def test_interrupted_scan_launch_retains_completed_source_upload(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
(tmp_path / "app.py").write_text("print('safe')\n", encoding="utf-8")
|
||||
paths: list[tuple[str, str]] = []
|
||||
|
||||
def fake_request(method: str, path: str, **_kwargs: Any) -> FakeResponse:
|
||||
paths.append((method, path))
|
||||
if path == "/uploads/request":
|
||||
return FakeResponse(
|
||||
{
|
||||
"upload_id": "upload-interrupted",
|
||||
"signed_url": "https://storage.test/object",
|
||||
"token": "signed",
|
||||
}
|
||||
)
|
||||
if path == "/uploads/complete":
|
||||
return FakeResponse({"id": "upload-interrupted"})
|
||||
if path == "/scans":
|
||||
raise KeyboardInterrupt
|
||||
if path == "/uploads/upload-interrupted":
|
||||
pytest.fail("an upload with an interrupted launch must not be deleted")
|
||||
raise AssertionError(path)
|
||||
|
||||
monkeypatch.setattr(http, "request", fake_request)
|
||||
monkeypatch.setattr(http, "upload_file", lambda *_args, **_kwargs: None)
|
||||
|
||||
assert cloud.run_cloud(["scans", "start", "--source", str(tmp_path), "--yes", "--json"]) == 130
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert payload["interrupted"] is True
|
||||
assert payload["upload_id"] == "upload-interrupted"
|
||||
assert payload["upload_retained"] is True
|
||||
assert payload["launch_outcome_unknown"] is True
|
||||
assert "scans list" in payload["error"]
|
||||
assert ("DELETE", "/uploads/upload-interrupted") not in paths
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cleanup_failure", ["timeout", "server"])
|
||||
def test_failed_automatic_upload_cleanup_reports_retained_id(
|
||||
cleanup_failure: str,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: Any,
|
||||
) -> None:
|
||||
(tmp_path / "app.py").write_text("print('safe')\n", encoding="utf-8")
|
||||
|
||||
def fake_request(method: str, path: str, **_kwargs: Any) -> FakeResponse:
|
||||
if path == "/uploads/request":
|
||||
return FakeResponse(
|
||||
{
|
||||
"upload_id": "upload-orphaned",
|
||||
"signed_url": "https://storage.test/object",
|
||||
"token": "signed",
|
||||
}
|
||||
)
|
||||
if path == "/uploads/upload-orphaned" and method == "DELETE":
|
||||
if cleanup_failure == "timeout":
|
||||
raise http.CloudError("cleanup timed out")
|
||||
return FakeResponse({"detail": "cleanup unavailable"}, status_code=500)
|
||||
raise AssertionError((method, path))
|
||||
|
||||
monkeypatch.setattr(http, "request", fake_request)
|
||||
monkeypatch.setattr(
|
||||
http,
|
||||
"upload_file",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(http.CloudError("upload failed")),
|
||||
)
|
||||
|
||||
assert (
|
||||
cloud.run_cloud(["scans", "start", "--source", str(tmp_path), "--yes", "--json"])
|
||||
== http.EXIT_ERROR
|
||||
)
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert payload["upload_id"] == "upload-orphaned"
|
||||
assert payload["upload_retained"] is True
|
||||
assert payload["cleanup_unknown"] is True
|
||||
assert "uploads delete upload-orphaned" in payload["error"]
|
||||
|
||||
|
||||
def test_incomplete_upload_credentials_delete_the_reserved_upload(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
(tmp_path / "app.py").write_text("print('safe')\n", encoding="utf-8")
|
||||
paths: list[tuple[str, str]] = []
|
||||
|
||||
def fake_request(method: str, path: str, **_kwargs: Any) -> FakeResponse:
|
||||
paths.append((method, path))
|
||||
if path == "/uploads/request":
|
||||
return FakeResponse({"upload_id": "upload-incomplete"})
|
||||
if path == "/uploads/upload-incomplete":
|
||||
return FakeResponse({"ok": True})
|
||||
raise AssertionError(path)
|
||||
|
||||
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
|
||||
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
|
||||
164
tests/test_completions.py
Normal file
164
tests/test_completions.py
Normal file
@@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from strix.interface.completions import completion_candidates, run_completions
|
||||
|
||||
|
||||
def test_root_completion_candidates() -> None:
|
||||
assert completion_candidates(["cl"]) == ["cloud"]
|
||||
assert "completions" in completion_candidates([""])
|
||||
|
||||
|
||||
def test_cloud_group_and_alias_candidates() -> None:
|
||||
candidates = completion_candidates(["cloud", "work"])
|
||||
assert candidates == ["workspace", "workspaces"]
|
||||
|
||||
|
||||
def test_cloud_verb_candidates_include_multiword_prefixes() -> None:
|
||||
assert "test-users" in completion_candidates(["cloud", "domains", ""])
|
||||
assert completion_candidates(["cloud", "domains", "test-users", "in"]) == [
|
||||
"inbox",
|
||||
"inbox-message",
|
||||
]
|
||||
|
||||
|
||||
def test_cloud_leaf_flag_candidates_come_from_command_spec() -> None:
|
||||
candidates = completion_candidates(["cloud", "scans", "start", "--"])
|
||||
assert "--domain-ids" in candidates
|
||||
assert "--json" in candidates
|
||||
assert "--wait" in candidates
|
||||
assert "--source" in candidates
|
||||
assert "--approve-sha256" in candidates
|
||||
assert "--dry-run" in candidates
|
||||
assert "--include-hidden" in candidates
|
||||
|
||||
|
||||
def test_boolean_completion_includes_positive_and_negative_flags() -> None:
|
||||
candidates = completion_candidates(["cloud", "billing", "auto-topup", "update", "--"])
|
||||
assert "--enabled" in candidates
|
||||
assert "--no-enabled" in candidates
|
||||
assert "--no-monthly-cap" in candidates
|
||||
|
||||
|
||||
def test_session_and_workspace_use_completions_include_their_real_flags() -> None:
|
||||
credit_flags = completion_candidates(["cloud", "credits", "--"])
|
||||
assert {"--json", "--token", "--app-url", "--timeout", "--help"} <= set(credit_flags)
|
||||
assert "--json" in completion_candidates(["cloud", "logout", "--"])
|
||||
|
||||
workspace_use = completion_candidates(["cloud", "workspace", "use", "--"])
|
||||
assert {"--scopes", "--json", "--token", "--app-url", "--timeout"} <= set(workspace_use)
|
||||
|
||||
|
||||
def test_leaf_flags_remain_available_after_options_and_positionals() -> None:
|
||||
after_option = completion_candidates(["cloud", "scans", "list", "--status", "running", "--"])
|
||||
assert {"--page", "--limit", "--json"} <= set(after_option)
|
||||
|
||||
after_positional = completion_candidates(["cloud", "scans", "get", "scan-1", "--"])
|
||||
assert {"--json", "--token", "--app-url", "--timeout"} <= set(after_positional)
|
||||
|
||||
|
||||
def test_default_verbs_complete_flags_without_an_explicit_verb() -> None:
|
||||
audit = completion_candidates(["cloud", "audit", "--"])
|
||||
assert {"--page", "--limit", "--json"} <= set(audit)
|
||||
|
||||
after_option = completion_candidates(["cloud", "audit", "--page", "2", "--"])
|
||||
assert {"--limit", "--format", "--json"} <= set(after_option)
|
||||
|
||||
workspaces = completion_candidates(["cloud", "workspace", "--"])
|
||||
assert {"--json", "--token", "--app-url", "--timeout"} <= set(workspaces)
|
||||
|
||||
|
||||
def test_completion_does_not_offer_flags_while_an_option_value_is_empty() -> None:
|
||||
assert completion_candidates(["cloud", "scans", "list", "--page", ""]) == []
|
||||
assert completion_candidates(["cloud", "scans", "start", "--approve-sha256", ""]) == []
|
||||
|
||||
|
||||
def test_exact_verbs_that_are_also_prefixes_keep_their_subverbs() -> None:
|
||||
candidates = completion_candidates(["cloud", "billing", "auto-topup", ""])
|
||||
assert "update" in candidates
|
||||
assert "--json" in candidates
|
||||
|
||||
|
||||
def test_contract_fix_flags_are_completed() -> None:
|
||||
integration_connect = completion_candidates(
|
||||
["cloud", "integrations", "connect", "gitlab", "--"]
|
||||
)
|
||||
assert {
|
||||
"--provider-token",
|
||||
"--instance-url",
|
||||
"--account-email",
|
||||
"--installation-id",
|
||||
} <= set(integration_connect)
|
||||
|
||||
disconnect = completion_candidates(["cloud", "integrations", "disconnect", "--"])
|
||||
assert "--installation-id" in disconnect
|
||||
|
||||
connector = completion_candidates(["cloud", "connectors", "get", "connector-1", "--"])
|
||||
assert "--include-command" in connector
|
||||
assert "--no-include-command" in connector
|
||||
|
||||
scan_wait = completion_candidates(["cloud", "scans", "start", "--"])
|
||||
assert "--wait-timeout" in scan_wait
|
||||
|
||||
audit_export = completion_candidates(["cloud", "audit", "--"])
|
||||
assert {"--output", "--force"} <= set(audit_export)
|
||||
|
||||
token_create = completion_candidates(["cloud", "tokens", "create", "--"])
|
||||
assert {"--expires-at", "--rbac-scopes"} <= set(token_create)
|
||||
|
||||
|
||||
def test_filesystem_completion_for_source_output_and_data(tmp_path: Any, monkeypatch: Any) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "source tree").mkdir()
|
||||
(tmp_path / "source.txt").write_text("source", encoding="utf-8")
|
||||
(tmp_path / "request.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
source = completion_candidates(["cloud", "scans", "start", "--source", "sou"])
|
||||
assert source == ["source tree/"]
|
||||
|
||||
output = completion_candidates(["cloud", "scans", "report", "scan-1", "--output", "req"])
|
||||
assert output == ["request.json"]
|
||||
|
||||
audit_output = completion_candidates(["cloud", "audit", "--output", "req"])
|
||||
assert audit_output == ["request.json"]
|
||||
|
||||
data = completion_candidates(["cloud", "scans", "start", "--data", "@req"])
|
||||
assert data == ["@request.json"]
|
||||
|
||||
|
||||
def test_filesystem_completion_omits_terminal_control_names(
|
||||
tmp_path: Any, monkeypatch: Any, capsys: Any
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "safe.json").write_text("{}", encoding="utf-8")
|
||||
(tmp_path / "unsafe\nname.json").write_text("{}", encoding="utf-8")
|
||||
(tmp_path / "unsafe\x1b]52;c;payload\x07.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
words = ["cloud", "scans", "start", "--data", "@"]
|
||||
assert completion_candidates(words) == ["@safe.json"]
|
||||
assert run_completions(["--candidates", *words]) == 0
|
||||
assert capsys.readouterr().out == "@safe.json\n"
|
||||
|
||||
|
||||
def test_completion_scripts_cover_supported_shells(capsys: Any) -> None:
|
||||
for shell in ("zsh", "bash", "fish"):
|
||||
assert run_completions([shell]) == 0
|
||||
output = capsys.readouterr().out
|
||||
assert "completions --candidates" in output
|
||||
|
||||
|
||||
def test_bash_completion_preserves_candidates_with_spaces(capsys: Any) -> None:
|
||||
assert run_completions(["bash"]) == 0
|
||||
output = capsys.readouterr().out
|
||||
assert 'COMPREPLY=("${candidates[@]}")' in output
|
||||
assert "while IFS= read -r candidate" in output
|
||||
assert "mapfile" not in output
|
||||
|
||||
|
||||
def test_completion_rejects_unknown_shell(capsys: Any) -> None:
|
||||
assert run_completions(["powershell\x1b]52;c;payload\x07"]) == 2
|
||||
error = capsys.readouterr().err
|
||||
assert "Choose zsh, bash, or fish" in error
|
||||
assert "\x1b" not in error
|
||||
assert "\\x1b" in error
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -11,6 +11,8 @@ import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
@@ -160,11 +162,15 @@ def _config(name: str, allowed_tools: list[str] | None) -> McpConnectionConfig:
|
||||
return McpConnectionConfig(
|
||||
name=name,
|
||||
url="https://mcp.example.com",
|
||||
auth=BearerAuth(token="run-token"),
|
||||
auth=BearerAuth(token="run-token"), # nosec B106
|
||||
allowed_tools=allowed_tools,
|
||||
)
|
||||
|
||||
|
||||
def _built_server(server: MCPServer) -> mcp_client.BuiltMcpServer:
|
||||
return mcp_client.BuiltMcpServer(server, None)
|
||||
|
||||
|
||||
def _ctx(registry: McpRegistry | None) -> ToolContext[dict[str, Any]]:
|
||||
context: dict[str, Any] = {} if registry is None else {MCP_REGISTRY_CONTEXT_KEY: registry}
|
||||
return ToolContext(
|
||||
@@ -204,7 +210,7 @@ def test_bearer_config_parses_from_dict() -> None:
|
||||
)
|
||||
|
||||
assert isinstance(config.auth, BearerAuth)
|
||||
assert config.auth.token == "abc"
|
||||
assert config.auth.token == "abc" # nosec B105
|
||||
assert config.allowed_tools == ["list_files"]
|
||||
|
||||
|
||||
@@ -298,7 +304,9 @@ async def test_connect_returns_sessions_without_registering_agent_tools(
|
||||
"fs": FakeMCPServer("fs", [_mcp_tool("read_file"), _mcp_tool("write_file")]),
|
||||
"db": FakeMCPServer("db", [_mcp_tool("query")]),
|
||||
}
|
||||
monkeypatch.setattr(mcp_client, "_build_server", lambda config: servers[config.name])
|
||||
monkeypatch.setattr(
|
||||
mcp_client, "_build_server", lambda config: _built_server(servers[config.name])
|
||||
)
|
||||
|
||||
connections = await mcp_client.connect_mcp_servers(
|
||||
[_config("fs", None), _config("db", ["query"])]
|
||||
@@ -315,7 +323,7 @@ async def test_connect_returns_sessions_without_registering_agent_tools(
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_count_honors_the_allowlist(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
server = FakeMCPServer("fs", [_mcp_tool("read_file"), _mcp_tool("write_file")])
|
||||
monkeypatch.setattr(mcp_client, "_build_server", lambda _config: server)
|
||||
monkeypatch.setattr(mcp_client, "_build_server", lambda _config: _built_server(server))
|
||||
|
||||
connections = await mcp_client.connect_mcp_servers([_config("fs", ["read_file"])])
|
||||
|
||||
@@ -329,7 +337,7 @@ async def test_connection_notes_ride_on_the_connection(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
server = FakeMCPServer("db", [_mcp_tool("query")])
|
||||
monkeypatch.setattr(mcp_client, "_build_server", lambda _config: server)
|
||||
monkeypatch.setattr(mcp_client, "_build_server", lambda _config: _built_server(server))
|
||||
config = McpConnectionConfig(
|
||||
name="db",
|
||||
url="https://mcp.example.com",
|
||||
@@ -356,7 +364,7 @@ def test_build_server_stdio_branch() -> None:
|
||||
env={"TOKEN": "x"},
|
||||
)
|
||||
|
||||
server = mcp_client._build_server(config)
|
||||
server = mcp_client._build_server(config).server
|
||||
|
||||
assert isinstance(server, MCPServerStdio)
|
||||
assert server.name == "local_fs"
|
||||
@@ -366,7 +374,7 @@ def test_build_server_stdio_branch() -> None:
|
||||
|
||||
|
||||
def test_build_server_http_branch() -> None:
|
||||
server = mcp_client._build_server(_config("files_main", ["list_files"]))
|
||||
server = mcp_client._build_server(_config("files_main", ["list_files"])).server
|
||||
|
||||
assert isinstance(server, MCPServerStreamableHttp)
|
||||
assert server.name == "files_main"
|
||||
@@ -847,7 +855,9 @@ async def test_connect_skips_a_connection_whose_connect_is_cancelled(
|
||||
cleaned.append(self._name)
|
||||
|
||||
servers = {"good": _Tracking("good"), "bad": _Tracking("bad", cancel_connect=True)}
|
||||
monkeypatch.setattr(mcp_client, "_build_server", lambda config: servers[config.name])
|
||||
monkeypatch.setattr(
|
||||
mcp_client, "_build_server", lambda config: _built_server(servers[config.name])
|
||||
)
|
||||
|
||||
configs = [_config("good", ["t"]), _config("bad", ["t"])]
|
||||
|
||||
@@ -883,7 +893,9 @@ async def test_connect_cleans_up_started_sessions_when_attach_is_cancelled(
|
||||
cleaned.append(self._name)
|
||||
|
||||
servers = {"good": _Tracking("good"), "slow": _Tracking("slow", block_connect=True)}
|
||||
monkeypatch.setattr(mcp_client, "_build_server", lambda config: servers[config.name])
|
||||
monkeypatch.setattr(
|
||||
mcp_client, "_build_server", lambda config: _built_server(servers[config.name])
|
||||
)
|
||||
|
||||
async def _attach() -> list[Any]:
|
||||
# Connect "good" first, then hang forever connecting "slow".
|
||||
@@ -962,7 +974,7 @@ async def test_attach_populates_registry_with_provider_and_transform(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
server = FakeMCPServer("db", [_mcp_tool("query")])
|
||||
monkeypatch.setattr(mcp_client, "_build_server", lambda _config: server)
|
||||
monkeypatch.setattr(mcp_client, "_build_server", lambda _config: _built_server(server))
|
||||
|
||||
def transform(_label: str, structured: Any) -> Any:
|
||||
return {"kept": structured}
|
||||
@@ -995,7 +1007,7 @@ async def test_attach_bare_request_matches_the_command_line_shape(
|
||||
# The command-line path wraps each config in a bare request (no provider or
|
||||
# transform); purpose then falls back to the connection's notes.
|
||||
server = FakeMCPServer("db", [_mcp_tool("query")])
|
||||
monkeypatch.setattr(mcp_client, "_build_server", lambda _config: server)
|
||||
monkeypatch.setattr(mcp_client, "_build_server", lambda _config: _built_server(server))
|
||||
config = McpConnectionConfig(
|
||||
name="db",
|
||||
url="https://mcp.example.com",
|
||||
@@ -1026,7 +1038,9 @@ async def test_attach_is_fail_open_and_skips_a_failed_connection(
|
||||
raise RuntimeError("cannot reach server")
|
||||
|
||||
servers = {"good": good, "bad": _Failing("bad", [_mcp_tool("t")])}
|
||||
monkeypatch.setattr(mcp_client, "_build_server", lambda config: servers[config.name])
|
||||
monkeypatch.setattr(
|
||||
mcp_client, "_build_server", lambda config: _built_server(servers[config.name])
|
||||
)
|
||||
|
||||
registry = McpRegistry()
|
||||
connections = await attach_mcp_requests(
|
||||
@@ -1213,7 +1227,7 @@ def _secret_config(name: str) -> McpConnectionConfig:
|
||||
return McpConnectionConfig(
|
||||
name=name,
|
||||
url="https://mcp.example.com",
|
||||
auth=BearerAuth(token="super-secret-bearer-token-42"),
|
||||
auth=BearerAuth(token="super-secret-bearer-token-42"), # nosec B106
|
||||
allowed_tools=["read_file"],
|
||||
)
|
||||
|
||||
@@ -1234,7 +1248,7 @@ async def test_call_mcp_reconnects_and_retries_after_a_session_death(
|
||||
first = _DyingHttpServer("fs", [_mcp_tool("read_file")], death=ConnectionError("403"))
|
||||
second = FakeMCPServer("fs", [_mcp_tool("read_file")])
|
||||
built = iter([first, second])
|
||||
monkeypatch.setattr(mcp_client, "_build_server", lambda _config: next(built))
|
||||
monkeypatch.setattr(mcp_client, "_build_server", lambda _config: _built_server(next(built)))
|
||||
|
||||
session = await _started_session(_secret_config("fs"))
|
||||
registry = McpRegistry()
|
||||
@@ -1257,15 +1271,15 @@ async def test_call_mcp_reconnects_and_retries_after_a_session_death(
|
||||
async def test_call_mcp_marks_connection_dead_when_reconnect_keeps_failing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# The session dies and the reconnect attempt also fails: the connection is
|
||||
# marked dead and the call returns the standard failed-tool output.
|
||||
# Reconnect failures are retried and then quarantine the connection rather
|
||||
# than permanently retiring it on the first failed reconnect.
|
||||
first = _DyingHttpServer("fs", [_mcp_tool("read_file")], death=ConnectionError("403"))
|
||||
built = {"n": 0}
|
||||
|
||||
def _build(_config: McpConnectionConfig) -> MCPServer:
|
||||
def _build(_config: McpConnectionConfig) -> mcp_client.BuiltMcpServer:
|
||||
built["n"] += 1
|
||||
if built["n"] == 1:
|
||||
return first
|
||||
return _built_server(first)
|
||||
raise ConnectionError("cannot reconnect")
|
||||
|
||||
monkeypatch.setattr(mcp_client, "_build_server", _build)
|
||||
@@ -1282,9 +1296,12 @@ async def test_call_mcp_marks_connection_dead_when_reconnect_keeps_failing(
|
||||
assert isinstance(out, dict)
|
||||
assert out["success"] is False
|
||||
assert "unavailable" in out["content"]
|
||||
assert session.is_dead is True
|
||||
assert session.is_dead is False
|
||||
assert session.is_unavailable is True
|
||||
assert session.server is None
|
||||
assert session._task is not None and not session._task.done()
|
||||
|
||||
# A later call short-circuits to the same failed output without a new attempt.
|
||||
# A later call during cooldown short-circuits to the same failed output.
|
||||
again = await call_mcp.on_invoke_tool(
|
||||
_ctx(registry), json.dumps({"connection": "fs", "tool": "read_file"})
|
||||
)
|
||||
@@ -1309,18 +1326,21 @@ async def _pump_until(predicate: Callable[[], bool], *, limit: int = 100) -> Non
|
||||
raise AssertionError("condition not reached")
|
||||
|
||||
|
||||
def _quarantine_reached(session: SupervisedMcpSession, count: int) -> bool:
|
||||
return session.is_dead or session._quarantine_count >= count
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idle_session_death_self_heals_on_reconnect(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# A session that dies while idle (its supervising task cancelled between calls,
|
||||
# modeling the transport scope dying with no call in flight) reconnects once on
|
||||
# its own and keeps serving, rather than staying dead until a later call would
|
||||
# have triggered a reconnect.
|
||||
# modeling the transport scope dying with no call in flight) is quarantined and
|
||||
# keeps serving, rather than ending its supervising task.
|
||||
first = FakeMCPServer("fs", [_mcp_tool("read_file")])
|
||||
second = FakeMCPServer("fs", [_mcp_tool("read_file")])
|
||||
built = iter([first, second])
|
||||
monkeypatch.setattr(mcp_client, "_build_server", lambda _config: next(built))
|
||||
monkeypatch.setattr(mcp_client, "_build_server", lambda _config: _built_server(next(built)))
|
||||
|
||||
session = await _started_session(_secret_config("fs"))
|
||||
registry = McpRegistry()
|
||||
@@ -1328,15 +1348,16 @@ async def test_idle_session_death_self_heals_on_reconnect(
|
||||
|
||||
assert session._task is not None
|
||||
session._task.cancel() # idle transport death: no call in flight
|
||||
await _pump_until(lambda: session.server is second)
|
||||
await _pump_until(lambda: session.is_unavailable)
|
||||
assert session.is_dead is False
|
||||
assert session.server is None
|
||||
|
||||
# The reconnected session serves calls normally.
|
||||
# Once the cooldown expires, the next call reconnects onto a fresh session.
|
||||
session._unavailable_until = time.monotonic() - 1
|
||||
out = await call_mcp.on_invoke_tool(
|
||||
_ctx(registry), json.dumps({"connection": "fs", "tool": "read_file"})
|
||||
)
|
||||
assert out == {"type": "text", "text": "routed:read_file"}
|
||||
|
||||
await session.aclose()
|
||||
|
||||
|
||||
@@ -1344,26 +1365,24 @@ async def test_idle_session_death_self_heals_on_reconnect(
|
||||
async def test_flapping_idle_session_is_marked_dead_without_looping(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# If a session reconnects after an idle death but dies again before serving any
|
||||
# call, the supervisor stops reconnecting and marks the connection dead, so a
|
||||
# server that instantly drops on connect cannot spin in a reconnect loop.
|
||||
# Repeated idle deaths consume quarantine slots; the supervisor stays alive
|
||||
# until the configured permanent-death threshold is reached.
|
||||
first = FakeMCPServer("fs", [_mcp_tool("read_file")])
|
||||
second = FakeMCPServer("fs", [_mcp_tool("read_file")])
|
||||
built = iter([first, second])
|
||||
monkeypatch.setattr(mcp_client, "_build_server", lambda _config: next(built))
|
||||
monkeypatch.setattr(mcp_client, "_build_server", lambda _config: _built_server(next(built)))
|
||||
|
||||
session = await _started_session(_secret_config("fs"))
|
||||
registry = McpRegistry()
|
||||
registry.add(name="fs", session=session, tool_count=1)
|
||||
|
||||
assert session._task is not None
|
||||
# First idle death heals onto the second server (only two builds ever happen).
|
||||
session._task.cancel()
|
||||
await _pump_until(lambda: session.server is second)
|
||||
assert session.is_dead is False
|
||||
|
||||
# Second idle death before any call is served: give up rather than reconnect.
|
||||
session._task.cancel()
|
||||
# Three idle deaths exhaust the quarantine budget.
|
||||
for count in range(1, 4):
|
||||
session._task.cancel()
|
||||
await _pump_until(partial(_quarantine_reached, session, count))
|
||||
if session.is_dead:
|
||||
break
|
||||
await _pump_until(lambda: session._task is not None and session._task.done())
|
||||
assert session.is_dead is True
|
||||
|
||||
@@ -1420,7 +1439,7 @@ async def test_aclose_is_bounded_when_an_in_flight_call_hangs(
|
||||
# aclose falls back to cancelling the supervising task, and cleanup still runs.
|
||||
monkeypatch.setattr(mcp_session_mod, "_SHUTDOWN_TIMEOUT", 0.2)
|
||||
server = _HangingCallServer("fs", [_mcp_tool("read_file")])
|
||||
monkeypatch.setattr(mcp_client, "_build_server", lambda _config: server)
|
||||
monkeypatch.setattr(mcp_client, "_build_server", lambda _config: _built_server(server))
|
||||
|
||||
session = await _started_session(_secret_config("fs"))
|
||||
call = asyncio.create_task(session.dispatch("read_file", {}, label="fs_read_file"))
|
||||
@@ -1444,7 +1463,7 @@ async def test_aclose_cleans_up_when_connect_is_cancelled_mid_await(
|
||||
# is cancelled; aclose must not raise on it and must still cancel + clean up the
|
||||
# partially connected supervisor.
|
||||
server = _HangingConnectServer("fs", [_mcp_tool("read_file")])
|
||||
monkeypatch.setattr(mcp_client, "_build_server", lambda _config: server)
|
||||
monkeypatch.setattr(mcp_client, "_build_server", lambda _config: _built_server(server))
|
||||
|
||||
session = SupervisedMcpSession(_secret_config("fs"))
|
||||
start = asyncio.create_task(session.start())
|
||||
@@ -1469,14 +1488,14 @@ async def test_a_session_death_is_contained_and_other_connections_survive(
|
||||
healthy = FakeMCPServer("healthy", [_mcp_tool("read_file")])
|
||||
dying_builds = {"n": 0}
|
||||
|
||||
def _build(config: McpConnectionConfig) -> MCPServer:
|
||||
def _build(config: McpConnectionConfig) -> mcp_client.BuiltMcpServer:
|
||||
if config.name == "healthy":
|
||||
return healthy
|
||||
return _built_server(healthy)
|
||||
# The dying connection connects once, then its rebuild raises, so it ends
|
||||
# up marked dead rather than recovering.
|
||||
dying_builds["n"] += 1
|
||||
if dying_builds["n"] == 1:
|
||||
return dying
|
||||
return _built_server(dying)
|
||||
raise ConnectionError("cannot reconnect")
|
||||
|
||||
monkeypatch.setattr(mcp_client, "_build_server", _build)
|
||||
@@ -1513,11 +1532,13 @@ async def test_reconnect_reuses_the_stored_config_and_never_logs_the_token(
|
||||
# inventory list_mcps emits.
|
||||
seen_tokens: list[str | None] = []
|
||||
|
||||
def _build(config: McpConnectionConfig) -> MCPServer:
|
||||
def _build(config: McpConnectionConfig) -> mcp_client.BuiltMcpServer:
|
||||
seen_tokens.append(config.auth.token if config.auth else None)
|
||||
if len(seen_tokens) == 1:
|
||||
return _DyingHttpServer("fs", [_mcp_tool("read_file")], death=ConnectionError("403"))
|
||||
return FakeMCPServer("fs", [_mcp_tool("read_file")])
|
||||
return _built_server(
|
||||
_DyingHttpServer("fs", [_mcp_tool("read_file")], death=ConnectionError("403"))
|
||||
)
|
||||
return _built_server(FakeMCPServer("fs", [_mcp_tool("read_file")]))
|
||||
|
||||
monkeypatch.setattr(mcp_client, "_build_server", _build)
|
||||
|
||||
@@ -1566,23 +1587,37 @@ class _RaisingMCPServer(FakeMCPServer):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_on_dead_fires_once_on_the_death_transition() -> None:
|
||||
# An adopted session with no config cannot reconnect, so the first failed
|
||||
# call marks it dead; the on-dead callback fires exactly once, on the edge.
|
||||
async def test_session_on_dead_fires_once_on_the_death_transition(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# An adopted session with no config cannot reconnect, so repeated transient
|
||||
# exhaustion eventually marks it dead; the callback fires once on that edge.
|
||||
server = _RaisingMCPServer("db", [_mcp_tool("read")])
|
||||
session = SupervisedMcpSession.adopt(server, name="db")
|
||||
fires: list[int] = []
|
||||
session.set_on_dead(lambda: fires.append(1))
|
||||
|
||||
clock = [100.0]
|
||||
monkeypatch.setattr("strix.tools.mcp.session.time.monotonic", lambda: clock[0])
|
||||
monkeypatch.setattr(mcp_session_mod, "_retry_delay", lambda _attempt, _retry_after: 0)
|
||||
|
||||
async def no_sleep(_delay: float) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(asyncio, "sleep", no_sleep)
|
||||
out = await session.dispatch("read", {}, label="db_read")
|
||||
|
||||
assert session.is_dead is True
|
||||
assert session.is_dead is False
|
||||
assert isinstance(out, dict) and out.get("success") is False
|
||||
assert fires == [1]
|
||||
assert fires == []
|
||||
|
||||
# A later call to the already-dead session must not fire the callback again.
|
||||
clock[0] += 31
|
||||
await session.dispatch("read", {}, label="db_read")
|
||||
assert session.is_dead is False
|
||||
clock[0] += 61
|
||||
await session.dispatch("read", {}, label="db_read")
|
||||
assert fires == [1]
|
||||
assert session.is_dead is True
|
||||
|
||||
|
||||
def test_registry_statuses_report_the_live_dead_flag_and_provider() -> None:
|
||||
|
||||
521
tests/test_mcp_resilience.py
Normal file
521
tests/test_mcp_resilience.py
Normal file
@@ -0,0 +1,521 @@
|
||||
"""Fast regression tests for MCP failure handling and lifecycle resilience."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from agents.exceptions import UserError
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.types import ErrorData
|
||||
|
||||
from strix.tools.mcp import BearerAuth, McpConnectionConfig
|
||||
from strix.tools.mcp import client as mcp_client
|
||||
from strix.tools.mcp import session as mcp_session
|
||||
from strix.tools.mcp.failures import FailureInfo, HttpStatusRecorder, classify
|
||||
|
||||
|
||||
_test_mcp_client = importlib.import_module("tests.test_mcp_client")
|
||||
FakeMCPServer: Any = _test_mcp_client.FakeMCPServer
|
||||
_mcp_tool: Any = _test_mcp_client._mcp_tool
|
||||
|
||||
|
||||
def _built_server(server: Any) -> Any:
|
||||
return mcp_client.BuiltMcpServer(server, None)
|
||||
|
||||
|
||||
def _http_error(status: int, *, retry_after: str | None = None) -> httpx.HTTPStatusError:
|
||||
request = httpx.Request(
|
||||
"POST",
|
||||
"https://provider.example/tools?token=secret-query",
|
||||
headers={"Authorization": "Bearer secret-header"},
|
||||
content=b"secret-body",
|
||||
)
|
||||
response = httpx.Response(
|
||||
status,
|
||||
request=request,
|
||||
headers={"Retry-After": retry_after} if retry_after else None,
|
||||
)
|
||||
return httpx.HTTPStatusError("provider failure", request=request, response=response)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exc", "kind"),
|
||||
[
|
||||
(_http_error(401), "auth"),
|
||||
(_http_error(403), "permission"),
|
||||
(_http_error(429), "rate_limit"),
|
||||
(_http_error(503), "server"),
|
||||
(_http_error(404), "protocol"),
|
||||
(httpx.ReadTimeout("timed out"), "timeout"),
|
||||
(httpx.ConnectError("disconnected"), "transport"),
|
||||
(McpError(ErrorData(code=-1, message="bad response")), "protocol"),
|
||||
(UserError("Failed to call tool: HTTP error 403"), "permission"),
|
||||
],
|
||||
)
|
||||
def test_classifies_failures(exc: BaseException, kind: str) -> None:
|
||||
assert classify(exc).kind == kind
|
||||
|
||||
|
||||
def test_classifies_nested_exception_groups_by_specificity() -> None:
|
||||
error = ExceptionGroup(
|
||||
"outer",
|
||||
[ExceptionGroup("inner", [httpx.ConnectError("down"), _http_error(401)])],
|
||||
)
|
||||
info = classify(error)
|
||||
assert info.kind == "auth"
|
||||
assert info.status == 401
|
||||
assert info.retryable is False
|
||||
|
||||
|
||||
def test_classifies_permission_before_rate_limit() -> None:
|
||||
error = ExceptionGroup("outer", [_http_error(429), _http_error(403)])
|
||||
info = classify(error)
|
||||
assert info.kind == "permission"
|
||||
assert info.status == 403
|
||||
assert info.retryable is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("control_flow", [SystemExit, KeyboardInterrupt])
|
||||
@pytest.mark.asyncio
|
||||
async def test_control_flow_exceptions_propagate(
|
||||
control_flow: type[BaseException],
|
||||
) -> None:
|
||||
server = _sequence_server("control-flow", control_flow("stop"))
|
||||
session = mcp_session.SupervisedMcpSession.adopt(server, name="control-flow")
|
||||
|
||||
with pytest.raises(control_flow):
|
||||
await session.dispatch("read", {}, label="control_flow")
|
||||
|
||||
await session.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_after_parses_seconds_and_http_date() -> None:
|
||||
seconds = HttpStatusRecorder()
|
||||
await seconds(_http_error(429, retry_after="12").response)
|
||||
assert seconds.take() is not None
|
||||
assert seconds.take() is None
|
||||
|
||||
date = (datetime.now(UTC) + timedelta(seconds=20)).strftime("%a, %d %b %Y %H:%M:%S GMT")
|
||||
recorder = HttpStatusRecorder()
|
||||
await recorder(_http_error(429, retry_after=date).response)
|
||||
info = recorder.take()
|
||||
assert info is not None
|
||||
retry_after = info.retry_after
|
||||
assert retry_after is not None
|
||||
assert 0 <= retry_after <= 20
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recorder_only_keeps_non_sensitive_request_metadata() -> None:
|
||||
recorder = HttpStatusRecorder()
|
||||
response = _http_error(500, retry_after="3").response
|
||||
await recorder(response)
|
||||
info = recorder.take()
|
||||
assert info == FailureInfo(
|
||||
"server",
|
||||
500,
|
||||
"Internal Server Error",
|
||||
3,
|
||||
"POST",
|
||||
"/tools",
|
||||
)
|
||||
assert "secret" not in repr(info)
|
||||
assert recorder.take() is None
|
||||
|
||||
|
||||
def _config(name: str, **kwargs: Any) -> McpConnectionConfig:
|
||||
return McpConnectionConfig(
|
||||
name=name,
|
||||
url="https://provider.example/mcp",
|
||||
auth=BearerAuth(token="secret-token"), # noqa: S106 # nosec B106
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
async def _no_sleep(_delay: float) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _zero_delay(_attempt: int, _retry_after: float | None) -> float:
|
||||
return 0
|
||||
|
||||
|
||||
def _sequence_server(name: str, error: BaseException | None = None) -> Any:
|
||||
server = FakeMCPServer(name, [_mcp_tool("read")])
|
||||
original_call_tool = server.call_tool
|
||||
|
||||
async def call_tool(tool_name: str, arguments: dict[str, Any] | None, meta: Any = None) -> Any:
|
||||
if error is not None:
|
||||
raise error
|
||||
return await original_call_tool(tool_name, arguments, meta)
|
||||
|
||||
server.call_tool = call_tool
|
||||
return server
|
||||
|
||||
|
||||
def _list_tools_error_server(name: str, error: BaseException) -> Any:
|
||||
server = FakeMCPServer(name, [_mcp_tool("read")])
|
||||
|
||||
async def list_tools(*_args: Any, **_kwargs: Any) -> Any:
|
||||
raise error
|
||||
|
||||
server.list_tools = list_tools
|
||||
return server
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_retries_and_succeeds(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(mcp_session, "_retry_delay", _zero_delay)
|
||||
monkeypatch.setattr(asyncio, "sleep", _no_sleep)
|
||||
builds = iter(
|
||||
[
|
||||
_sequence_server("rate", _http_error(429, retry_after="0")),
|
||||
_sequence_server("rate"),
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(mcp_client, "_build_server", lambda _config: _built_server(next(builds)))
|
||||
session = mcp_session.SupervisedMcpSession(_config("rate"))
|
||||
assert await session.start()
|
||||
result = await session.dispatch("read", {}, label="rate_read")
|
||||
assert result == {"type": "text", "text": "routed:read"}
|
||||
assert session.is_dead is False
|
||||
await session.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_exhaustion_quarantines_then_revives(
|
||||
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("quarantine", _http_error(500)),
|
||||
_sequence_server("quarantine", _http_error(500)),
|
||||
_sequence_server("quarantine", _http_error(500)),
|
||||
_sequence_server("quarantine"),
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(mcp_client, "_build_server", lambda _config: _built_server(next(builds)))
|
||||
session = mcp_session.SupervisedMcpSession(_config("quarantine"))
|
||||
assert await session.start()
|
||||
result = await session.dispatch("read", {}, label="quarantine_read")
|
||||
assert result["success"] is False
|
||||
assert session.is_dead is False
|
||||
assert session.is_unavailable is True
|
||||
assert session.server is None
|
||||
clock[0] += 31
|
||||
result = await session.dispatch("read", {}, label="quarantine_read")
|
||||
assert result == {"type": "text", "text": "routed:read"}
|
||||
await session.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_resets_quarantine_strikes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# A quarantine strike must be cleared by a successful revival, so transient
|
||||
# failure bursts separated by successes do not accumulate toward permanent
|
||||
# retirement. Without the reset, three such bursts would mark the connection
|
||||
# dead even though it recovered between each one.
|
||||
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("strikes", _http_error(500)),
|
||||
_sequence_server("strikes", _http_error(500)),
|
||||
_sequence_server("strikes", _http_error(500)),
|
||||
_sequence_server("strikes"),
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(mcp_client, "_build_server", lambda _config: _built_server(next(builds)))
|
||||
session = mcp_session.SupervisedMcpSession(_config("strikes"))
|
||||
assert await session.start()
|
||||
|
||||
# First burst exhausts three attempts and quarantines: one strike.
|
||||
result = await session.dispatch("read", {}, label="strikes_read")
|
||||
assert result["success"] is False
|
||||
assert session._quarantine_count == 1
|
||||
|
||||
# The revive succeeds, which must clear the strike back to zero.
|
||||
clock[0] += 31
|
||||
result = await session.dispatch("read", {}, label="strikes_read")
|
||||
assert result == {"type": "text", "text": "routed:read"}
|
||||
assert session._quarantine_count == 0
|
||||
assert session.is_dead is False
|
||||
await session.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_failure_dies_without_retry(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
builds = [_sequence_server("auth", _http_error(401))]
|
||||
monkeypatch.setattr(mcp_client, "_build_server", lambda _config: _built_server(builds.pop()))
|
||||
session = mcp_session.SupervisedMcpSession(_config("auth"))
|
||||
assert await session.start()
|
||||
result = await session.dispatch("read", {}, label="auth_read")
|
||||
assert result["success"] is False
|
||||
assert session.is_dead is True
|
||||
assert builds == []
|
||||
await session.aclose()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status", "name"),
|
||||
[(403, "permission-call"), (400, "protocol-call")],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_http_rejection_preserves_session(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
status: int,
|
||||
name: str,
|
||||
) -> None:
|
||||
first = _sequence_server(name, _http_error(status))
|
||||
second = _sequence_server(name)
|
||||
builds = iter([first, second])
|
||||
monkeypatch.setattr(mcp_client, "_build_server", lambda _config: _built_server(next(builds)))
|
||||
|
||||
session = mcp_session.SupervisedMcpSession(_config(name))
|
||||
assert await session.start()
|
||||
result = await session.dispatch("read", {}, label=f"{name}_read")
|
||||
assert result["success"] is False
|
||||
assert "not the connection" in result["content"]
|
||||
assert session.is_dead is False
|
||||
assert session.is_unavailable is False
|
||||
assert session._quarantine_count == 0
|
||||
|
||||
result = await session.dispatch("read", {}, label=f"{name}_read")
|
||||
assert result == {"type": "text", "text": "routed:read"}
|
||||
assert session.is_dead is False
|
||||
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))
|
||||
session = mcp_session.SupervisedMcpSession.adopt(
|
||||
server,
|
||||
name="connect-403",
|
||||
config=_config("connect-403"),
|
||||
)
|
||||
|
||||
with pytest.raises(mcp_session.McpConnectionUnavailableError):
|
||||
await session.list_tools()
|
||||
assert session.is_dead is True
|
||||
await session.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_call_uses_recorded_status(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
recorder = HttpStatusRecorder()
|
||||
first = _sequence_server("cancelled", asyncio.CancelledError())
|
||||
second = _sequence_server("cancelled")
|
||||
builds = iter(
|
||||
[
|
||||
mcp_client.BuiltMcpServer(first, recorder),
|
||||
mcp_client.BuiltMcpServer(second, None),
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(mcp_client, "_build_server", lambda _config: next(builds))
|
||||
monkeypatch.setattr(mcp_session, "_retry_delay", _zero_delay)
|
||||
monkeypatch.setattr(asyncio, "sleep", _no_sleep)
|
||||
|
||||
session = mcp_session.SupervisedMcpSession(_config("cancelled"))
|
||||
assert await session.start()
|
||||
await recorder(_http_error(503).response)
|
||||
result = await session.dispatch("read", {}, label="cancelled_read")
|
||||
assert result == {"type": "text", "text": "routed:read"}
|
||||
await session.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_server_passes_explicit_http_values(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
class Server:
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
captured.update(kwargs)
|
||||
|
||||
monkeypatch.setattr(mcp_client, "MCPServerStreamableHttp", Server)
|
||||
config = _config(
|
||||
"values",
|
||||
http_timeout_seconds=11,
|
||||
sse_read_timeout_seconds=22,
|
||||
session_timeout_seconds=33,
|
||||
)
|
||||
mcp_client._build_server(config)
|
||||
assert captured["params"]["timeout"] == 11
|
||||
assert captured["params"]["sse_read_timeout"] == 22
|
||||
assert captured["client_session_timeout_seconds"] == 33
|
||||
factory = captured["params"]["httpx_client_factory"]
|
||||
client = factory(headers={}, timeout=httpx.Timeout(1), auth=None)
|
||||
assert client.event_hooks["response"]
|
||||
await client.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_factory_awaits_response_recorder() -> None:
|
||||
built = mcp_client._build_server(_config("hook"))
|
||||
assert built.recorder is not None
|
||||
factory = cast("Any", built.server).params["httpx_client_factory"]
|
||||
client = factory(headers={}, timeout=httpx.Timeout(1), auth=None)
|
||||
|
||||
def response(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(429, headers={"Retry-After": "7"}, request=request)
|
||||
|
||||
client._transport = httpx.MockTransport(response)
|
||||
result = await client.get("https://provider.example/mcp?token=secret-query")
|
||||
assert result.status_code == 429
|
||||
info = built.recorder.take()
|
||||
assert info is not None
|
||||
assert info.kind == "rate_limit"
|
||||
assert info.status == 429
|
||||
assert info.retry_after == 7
|
||||
assert info.request_method == "GET"
|
||||
assert info.request_path == "/mcp"
|
||||
await client.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_name_sessions_share_concurrency_cap() -> None:
|
||||
active = 0
|
||||
peak = 0
|
||||
|
||||
def slow_server() -> Any:
|
||||
server = FakeMCPServer("cap", [_mcp_tool("read")])
|
||||
original_call_tool = server.call_tool
|
||||
|
||||
async def call_tool(
|
||||
tool_name: str, arguments: dict[str, Any] | None, meta: Any = None
|
||||
) -> Any:
|
||||
nonlocal active, peak
|
||||
active += 1
|
||||
peak = max(peak, active)
|
||||
await asyncio.sleep(0.01)
|
||||
active -= 1
|
||||
return await original_call_tool(tool_name, arguments, meta)
|
||||
|
||||
server.call_tool = call_tool
|
||||
return server
|
||||
|
||||
first = slow_server()
|
||||
second = slow_server()
|
||||
config = _config("cap", max_concurrent_calls=1)
|
||||
left = mcp_session.SupervisedMcpSession.adopt(first, name="cap", config=config)
|
||||
right = mcp_session.SupervisedMcpSession.adopt(second, name="cap", config=config)
|
||||
await asyncio.gather(
|
||||
left.dispatch("read", {}, label="cap_read"),
|
||||
right.dispatch("read", {}, label="cap_read"),
|
||||
)
|
||||
assert peak == 1
|
||||
await left.aclose()
|
||||
await right.aclose()
|
||||
|
||||
|
||||
def test_same_name_semaphore_works_across_event_loops() -> None:
|
||||
async def run_once() -> None:
|
||||
server = FakeMCPServer("loop-cap", [_mcp_tool("read")])
|
||||
session = mcp_session.SupervisedMcpSession.adopt(
|
||||
server,
|
||||
name="loop-cap",
|
||||
config=_config("loop-cap", max_concurrent_calls=1),
|
||||
)
|
||||
assert await session.dispatch("read", {}, label="loop_cap_read") == {
|
||||
"type": "text",
|
||||
"text": "routed:read",
|
||||
}
|
||||
await session.aclose()
|
||||
|
||||
asyncio.run(run_once())
|
||||
asyncio.run(run_once())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resilience_logs_do_not_include_request_secrets(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
monkeypatch.setattr(mcp_session, "_retry_delay", _zero_delay)
|
||||
monkeypatch.setattr(asyncio, "sleep", _no_sleep)
|
||||
server = _sequence_server("redaction", _http_error(401))
|
||||
monkeypatch.setattr(mcp_client, "_build_server", lambda _config: _built_server(server))
|
||||
session = mcp_session.SupervisedMcpSession(_config("redaction"))
|
||||
assert await session.start()
|
||||
with caplog.at_level("WARNING"):
|
||||
await session.dispatch("read", {}, label="redaction_read")
|
||||
assert "secret-token" not in caplog.text
|
||||
assert "secret-query" not in caplog.text
|
||||
assert "secret-header" not in caplog.text
|
||||
assert "secret-body" not in caplog.text
|
||||
await session.aclose()
|
||||
@@ -14,7 +14,10 @@ def test_resolves_common_bare_model_names() -> None:
|
||||
assert resolve_litellm_model("deepseek-v4-flash") == "deepseek/deepseek-v4-flash"
|
||||
assert resolve_litellm_model("openai/deepseek-v4-flash") == "deepseek/deepseek-v4-flash"
|
||||
assert resolve_litellm_model("grok-4.5") == "xai/grok-4.5"
|
||||
assert resolve_litellm_model("MiniMax-M3") == "minimax/MiniMax-M3"
|
||||
# MiniMax-M3 is sold by several LiteLLM providers at different prices, so
|
||||
# the resolver must not guess from its bare name. A provider-qualified
|
||||
# model remains deterministic.
|
||||
assert resolve_litellm_model("minimax/MiniMax-M3") == "minimax/MiniMax-M3"
|
||||
|
||||
|
||||
def test_resolver_returns_none_for_unresolvable_model() -> None:
|
||||
|
||||
@@ -4,13 +4,19 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from io import BytesIO
|
||||
from itertools import product
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from pypdf import PdfReader
|
||||
from pypdf.errors import WrongPasswordError
|
||||
from reportlab.lib.styles import ParagraphStyle
|
||||
from reportlab.platypus import Paragraph
|
||||
|
||||
from strix.interface.viewer.report_pdf import (
|
||||
_duration,
|
||||
_inline_md,
|
||||
_normalize_severity,
|
||||
build_encrypted_report,
|
||||
encrypt_pdf,
|
||||
generate_password,
|
||||
@@ -60,6 +66,10 @@ def _make_run(base: Path, name: str = "sample") -> Path:
|
||||
return run_dir
|
||||
|
||||
|
||||
def _pdf_text(pdf: bytes) -> str:
|
||||
return "\n".join(page.extract_text() or "" for page in PdfReader(BytesIO(pdf)).pages)
|
||||
|
||||
|
||||
def test_generate_report_pdf_has_pdf_header(tmp_path: Path) -> None:
|
||||
run_dir = _make_run(tmp_path)
|
||||
pdf = generate_report_pdf(run_dir)
|
||||
@@ -103,3 +113,132 @@ def test_build_encrypted_report(tmp_path: Path) -> None:
|
||||
reader = PdfReader(BytesIO(pdf_bytes))
|
||||
assert reader.is_encrypted
|
||||
assert reader.decrypt(password)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "expected"),
|
||||
[
|
||||
("**bold**", "<b>bold</b>"),
|
||||
("__bold__", "<b>bold</b>"),
|
||||
("*italic*", "<i>italic</i>"),
|
||||
("***both***", "<i><b>both</b></i>"),
|
||||
("**bold with *italic* inside**", "<b>bold with <i>italic</i> inside</b>"),
|
||||
("*outer **bold** inner*", "<i>outer <b>bold</b> inner</i>"),
|
||||
(r"\*literal\*", "*literal*"),
|
||||
("******", "******"),
|
||||
("`a * < &`", '<font face="Courier" color="#b31d28">a * < &</font>'),
|
||||
(
|
||||
"",
|
||||
"",
|
||||
),
|
||||
("<https://example.invalid>", "<https://example.invalid>"),
|
||||
],
|
||||
)
|
||||
def test_inline_md_emits_only_safe_balanced_markup(text: str, expected: str) -> None:
|
||||
markup = _inline_md(text)
|
||||
assert markup == expected
|
||||
Paragraph(markup, ParagraphStyle("test"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text",
|
||||
[
|
||||
"*a **b* c**",
|
||||
"**a *b** c*",
|
||||
"*outer **inner* end**",
|
||||
"__a *b__ c*",
|
||||
"***__***__",
|
||||
"__***__***",
|
||||
"<b><i></b></i>",
|
||||
"<font size='999'>x</font>",
|
||||
"<img src='/definitely/missing.png'/>",
|
||||
"\x000\x00 `code` \x0099\x00",
|
||||
"\ud800",
|
||||
],
|
||||
)
|
||||
def test_inline_md_survives_malformed_external_text(text: str) -> None:
|
||||
markup = _inline_md(text)
|
||||
assert "\x00" not in markup
|
||||
assert "\ud800" not in markup
|
||||
Paragraph(markup, ParagraphStyle("test"))
|
||||
|
||||
|
||||
def test_inline_md_generated_corpus_never_breaks_reportlab() -> None:
|
||||
style = ParagraphStyle("test")
|
||||
for length in range(1, 6):
|
||||
for chars in product("*_`a ", repeat=length):
|
||||
Paragraph(_inline_md("".join(chars)), style)
|
||||
|
||||
|
||||
def test_generate_report_pdf_survives_hostile_run_fields(tmp_path: Path) -> None:
|
||||
run_dir = _make_run(tmp_path)
|
||||
hostile = "****** <b><i></b></i> <img src='/definitely/missing.png'/> \x000\x00 \ud800"
|
||||
record = json.loads((run_dir / "run.json").read_text(encoding="utf-8"))
|
||||
record.update(
|
||||
{
|
||||
"run_name": hostile,
|
||||
"targets_info": [{"original": hostile}],
|
||||
"scan_mode": hostile,
|
||||
"status": hostile,
|
||||
"start_time": hostile,
|
||||
"end_time": hostile,
|
||||
"scan_results": {
|
||||
"executive_summary": hostile,
|
||||
"methodology": hostile,
|
||||
"technical_analysis": hostile,
|
||||
"recommendations": hostile,
|
||||
},
|
||||
}
|
||||
)
|
||||
(run_dir / "run.json").write_text(json.dumps(record), encoding="utf-8")
|
||||
|
||||
text = _pdf_text(generate_report_pdf(run_dir))
|
||||
assert "******" in text
|
||||
assert "<b><i></b></i>" in text
|
||||
assert "<img src='/definitely/missing.png'/>" in text
|
||||
|
||||
|
||||
def test_generate_report_pdf_survives_hostile_finding_fields(tmp_path: Path) -> None:
|
||||
run_dir = _make_run(tmp_path)
|
||||
hostile = "****** <b><i></b></i> <img src='/definitely/missing.png'/> \x000\x00 \ud800"
|
||||
vulnerability = {
|
||||
"title": hostile,
|
||||
"severity": hostile,
|
||||
"cvss": hostile,
|
||||
"description": hostile,
|
||||
"impact": hostile,
|
||||
"technical_analysis": hostile,
|
||||
"poc_description": hostile,
|
||||
"poc_script_code": hostile,
|
||||
"evidence": hostile,
|
||||
"remediation_steps": [hostile],
|
||||
"target": hostile,
|
||||
"endpoint": hostile,
|
||||
"method": hostile,
|
||||
}
|
||||
(run_dir / "vulnerabilities.json").write_text(json.dumps([vulnerability]), encoding="utf-8")
|
||||
|
||||
text = _pdf_text(generate_report_pdf(run_dir))
|
||||
assert "******" in text
|
||||
assert "<b><i></b></i>" in text
|
||||
assert "<img src='/definitely/missing.png'/>" in text
|
||||
assert text.count("LOW") == 2 # severity grid label plus canonicalized finding badge
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("CRITICAL", "critical"),
|
||||
(" info ", "info"),
|
||||
("informational", "info"),
|
||||
("<b><i></b></i>", "low"),
|
||||
({"severity": "critical"}, "low"),
|
||||
(None, "low"),
|
||||
],
|
||||
)
|
||||
def test_normalize_severity_restricts_badge_markup(value: object, expected: str) -> None:
|
||||
assert _normalize_severity(value) == expected
|
||||
|
||||
|
||||
def test_duration_rejects_mixed_timezone_awareness() -> None:
|
||||
assert _duration("2026-01-01T00:00:00", "2026-01-01T01:00:00Z") == "n/a"
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from agents.sandbox.entries import File, LocalDir
|
||||
|
||||
from strix.runtime import session_manager
|
||||
from strix.runtime.backends import (
|
||||
_BACKENDS,
|
||||
_BIND_MOUNT_BACKENDS,
|
||||
@@ -18,6 +21,7 @@ from strix.runtime.session_manager import (
|
||||
build_extra_file_bind_mounts,
|
||||
build_extra_file_entries,
|
||||
build_manifest_entries,
|
||||
extra_file_staging_dir,
|
||||
)
|
||||
|
||||
|
||||
@@ -319,6 +323,43 @@ def test_extra_file_bind_mounts_avoid_basename_collisions(tmp_path: Path) -> Non
|
||||
assert mounts[0]["source"] != mounts[1]["source"]
|
||||
|
||||
|
||||
def test_extra_file_staging_lives_under_the_temp_dir_not_the_run_dir() -> None:
|
||||
staging = extra_file_staging_dir("clients-release-evisort-dev_86b7")
|
||||
|
||||
assert staging.is_dir()
|
||||
assert staging.is_relative_to(Path(tempfile.gettempdir()))
|
||||
assert "strix_runs" not in staging.parts
|
||||
|
||||
|
||||
def test_extra_file_staging_dir_sanitizes_the_scan_id() -> None:
|
||||
staging = extra_file_staging_dir("../weird id/../")
|
||||
|
||||
assert staging.is_dir()
|
||||
assert staging.is_relative_to(Path(tempfile.gettempdir()))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_removes_the_extra_file_staging_dir() -> None:
|
||||
staging = extra_file_staging_dir("scan-staging-cleanup")
|
||||
(staging / "0").mkdir()
|
||||
(staging / "0" / "README.md").write_bytes(b"hi")
|
||||
|
||||
class _Client:
|
||||
async def delete(self, _session: Any) -> None:
|
||||
return None
|
||||
|
||||
session_manager._SESSION_CACHE["scan-staging-cleanup"] = {
|
||||
"client": _Client(),
|
||||
"session": object(),
|
||||
"caido_client": None,
|
||||
"extra_file_staging_dir": staging,
|
||||
}
|
||||
|
||||
await session_manager.cleanup("scan-staging-cleanup")
|
||||
|
||||
assert not staging.exists()
|
||||
|
||||
|
||||
def test_only_bind_mount_capable_backends_are_registered_as_such() -> None:
|
||||
assert backend_supports_bind_mounts("docker")
|
||||
assert not backend_supports_bind_mounts("e2b")
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
4
uv.lock
generated
4
uv.lock
generated
@@ -2378,7 +2378,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "strix-agent"
|
||||
version = "1.5.3"
|
||||
version = "1.6.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "caido-sdk-client" },
|
||||
@@ -2386,6 +2386,7 @@ dependencies = [
|
||||
{ name = "cvss" },
|
||||
{ name = "docker" },
|
||||
{ name = "litellm" },
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "openai" },
|
||||
{ name = "openai-agents", extra = ["litellm"] },
|
||||
{ name = "pydantic" },
|
||||
@@ -2427,6 +2428,7 @@ requires-dist = [
|
||||
{ name = "docker", specifier = ">=7.1.0" },
|
||||
{ name = "google-auth", marker = "extra == 'vertex'", specifier = ">=2.0.0" },
|
||||
{ name = "litellm" },
|
||||
{ name = "markdown-it-py", specifier = ">=3.0.0" },
|
||||
{ name = "openai", specifier = ">=2.45.0,<3" },
|
||||
{ name = "openai-agents", extras = ["litellm"], specifier = ">=0.19.0,<0.20" },
|
||||
{ name = "pydantic", specifier = ">=2.11.3" },
|
||||
|
||||
Reference in New Issue
Block a user