From 56008a6c507769caabe8ff312c7318c6ba322a36 Mon Sep 17 00:00:00 2001 From: Alex Schapiro Date: Wed, 26 Aug 2026 22:30:09 +0000 Subject: [PATCH] feat(cli): add the strix cloud command surface for the managed platform --- AGENTS.md | 12 +- README.md | 38 +- docs/integrations/coding-agents.mdx | 3 +- skills/managed-pentesting-with-strix/SKILL.md | 151 +-- strix/interface/cloud/__init__.py | 72 ++ strix/interface/cloud/http.py | 114 +++ strix/interface/cloud/render.py | 94 ++ strix/interface/cloud/runner.py | 342 +++++++ strix/interface/cloud/spec.py | 890 ++++++++++++++++++ strix/interface/main.py | 10 +- strix/interface/platform_cli.py | 24 +- tests/test_cli_target_list.py | 5 +- tests/test_cloud_cli.py | 355 +++++++ 13 files changed, 2008 insertions(+), 102 deletions(-) create mode 100644 strix/interface/cloud/__init__.py create mode 100644 strix/interface/cloud/http.py create mode 100644 strix/interface/cloud/render.py create mode 100644 strix/interface/cloud/runner.py create mode 100644 strix/interface/cloud/spec.py create mode 100644 tests/test_cloud_cli.py diff --git a/AGENTS.md b/AGENTS.md index 173884ea..96e38080 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,12 +38,14 @@ 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 `strix login` (device sign-in, stored in ~/.strix/platform-auth.json) - # or 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":[""]}' + strix cloud login --scopes scans:read scans:write billing:read # device sign-in, no prompts + strix cloud domains add --domain example.com --asset-type web_app + strix cloud scans start --engagement-type live_test --domain-ids --wait + strix cloud vulns list --severity critical + strix cloud billing topup --credits 20 # buy credits when a scan returns exit code 5 ``` - - API docs: https://docs.app.strix.ai (OpenAPI: https://docs.app.strix.ai/openapi.json). + - Every REST operation has a `strix cloud ` 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. Exit codes: `0` success, `1` error, `2` usage, `4` auth or plan limit, `5` payment required. `--token` or `STRIX_API_TOKEN` overrides the stored sign-in. `--data` adds extra request fields as JSON, and accepts `@file` or `-` for standard input. + - 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). - Only scan targets the user is authorized to test. diff --git a/README.md b/README.md index 413a758c..1ed3c3ea 100644 --- a/README.md +++ b/README.md @@ -320,19 +320,41 @@ strix auth status # show the active sign-in strix auth logout # forget the sign-in ``` -#### Sign in to the managed platform +#### Use the managed platform: `strix cloud` -To use the managed platform ([app.strix.ai](https://app.strix.ai)) from the terminal, run the device sign-in. It creates your account and workspace on first use and stores a personal API token in `~/.strix/platform-auth.json`: +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 login # opens the browser, then pick a workspace and scopes -strix login --scopes scans:read scans:write # request specific token scopes -strix login --workspace "My Team" # select a workspace by name or ID -strix login status # show the active sign-in -strix login logout # forget the sign-in +strix cloud login # opens the browser, then pick a workspace and scopes +strix cloud login --scopes scans:read scans:write # request specific token scopes +strix cloud login --workspace "My Team" # select a workspace by name or ID +strix cloud whoami # show the active sign-in +strix cloud logout # forget the sign-in ``` -The token authorizes the [REST API](https://docs.app.strix.ai) — scans, vulnerabilities, credits, and top-ups — with no dashboard visit. +Every operation of the [REST API](https://docs.app.strix.ai) has a matching command in the form `strix cloud `: + +```bash +strix cloud # list all resources +strix cloud scans # 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 --wait +strix cloud vulns list --severity critical +strix cloud credits # credit balance +strix cloud billing topup --credits 20 # buy credits (agent payment, HTTP 402) +``` + +The commands are agent friendly. Output is JSON when stdout is not a terminal or when you pass `--json`. There are no prompts when stdin is not a terminal. Exit codes: `0` success, `1` error, `2` invalid usage, `4` authentication required, `5` payment required. Set the token with `--token` or `STRIX_API_TOKEN` to skip the stored sign-in. + +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 +``` + +The platform enforces plan and role limits. Report downloads need the Enterprise plan, schedules need the Pro plan, and billing writes need an admin token. Those commands return the platform message and exit code `4`. #### Connect your own MCP servers diff --git a/docs/integrations/coding-agents.mdx b/docs/integrations/coding-agents.mdx index 3f32f803..f74f86f5 100644 --- a/docs/integrations/coding-agents.mdx +++ b/docs/integrations/coding-agents.mdx @@ -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. Get an API token with `strix login` (browser device sign-in, account created on first use) or in the dashboard 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 ` 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`). - **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//`; the cloud exposes the same as JSON plus SARIF export. - **Budget controls** — `--max-budget` and `--max-turns` give agents hard cost/time caps. diff --git a/skills/managed-pentesting-with-strix/SKILL.md b/skills/managed-pentesting-with-strix/SKILL.md index ab2c29fb..720c5827 100644 --- a/skills/managed-pentesting-with-strix/SKILL.md +++ b/skills/managed-pentesting-with-strix/SKILL.md @@ -1,23 +1,46 @@ --- 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 or API on the app.strix.ai platform with the `strix cloud` CLI or the REST API — no local Docker, LLM key, or install needed. Sign in with a browser device flow, 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, buy credits with an agent payment, 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. 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 `. Install with `curl -sSL https://strix.ai/install | bash`. Run `strix cloud` to list all resources and `strix cloud ` to list its verbs. +- **REST API** — base URL `https://app.strix.ai/api/v1`, `Authorization: Bearer ` on every request. Full reference: **[docs.app.strix.ai](https://docs.app.strix.ai)** · OpenAPI: `https://docs.app.strix.ai/openapi.json`. -- **Base URL:** `https://app.strix.ai/api/v1` -- **Auth:** every request sends `Authorization: Bearer `. Tokens are **org-scoped**. -- **Get a token (no dashboard needed):** run `strix login` from the open-source CLI. It starts an OAuth device flow — the user approves the sign-in in the browser, the account and workspace are created on first use, and a personal API token is stored in `~/.strix/platform-auth.json` (read `api_token` from that file). In an interactive terminal the CLI then offers a workspace picker and scope presets (Recommended, Full access, Minimal, Custom). For non-interactive use pass `--scopes SCOPE ...` and `--workspace ` to skip the prompts. Alternatively the user creates a token in the dashboard at **Settings → API Access** (app.strix.ai). Never hardcode, log, or commit the token. Store it in an env var or the CI secret store. +The CLI is agent friendly. Output is JSON when stdout is not a terminal, or when you pass `--json`. There are no interactive prompts when stdin is not a terminal. Exit codes: `0` success, `1` error, `2` invalid usage, `4` authentication required, `5` payment required. + +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 --scopes scans:read scans:write billing:read vulnerabilities:read assets:read assets:write +``` + +The user approves the sign-in in the browser. With `--scopes` (and optionally `--workspace `) there are no 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). + +- `strix cloud whoami` shows the active sign-in. `strix cloud logout` removes it. +- Every other `strix cloud` command uses the stored token automatically. To use a different token (for example one created in the dashboard at **Settings → API Access**), pass `--token ` or set `STRIX_API_TOKEN`. +- 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 | @@ -31,30 +54,32 @@ Full reference: **[docs.app.strix.ai](https://docs.app.strix.ai)** · OpenAPI: ` | `tokens:write` | create/revoke API tokens | | `billing:read` / `billing:write` | read credit balance & auto top-up settings · buy credits (admin) | -```bash -export STRIX_API_TOKEN="" -BASE=https://app.strix.ai/api/v1 -auth=(-H "Authorization: Bearer $STRIX_API_TOKEN") -``` - -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. +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`). ## 0. Credits & top-ups Scans consume org credits. Check the balance before a scan (`billing:read`): ```bash -curl -sS "$BASE/billing/credits" "${auth[@]}" | jq '{credits_available, price_per_credit_cents}' +strix cloud credits ``` -When the balance is too low, buy credits with `POST /billing/topup` (`billing:write`, admin token). The first request returns **HTTP 402 with a machine-payment challenge** (Stripe Machine Payments Protocol). Pay the challenge with a configured agent wallet — for example the [Link Agent Wallet](https://link.com/agents) — and retry with the payment credential. The response returns the receipt and the new balance. If no wallet is available, ask the user to top up in the dashboard instead. +When the balance is too low, buy credits with `strix cloud billing topup` (`billing:write`, admin token). The server answers the first request with **HTTP 402 and a machine-payment challenge** (Stripe Machine Payments Protocol). The CLI pays the challenge with the `mppx` client when Node.js is available — the user approves the spend in their agent wallet, for example the [Link Agent Wallet](https://link.com/agents). The response returns the receipt (`credits_granted`, `duplicate`, `reference`) and the new balance. ```bash -curl -sS "$BASE/billing/topup" "${auth[@]}" -H "Content-Type: application/json" \ - -d '{"credits": 20}' # → 402 + payment challenge, then retry with payment +strix cloud billing topup --credits 20 --yes # --yes skips the confirmation prompt +strix cloud billing topup --credits 20 --no-pay # print the 402 challenge without paying ``` -Automatic top-ups (admin): `GET /billing/auto-topup` shows the setting, `PUT /billing/auto-topup` enables it with `{"enabled": true, "topup_credits": 20, "monthly_cap_credits": 200}` — an omitted `monthly_cap_credits` keeps the stored cap, an explicit `null` removes it. +If no wallet is available, print the challenge with `--no-pay` and ask the user to top up in the dashboard instead. + +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. ## 1. Register the target as an asset @@ -62,67 +87,53 @@ 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": [""], - "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 \ + --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 HTTP headers (API keys, for example) for the target (JSON map) | +| `--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 | -Response is `{ scan_id, title, status }` with `status` = `pending`. +The response is `{ scan_id, title, status }` with `status` = `pending`. -## 3. Poll to completion +## 3. Wait for completion -`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. - -```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 -``` +Pass `--wait` to `scans start` to poll until the scan reaches a final state, or poll yourself with `strix cloud scans get ` (`scans:read`). Status flow: `pending → running → completed` (or `failed` / `cancelled`). Scans take minutes to hours — poll on an interval, do not block. ## 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 --json \ | jq '["critical","high","medium","low","info"] as $order | .vulnerabilities | sort_by(.severity as $s | $order | index($s)) @@ -131,37 +142,35 @@ curl -sS "$BASE/scans/$scan_id" "${auth[@]}" \ 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). -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 --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 --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 --format technical --type pdf --output strix-report.pdf ``` ## 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`). 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 --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 `. 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 ` 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. diff --git a/strix/interface/cloud/__init__.py b/strix/interface/cloud/__init__.py new file mode 100644 index 00000000..2f36542a --- /dev/null +++ b/strix/interface/cloud/__init__.py @@ -0,0 +1,72 @@ +"""`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 + +from rich.console import Console + +from strix.interface.cloud.runner import resolve, run +from strix.interface.cloud.spec import DEFAULT_VERBS, GROUP_HELP, SPEC +from strix.interface.platform_cli import run_login + + +_USAGE_HEADER = """[bold]Usage:[/] strix cloud [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 + credits Show the credit balance of the workspace + +[bold]Resource commands:[/]""" + +_USAGE_FOOTER = """ +Run [bold]strix cloud [/] without a verb to list its verbs. +Every command accepts [bold]--json[/] and [bold]--token[/]. Write commands +accept [bold]--data[/] with a JSON object of extra request fields. +API reference: https://docs.app.strix.ai""" + + +def run_cloud(argv: list[str]) -> int: + """Entry point for ``strix cloud …``. Returns a process exit code.""" + console = Console() + if not argv or argv[0] in ("-h", "--help", "help"): + _print_usage(console) + return 0 + + group, rest = argv[0], argv[1:] + if group in ("login", "logout", "whoami"): + session_argv = {"login": rest, "logout": ["logout"], "whoami": ["status"]} + return run_login(session_argv[group]) + if group == "credits": + group, rest = "billing", ["credits", *rest] + + if group not in SPEC: + console.print(f"[red]Unknown command:[/] {group}") + _print_usage(console) + return 2 + resolved = resolve(group, rest) + if resolved is None: + _print_verbs(console, group) + return 0 if not rest or rest[0] in ("-h", "--help", "help") else 2 + cmd, remaining = resolved + verb_label = " ".join(rest[: len(rest) - len(remaining)]) or DEFAULT_VERBS.get(group, "") + return run(group, verb_label, cmd, remaining) + + +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) -> None: + console.print(f"[bold]strix cloud {group}[/] verbs:") + for verb, cmd in SPEC[group].items(): + console.print(f" {verb:<28}{cmd.help}") diff --git a/strix/interface/cloud/http.py b/strix/interface/cloud/http.py new file mode 100644 index 00000000..4c735dc0 --- /dev/null +++ b/strix/interface/cloud/http.py @@ -0,0 +1,114 @@ +"""HTTP client for the managed Strix platform API (app.strix.ai).""" + +from __future__ import annotations + +import os +from typing import Any, cast + +import requests + +from strix.config import load_settings +from strix.interface.platform_cli import read_record + + +_DEFAULT_TIMEOUT_S = 120 +_app_url_override: str | None = None +_timeout_s: float = _DEFAULT_TIMEOUT_S + +EXIT_OK = 0 +EXIT_ERROR = 1 +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 + + +def configure(*, base_url: str | None = None, timeout: float | None = None) -> None: + """Set the platform URL and the request timeout for this process.""" + global _app_url_override, _timeout_s # noqa: PLW0603 + if base_url: + _app_url_override = base_url.rstrip("/") + if timeout: + _timeout_s = timeout + + +def app_url() -> str: + if _app_url_override: + return _app_url_override + return load_settings().viewer.app_url.rstrip("/") + + +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): + 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 request( + method: str, + path: str, + *, + token: str | None = None, + query: dict[str, Any] | None = None, + body: dict[str, Any] | None = None, +) -> requests.Response: + url = f"{app_url()}/api/v1{path}" + headers = {"Authorization": f"Bearer {api_token(token)}"} + try: + response = requests.request( + method, + url, + headers=headers, + params={k: v for k, v in (query or {}).items() if v is not None} or None, + json=body, + timeout=_timeout_s, + ) + except requests.RequestException as exc: + raise CloudError(f"could not reach {app_url()}: {exc}") from exc + return response + + +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 response.ok: + return data + detail = "" + if isinstance(data, dict): + raw = cast("dict[str, Any]", data) + detail = str(raw.get("detail") or raw.get("error") or "") + message = detail or f"HTTP {response.status_code}" + 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) diff --git a/strix/interface/cloud/render.py b/strix/interface/cloud/render.py new file mode 100644 index 00000000..a1c22f8d --- /dev/null +++ b/strix/interface/cloud/render.py @@ -0,0 +1,94 @@ +"""Output rendering for `strix cloud` commands.""" + +from __future__ import annotations + +import json +import sys +from typing import TYPE_CHECKING, Any + +from rich.table import Table + + +if TYPE_CHECKING: + from rich.console import Console + + +_MAX_TABLE_COLUMNS = 8 +_MAX_CELL_LENGTH = 60 + +_PREFERRED_KEYS = ( + "id", + "name", + "title", + "domain", + "status", + "state", + "severity", + "role", + "email", + "url", + "scan_type", + "engagement_type", + "created_at", + "updated_at", +) + + +def json_mode(*, flag: bool) -> bool: + """JSON output is on when the flag is set or when stdout is not a terminal.""" + return flag or not sys.stdout.isatty() + + +def emit(console: Console, data: Any, *, as_json: bool) -> None: + if as_json: + sys.stdout.write(json.dumps(data, indent=2, default=str) + "\n") + return + rows = _list_of_dicts(data) + if rows is not None: + _print_table(console, rows) + return + if isinstance(data, str): + console.print(data) + return + console.print_json(json.dumps(data, default=str)) + + +def _list_of_dicts(data: Any) -> list[dict[str, Any]] | None: + if isinstance(data, dict) and len(data) >= 1: + lists = [v for v in data.values() if isinstance(v, list)] + scalars = [v for v in data.values() if not isinstance(v, list | dict)] + if len(lists) == 1 and not scalars: + data = lists[0] + if not isinstance(data, list) or not data: + return None + if not all(isinstance(item, dict) for item in data): + return None + return data + + +def _print_table(console: Console, rows: list[dict[str, Any]]) -> None: + columns: list[str] = [key for key in _PREFERRED_KEYS if any(key in row for row in rows)] + for row in rows: + for key in row: + if ( + key not in columns + and len(columns) < _MAX_TABLE_COLUMNS + and not isinstance(row[key], dict | list) + ): + columns.append(key) + table = Table(show_lines=False) + for column in columns[:_MAX_TABLE_COLUMNS]: + table.add_column(column) + for row in rows: + table.add_row(*[_cell(row.get(column)) for column in columns[:_MAX_TABLE_COLUMNS]]) + console.print(table) + console.print(f"[dim]{len(rows)} item(s). Use --json for the full records.[/]") + + +def _cell(value: Any) -> str: + if value is None: + return "" + text = str(value) + if len(text) > _MAX_CELL_LENGTH: + return text[: _MAX_CELL_LENGTH - 1] + "…" + return text diff --git a/strix/interface/cloud/runner.py b/strix/interface/cloud/runner.py new file mode 100644 index 00000000..54738fd5 --- /dev/null +++ b/strix/interface/cloud/runner.py @@ -0,0 +1,342 @@ +"""Generic command runner for `strix cloud`. + +The runner turns one entry of the command table into an argument parser, +sends the HTTP request, renders the result, and returns the exit code. +""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import subprocess +import sys +import time +from pathlib import Path +from typing import Any, cast + +from rich.console import Console + +from strix.interface.cloud import http +from strix.interface.cloud.render import emit, json_mode +from strix.interface.cloud.spec import DEFAULT_VERBS, SPEC, Cmd, P + + +_PLACEHOLDER = re.compile(r"\{([^{}]+)\}") +_CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])") +_WAIT_POLL_S = 15 +_TERMINAL_STATUSES = frozenset( + { + "completed", + "failed", + "cancelled", + "canceled", + "stopped", + "error", + "expired", + "succeeded", + } +) + + +def _dest(name: str) -> str: + return _CAMEL_BOUNDARY.sub("_", name).lower() + + +def _metavar(name: str) -> str: + return _CAMEL_BOUNDARY.sub("_", name).upper() + + +def resolve(group: str, tokens: list[str]) -> tuple[Cmd, list[str]] | None: + """Find the command for a verb. Two-word verbs match before one-word verbs.""" + commands = SPEC.get(group) + if commands is None: + return None + if len(tokens) >= 2: + two = f"{tokens[0]} {tokens[1]}" + if two in commands: + return commands[two], tokens[2:] + if tokens and tokens[0] in commands: + return commands[tokens[0]], tokens[1:] + default = DEFAULT_VERBS.get(group) + if default is not None and (not tokens or tokens[0].startswith("-")): + return commands[default], tokens + return None + + +def run(group: str, verb_label: str, cmd: Cmd, argv: list[str]) -> int: + console = Console() + parser = _build_parser(group, verb_label, cmd) + try: + args = parser.parse_args(argv) + except SystemExit as exc: + return exc.code if isinstance(exc.code, int) else 2 + + path = cmd.path + for name in _PLACEHOLDER.findall(cmd.path): + path = path.replace("{" + name + "}", str(getattr(args, _dest(name)))) + + as_json = json_mode(flag=bool(getattr(args, "json", False))) + token = getattr(args, "token", None) + http.configure(base_url=getattr(args, "app_url", None), timeout=getattr(args, "timeout", None)) + + try: + query = _collect(args, cmd.query) + body = _collect(args, cmd.body) + data = getattr(args, "data", None) + if data: + body.update(_load_data(data)) + if getattr(args, "no_monthly_cap", False): + body["monthly_cap_credits"] = None + return _execute(console, cmd, args, path, query, body, as_json=as_json, token=token) + except http.CloudError as exc: + _emit_error(console, exc, as_json=as_json) + return exc.exit_code + + +def _execute( + console: Console, + cmd: Cmd, + args: argparse.Namespace, + path: str, + query: dict[str, Any], + body: dict[str, Any], + *, + as_json: bool, + token: str | None, +) -> int: + if cmd.path == "/billing/topup": + return _topup(console, args, body, as_json=as_json, token=token) + response = http.request( + cmd.method, + path, + token=token, + query=query or None, + body=body if cmd.method in ("POST", "PUT", "PATCH") else None, + ) + if cmd.binary: + return _emit_binary(console, response, getattr(args, "output", None)) + result = http.check(response) + if getattr(args, "wait", False): + if cmd.wait_self: + result = _poll(console, path, token=token, as_json=as_json) + elif cmd.wait_path: + result = _wait(console, cmd, result, token=token, as_json=as_json) + emit(console, result, as_json=as_json) + return http.EXIT_OK + + +def _load_data(value: str) -> dict[str, Any]: + """Read a JSON object from a literal string, a `@file` path, or `-` for stdin.""" + if value == "-": + text = sys.stdin.read() + elif value.startswith("@"): + path = Path(value[1:]).expanduser() + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + raise http.CloudError(f"could not read {path}: {exc}") from exc + else: + text = value + try: + parsed_value = json.loads(text) + except ValueError as exc: + raise http.CloudError("--data must be a JSON object.") from exc + if not isinstance(parsed_value, dict): + raise http.CloudError("--data must be a JSON object.") + return cast("dict[str, Any]", parsed_value) + + +def _build_parser(group: str, verb_label: str, cmd: Cmd) -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog=f"strix cloud {group} {verb_label}", description=cmd.help) + for name in _PLACEHOLDER.findall(cmd.path): + parser.add_argument(_dest(name), metavar=_metavar(name)) + for param in cmd.query + cmd.body: + _add_option(parser, param) + 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("--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.", + ) + if cmd.method in ("POST", "PUT", "PATCH"): + parser.add_argument( + "--data", + default=None, + metavar="JSON", + help="JSON object with extra request fields. Use @file to read a file, or - for stdin.", + ) + if cmd.path == "/billing/auto-topup" and cmd.method == "PUT": + parser.add_argument( + "--no-monthly-cap", + action="store_true", + help="Remove the monthly cap. Omit this flag to keep the stored cap.", + ) + if cmd.binary: + parser.add_argument("--output", default=None, metavar="FILE", help="Write to this file.") + if cmd.wait_path or cmd.wait_self: + parser.add_argument( + "--wait", action="store_true", help="Wait until the operation reaches a final state." + ) + if cmd.path == "/billing/topup": + parser.add_argument( + "--yes", action="store_true", help="Do not ask for confirmation before payment." + ) + parser.add_argument( + "--no-pay", + action="store_true", + help="Print the payment challenge instead of paying it.", + ) + return parser + + +def _add_option(parser: argparse.ArgumentParser, param: P) -> None: + flag = "--" + (param.flag or param.name.replace("_", "-")) + if param.kind == "bool": + parser.add_argument( + flag, + dest=param.name, + action=argparse.BooleanOptionalAction, + default=None, + required=param.required, + help=param.help, + ) + elif param.kind == "list": + parser.add_argument( + flag, + dest=param.name, + nargs="+", + default=None, + required=param.required, + help=param.help, + ) + elif param.kind in ("int", "float"): + parser.add_argument( + flag, + dest=param.name, + type=int if param.kind == "int" else float, + default=None, + required=param.required, + help=param.help, + ) + else: + parser.add_argument( + flag, dest=param.name, default=None, required=param.required, help=param.help + ) + + +def _collect(args: argparse.Namespace, params: tuple[P, ...]) -> dict[str, Any]: + values: dict[str, Any] = {} + for param in params: + value = getattr(args, param.name, None) + if value is None: + continue + if param.kind == "json" and isinstance(value, str): + try: + value = json.loads(value) + except ValueError as exc: + raise http.CloudError(f"--{param.name.replace('_', '-')} must be JSON") from exc + values[param.name] = value + return values + + +def _emit_binary(console: Console, response: Any, output: str | None) -> int: + if not response.ok: + http.check(response) + if output: + Path(output).write_bytes(response.content) + console.print(f"Saved to [bold]{output}[/]") + return http.EXIT_OK + sys.stdout.buffer.write(response.content) + return http.EXIT_OK + + +def _emit_error(console: Console, exc: http.CloudError, *, as_json: bool) -> None: + if as_json: + payload = {"error": str(exc)} + if exc.payload is not None: + payload["detail"] = exc.payload + sys.stdout.write(json.dumps(payload, indent=2, default=str) + "\n") + return + console.print(f"[red]Error:[/] {exc}") + + +def _created_id(created: Any) -> str | None: + """Read the identifier of a created item. The API names it `id` or `_id`.""" + if not isinstance(created, dict): + return None + fields = cast("dict[str, Any]", created) + for key, value in fields.items(): + if (key == "id" or key.endswith("_id")) and isinstance(value, str): + return value + return None + + +def _wait(console: Console, cmd: Cmd, created: Any, *, token: str | None, as_json: bool) -> Any: + item_id = _created_id(created) + if not item_id or not cmd.wait_path: + return created + path = cmd.wait_path.replace("{id}", str(item_id)) + if not as_json: + console.print(f"[dim]Waiting for {item_id} to reach a final state…[/]") + return _poll(console, path, token=token, as_json=as_json) + + +def _poll(console: Console, path: str, *, token: str | None, as_json: bool) -> Any: + """Poll a GET path until its status is final. Returns the last response.""" + while True: + time.sleep(_WAIT_POLL_S) + current = http.check(http.request("GET", path, token=token)) + status = str(current.get("status", "")) if isinstance(current, dict) else "" + if status.lower() in _TERMINAL_STATUSES: + return current + if not as_json: + console.print(f"[dim] status: {status or 'unknown'}[/]") + + +def _topup( + console: Console, + args: argparse.Namespace, + body: dict[str, Any], + *, + as_json: bool, + token: str | None, +) -> int: + 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, challenge, as_json=as_json) + return http.EXIT_PAYMENT + + npx = shutil.which("npx") + if npx is None: + emit(console, challenge, as_json=as_json) + console.print( + "[yellow]Payment required.[/] Install Node.js and run the command again, " + "or pay the challenge above with an MPP wallet client." + ) + return http.EXIT_PAYMENT + + credit_count = body.get("credits") + if not getattr(args, "yes", False) and sys.stdin.isatty(): + 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 + + url = f"{http.app_url()}/api/v1/billing/topup" + auth_header = f"Authorization: Bearer {http.api_token(token)}" + result = subprocess.run( # noqa: S603 + [npx, "--yes", "mppx", url, "-J", json.dumps(body), "-H", auth_header], + check=False, + ) + return http.EXIT_OK if result.returncode == 0 else http.EXIT_PAYMENT diff --git a/strix/interface/cloud/spec.py b/strix/interface/cloud/spec.py new file mode 100644 index 00000000..fcdef735 --- /dev/null +++ b/strix/interface/cloud/spec.py @@ -0,0 +1,890 @@ +"""Declarative command table for `strix cloud`. + +Each command maps one CLI verb to one managed API operation. The runner +builds the argument parser and the HTTP request from this table, so the +CLI surface stays aligned with the OpenAPI specification. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class P: + """One command parameter. + + ``kind`` is one of ``str``, ``int``, ``float``, ``bool``, ``list``, or ``json``. + """ + + name: str + kind: str = "str" + required: bool = False + help: str = "" + # Command-line name when the field name collides with a common option. + flag: str | None = None + + +@dataclass(frozen=True) +class Cmd: + method: str + path: str + help: str + query: tuple[P, ...] = () + body: tuple[P, ...] = () + binary: bool = False + wait_path: str | None = None + # When true, `--wait` polls GET on this same path until the status is final. + wait_self: bool = False + + +def _q(*names: str) -> tuple[P, ...]: + return tuple(P(name) for name in names) + + +_SCAN_START_BODY = ( + P("engagement_type", help="Test category, for example live_test or code_review."), + P("domain_ids", "list", help="Domain asset IDs to test."), + P("domain_paths", "json", help="JSON map of domain ID to start paths."), + P("repository_ids", "list", help="Repository asset IDs to test."), + P("repository_branches", "json", help="JSON map of repository ID to branch."), + P("credentials", "json", help="JSON list of credential objects."), + P("headers", "json", help="JSON map of extra HTTP headers for the target."), + P("concerns", "list", help="Vulnerability classes to focus on."), + P("focus", help="Free-form focus instructions for the agents."), + P("context", help="Extra context about the target."), + P("upload_ids", "list", help="Upload IDs to attach to the scan."), + P("connector_id", help="Network connector ID for internal targets."), + P("internal_targets", "list", help="Internal IP addresses or ranges."), + P("org_knowledge_enabled", "bool", help="Use the organization knowledge base."), + P("notify_on_completion", "bool", help="Send an email when the scan completes."), + P("notification_emails", "list", help="Extra notification email addresses."), + P("scan_tier", help="Scan tier, for example lite, pro, or max."), + P("model_config_id", help="Model configuration ID to run with."), + P("max_budget_usd", "float", help="Budget limit for the scan in USD."), +) + +_TEST_USER_ADD_BODY = ( + P("label", required=True, help="Display label for the test user."), + P("username", required=True, help="Sign-in username or email address."), + P("password", help="Sign-in password."), + P("notes", help="Free-form notes for the agents."), + P("login_url", help="URL of the sign-in page."), + P("mfa_method", help="MFA method, for example totp or email."), + P("totp_secret", help="TOTP secret for MFA sign-in."), + P("mfa_email", help="Email address that receives MFA codes."), + P("scope_domain_ids", "list", help="Domain IDs where this user applies."), +) + +_TEST_USER_BODY = ( + P("label", help="Display label for the test user."), + P("username", help="Sign-in username or email address."), + P("password", help="Sign-in password."), + P("notes", help="Free-form notes for the agents."), + P("login_url", help="URL of the sign-in page."), + P("mfa_method", help="MFA method, for example totp or email."), + P("totp_secret", help="TOTP secret for MFA sign-in."), + P("mfa_email", help="Email address that receives MFA codes."), + P("scope_domain_ids", "list", help="Domain IDs where this user applies."), +) + +_GIT_TOKEN_BODY = ( + P("token", required=True, help="Provider access token.", flag="provider-token"), + P("instance_url", help="Base URL for a self-hosted instance."), + P("workspace", help="Bitbucket workspace name."), +) + + +SPEC: dict[str, dict[str, Cmd]] = { + "scans": { + "list": Cmd( + "GET", + "/scans", + "List scans.", + query=_q( + "status", + "scan_type", + "date_from", + "date_to", + "domain_id", + "repository_id", + "search", + "include_retests", + ), + ), + "start": Cmd( + "POST", + "/scans", + "Start a scan.", + body=_SCAN_START_BODY, + wait_path="/scans/{id}", + ), + "get": Cmd("GET", "/scans/{scanId}", "Get one scan."), + "delete": Cmd("DELETE", "/scans/{scanId}", "Delete a scan."), + "agents": Cmd("GET", "/scans/{scanId}/agents", "List the agents of a scan."), + "cancel": Cmd("POST", "/scans/{scanId}/cancel", "Cancel a running scan."), + "message": Cmd( + "POST", + "/scans/{scanId}/message", + "Send a message to the scan agents.", + body=( + P("message", required=True, help="Message text for the agents."), + P("cancel_current", "bool", help="Stop the current task first."), + P("agent_id", help="Target one agent instead of the root agent."), + ), + ), + "report": Cmd( + "GET", + "/scans/{scanId}/report", + "Download the scan report.", + query=_q("format", "type"), + binary=True, + ), + "rerun": Cmd("POST", "/scans/{scanId}/rerun", "Run the scan again.", wait_path=None), + "retest-all": Cmd( + "POST", + "/scans/{scanId}/retest-all", + "Retest all open findings of a scan.", + body=( + P("scope", help="Retest scope."), + P("upload_ids", "list", help="Upload IDs with updated code."), + ), + ), + "retests": Cmd("GET", "/scans/{scanId}/retests", "List the retests of a scan."), + "sarif": Cmd( + "GET", + "/scans/{scanId}/sarif", + "Download the scan findings as SARIF.", + query=_q("repository"), + binary=True, + ), + "sarif-upload": Cmd( + "POST", + "/scans/{scanId}/sarif", + "Upload the scan findings to GitHub code scanning.", + body=( + P("repository", help="Repository full name."), + P("ref", help="Git ref for the upload."), + P("commit_sha", help="Commit SHA for the upload."), + P("checkout_uri", help="Checkout URI for the upload."), + P("github_api_base_url", help="GitHub API base URL."), + ), + ), + "template": Cmd("GET", "/scans/{scanId}/template", "Get the scan configuration template."), + "trace": Cmd( + "GET", + "/scans/{scanId}/trace", + "List trace events for one agent of a scan.", + query=( + P("agent_id", required=True, help="Agent ID to read the trace for."), + P("cursor"), + P("limit", "int"), + P("tool_name"), + ), + ), + "trace-event": Cmd( + "GET", "/scans/{scanId}/trace/{eventId}", "Get one trace event of a scan." + ), + }, + "vulns": { + "list": Cmd( + "GET", + "/vulnerabilities", + "List vulnerabilities.", + query=_q( + "scan_id", + "severity", + "status", + "search", + "from", + "to", + "domain_id", + "repository_id", + "finding_type", + "dependency_relation", + "reachability", + "sort_by", + ), + ), + "get": Cmd("GET", "/vulnerabilities/{vulnerabilityId}", "Get one vulnerability."), + "history": Cmd( + "GET", + "/vulnerabilities/{vulnerabilityId}/history", + "Get the change history of a vulnerability.", + ), + "update": Cmd( + "PATCH", + "/vulnerabilities/{vulnerabilityId}", + "Update the status or severity of a vulnerability.", + body=( + P("status", help="New status, for example triaged or false_positive."), + P("note", help="Note that explains the change."), + P("severity", help="New severity."), + P("severity_reason", help="Reason for the severity change."), + ), + ), + "retest": Cmd( + "POST", + "/vulnerabilities/{vulnerabilityId}/retest", + "Retest one vulnerability.", + body=(P("upload_ids", "list", help="Upload IDs with updated code."),), + ), + "fix-pr": Cmd( + "POST", + "/vulnerabilities/{vulnerabilityId}/create-fix-pr", + "Create a fix pull request for a vulnerability.", + ), + "push": Cmd( + "POST", + "/vulnerabilities/{vulnerabilityId}/push", + "Push one vulnerability to an issue tracker.", + body=( + P("provider", required=True, help="Tracker provider, for example jira or linear."), + P("collection_id", help="Tracker project or collection ID."), + ), + ), + "push-bulk": Cmd( + "POST", + "/vulnerabilities/bulk-push", + "Push many vulnerabilities to an issue tracker.", + body=( + P("provider", required=True, help="Tracker provider, for example jira or linear."), + P("vulnerability_ids", "list", required=True, help="Vulnerability IDs to push."), + P("collection_id", help="Tracker project or collection ID."), + ), + ), + }, + "domains": { + "list": Cmd( + "GET", + "/domains", + "List domain assets.", + query=_q("limit", "search", "verified", "business_unit", "tags", "sort_by"), + ), + "add": Cmd( + "POST", + "/domains", + "Add a domain asset.", + body=( + P("domain", required=True, help="Domain name or URL."), + P("asset_type", required=True, help="Asset type, for example web_app or api."), + P("context", help="Extra context about the asset."), + P("tags", "list", help="Tags for the asset."), + P("business_unit", help="Business unit for the asset."), + ), + ), + "update": Cmd( + "PATCH", + "/domains/{domainId}", + "Update a domain asset.", + body=( + P("context", help="Extra context about the asset."), + P("tags", "list", help="Tags for the asset."), + P("business_unit", help="Business unit for the asset."), + ), + ), + "remove": Cmd("DELETE", "/domains/{domainId}", "Remove a domain asset."), + "verify": Cmd("POST", "/domains/{domainId}/verify", "Verify domain ownership."), + "auto-verify": Cmd( + "POST", + "/domains/{domainId}/auto-verify", + "Verify domain ownership through a DNS provider.", + body=(P("provider", required=True, help="DNS provider name."),), + ), + "test-users list": Cmd( + "GET", "/domains/{domainId}/test-users", "List the test users of a domain." + ), + "test-users add": Cmd( + "POST", + "/domains/{domainId}/test-users", + "Add a test user to a domain.", + body=_TEST_USER_ADD_BODY, + ), + "test-users update": Cmd( + "PATCH", + "/domains/{domainId}/test-users/{userId}", + "Update a test user.", + body=_TEST_USER_BODY, + ), + "test-users remove": Cmd( + "DELETE", "/domains/{domainId}/test-users/{userId}", "Remove a test user." + ), + "test-users provision-inbox": Cmd( + "POST", + "/domains/{domainId}/test-users/provision-inbox", + "Create a test user with a managed email inbox.", + body=(P("label", help="Display label for the test user."),), + ), + "test-users inbox": Cmd( + "GET", + "/domains/{domainId}/test-users/{userId}/inbox", + "List the inbox messages of a test user.", + query=(P("limit", "int"),), + ), + "test-users inbox-message": Cmd( + "GET", + "/domains/{domainId}/test-users/{userId}/inbox/{messageId}", + "Get one inbox message of a test user.", + ), + "test-users verify": Cmd( + "POST", + "/domains/{domainId}/test-users/{userId}/verify", + "Verify that the test user credentials work.", + query=(P("force"),), + wait_self=True, + ), + "test-users verify-status": Cmd( + "GET", + "/domains/{domainId}/test-users/{userId}/verify", + "Get the verification status of a test user.", + ), + }, + "repos": { + "list": Cmd( + "GET", + "/repositories", + "List repository assets.", + query=_q("limit", "search", "business_unit", "tags", "sort_by"), + ), + "add": Cmd("POST", "/repositories", "Add a repository asset. Use --data for the fields."), + "update": Cmd( + "PATCH", + "/repositories/{repositoryId}", + "Update a repository asset.", + body=( + P("pr_review_enabled", "bool", help="Turn PR reviews on or off."), + P("pr_review_approvals_enabled", "bool", help="Let reviews approve clean PRs."), + P("pr_review_non_blocking", "bool", help="Make review verdicts non-blocking."), + P("pr_review_on_push", "bool", help="Review new pushes to open PRs."), + P("tags", "list", help="Tags for the asset."), + P("business_unit", help="Business unit for the asset."), + ), + ), + "remove": Cmd("DELETE", "/repositories/{repositoryId}", "Remove a repository asset."), + "supply-chain scan": Cmd( + "POST", + "/repositories/{repositoryId}/supply-chain/scan", + "Start a supply-chain scan for a repository.", + ), + "supply-chain summary": Cmd( + "GET", + "/repositories/{repositoryId}/supply-chain/summary", + "Get the supply-chain summary of a repository.", + query=_q("job_id", "snapshot_id"), + ), + "supply-chain findings": Cmd( + "GET", + "/repositories/{repositoryId}/supply-chain/findings", + "List the supply-chain findings of a repository.", + query=_q("job_id", "snapshot_id", "component_id"), + ), + "supply-chain components": Cmd( + "GET", + "/repositories/{repositoryId}/supply-chain/components", + "List the dependency components of a repository.", + query=_q( + "job_id", + "snapshot_id", + "component_id", + "ecosystem", + "status", + "relationship", + "source_file", + "q", + "changed", + "limit", + "offset", + ), + ), + "supply-chain sbom": Cmd( + "GET", + "/repositories/{repositoryId}/supply-chain/sbom", + "Download the SBOM of a repository.", + query=_q("job_id", "snapshot_id", "format"), + binary=True, + ), + "supply-chain policy": Cmd( + "PATCH", + "/repositories/{repositoryId}/supply-chain/policy", + "Update the supply-chain policy of a repository.", + body=( + P("supply_chain_enabled", "bool", help="Turn supply-chain scans on or off."), + P("supply_chain_pr_checks_enabled", "bool", help="Run checks on pull requests."), + P("supply_chain_policy_mode", help="Policy mode for new findings."), + ), + ), + }, + "supply-chain": { + "summary": Cmd( + "GET", "/supply-chain/summary", "Get the organization supply-chain summary." + ), + }, + "schedules": { + "list": Cmd("GET", "/schedules", "List scan schedules."), + "create": Cmd("POST", "/schedules", "Create a scan schedule. Use --data for the fields."), + "get": Cmd("GET", "/schedules/{scheduleId}", "Get one schedule."), + "update": Cmd( + "PATCH", + "/schedules/{scheduleId}", + "Update a schedule. Use --data for fields that have no option.", + body=( + P("action", help="Lifecycle action, for example pause or resume."), + P("cron_expression", help="Cron expression for the schedule."), + P("timezone", help="Time zone for the cron expression."), + P("name", help="Display name of the schedule."), + P("max_budget_usd", "int", help="Budget limit per run in USD."), + P("scan_tier", help="Scan tier for scheduled runs."), + ), + ), + "delete": Cmd("DELETE", "/schedules/{scheduleId}", "Delete a schedule."), + "template": Cmd( + "GET", "/schedules/{scheduleId}/template", "Get the schedule configuration template." + ), + "trigger": Cmd("POST", "/schedules/{scheduleId}/trigger", "Run a schedule now."), + }, + "pr-reviews": { + "list": Cmd( + "GET", + "/pr-reviews", + "List PR reviews.", + query=_q( + "search", + "status", + "group", + "pr_state", + "repository_full_name", + "date_from", + "date_to", + "sort_by", + "sort_order", + "include_counts", + ), + ), + "get": Cmd("GET", "/pr-reviews/{prReviewId}", "Get one PR review."), + "findings": Cmd( + "GET", + "/pr-reviews/findings", + "List PR review findings.", + query=_q("severity", "pr_state", "search", "repository_full_name", "include_stats"), + ), + "start": Cmd( + "POST", + "/pr-reviews/start", + "Start a PR review.", + body=( + P("repository_full_name", required=True, help="Repository full name."), + P("pr_number", "int", required=True, help="Pull request number."), + ), + ), + "settings": Cmd("GET", "/pr-reviews/settings", "Get the PR review settings."), + "settings update": Cmd( + "PATCH", + "/pr-reviews/settings", + "Update the PR review settings. Use --data for fields that have no option.", + body=( + P("review_on_push", "bool", help="Review new pushes to open PRs."), + P("block_on_findings", "bool", help="Block PRs that have findings."), + P("blocking_severities", "list", help="Severities that block a PR."), + P("approve_clean_prs", "bool", help="Approve PRs without findings."), + P("target_branches", "list", help="Branches that get reviews."), + ), + ), + }, + "billing": { + "credits": Cmd("GET", "/billing/credits", "Get the credit balance of the workspace."), + "topup": Cmd( + "POST", + "/billing/topup", + "Buy credits with an agent payment (HTTP 402 flow).", + body=(P("credits", "int", required=True, help="Number of credits to buy."),), + ), + "auto-topup": Cmd("GET", "/billing/auto-topup", "Get the automatic top-up settings."), + "auto-topup update": Cmd( + "PUT", + "/billing/auto-topup", + "Update the automatic top-up settings.", + body=( + P("enabled", "bool", required=True, help="Turn automatic top-up on or off."), + P("topup_credits", "int", required=True, help="Credits to buy on each top-up."), + P("monthly_cap_credits", "int", help="Monthly credit cap for automatic top-ups."), + ), + ), + }, + "chat": { + "list": Cmd("GET", "/chat", "List chat sessions."), + "start": Cmd( + "POST", + "/chat", + "Start a chat session.", + body=( + P("message", required=True, help="First message of the session."), + P("repos", "list", help="Repository full names for context."), + P("domain_ids", "list", help="Domain asset IDs for context."), + ), + ), + "get": Cmd("GET", "/chat/{chatId}", "Get one chat session."), + "send": Cmd( + "POST", + "/chat/{chatId}/message", + "Send a message in a chat session.", + body=( + P("message", required=True, help="Message text."), + P("cancel_current", "bool", help="Stop the current task first."), + P("stop_agent", "bool", help="Stop the agent."), + P("repos", "list", help="Repository full names for context."), + P("agent_id", help="Target one agent."), + ), + ), + "findings": Cmd("GET", "/chat/{chatId}/findings", "List the findings of a chat session."), + "finding": Cmd( + "GET", "/chat/{chatId}/findings/{findingId}", "Get one finding of a chat session." + ), + "finding file": Cmd( + "POST", + "/chat/{chatId}/findings/{findingId}/file", + "File a chat finding into the organization issue list.", + ), + "files": Cmd("GET", "/chat/{chatId}/files", "List the files of a chat session."), + "files download": Cmd( + "GET", + "/chat/{chatId}/files/download", + "Download one file of a chat session.", + query=(P("path", required=True, help="File path inside the session."),), + binary=True, + ), + "files archive": Cmd( + "GET", + "/chat/{chatId}/files/archive", + "Download all files of a chat session as an archive.", + binary=True, + ), + "credentials": Cmd( + "GET", + "/chat/{chatId}/credentials", + "Get the credentials of a chat session.", + query=_q("scan_ids"), + ), + "credentials set": Cmd( + "POST", + "/chat/{chatId}/credentials", + "Set the credentials of a chat session.", + body=( + P("test_user_ids", "list", help="Test user IDs to attach."), + P("credentials", "json", help="JSON list of credential objects."), + P("scan_ids", "list", help="Scan IDs that use the credentials."), + ), + ), + "credentials clear": Cmd( + "DELETE", "/chat/{chatId}/credentials", "Remove the credentials of a chat session." + ), + "domains set": Cmd( + "PUT", + "/chat/{chatId}/domains", + "Set the domains of a chat session.", + body=(P("domain_ids", "list", help="Domain asset IDs."),), + ), + "terminal": Cmd( + "POST", + "/chat/{chatId}/terminal", + "Run a command in the chat session sandbox.", + body=( + P("command", required=True, help="Shell command to run."), + P("cwd", help="Working directory for the command."), + ), + ), + "share": Cmd("POST", "/chat/{chatId}/share", "Create a share link for a chat session."), + }, + "knowledge": { + "list": Cmd( + "GET", + "/knowledge", + "List knowledge documents.", + query=_q("source_type", "search", "limit"), + ), + "add": Cmd( + "POST", + "/knowledge", + "Add a knowledge document.", + body=( + P("title", required=True, help="Document title."), + P("content", required=True, help="Document content."), + P("tags", "list", help="Tags for the document."), + P("metadata", "json", help="JSON metadata for the document."), + ), + ), + "update": Cmd( + "PATCH", + "/knowledge/{documentId}", + "Update a knowledge document.", + body=( + P("title", help="Document title."), + P("content", help="Document content."), + P("tags", "list", help="Tags for the document."), + P("metadata", "json", help="JSON metadata for the document."), + ), + ), + "delete": Cmd("DELETE", "/knowledge/{documentId}", "Delete a knowledge document."), + "query": Cmd( + "GET", + "/knowledge/query", + "Query the knowledge base.", + query=(P("q", help="Query text."), P("limit", "int")), + ), + "policies": Cmd("GET", "/knowledge/policies", "List knowledge policies."), + "policies add": Cmd( + "POST", + "/knowledge/policies", + "Add a knowledge policy.", + body=( + P("key", required=True, help="Policy key."), + P("content", help="Policy content."), + P("enabled", "bool", help="Turn the policy on or off."), + ), + ), + "policies delete": Cmd( + "DELETE", "/knowledge/policies/{policyKey}", "Delete a knowledge policy." + ), + "repos": Cmd("GET", "/knowledge/repos", "List repositories with knowledge entries."), + "repos entries": Cmd( + "GET", "/knowledge/repos/{repo}/entries", "List the knowledge entries of a repository." + ), + "repos profile": Cmd( + "PATCH", + "/knowledge/repos/{repo}/profile", + "Update the knowledge profile of a repository. Use --data for the fields.", + ), + "settings": Cmd("GET", "/knowledge/settings", "Get the knowledge settings."), + "settings update": Cmd( + "PATCH", + "/knowledge/settings", + "Update the knowledge settings.", + body=(P("org_knowledge_enabled", "bool", help="Use organization knowledge in scans."),), + ), + }, + "org": { + "get": Cmd("GET", "/organization", "Get the organization."), + "update": Cmd( + "PATCH", + "/organization", + "Update the organization.", + body=(P("name", required=True, help="Organization name."),), + ), + "members": Cmd("GET", "/organization/members", "List the organization members."), + "members invite": Cmd( + "POST", + "/organization/members", + "Invite a member to the organization.", + body=( + P("email", required=True, help="Email address of the new member."), + P("role", help="Member role, for example admin, analyst, or viewer."), + P("scopes", "list", help="RBAC scopes for the member."), + ), + ), + "members update": Cmd( + "PATCH", + "/organization/members/{membershipId}", + "Update a member of the organization.", + body=( + P("role", required=True, help="Member role."), + P("scopes", "list", help="RBAC scopes for the member."), + ), + ), + "members remove": Cmd("DELETE", "/organization/members/{membershipId}", "Remove a member."), + "invitations": Cmd("GET", "/organization/invitations", "List open invitations."), + "invitations revoke": Cmd( + "DELETE", "/organization/invitations/{invitationId}", "Revoke an invitation." + ), + }, + "integrations": { + "list": Cmd("GET", "/integrations", "List the connected integrations."), + "connect": Cmd( + "POST", + "/integrations/{provider}/connect", + "Connect a Git provider. The provider is gitlab or bitbucket.", + body=_GIT_TOKEN_BODY, + ), + "validate": Cmd( + "POST", + "/integrations/{provider}/validate", + "Validate a Git provider token. The provider is gitlab or bitbucket.", + body=_GIT_TOKEN_BODY, + ), + "disconnect": Cmd("DELETE", "/integrations/{provider}", "Disconnect an integration."), + }, + "connectors": { + "list": Cmd("GET", "/connectors", "List network connectors."), + "create": Cmd( + "POST", + "/connectors", + "Create a network connector.", + body=(P("name", required=True, help="Connector name."),), + ), + "get": Cmd( + "GET", + "/connectors/{connectorId}", + "Get one network connector.", + query=_q("include_command"), + ), + "status": Cmd( + "GET", "/connectors/{connectorId}/status", "Get the status of a network connector." + ), + "delete": Cmd("DELETE", "/connectors/{connectorId}", "Delete a network connector."), + }, + "webhooks": { + "list": Cmd("GET", "/webhooks", "List webhooks."), + "create": Cmd( + "POST", + "/webhooks", + "Create a webhook.", + body=( + P("url", required=True, help="Delivery URL."), + P("events", "list", required=True, help="Event names to deliver."), + P("business_unit", help="Business unit filter."), + P("is_active", "bool", help="Turn the webhook on or off."), + ), + ), + "get": Cmd("GET", "/webhooks/{webhookId}", "Get one webhook."), + "update": Cmd( + "PATCH", + "/webhooks/{webhookId}", + "Update a webhook.", + body=( + P("url", help="Delivery URL."), + P("events", "list", help="Event names to deliver."), + P("business_unit", help="Business unit filter."), + P("is_active", "bool", help="Turn the webhook on or off."), + P("rotate_secret", "bool", help="Create a new signing secret."), + ), + ), + "delete": Cmd("DELETE", "/webhooks/{webhookId}", "Delete a webhook."), + "deliveries": Cmd( + "GET", "/webhooks/{webhookId}/deliveries", "List the deliveries of a webhook." + ), + }, + "analytics": { + "overview": Cmd( + "GET", + "/analytics/overview", + "Get the analytics overview.", + query=_q("range", "from", "to"), + ), + "stats": Cmd("GET", "/analytics/stats", "Get the analytics statistics."), + "scan-frequency": Cmd( + "GET", "/analytics/scan-frequency", "Get the scan frequency data.", query=_q("tz") + ), + }, + "audit": { + "list": Cmd( + "GET", + "/audit", + "List audit log entries.", + query=_q( + "action", "resource_type", "actor_id", "date_from", "date_to", "format", "all" + ), + ), + }, + "costs": { + "overview": Cmd( + "GET", "/llm-costs", "Show the LLM cost overview.", query=_q("range", "from", "to") + ), + "run": Cmd("GET", "/llm-costs/runs/{runType}/{runId}", "Get the LLM costs of one run."), + }, + "llm-settings": { + "get": Cmd("GET", "/llm-settings", "Get the LLM settings."), + "update": Cmd( + "PUT", + "/llm-settings", + "Update the LLM settings.", + body=( + P("modelConfigs", "json", required=True, help="JSON list of model configurations."), + P("assignments", "json", required=True, help="JSON map of model assignments."), + ), + ), + }, + "settings": { + "notifications": Cmd("GET", "/settings/notifications", "Get the notification settings."), + "notifications update": Cmd( + "PATCH", + "/settings/notifications", + "Update the notification settings.", + body=( + P("sla_reminders_enabled", "bool", help="Turn SLA reminders on or off."), + P("sla_reminder_email", "bool", help="Send SLA reminders by email."), + P("sla_reminder_slack", "bool", help="Send SLA reminders to Slack."), + P("sla_warning_days", "int", help="Days before an SLA warning."), + ), + ), + }, + "license": { + "show": Cmd("GET", "/license", "Get the license information."), + }, + "tokens": { + "list": Cmd("GET", "/tokens", "List API tokens.", query=_q("type")), + "create": Cmd( + "POST", + "/tokens", + "Create an API token.", + body=( + P("type", required=True, help="Token type, personal or service."), + P("name", required=True, help="Token name."), + P("scopes", "list", help="API scopes for the token."), + P("expires_in_days", "int", help="Days until the token expires."), + ), + ), + "revoke": Cmd("DELETE", "/tokens/{tokenId}", "Revoke an API token."), + }, + "uploads": { + "request": Cmd( + "POST", + "/uploads/request", + "Request an upload URL.", + body=( + P("file_name", required=True, help="File name."), + P("file_size", "int", required=True, help="File size in bytes."), + P("category", help="Upload category."), + ), + ), + "complete": Cmd( + "POST", + "/uploads/complete", + "Mark an upload as complete.", + body=(P("upload_id", required=True, help="Upload ID."),), + ), + "delete": Cmd("DELETE", "/uploads/{uploadId}", "Delete an upload."), + }, +} + + +# Default verbs let a bare group name run its most common read command. +DEFAULT_VERBS: dict[str, str] = { + "costs": "overview", + "audit": "list", + "license": "show", + "supply-chain": "summary", +} + + +GROUP_HELP: dict[str, str] = { + "scans": "Start, watch, and manage scans", + "vulns": "Triage and remediate vulnerabilities", + "domains": "Manage domain assets and test users", + "repos": "Manage repository assets and supply-chain scans", + "supply-chain": "Organization supply-chain summary", + "schedules": "Manage scan schedules", + "pr-reviews": "Manage pull request reviews", + "billing": "Credits, top-ups, and automatic top-up", + "chat": "Interactive pentest chat sessions", + "knowledge": "Manage the knowledge base", + "org": "Manage the organization and its members", + "integrations": "Connect Git providers", + "connectors": "Manage network connectors", + "webhooks": "Manage webhooks", + "analytics": "Read analytics data", + "audit": "Read the audit log", + "costs": "Read LLM cost data", + "llm-settings": "Manage LLM model settings", + "settings": "Manage notification settings", + "license": "Read license information", + "tokens": "Manage API tokens", + "uploads": "Upload files for scans", +} diff --git a/strix/interface/main.py b/strix/interface/main.py index eeec81a2..69a74b3b 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -431,12 +431,12 @@ def main() -> None: sys.exit(run_auth(sys.argv[2:])) - # `strix login …` manages managed-platform sign-in (app.strix.ai) and - # exits; it needs no target, Docker, or scan setup. - if len(sys.argv) > 1 and sys.argv[1] == "login": - from strix.interface.platform_cli import run_login + # `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_login(sys.argv[2:])) + sys.exit(run_cloud(sys.argv[2:])) from strix.llm.warmup import start_import_warmup diff --git a/strix/interface/platform_cli.py b/strix/interface/platform_cli.py index 1b79ebfa..586fcc3d 100644 --- a/strix/interface/platform_cli.py +++ b/strix/interface/platform_cli.py @@ -1,4 +1,4 @@ -"""`strix login` — managed platform sign-in (app.strix.ai). +"""`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 @@ -35,8 +35,9 @@ _MAX_POLL_INTERVAL_S = 60 _MAX_EXPIRES_IN_S = 30 * 60 _LOGIN_USAGE = ( - "Usage:\n strix login [--no-browser] [--scopes SCOPE ...] [--workspace WORKSPACE]\n" - " strix login status\n strix login logout" + "Usage:\n" + " strix cloud login [--no-browser] [--scopes SCOPE ...] [--workspace WORKSPACE]\n" + " strix cloud whoami\n strix cloud logout" ) _ROLE_RANK = {"viewer": 0, "analyst": 1, "admin": 2} @@ -78,7 +79,7 @@ def logout() -> bool: def run_login(argv: list[str]) -> int: - """Entry point for ``strix login …``. Returns a process exit code.""" + """Entry point for ``strix cloud login``. Returns a process exit code.""" console = Console() subcommand = argv[0] if argv else None @@ -93,7 +94,7 @@ def run_login(argv: list[str]) -> int: def _login(console: Console, argv: list[str]) -> int: - parser = argparse.ArgumentParser(prog="strix login", add_help=True) + parser = argparse.ArgumentParser(prog="strix cloud login", add_help=True) parser.add_argument( "--no-browser", action="store_true", @@ -123,7 +124,7 @@ def _login(console: Console, argv: list[str]) -> int: try: args = parser.parse_args(argv) except SystemExit as exc: # argparse already printed the message - return int(exc.code or 2) + return exc.code if isinstance(exc.code, int) else 2 console.print() host = urlparse(_app_url()).netloc or _app_url() @@ -152,7 +153,8 @@ def _login(console: Console, argv: list[str]) -> int: except OSError as exc: console.print(f"[red]Sign-in succeeded, but the token could not be stored:[/] {exc}") console.print( - f"[dim]Check that {AUTH_PATH.parent} is writable, then run `strix login` again.[/]" + f"[dim]Check that {AUTH_PATH.parent} is writable, " + "then run `strix cloud login` again.[/]" ) return 1 _print_success(console, record) @@ -237,7 +239,7 @@ def _run_device_flow( break interval += delta - raise PlatformAuthError("the sign-in request expired. Run `strix login` again.") + raise PlatformAuthError("the sign-in request expired. Run `strix cloud login` again.") def _handle_poll_error(poll: requests.Response) -> int | None: @@ -461,15 +463,15 @@ def _print_success(console: Console, record: dict[str, Any]) -> None: console.print(f" Token: stored in [dim]{AUTH_PATH}[/]") console.print() console.print( - "[dim]The managed API is ready. " - "See https://docs.app.strix.ai for scans, credits, and top-ups.[/]" + "[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) -> int: record = read_record() if record is None: - console.print("[yellow]Not signed in.[/] Run [bold]strix login[/] to sign in.") + 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", "") diff --git a/tests/test_cli_target_list.py b/tests/test_cli_target_list.py index 6ba4ca22..d20230b1 100644 --- a/tests/test_cli_target_list.py +++ b/tests/test_cli_target_list.py @@ -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) diff --git a/tests/test_cloud_cli.py b/tests/test_cloud_cli.py new file mode 100644 index 00000000..89c5f794 --- /dev/null +++ b/tests/test_cloud_cli.py @@ -0,0 +1,355 @@ +"""Tests for the `strix cloud` CLI: routing, request building, and output.""" + +from __future__ import annotations + +import io +import json +from typing import TYPE_CHECKING, Any + +import pytest +import requests + +from strix.interface import cloud, platform_cli +from strix.interface.cloud import http, render, runner +from strix.interface.cloud.spec import SPEC + + +if TYPE_CHECKING: + from pathlib import Path + + +class FakeResponse: + def __init__( + self, + status_code: int = 200, + payload: Any = None, + text: str = "", + content: bytes = b"", + ) -> None: + self.status_code = status_code + self._payload = payload + self.text = text if payload is None else json.dumps(payload) + self.content = content + self.ok = 200 <= status_code < 400 + self.headers = {"content-type": "application/json" if payload is not None else "text/plain"} + + def json(self) -> Any: + if self._payload is None: + raise ValueError("no JSON") + return self._payload + + +@pytest.fixture(autouse=True) +def _token_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("STRIX_API_TOKEN", "test-token") + + +def test_help_returns_zero() -> None: + assert cloud.run_cloud([]) == 0 + assert cloud.run_cloud(["--help"]) == 0 + + +def test_unknown_group_returns_usage_error() -> None: + assert cloud.run_cloud(["bogus"]) == 2 + + +def test_unknown_verb_returns_usage_error() -> None: + assert cloud.run_cloud(["scans", "bogus"]) == 2 + + +def test_group_without_verb_lists_verbs() -> None: + assert cloud.run_cloud(["scans"]) == 0 + + +def test_resolve_prefers_two_word_verbs() -> None: + resolved = runner.resolve("billing", ["auto-topup", "update", "--enabled"]) + assert resolved is not None + cmd, remaining = resolved + assert cmd.path == "/billing/auto-topup" + assert cmd.method == "PUT" + assert remaining == ["--enabled"] + + +def test_resolve_default_verb() -> None: + resolved = runner.resolve("audit", []) + assert resolved is not None + cmd, remaining = resolved + assert cmd.method == "GET" + assert remaining == [] + + +def test_dest_converts_camel_case() -> None: + assert runner._dest("scanId") == "scan_id" + assert runner._dest("chatId") == "chat_id" + assert runner._metavar("findingId") == "FINDING_ID" + + +def test_placeholder_substitution(monkeypatch: pytest.MonkeyPatch, capsys: Any) -> None: + seen: dict[str, Any] = {} + + def fake_request(method: str, path: str, **kwargs: Any) -> FakeResponse: + seen.update(method=method, path=path, query=kwargs.get("query")) + return FakeResponse(payload={"id": "abc"}) + + monkeypatch.setattr(http, "request", fake_request) + code = cloud.run_cloud(["scans", "get", "abc-123", "--json"]) + assert code == 0 + assert seen["method"] == "GET" + assert seen["path"] == "/scans/abc-123" + assert json.loads(capsys.readouterr().out) == {"id": "abc"} + + +def test_query_and_body_collection(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + + def fake_request(_method: str, _path: str, **kwargs: Any) -> FakeResponse: + seen.update(query=kwargs.get("query"), body=kwargs.get("body")) + return FakeResponse(payload={"ok": True}) + + monkeypatch.setattr(http, "request", fake_request) + assert cloud.run_cloud(["scans", "list", "--status", "running", "--json"]) == 0 + assert seen["query"] == {"status": "running"} + + assert ( + cloud.run_cloud( + [ + "scans", + "start", + "--engagement-type", + "live_test", + "--domain-ids", + "d1", + "d2", + "--json", + ] + ) + == 0 + ) + assert seen["body"] == {"engagement_type": "live_test", "domain_ids": ["d1", "d2"]} + + +def test_data_merges_extra_fields(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + + def fake_request(_method: str, _path: str, **kwargs: Any) -> FakeResponse: + seen["body"] = kwargs.get("body") + return FakeResponse(payload={"ok": True}) + + monkeypatch.setattr(http, "request", fake_request) + code = cloud.run_cloud( + ["scans", "start", "--data", '{"engagement_type": "code_review"}', "--json"] + ) + assert code == 0 + assert seen["body"] == {"engagement_type": "code_review"} + + +def test_data_reads_a_file(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + seen: dict[str, Any] = {} + + def fake_request(_method: str, _path: str, **kwargs: Any) -> FakeResponse: + seen["body"] = kwargs.get("body") + return FakeResponse(payload={"ok": True}) + + monkeypatch.setattr(http, "request", fake_request) + request_file = tmp_path / "request.json" + request_file.write_text('{"focus": "IDOR"}', encoding="utf-8") + assert cloud.run_cloud(["scans", "start", "--data", f"@{request_file}", "--json"]) == 0 + assert seen["body"] == {"focus": "IDOR"} + + +def test_data_reads_stdin(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + + def fake_request(_method: str, _path: str, **kwargs: Any) -> FakeResponse: + seen["body"] = kwargs.get("body") + return FakeResponse(payload={"ok": True}) + + monkeypatch.setattr(http, "request", fake_request) + monkeypatch.setattr("sys.stdin", io.StringIO('{"context": "staging"}')) + assert cloud.run_cloud(["scans", "start", "--data", "-", "--json"]) == 0 + assert seen["body"] == {"context": "staging"} + + +def test_data_reports_a_missing_file(tmp_path: Path) -> None: + assert cloud.run_cloud(["scans", "start", "--data", f"@{tmp_path / 'nope.json'}"]) == 1 + + +def test_auto_topup_removes_the_monthly_cap(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + + def fake_request(_method: str, _path: str, **kwargs: Any) -> FakeResponse: + seen["body"] = kwargs.get("body") + return FakeResponse(payload={"ok": True}) + + monkeypatch.setattr(http, "request", fake_request) + code = cloud.run_cloud( + [ + "billing", + "auto-topup", + "update", + "--enabled", + "--topup-credits", + "20", + "--no-monthly-cap", + "--json", + ] + ) + assert code == 0 + assert seen["body"] == { + "enabled": True, + "topup_credits": 20, + "monthly_cap_credits": None, + } + + +def test_costs_default_verb_is_the_overview(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + + def fake_request(_method: str, path: str, **_kwargs: Any) -> FakeResponse: + seen["path"] = path + return FakeResponse(payload={"total_cost": 1}) + + monkeypatch.setattr(http, "request", fake_request) + assert cloud.run_cloud(["costs", "--json"]) == 0 + assert seen["path"] == "/llm-costs" + + +def test_binary_download_writes_a_file(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr( + http, "request", lambda *_a, **_k: FakeResponse(status_code=200, content=b"%PDF-1.7") + ) + target = tmp_path / "report.pdf" + assert cloud.run_cloud(["scans", "report", "scan-1", "--output", str(target)]) == 0 + assert target.read_bytes() == b"%PDF-1.7" + + +def test_wait_polls_until_the_status_is_final(monkeypatch: pytest.MonkeyPatch) -> None: + statuses = iter(["running", "completed"]) + + def fake_request(method: str, _path: str, **_kwargs: Any) -> FakeResponse: + if method == "POST": + return FakeResponse(payload={"id": "scan-1", "status": "pending"}) + return FakeResponse(payload={"id": "scan-1", "status": next(statuses)}) + + monkeypatch.setattr(http, "request", fake_request) + monkeypatch.setattr(runner, "_WAIT_POLL_S", 0) + assert cloud.run_cloud(["scans", "start", "--domain-ids", "d1", "--wait", "--json"]) == 0 + assert next(statuses, None) is None + + +def test_insufficient_credits_exits_with_payment_code(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + http, "request", lambda *_a, **_k: FakeResponse(status_code=402, payload={}) + ) + assert cloud.run_cloud(["scans", "start", "--domain-ids", "d1"]) == http.EXIT_PAYMENT + + +def test_data_rejects_non_object() -> None: + assert cloud.run_cloud(["scans", "start", "--data", "[1,2]"]) == 1 + assert cloud.run_cloud(["scans", "start", "--data", "not json"]) == 1 + + +def test_missing_token_exits_with_auth_code( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.delenv("STRIX_API_TOKEN", raising=False) + monkeypatch.setattr(platform_cli, "AUTH_PATH", tmp_path / "platform-auth.json") + assert cloud.run_cloud(["credits"]) == http.EXIT_AUTH + + +def test_http_error_exit_codes(monkeypatch: pytest.MonkeyPatch) -> None: + for status, expected in ((401, http.EXIT_AUTH), (403, http.EXIT_AUTH), (500, http.EXIT_ERROR)): + monkeypatch.setattr( + http, + "request", + lambda *_a, _s=status, **_k: FakeResponse(status_code=_s, payload={"error": "x"}), + ) + assert cloud.run_cloud(["scans", "list"]) == expected + + +def test_credits_alias_routes_to_billing(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + + def fake_request(_method: str, path: str, **_kwargs: Any) -> FakeResponse: + seen["path"] = path + return FakeResponse(payload={"balance": 3}) + + monkeypatch.setattr(http, "request", fake_request) + assert cloud.run_cloud(["credits", "--json"]) == 0 + assert seen["path"] == "/billing/credits" + + +def test_topup_no_pay_prints_challenge(monkeypatch: pytest.MonkeyPatch, capsys: Any) -> None: + challenge = {"payment_requirements": [{"amount": 500}]} + monkeypatch.setattr( + http, "request", lambda *_a, **_k: FakeResponse(status_code=402, payload=challenge) + ) + code = cloud.run_cloud(["billing", "topup", "--credits", "5", "--no-pay", "--json"]) + assert code == http.EXIT_PAYMENT + assert json.loads(capsys.readouterr().out) == challenge + + +def test_topup_success_without_payment(monkeypatch: pytest.MonkeyPatch, capsys: Any) -> None: + receipt = {"credits_granted": 5, "duplicate": False, "balance": 5} + monkeypatch.setattr( + http, "request", lambda *_a, **_k: FakeResponse(status_code=200, payload=receipt) + ) + code = cloud.run_cloud(["billing", "topup", "--credits", "5", "--json"]) + assert code == 0 + assert json.loads(capsys.readouterr().out) == receipt + + +def test_render_json_mode_when_not_a_tty() -> None: + assert render.json_mode(flag=True) is True + # Under pytest, stdout is captured and is not a terminal. + assert render.json_mode(flag=False) is True + + +def test_render_list_extraction() -> None: + rows = render._list_of_dicts({"scans": [{"id": "a"}, {"id": "b"}]}) + assert rows == [{"id": "a"}, {"id": "b"}] + assert render._list_of_dicts({"scans": [], "total": 1}) is None + assert render._list_of_dicts([{"id": "a"}, "x"]) is None + + +def test_spec_paths_are_well_formed() -> None: + for group, commands in SPEC.items(): + for verb, cmd in commands.items(): + assert cmd.path.startswith("/"), f"{group} {verb}" + assert cmd.method in ("GET", "POST", "PUT", "PATCH", "DELETE"), f"{group} {verb}" + assert cmd.help, f"{group} {verb} has no help text" + for param in cmd.query + cmd.body: + assert param.kind in ("str", "int", "float", "bool", "list", "json"), ( + f"{group} {verb} {param.name}" + ) + + +def test_every_command_builds_a_parser() -> None: + for group, commands in SPEC.items(): + for verb, cmd in commands.items(): + parser = runner._build_parser(group, verb, cmd) + assert parser.prog == f"strix cloud {group} {verb}" + + +def test_app_url_and_timeout_overrides(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + + def fake_request(_method: str, url: str, **kwargs: Any) -> FakeResponse: + seen["url"] = url + seen["timeout"] = kwargs.get("timeout") + return FakeResponse(status_code=200, payload={"balance": 1}) + + monkeypatch.setattr(http, "api_token", lambda _override=None: "t") + monkeypatch.setattr(requests, "request", fake_request) + code = cloud.run_cloud( + ["credits", "--app-url", "https://example.test/", "--timeout", "7", "--json"] + ) + assert code == 0 + assert seen["url"] == "https://example.test/api/v1/billing/credits" + assert seen["timeout"] == 7 + + +def test_created_id_reads_resource_id() -> None: + assert runner._created_id({"scan_id": "abc", "status": "pending"}) == "abc" + assert runner._created_id({"id": "xyz"}) == "xyz" + assert runner._created_id({"status": "pending"}) is None