From 28590a52e006c62291b962f0b2c1de66ad085d30 Mon Sep 17 00:00:00 2001 From: Alex Schapiro Date: Wed, 26 Aug 2026 19:35:18 +0000 Subject: [PATCH 01/28] feat(cli): add strix login for managed platform sign-in (device flow) --- strix/interface/main.py | 7 + strix/interface/platform_cli.py | 230 ++++++++++++++++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 strix/interface/platform_cli.py diff --git a/strix/interface/main.py b/strix/interface/main.py index 96459978..eeec81a2 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -431,6 +431,13 @@ 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 + + sys.exit(run_login(sys.argv[2:])) + from strix.llm.warmup import start_import_warmup start_import_warmup() diff --git a/strix/interface/platform_cli.py b/strix/interface/platform_cli.py new file mode 100644 index 00000000..1e382936 --- /dev/null +++ b/strix/interface/platform_cli.py @@ -0,0 +1,230 @@ +"""`strix 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 time +import webbrowser +from pathlib import Path +from typing import Any, cast + +import requests +from rich.console import Console +from rich.panel import Panel +from rich.text import Text + +from strix.config import load_settings +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 + +_LOGIN_USAGE = "Usage:\n strix login [--no-browser]\n strix login status\n strix login logout" + + +class PlatformAuthError(Exception): + """Raised when the device authorization flow fails.""" + + +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() -> None: + with contextlib.suppress(OSError): + AUTH_PATH.unlink() + + +def run_login(argv: list[str]) -> int: + """Entry point for ``strix login …``. Returns a process exit code.""" + console = Console() + subcommand = argv[0] if argv else None + + if subcommand in ("-h", "--help", "help"): + console.print(_LOGIN_USAGE) + return 0 + if subcommand == "status": + return _status(console) + if subcommand == "logout": + return _logout(console) + return _login(console, argv) + + +def _login(console: Console, argv: list[str]) -> int: + parser = argparse.ArgumentParser(prog="strix login", add_help=True) + parser.add_argument( + "--no-browser", + action="store_true", + help="Do not open the browser. Print the verification URL instead.", + ) + try: + args = parser.parse_args(argv) + except SystemExit as exc: # argparse already printed the message + return int(exc.code or 2) + + console.print() + console.print("[bold]Signing in to the Strix platform[/] [dim](app.strix.ai)[/]") + 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) + except PlatformAuthError as exc: + console.print(f"[red]Sign-in failed:[/] {exc}") + return 1 + except KeyboardInterrupt: + console.print("\n[yellow]Sign-in cancelled.[/]") + return 130 + + save_record(record) + _print_success(console, record) + return 0 + + +def _run_device_flow(console: Console, *, open_browser: bool) -> dict[str, Any]: + app_url = _app_url() + + try: + response = requests.post(f"{app_url}/api/v1/cli/login", timeout=_HTTP_TIMEOUT_S) + except requests.RequestException as exc: + raise PlatformAuthError(f"could not reach {app_url}: {exc}") from exc + if not response.ok: + raise PlatformAuthError(_error_detail(response)) + authorization = response.json() + + user_code = authorization.get("user_code", "") + verification_uri = authorization.get("verification_uri_complete") or authorization.get( + "verification_uri", "" + ) + device_code = authorization.get("device_code", "") + expires_in = int(authorization.get("expires_in") or 300) + interval = int(authorization.get("interval") or _DEFAULT_POLL_INTERVAL_S) + if not device_code or not verification_uri: + raise PlatformAuthError("the server returned an incomplete device authorization") + + console.print( + Panel.fit( + Text.assemble( + ("Confirmation code: ", "dim"), + (user_code, "bold cyan"), + ("\n\nOpen this URL in your browser and confirm the code:\n", "dim"), + (verification_uri, "underline"), + ), + title="Verify this device", + ) + ) + + if open_browser: + with contextlib.suppress(Exception): + webbrowser.open(verification_uri) + + console.print("[dim]Waiting for browser confirmation…[/]") + + deadline = time.monotonic() + expires_in + while time.monotonic() < deadline: + time.sleep(interval) + try: + poll = requests.post( + f"{app_url}/api/v1/cli/login/poll", + json={"device_code": device_code}, + timeout=_HTTP_TIMEOUT_S, + ) + except requests.RequestException: + continue + if poll.ok: + return dict(poll.json()) + error = "" + with contextlib.suppress(ValueError): + error = str(poll.json().get("error", "")) + if error == "authorization_pending": + continue + if error == "slow_down": + interval += 5 + continue + if error == "access_denied": + raise PlatformAuthError("the sign-in request was denied in the browser") + if error == "expired_token": + break + raise PlatformAuthError(_error_detail(poll)) + + raise PlatformAuthError("the sign-in request expired. Run `strix login` again.") + + +def _error_detail(response: requests.Response) -> str: + with contextlib.suppress(ValueError): + detail = response.json().get("detail") + if detail: + return str(detail) + return f"HTTP {response.status_code}" + + +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]{email}[/]") + if organization: + console.print(f" Workspace: [bold]{organization}[/]") + 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.[/]" + ) + + +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.") + return 1 + email = record.get("email", "unknown") + organization = record.get("organization_name") or record.get("organization_id", "") + expires_at = record.get("expires_at", "") + console.print(f"[green]Signed in[/] as [bold]{email}[/]") + if organization: + console.print(f" Workspace: {organization}") + if expires_at: + console.print(f" Token expires: {expires_at}") + return 0 + + +def _logout(console: Console) -> int: + if read_record() is None: + console.print("[yellow]Not signed in.[/]") + return 0 + logout() + console.print("[green]Signed out.[/] The stored API token was removed from this machine.") + return 0 From 17007bd43c1a31b02bddd381d5b50354922dde88 Mon Sep 17 00:00:00 2001 From: Alex Schapiro Date: Wed, 26 Aug 2026 19:58:21 +0000 Subject: [PATCH 02/28] feat(cli): add --scopes flag to strix login --- strix/interface/platform_cli.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/strix/interface/platform_cli.py b/strix/interface/platform_cli.py index 1e382936..121aba6a 100644 --- a/strix/interface/platform_cli.py +++ b/strix/interface/platform_cli.py @@ -30,7 +30,10 @@ AUTH_PATH = Path.home() / ".strix" / "platform-auth.json" _HTTP_TIMEOUT_S = 30 _DEFAULT_POLL_INTERVAL_S = 5 -_LOGIN_USAGE = "Usage:\n strix login [--no-browser]\n strix login status\n strix login logout" +_LOGIN_USAGE = ( + "Usage:\n strix login [--no-browser] [--scopes SCOPE ...]\n" + " strix login status\n strix login logout" +) class PlatformAuthError(Exception): @@ -85,6 +88,16 @@ def _login(console: Console, argv: list[str]) -> int: action="store_true", help="Do not open the browser. Print the verification URL instead.", ) + parser.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." + ), + ) try: args = parser.parse_args(argv) except SystemExit as exc: # argparse already printed the message @@ -98,7 +111,7 @@ def _login(console: Console, argv: list[str]) -> int: console.print() try: - record = _run_device_flow(console, open_browser=not args.no_browser) + record = _run_device_flow(console, open_browser=not args.no_browser, scopes=args.scopes) except PlatformAuthError as exc: console.print(f"[red]Sign-in failed:[/] {exc}") return 1 @@ -111,7 +124,9 @@ def _login(console: Console, argv: list[str]) -> int: return 0 -def _run_device_flow(console: Console, *, open_browser: bool) -> dict[str, Any]: +def _run_device_flow( + console: Console, *, open_browser: bool, scopes: list[str] | None = None +) -> dict[str, Any]: app_url = _app_url() try: @@ -150,13 +165,17 @@ def _run_device_flow(console: Console, *, open_browser: bool) -> dict[str, Any]: console.print("[dim]Waiting for browser confirmation…[/]") + poll_body: dict[str, Any] = {"device_code": device_code} + if scopes: + poll_body["scopes"] = scopes + deadline = time.monotonic() + expires_in while time.monotonic() < deadline: time.sleep(interval) try: poll = requests.post( f"{app_url}/api/v1/cli/login/poll", - json={"device_code": device_code}, + json=poll_body, timeout=_HTTP_TIMEOUT_S, ) except requests.RequestException: From 6cb78d495a884e6f6798264df18d3f14b1201865 Mon Sep 17 00:00:00 2001 From: Alex Schapiro Date: Wed, 26 Aug 2026 20:53:13 +0000 Subject: [PATCH 03/28] docs: document strix login and managed billing in README, AGENTS, docs, and managed skill --- AGENTS.md | 3 ++- README.md | 13 ++++++++++++ docs/integrations/coding-agents.mdx | 2 +- skills/managed-pentesting-with-strix/SKILL.md | 20 ++++++++++++++++++- 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b347278b..173884ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,8 @@ 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: + # 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":[""]}' ``` diff --git a/README.md b/README.md index 99c235af..57f217c5 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,19 @@ strix auth status # show the active sign-in strix auth logout # forget the sign-in ``` +#### Sign in to the managed platform + +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`: + +```bash +strix login # opens the browser to approve the sign-in +strix login --scopes scans:read scans:write # request specific token scopes +strix login status # show the active sign-in +strix login 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. + #### 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: diff --git a/docs/integrations/coding-agents.mdx b/docs/integrations/coding-agents.mdx index 59e598f1..3f32f803 100644 --- a/docs/integrations/coding-agents.mdx +++ b/docs/integrations/coding-agents.mdx @@ -36,7 +36,7 @@ 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 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. ## Agent-Friendly Interfaces diff --git a/skills/managed-pentesting-with-strix/SKILL.md b/skills/managed-pentesting-with-strix/SKILL.md index 08feeb36..bc8d5daa 100644 --- a/skills/managed-pentesting-with-strix/SKILL.md +++ b/skills/managed-pentesting-with-strix/SKILL.md @@ -17,7 +17,7 @@ Full reference: **[docs.app.strix.ai](https://docs.app.strix.ai)** · OpenAPI: ` - **Base URL:** `https://app.strix.ai/api/v1` - **Auth:** every request sends `Authorization: Bearer `. 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. +- **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). Pass `--scopes SCOPE ...` to request specific scopes. 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. - **Scopes (least-privilege):** assign only what the integration needs and rotate regularly: | Scope | Grants | @@ -29,6 +29,7 @@ Full reference: **[docs.app.strix.ai](https://docs.app.strix.ai)** · OpenAPI: ` | `pr_reviews:write` | trigger PR security reviews | | `webhooks:read` / `:write` | manage webhook subscriptions | | `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="" @@ -38,6 +39,23 @@ 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. +## 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}' +``` + +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. + +```bash +curl -sS "$BASE/billing/topup" "${auth[@]}" -H "Content-Type: application/json" \ + -d '{"credits": 20}' # → 402 + payment challenge, then retry with payment +``` + +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. + ## 1. Register the target as an asset Scans run against **registered assets**, not raw URLs. Register once, then reuse the returned UUID. From 8dd26f016fbdd4bbbdde3c70f2bedd367153bed0 Mon Sep 17 00:00:00 2001 From: Alex Schapiro Date: Wed, 26 Aug 2026 20:56:04 +0000 Subject: [PATCH 04/28] fix(cli): handle malformed login responses and credential file failures --- strix/interface/platform_cli.py | 64 +++++++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 14 deletions(-) diff --git a/strix/interface/platform_cli.py b/strix/interface/platform_cli.py index 121aba6a..f50e3a8a 100644 --- a/strix/interface/platform_cli.py +++ b/strix/interface/platform_cli.py @@ -61,9 +61,14 @@ def save_record(record: dict[str, Any]) -> None: write_secret_text(AUTH_PATH, json.dumps(record, indent=2)) -def logout() -> None: - with contextlib.suppress(OSError): +def logout() -> bool: + try: AUTH_PATH.unlink() + except FileNotFoundError: + return True + except OSError: + return False + return True def run_login(argv: list[str]) -> int: @@ -119,7 +124,14 @@ def _login(console: Console, argv: list[str]) -> int: console.print("\n[yellow]Sign-in cancelled.[/]") return 130 - save_record(record) + try: + save_record(record) + 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.[/]" + ) + return 1 _print_success(console, record) return 0 @@ -135,15 +147,17 @@ def _run_device_flow( raise PlatformAuthError(f"could not reach {app_url}: {exc}") from exc if not response.ok: raise PlatformAuthError(_error_detail(response)) - authorization = response.json() + authorization = _json_object(response) - user_code = authorization.get("user_code", "") - verification_uri = authorization.get("verification_uri_complete") or authorization.get( - "verification_uri", "" + user_code = str(authorization.get("user_code") or "") + verification_uri = str( + authorization.get("verification_uri_complete") + or authorization.get("verification_uri") + or "" ) - device_code = authorization.get("device_code", "") - expires_in = int(authorization.get("expires_in") or 300) - interval = int(authorization.get("interval") or _DEFAULT_POLL_INTERVAL_S) + device_code = str(authorization.get("device_code") or "") + expires_in = _as_positive_int(authorization.get("expires_in"), default=300) + interval = _as_positive_int(authorization.get("interval"), default=_DEFAULT_POLL_INTERVAL_S) if not device_code or not verification_uri: raise PlatformAuthError("the server returned an incomplete device authorization") @@ -181,9 +195,9 @@ def _run_device_flow( except requests.RequestException: continue if poll.ok: - return dict(poll.json()) + return _json_object(poll) error = "" - with contextlib.suppress(ValueError): + with contextlib.suppress(ValueError, AttributeError): error = str(poll.json().get("error", "")) if error == "authorization_pending": continue @@ -199,8 +213,26 @@ def _run_device_flow( raise PlatformAuthError("the sign-in request expired. Run `strix login` again.") +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) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return parsed if parsed > 0 else default + + def _error_detail(response: requests.Response) -> str: - with contextlib.suppress(ValueError): + with contextlib.suppress(ValueError, AttributeError): detail = response.json().get("detail") if detail: return str(detail) @@ -244,6 +276,10 @@ def _logout(console: Console) -> int: if read_record() is None: console.print("[yellow]Not signed in.[/]") return 0 - logout() + if not logout(): + console.print( + f"[red]Could not remove the stored API token.[/] Delete {AUTH_PATH} manually." + ) + return 1 console.print("[green]Signed out.[/] The stored API token was removed from this machine.") return 0 From 67082e21d93e24a347a6cac20a0113589ff7199b Mon Sep 17 00:00:00 2001 From: Alex Schapiro Date: Wed, 26 Aug 2026 21:02:57 +0000 Subject: [PATCH 05/28] fix(cli): reject sign-in responses without an API token --- strix/interface/platform_cli.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/strix/interface/platform_cli.py b/strix/interface/platform_cli.py index f50e3a8a..90ff82e5 100644 --- a/strix/interface/platform_cli.py +++ b/strix/interface/platform_cli.py @@ -195,7 +195,7 @@ def _run_device_flow( except requests.RequestException: continue if poll.ok: - return _json_object(poll) + return _signed_in_record(poll) error = "" with contextlib.suppress(ValueError, AttributeError): error = str(poll.json().get("error", "")) @@ -213,6 +213,13 @@ def _run_device_flow( raise PlatformAuthError("the sign-in request expired. Run `strix login` again.") +def _signed_in_record(response: requests.Response) -> dict[str, Any]: + record = _json_object(response) + if not str(record.get("api_token") or ""): + raise PlatformAuthError("the server returned a sign-in response without an API token") + return record + + def _json_object(response: requests.Response) -> dict[str, Any]: try: data = response.json() From d6ccb7c38aa06346d7dc32e40e6dee77742f5580 Mon Sep 17 00:00:00 2001 From: Alex Schapiro Date: Wed, 26 Aug 2026 21:23:13 +0000 Subject: [PATCH 06/28] feat(login): interactive workspace and scope selection with presets --- README.md | 3 +- skills/managed-pentesting-with-strix/SKILL.md | 2 +- strix/interface/platform_cli.py | 220 ++++++++++++++++-- 3 files changed, 204 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 57f217c5..413a758c 100644 --- a/README.md +++ b/README.md @@ -325,8 +325,9 @@ strix auth logout # forget the sign-in 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`: ```bash -strix login # opens the browser to approve the sign-in +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 ``` diff --git a/skills/managed-pentesting-with-strix/SKILL.md b/skills/managed-pentesting-with-strix/SKILL.md index bc8d5daa..ab2c29fb 100644 --- a/skills/managed-pentesting-with-strix/SKILL.md +++ b/skills/managed-pentesting-with-strix/SKILL.md @@ -17,7 +17,7 @@ Full reference: **[docs.app.strix.ai](https://docs.app.strix.ai)** · OpenAPI: ` - **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). Pass `--scopes SCOPE ...` to request specific scopes. 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. +- **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. - **Scopes (least-privilege):** assign only what the integration needs and rotate regularly: | Scope | Grants | diff --git a/strix/interface/platform_cli.py b/strix/interface/platform_cli.py index 90ff82e5..99c72411 100644 --- a/strix/interface/platform_cli.py +++ b/strix/interface/platform_cli.py @@ -11,6 +11,7 @@ from __future__ import annotations import argparse import contextlib import json +import sys import time import webbrowser from pathlib import Path @@ -31,10 +32,12 @@ _HTTP_TIMEOUT_S = 30 _DEFAULT_POLL_INTERVAL_S = 5 _LOGIN_USAGE = ( - "Usage:\n strix login [--no-browser] [--scopes SCOPE ...]\n" + "Usage:\n strix login [--no-browser] [--scopes SCOPE ...] [--workspace WORKSPACE]\n" " strix login status\n strix login logout" ) +_ROLE_RANK = {"viewer": 0, "analyst": 1, "admin": 2} + class PlatformAuthError(Exception): """Raised when the device authorization flow fails.""" @@ -100,7 +103,18 @@ def _login(console: Console, argv: list[str]) -> int: default=None, help=( "API scopes for the token, for example scans:read billing:write. " - "The server always includes a minimum scope set." + "The server always includes a minimum scope set. " + "Without this option, an interactive picker opens after the browser step." + ), + ) + 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." ), ) try: @@ -116,7 +130,12 @@ def _login(console: Console, argv: list[str]) -> int: console.print() try: - record = _run_device_flow(console, open_browser=not args.no_browser, scopes=args.scopes) + record = _run_device_flow( + console, + open_browser=not args.no_browser, + scopes=args.scopes, + workspace=args.workspace, + ) except PlatformAuthError as exc: console.print(f"[red]Sign-in failed:[/] {exc}") return 1 @@ -137,9 +156,14 @@ def _login(console: Console, argv: list[str]) -> int: def _run_device_flow( - console: Console, *, open_browser: bool, scopes: list[str] | None = None + console: Console, + *, + open_browser: bool, + scopes: list[str] | None = None, + workspace: str | None = None, ) -> dict[str, Any]: app_url = _app_url() + interactive = workspace is not None or (sys.stdin.isatty() and scopes is None) try: response = requests.post(f"{app_url}/api/v1/cli/login", timeout=_HTTP_TIMEOUT_S) @@ -180,7 +204,9 @@ def _run_device_flow( console.print("[dim]Waiting for browser confirmation…[/]") poll_body: dict[str, Any] = {"device_code": device_code} - if scopes: + if interactive: + poll_body["interactive"] = True + elif scopes: poll_body["scopes"] = scopes deadline = time.monotonic() + expires_in @@ -195,31 +221,184 @@ def _run_device_flow( except requests.RequestException: continue if poll.ok: - return _signed_in_record(poll) - error = "" - with contextlib.suppress(ValueError, AttributeError): - error = str(poll.json().get("error", "")) - if error == "authorization_pending": - continue - if error == "slow_down": - interval += 5 - continue - if error == "access_denied": - raise PlatformAuthError("the sign-in request was denied in the browser") - if error == "expired_token": + return _finish_login(console, app_url, poll, scopes=scopes, workspace=workspace) + delta = _handle_poll_error(poll) + if delta is None: break - raise PlatformAuthError(_error_detail(poll)) + interval += delta raise PlatformAuthError("the sign-in request expired. Run `strix 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, + workspace: str | None, +) -> dict[str, Any]: + result = _json_object(poll) + if result.get("selection_required"): + return _complete_selection(console, app_url, result, scopes=scopes, workspace=workspace) + return _require_api_token(result) + + def _signed_in_record(response: requests.Response) -> dict[str, Any]: - record = _json_object(response) + return _require_api_token(_json_object(response)) + + +def _require_api_token(record: dict[str, Any]) -> dict[str, Any]: if not str(record.get("api_token") or ""): raise PlatformAuthError("the server returned a sign-in response without an API token") return record +def _complete_selection( + console: Console, + app_url: str, + selection: dict[str, Any], + *, + scopes: list[str] | None, + workspace: str | None, +) -> dict[str, Any]: + organizations = [org for org in selection.get("organizations") or [] if isinstance(org, dict)] + catalog = [item for item in selection.get("scopes") or [] if isinstance(item, dict)] + 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 if scopes else _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 + try: + response = requests.post( + f"{app_url}/api/v1/cli/login/complete", + json=body, + timeout=_HTTP_TIMEOUT_S, + ) + except requests.RequestException as exc: + raise PlatformAuthError(f"could not reach {app_url}: {exc}") from exc + if not response.ok: + raise PlatformAuthError(_error_detail(response)) + return _signed_in_record(response) + + +def _choose_workspace( + console: Console, organizations: list[dict[str, Any]], workspace: str | None +) -> dict[str, Any]: + if workspace is not None: + wanted = workspace.strip().lower() + for org in organizations: + if wanted in (str(org.get("id", "")).lower(), str(org.get("name", "")).lower()): + return org + 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] + + console.print() + console.print("[bold]Select a workspace for the API token:[/]") + for index, org in enumerate(organizations, start=1): + console.print(f" [cyan]{index}[/]. {org.get('name', '')} [dim]({org.get('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) -> list[str] | None: + """Prompt for token scopes. Returns None to accept the server defaults.""" + 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 None + + console.print() + console.print("[bold]Select token scopes:[/]") + console.print( + " [cyan]1[/]. Recommended [dim](scans, vulnerabilities, schedules, assets, billing)[/]" + ) + console.print(" [cyan]2[/]. Full access [dim](every scope your role allows)[/]") + console.print(" [cyan]3[/]. Minimal [dim](scans and billing read only)[/]") + 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 None + if answer == "2": + return [str(item["scope"]) for item in allowed if item.get("scope")] + if answer == "3": + return [ + str(item["scope"]) for item in allowed if item.get("scope") and item.get("minimum") + ] + if answer == "4": + return _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 "" + console.print( + f" [{mark}] [cyan]{index:>2}[/]. {scope}{required}" + f"\n [dim]{item.get('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 {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]{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() @@ -255,6 +434,9 @@ def _print_success(console: Console, record: dict[str, Any]) -> None: console.print(f" Account: [bold]{email}[/]") if organization: console.print(f" Workspace: [bold]{organization}[/]") + scopes = record.get("scopes") + if isinstance(scopes, list) and scopes: + console.print(f" Scopes: [dim]{' '.join(str(s) for s in scopes)}[/]") console.print(f" Token: stored in [dim]{AUTH_PATH}[/]") console.print() console.print( From 04a77e50e7b64697b7ace356d62b2fb05257b315 Mon Sep 17 00:00:00 2001 From: Alex Schapiro Date: Wed, 26 Aug 2026 21:24:57 +0000 Subject: [PATCH 07/28] fix(login): reject malformed API token values in sign-in responses --- strix/interface/platform_cli.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/strix/interface/platform_cli.py b/strix/interface/platform_cli.py index 99c72411..1f05eb05 100644 --- a/strix/interface/platform_cli.py +++ b/strix/interface/platform_cli.py @@ -265,7 +265,8 @@ def _signed_in_record(response: requests.Response) -> dict[str, Any]: def _require_api_token(record: dict[str, Any]) -> dict[str, Any]: - if not str(record.get("api_token") or ""): + 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 From baf0ff41fd86cb7593d758db6fcdc66177728b68 Mon Sep 17 00:00:00 2001 From: Alex Schapiro Date: Wed, 26 Aug 2026 21:28:56 +0000 Subject: [PATCH 08/28] fix(login): skip the scope prompt when stdin is not a terminal --- strix/interface/platform_cli.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/strix/interface/platform_cli.py b/strix/interface/platform_cli.py index 1f05eb05..40f49943 100644 --- a/strix/interface/platform_cli.py +++ b/strix/interface/platform_cli.py @@ -287,7 +287,9 @@ def _complete_selection( chosen_org = _choose_workspace(console, organizations, workspace) role = str(chosen_org.get("role") or "admin") - chosen_scopes = scopes if scopes else _choose_scopes(console, catalog, role) + chosen_scopes = scopes + if chosen_scopes is None and sys.stdin.isatty(): + chosen_scopes = _choose_scopes(console, catalog, role) body: dict[str, Any] = { "selection_token": selection_token, From 5c61a2aee40a54bac12f1d4fa5425b3e2214de16 Mon Sep 17 00:00:00 2001 From: Alex Schapiro Date: Wed, 26 Aug 2026 21:32:52 +0000 Subject: [PATCH 09/28] fix(login): tolerate malformed selection containers and remove unreadable credential files on logout --- strix/interface/platform_cli.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/strix/interface/platform_cli.py b/strix/interface/platform_cli.py index 40f49943..cbb18eb9 100644 --- a/strix/interface/platform_cli.py +++ b/strix/interface/platform_cli.py @@ -279,8 +279,8 @@ def _complete_selection( scopes: list[str] | None, workspace: str | None, ) -> dict[str, Any]: - organizations = [org for org in selection.get("organizations") or [] if isinstance(org, dict)] - catalog = [item for item in selection.get("scopes") or [] if isinstance(item, dict)] + 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") @@ -310,6 +310,12 @@ def _complete_selection( return _signed_in_record(response) +def _dict_items(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, dict)] + + def _choose_workspace( console: Console, organizations: list[dict[str, Any]], workspace: str | None ) -> dict[str, Any]: @@ -465,7 +471,7 @@ def _status(console: Console) -> int: def _logout(console: Console) -> int: - if read_record() is None: + if read_record() is None and not AUTH_PATH.exists(): console.print("[yellow]Not signed in.[/]") return 0 if not logout(): From 93dc0019faf18c5679ba4761dc235dbe4698c441 Mon Sep 17 00:00:00 2001 From: Alex Schapiro Date: Wed, 26 Aug 2026 21:43:54 +0000 Subject: [PATCH 10/28] fix(login): treat overflowing timing values as invalid --- strix/interface/platform_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/strix/interface/platform_cli.py b/strix/interface/platform_cli.py index cbb18eb9..39e2d79f 100644 --- a/strix/interface/platform_cli.py +++ b/strix/interface/platform_cli.py @@ -421,7 +421,7 @@ def _json_object(response: requests.Response) -> dict[str, Any]: def _as_positive_int(value: Any, *, default: int) -> int: try: parsed = int(value) - except (TypeError, ValueError): + except (TypeError, ValueError, OverflowError): return default return parsed if parsed > 0 else default From ef722c2ee0891342e1ff92c0c26d90e53e58a1f5 Mon Sep 17 00:00:00 2001 From: Alex Schapiro Date: Wed, 26 Aug 2026 21:48:05 +0000 Subject: [PATCH 11/28] fix(login): show the configured platform host in the sign-in banner --- strix/interface/platform_cli.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/strix/interface/platform_cli.py b/strix/interface/platform_cli.py index 39e2d79f..a4dc88af 100644 --- a/strix/interface/platform_cli.py +++ b/strix/interface/platform_cli.py @@ -16,6 +16,7 @@ import time import webbrowser from pathlib import Path from typing import Any, cast +from urllib.parse import urlparse import requests from rich.console import Console @@ -123,7 +124,8 @@ def _login(console: Console, argv: list[str]) -> int: return int(exc.code or 2) console.print() - console.print("[bold]Signing in to the Strix platform[/] [dim](app.strix.ai)[/]") + host = urlparse(_app_url()).netloc or _app_url() + console.print(f"[bold]Signing in to the Strix platform[/] [dim]({host})[/]") console.print( "[dim]This creates your account and workspace when needed, and stores an API token.[/]" ) From 44fb7ac969c495519a15434473dcfd33f4885565 Mon Sep 17 00:00:00 2001 From: Alex Schapiro Date: Wed, 26 Aug 2026 21:54:04 +0000 Subject: [PATCH 12/28] fix(login): bound device flow timing values and clean up unreplaced secret temp files --- strix/interface/platform_cli.py | 18 ++++++++++++++---- strix/utils/secret_files.py | 7 ++++++- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/strix/interface/platform_cli.py b/strix/interface/platform_cli.py index a4dc88af..1b79ebfa 100644 --- a/strix/interface/platform_cli.py +++ b/strix/interface/platform_cli.py @@ -31,6 +31,8 @@ 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 _LOGIN_USAGE = ( "Usage:\n strix login [--no-browser] [--scopes SCOPE ...] [--workspace WORKSPACE]\n" @@ -182,8 +184,14 @@ def _run_device_flow( or "" ) device_code = str(authorization.get("device_code") or "") - expires_in = _as_positive_int(authorization.get("expires_in"), default=300) - interval = _as_positive_int(authorization.get("interval"), default=_DEFAULT_POLL_INTERVAL_S) + 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") @@ -420,12 +428,14 @@ def _json_object(response: requests.Response) -> dict[str, Any]: return cast("dict[str, Any]", data) -def _as_positive_int(value: Any, *, default: int) -> int: +def _as_positive_int(value: Any, *, default: int, maximum: int) -> int: try: parsed = int(value) except (TypeError, ValueError, OverflowError): return default - return parsed if parsed > 0 else default + if parsed <= 0: + return default + return min(parsed, maximum) def _error_detail(response: requests.Response) -> str: diff --git a/strix/utils/secret_files.py b/strix/utils/secret_files.py index b2170bf9..5ba0fc1c 100644 --- a/strix/utils/secret_files.py +++ b/strix/utils/secret_files.py @@ -28,4 +28,9 @@ def write_secret_text(path: Path, text: str) -> None: tmp.unlink() raise - tmp.replace(path) + try: + tmp.replace(path) + except BaseException: + with contextlib.suppress(OSError): + tmp.unlink() + raise From 56008a6c507769caabe8ff312c7318c6ba322a36 Mon Sep 17 00:00:00 2001 From: Alex Schapiro Date: Wed, 26 Aug 2026 22:30:09 +0000 Subject: [PATCH 13/28] 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 From 8d66c66968071ecccb4f0b8524c46b7df6247f85 Mon Sep 17 00:00:00 2001 From: Alex Schapiro Date: Wed, 26 Aug 2026 23:01:36 +0000 Subject: [PATCH 14/28] feat(cli): manage workspaces and hosted onboarding links from strix cloud --- AGENTS.md | 1 + README.md | 14 ++ docs/integrations/coding-agents.mdx | 2 +- pyproject.toml | 1 + skills/managed-pentesting-with-strix/SKILL.md | 26 ++++ strix/interface/cloud/__init__.py | 5 + strix/interface/cloud/runner.py | 27 ++++ strix/interface/cloud/spec.py | 44 ++++++- strix/interface/cloud/workspaces.py | 123 ++++++++++++++++++ tests/test_cloud_cli.py | 93 ++++++++++++- 10 files changed, 333 insertions(+), 3 deletions(-) create mode 100644 strix/interface/cloud/workspaces.py diff --git a/AGENTS.md b/AGENTS.md index 96e38080..66b222d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,7 @@ Target-specific workflows built on the same engine: strix cloud vulns list --severity critical strix cloud billing topup --credits 20 # buy credits when a scan returns exit code 5 ``` + - Account setup runs from the CLI too: `strix cloud workspaces list|create|use`, `strix cloud org members invite`, `strix cloud billing subscribe --plan strix_pro`, `strix cloud billing portal`, `strix cloud integrations install github`, and `strix cloud domains verify `. 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, the installation, or the DNS change for them. - 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). diff --git a/README.md b/README.md index 1ed3c3ea..6b6d15f7 100644 --- a/README.md +++ b/README.md @@ -344,6 +344,20 @@ strix cloud credits # credit balance strix cloud billing topup --credits 20 # buy credits (agent payment, HTTP 402) ``` +Workspaces and account setup also work from the terminal: + +```bash +strix cloud workspaces list # your workspaces +strix cloud workspaces create --name "My Team" +strix cloud workspaces use "My Team" # store a token for another workspace +strix cloud billing subscribe --plan strix_pro # 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 # 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 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`: diff --git a/docs/integrations/coding-agents.mdx b/docs/integrations/coding-agents.mdx index f74f86f5..80f24597 100644 --- a/docs/integrations/coding-agents.mdx +++ b/docs/integrations/coding-agents.mdx @@ -43,7 +43,7 @@ Both use the same engine and produce the same validated findings and SARIF, so a 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`). +- **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//`; 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/pyproject.toml b/pyproject.toml index 72041a3b..be216b5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -229,6 +229,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"] diff --git a/skills/managed-pentesting-with-strix/SKILL.md b/skills/managed-pentesting-with-strix/SKILL.md index 720c5827..261d6a55 100644 --- a/skills/managed-pentesting-with-strix/SKILL.md +++ b/skills/managed-pentesting-with-strix/SKILL.md @@ -81,6 +81,32 @@ strix cloud billing auto-topup update --enabled --topup-credits 20 --monthly-cap 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 # id, name, role, and the active one +strix cloud workspaces create --name "My Team" +strix cloud workspaces use "My Team" # store a token for that workspace +strix cloud org members invite --email dev@example.com --role analyst +``` + +`workspaces use` mints a new token for a workspace the user already belongs to, and the role in that workspace limits the scopes. Add `--scopes` to request a smaller set. + +### 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_pro # hosted checkout page for a 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 # 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`. + ## 1. Register the target as an asset Scans run against **registered assets**, not raw URLs. Register once, then reuse the returned UUID. diff --git a/strix/interface/cloud/__init__.py b/strix/interface/cloud/__init__.py index 2f36542a..ce0b753d 100644 --- a/strix/interface/cloud/__init__.py +++ b/strix/interface/cloud/__init__.py @@ -12,6 +12,7 @@ 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.cloud.workspaces import run_workspace_use from strix.interface.platform_cli import run_login @@ -45,6 +46,8 @@ def run_cloud(argv: list[str]) -> int: return run_login(session_argv[group]) if group == "credits": group, rest = "billing", ["credits", *rest] + if group == "workspaces" and rest and rest[0] == "use": + return run_workspace_use(rest[1:]) if group not in SPEC: console.print(f"[red]Unknown command:[/] {group}") @@ -70,3 +73,5 @@ 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}") + if group == "workspaces": + console.print(f" {'use':<28}Switch the stored token to another workspace.") diff --git a/strix/interface/cloud/runner.py b/strix/interface/cloud/runner.py index 54738fd5..7c400609 100644 --- a/strix/interface/cloud/runner.py +++ b/strix/interface/cloud/runner.py @@ -13,6 +13,7 @@ import shutil import subprocess import sys import time +import webbrowser from pathlib import Path from typing import Any, cast @@ -123,10 +124,30 @@ def _execute( 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) + if cmd.link: + return _handoff_link(console, cmd, args, result, as_json=as_json) emit(console, result, as_json=as_json) return http.EXIT_OK +def _handoff_link( + console: Console, cmd: Cmd, args: argparse.Namespace, result: Any, *, as_json: bool +) -> int: + """Print a hosted URL a person must open, and open the browser when interactive.""" + url = result.get(cmd.link) if isinstance(result, dict) else None + if not isinstance(url, str) or not url: + emit(console, result, as_json=as_json) + return http.EXIT_OK + interactive = sys.stdout.isatty() and not getattr(args, "no_browser", False) + if as_json: + emit(console, result, as_json=True) + else: + console.print(f"Open this URL to continue:\n [bold]{url}[/]") + if interactive: + webbrowser.open(url) + 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 == "-": @@ -179,6 +200,12 @@ def _build_parser(group: str, verb_label: str, cmd: Cmd) -> argparse.ArgumentPar ) if cmd.binary: parser.add_argument("--output", default=None, metavar="FILE", help="Write to this file.") + if cmd.link: + parser.add_argument( + "--no-browser", + action="store_true", + help="Do not open the browser. Print the URL only.", + ) if cmd.wait_path or cmd.wait_self: parser.add_argument( "--wait", action="store_true", help="Wait until the operation reaches a final state." diff --git a/strix/interface/cloud/spec.py b/strix/interface/cloud/spec.py index fcdef735..d26a1cd3 100644 --- a/strix/interface/cloud/spec.py +++ b/strix/interface/cloud/spec.py @@ -36,6 +36,10 @@ class Cmd: wait_path: str | None = None # When true, `--wait` polls GET on this same path until the status is final. wait_self: bool = False + # Response field that holds a URL a person must open, for example a hosted + # checkout page. The runner opens the browser for an interactive terminal + # and always prints the URL. + link: str | None = None def _q(*names: str) -> tuple[P, ...]: @@ -498,6 +502,27 @@ SPEC: dict[str, dict[str, Cmd]] = { "Buy credits with an agent payment (HTTP 402 flow).", body=(P("credits", "int", required=True, help="Number of credits to buy."),), ), + "subscribe": Cmd( + "POST", + "/billing/checkout", + "Create a checkout link for a plan or a credit pack. A person completes the payment.", + body=( + P( + "product", + required=True, + flag="plan", + help="Product to buy, for example strix_pro, strix_cloud, or strix_top_up.", + ), + P("success_url", help="Page to open after the payment."), + ), + link="checkout_url", + ), + "portal": Cmd( + "POST", + "/billing/portal", + "Create a billing portal link. A person manages the card and the plan there.", + link="portal_url", + ), "auto-topup": Cmd("GET", "/billing/auto-topup", "Get the automatic top-up settings."), "auto-topup update": Cmd( "PUT", @@ -709,6 +734,12 @@ SPEC: dict[str, dict[str, Cmd]] = { "Validate a Git provider token. The provider is gitlab or bitbucket.", body=_GIT_TOKEN_BODY, ), + "install": Cmd( + "POST", + "/integrations/{provider}/install-url", + "Create an installation link. The provider is github or slack. A person approves it.", + link="url", + ), "disconnect": Cmd("DELETE", "/integrations/{provider}", "Disconnect an integration."), }, "connectors": { @@ -852,11 +883,21 @@ SPEC: dict[str, dict[str, Cmd]] = { ), "delete": Cmd("DELETE", "/uploads/{uploadId}", "Delete an upload."), }, + "workspaces": { + "list": Cmd("GET", "/workspaces", "List the workspaces of your account."), + "create": Cmd( + "POST", + "/workspaces", + "Create a workspace and become its admin.", + body=(P("name", required=True, help="Workspace name."),), + ), + }, } # Default verbs let a bare group name run its most common read command. DEFAULT_VERBS: dict[str, str] = { + "workspaces": "list", "costs": "overview", "audit": "list", "license": "show", @@ -876,7 +917,8 @@ GROUP_HELP: dict[str, str] = { "chat": "Interactive pentest chat sessions", "knowledge": "Manage the knowledge base", "org": "Manage the organization and its members", - "integrations": "Connect Git providers", + "integrations": "Connect Git providers and other integrations", + "workspaces": "List, create, and switch workspaces", "connectors": "Manage network connectors", "webhooks": "Manage webhooks", "analytics": "Read analytics data", diff --git a/strix/interface/cloud/workspaces.py b/strix/interface/cloud/workspaces.py new file mode 100644 index 00000000..4958cd73 --- /dev/null +++ b/strix/interface/cloud/workspaces.py @@ -0,0 +1,123 @@ +"""`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 for a token in that workspace, and +stores the token in the credential file. The role of the account in the new +workspace limits the granted scopes. +""" + +from __future__ import annotations + +import argparse +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.platform_cli import AUTH_PATH, read_record, save_record + + +def run_workspace_use(argv: list[str]) -> int: + """Entry point for ``strix cloud workspaces use``. Returns an exit code.""" + console = Console() + parser = argparse.ArgumentParser( + prog="strix cloud workspaces use", + description="Switch the stored API token to another workspace.", + ) + parser.add_argument("workspace", metavar="WORKSPACE", help="Workspace ID or exact name.") + parser.add_argument( + "--scopes", + nargs="+", + metavar="SCOPE", + default=None, + help="API scopes for the new token. Without this option the server grants the defaults.", + ) + 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." + ) + try: + args = parser.parse_args(argv) + except SystemExit as exc: + return exc.code if isinstance(exc.code, int) else 2 + + as_json = json_mode(flag=bool(args.json)) + http.configure(base_url=args.app_url, timeout=args.timeout) + try: + return _use(console, args, as_json=as_json) + except http.CloudError as exc: + if as_json: + emit(console, {"error": str(exc)}, as_json=True) + else: + console.print(f"[red]Error:[/] {exc}") + return exc.exit_code + + +def _use(console: Console, args: argparse.Namespace, *, as_json: bool) -> int: + workspace = _find_workspace(args.workspace, token=args.token) + body: dict[str, Any] = {} + if args.scopes: + body["scopes"] = args.scopes + minted = http.check( + http.request( + "POST", + f"/workspaces/{workspace['id']}/token", + token=args.token, + body=body or None, + ) + ) + if not isinstance(minted, dict) or not minted.get("api_token"): + raise http.CloudError("the platform did not return a token.") + minted_record = cast("dict[str, Any]", minted) + + record = read_record() or {} + record.update( + { + "api_token": minted_record["api_token"], + "organization_id": minted_record.get("organization_id", workspace["id"]), + "organization_name": minted_record.get("organization_name", workspace.get("name", "")), + "expires_at": minted_record.get("expires_at"), + "scopes": minted_record.get("scopes", []), + } + ) + if minted_record.get("email"): + record["email"] = minted_record["email"] + save_record(record) + + result = { + "workspace_id": record["organization_id"], + "workspace_name": record["organization_name"], + "scopes": record["scopes"], + } + if as_json: + emit(console, result, as_json=True) + return http.EXIT_OK + console.print(f"[green]✓ Switched to workspace [bold]{record['organization_name']}[/].[/]") + scopes = record.get("scopes") + if isinstance(scopes, list) and scopes: + console.print(f" Scopes: [dim]{' '.join(str(s) for s in scopes)}[/]") + console.print(f" Token: stored in [dim]{AUTH_PATH}[/]") + return http.EXIT_OK + + +def _find_workspace(selector: str, *, token: str | None) -> dict[str, Any]: + listed = http.check(http.request("GET", "/workspaces", token=token)) + items = listed.get("workspaces") if isinstance(listed, dict) else None + workspaces = [cast("dict[str, Any]", item) for item in (items or []) if isinstance(item, dict)] + if not workspaces: + raise http.CloudError("no workspaces found for this account.") + wanted = selector.strip() + 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: + ids = ", ".join(str(w.get("id")) for w in by_name) + raise http.CloudError(f"multiple workspaces are named {wanted!r}. Use an ID: {ids}") + names = ", ".join(str(w.get("name")) for w in workspaces) + raise http.CloudError(f"no workspace matches {wanted!r}. Your workspaces: {names}") diff --git a/tests/test_cloud_cli.py b/tests/test_cloud_cli.py index 89c5f794..08501c35 100644 --- a/tests/test_cloud_cli.py +++ b/tests/test_cloud_cli.py @@ -4,13 +4,14 @@ from __future__ import annotations import io import json +import webbrowser 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 import http, render, runner, workspaces from strix.interface.cloud.spec import SPEC @@ -353,3 +354,93 @@ 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 + + +def test_billing_subscribe_prints_checkout_url( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + seen: dict[str, Any] = {} + + def fake_request(method: str, path: str, **kwargs: Any) -> FakeResponse: + seen["method"], seen["path"] = method, path + seen["body"] = kwargs.get("body") + return FakeResponse(status_code=200, payload={"checkout_url": "https://pay.test/session"}) + + monkeypatch.setattr(http, "request", fake_request) + code = cloud.run_cloud(["billing", "subscribe", "--plan", "strix_pro", "--json"]) + assert code == 0 + assert seen["method"] == "POST" + assert seen["path"] == "/billing/checkout" + assert seen["body"] == {"product": "strix_pro"} + assert "https://pay.test/session" in capsys.readouterr().out + + +def test_integration_install_url_does_not_open_browser( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + opened: list[str] = [] + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse(status_code=200, payload={"url": "https://github.test/app"}), + ) + + def fake_open(url: str) -> bool: + opened.append(url) + return True + + monkeypatch.setattr(webbrowser, "open", fake_open) + code = cloud.run_cloud(["integrations", "install", "github", "--json"]) + assert code == 0 + assert opened == [] + assert "https://github.test/app" in capsys.readouterr().out + + +def test_workspaces_use_switches_stored_token( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: Any +) -> None: + auth_path = tmp_path / "platform-auth.json" + monkeypatch.setattr(platform_cli, "AUTH_PATH", auth_path) + monkeypatch.setattr(workspaces, "AUTH_PATH", auth_path) + platform_cli.save_record({"api_token": "old", "email": "a@b.test"}) + + calls: list[tuple[str, str]] = [] + + def fake_request(method: str, path: str, **kwargs: Any) -> FakeResponse: + calls.append((method, path)) + if path == "/workspaces": + return FakeResponse( + status_code=200, + payload={"workspaces": [{"id": "org_1", "name": "Team One", "role": "admin"}]}, + ) + return FakeResponse( + status_code=201, + payload={ + "api_token": "new-token", + "organization_id": "org_1", + "organization_name": "Team One", + "scopes": ["scans:read"], + }, + ) + + monkeypatch.setattr(http, "request", fake_request) + code = cloud.run_cloud(["workspaces", "use", "team one", "--json"]) + assert code == 0 + assert calls == [("GET", "/workspaces"), ("POST", "/workspaces/org_1/token")] + record = platform_cli.read_record() + assert record is not None + assert record["api_token"] == "new-token" + assert record["organization_name"] == "Team One" + assert record["email"] == "a@b.test" + assert "org_1" in capsys.readouterr().out + + +def test_workspaces_use_reports_unknown_workspace(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse( + status_code=200, payload={"workspaces": [{"id": "org_1", "name": "Team One"}]} + ), + ) + assert cloud.run_cloud(["workspaces", "use", "missing", "--json"]) == 1 From 268769fc45b8ba1c0fb4127bb28c8d79c8c15948 Mon Sep 17 00:00:00 2001 From: Alex Schapiro Date: Wed, 26 Aug 2026 23:04:35 +0000 Subject: [PATCH 15/28] fix(cli): report a leftover temporary secret file instead of hiding it --- strix/utils/secret_files.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/strix/utils/secret_files.py b/strix/utils/secret_files.py index 5ba0fc1c..023aa87f 100644 --- a/strix/utils/secret_files.py +++ b/strix/utils/secret_files.py @@ -23,14 +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 try: tmp.replace(path) - except BaseException: - with contextlib.suppress(OSError): - tmp.unlink() + 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 From 2df99a287371162c0d46ef8873501823fc98a6e7 Mon Sep 17 00:00:00 2001 From: Alex Schapiro Date: Wed, 26 Aug 2026 23:36:42 +0000 Subject: [PATCH 16/28] feat(cli): pass a Stripe payment method to the top-up wallet client --- skills/managed-pentesting-with-strix/SKILL.md | 2 ++ strix/interface/cloud/runner.py | 25 ++++++++++++++++--- tests/test_cloud_cli.py | 24 ++++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/skills/managed-pentesting-with-strix/SKILL.md b/skills/managed-pentesting-with-strix/SKILL.md index 261d6a55..4195111a 100644 --- a/skills/managed-pentesting-with-strix/SKILL.md +++ b/skills/managed-pentesting-with-strix/SKILL.md @@ -71,6 +71,8 @@ strix cloud billing topup --credits 20 --yes # --yes skips the confirmation pr strix cloud billing topup --credits 20 --no-pay # print the 402 challenge without paying ``` +Card payments need a payer-side Stripe payment method. Pass it with `--payment-method pm_...` or set `MPPX_STRIPE_PAYMENT_METHOD`, together with the payer key in `MPPX_STRIPE_SECRET_KEY`. A funded `mppx` account (`npx mppx account create`) works for stablecoin payments without those variables. + 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: diff --git a/strix/interface/cloud/runner.py b/strix/interface/cloud/runner.py index 7c400609..7f7300c2 100644 --- a/strix/interface/cloud/runner.py +++ b/strix/interface/cloud/runner.py @@ -8,6 +8,7 @@ from __future__ import annotations import argparse import json +import os import re import shutil import subprocess @@ -219,6 +220,15 @@ def _build_parser(group: str, verb_label: str, cmd: Cmd) -> argparse.ArgumentPar action="store_true", help="Print the payment challenge instead of paying it.", ) + parser.add_argument( + "--payment-method", + default=None, + metavar="PM_ID", + help=( + "Stripe payment method for the card payment, for example pm_card_visa " + "in test mode. Defaults to MPPX_STRIPE_PAYMENT_METHOD." + ), + ) return parser @@ -362,8 +372,17 @@ def _topup( 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, + command = [npx, "--yes", "mppx", url, "-J", json.dumps(body), "-H", auth_header] + payment_method = getattr(args, "payment_method", None) or os.environ.get( + "MPPX_STRIPE_PAYMENT_METHOD" ) + if payment_method: + command += ["-M", f"paymentMethod={payment_method}"] + elif not os.environ.get("MPPX_ACCOUNT") and not os.environ.get("MPPX_STRIPE_SECRET_KEY"): + console.print( + "[dim]Tip: card payments need a wallet. Pass --payment-method, or set " + "MPPX_STRIPE_SECRET_KEY and MPPX_STRIPE_PAYMENT_METHOD, or create an " + "mppx account first with `npx mppx account create`.[/]" + ) + result = subprocess.run(command, check=False) # noqa: S603 return http.EXIT_OK if result.returncode == 0 else http.EXIT_PAYMENT diff --git a/tests/test_cloud_cli.py b/tests/test_cloud_cli.py index 08501c35..820a30b5 100644 --- a/tests/test_cloud_cli.py +++ b/tests/test_cloud_cli.py @@ -4,6 +4,8 @@ from __future__ import annotations import io import json +import shutil +import subprocess import webbrowser from typing import TYPE_CHECKING, Any @@ -300,6 +302,28 @@ def test_topup_success_without_payment(monkeypatch: pytest.MonkeyPatch, capsys: assert json.loads(capsys.readouterr().out) == receipt +def test_topup_passes_payment_method_to_wallet(monkeypatch: pytest.MonkeyPatch) -> None: + challenge = {"payment_requirements": [{"amount": 500}]} + monkeypatch.setattr( + http, "request", lambda *_a, **_k: FakeResponse(status_code=402, payload=challenge) + ) + monkeypatch.setattr(http, "api_token", lambda *_a, **_k: "tok") + monkeypatch.setattr(shutil, "which", lambda _name: "/usr/bin/npx") + commands: list[list[str]] = [] + + def fake_run(command: list[str], **_kwargs: Any) -> Any: + commands.append(command) + return type("Result", (), {"returncode": 0})() + + monkeypatch.setattr(subprocess, "run", fake_run) + code = cloud.run_cloud( + ["billing", "topup", "--credits", "5", "--yes", "--payment-method", "pm_card_visa"] + ) + assert code == 0 + assert "-M" in commands[0] + assert "paymentMethod=pm_card_visa" in commands[0] + + 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. From ca0eddc481fc40035b229657ddbbcc354d295496 Mon Sep 17 00:00:00 2001 From: Alex Schapiro Date: Thu, 27 Aug 2026 04:45:19 +0000 Subject: [PATCH 17/28] docs(cloud): recommend the Stripe agent wallet as the default payment path --- skills/managed-pentesting-with-strix/SKILL.md | 4 ++-- strix/interface/cloud/runner.py | 11 ++++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/skills/managed-pentesting-with-strix/SKILL.md b/skills/managed-pentesting-with-strix/SKILL.md index 4195111a..2b46ec06 100644 --- a/skills/managed-pentesting-with-strix/SKILL.md +++ b/skills/managed-pentesting-with-strix/SKILL.md @@ -71,9 +71,9 @@ strix cloud billing topup --credits 20 --yes # --yes skips the confirmation pr strix cloud billing topup --credits 20 --no-pay # print the 402 challenge without paying ``` -Card payments need a payer-side Stripe payment method. Pass it with `--payment-method pm_...` or set `MPPX_STRIPE_PAYMENT_METHOD`, together with the payer key in `MPPX_STRIPE_SECRET_KEY`. A funded `mppx` account (`npx mppx account create`) works for stablecoin payments without those variables. +The default payment path is the Stripe agent wallet. Tell the user to set it up one time at [link.com/agents](https://link.com/agents). After setup, the user approves each payment in the Link app, and no keys or variables are necessary. -If no wallet is available, print the challenge with `--no-pay` and ask the user to top up in the dashboard instead. +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: diff --git a/strix/interface/cloud/runner.py b/strix/interface/cloud/runner.py index 7f7300c2..c4f461e3 100644 --- a/strix/interface/cloud/runner.py +++ b/strix/interface/cloud/runner.py @@ -225,8 +225,8 @@ def _build_parser(group: str, verb_label: str, cmd: Cmd) -> argparse.ArgumentPar default=None, metavar="PM_ID", help=( - "Stripe payment method for the card payment, for example pm_card_visa " - "in test mode. Defaults to MPPX_STRIPE_PAYMENT_METHOD." + "Stripe payment method for the card payment. " + "Defaults to MPPX_STRIPE_PAYMENT_METHOD." ), ) return parser @@ -380,9 +380,10 @@ def _topup( command += ["-M", f"paymentMethod={payment_method}"] elif not os.environ.get("MPPX_ACCOUNT") and not os.environ.get("MPPX_STRIPE_SECRET_KEY"): console.print( - "[dim]Tip: card payments need a wallet. Pass --payment-method, or set " - "MPPX_STRIPE_SECRET_KEY and MPPX_STRIPE_PAYMENT_METHOD, or create an " - "mppx account first with `npx mppx account create`.[/]" + "[dim]Tip: payments need a wallet. Set up a Stripe agent wallet at " + "https://link.com/agents, and the user approves each payment in the Link app. " + "If the user does not want a wallet, run " + "`strix cloud billing subscribe --plan strix_top_up` for a hosted checkout link.[/]" ) result = subprocess.run(command, check=False) # noqa: S603 return http.EXIT_OK if result.returncode == 0 else http.EXIT_PAYMENT From 41927358eba5daa22c22926cf773cacf169090aa Mon Sep 17 00:00:00 2001 From: bearsyankees Date: Thu, 27 Aug 2026 01:01:32 -0400 Subject: [PATCH 18/28] fix(cloud): preserve API auth during MPP payment --- strix/interface/cloud/runner.py | 13 +++++++++++-- tests/test_cloud_cli.py | 2 ++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/strix/interface/cloud/runner.py b/strix/interface/cloud/runner.py index c4f461e3..57b7ced3 100644 --- a/strix/interface/cloud/runner.py +++ b/strix/interface/cloud/runner.py @@ -371,8 +371,17 @@ def _topup( return http.EXIT_PAYMENT url = f"{http.app_url()}/api/v1/billing/topup" - auth_header = f"Authorization: Bearer {http.api_token(token)}" - command = [npx, "--yes", "mppx", url, "-J", json.dumps(body), "-H", auth_header] + auth_header = f"X-Strix-Authorization: Bearer {http.api_token(token)}" + command = [ + npx, + "--yes", + "mppx", + url, + "-J", + json.dumps(body), + "-H", + auth_header, + ] payment_method = getattr(args, "payment_method", None) or os.environ.get( "MPPX_STRIPE_PAYMENT_METHOD" ) diff --git a/tests/test_cloud_cli.py b/tests/test_cloud_cli.py index 820a30b5..2f03d75b 100644 --- a/tests/test_cloud_cli.py +++ b/tests/test_cloud_cli.py @@ -320,6 +320,8 @@ def test_topup_passes_payment_method_to_wallet(monkeypatch: pytest.MonkeyPatch) ["billing", "topup", "--credits", "5", "--yes", "--payment-method", "pm_card_visa"] ) assert code == 0 + assert "X-Strix-Authorization: Bearer tok" in commands[0] + assert "Authorization: Bearer tok" not in commands[0] assert "-M" in commands[0] assert "paymentMethod=pm_card_visa" in commands[0] From 3580412178a5052ce7f04aa3cb92106c2aa9ce2f Mon Sep 17 00:00:00 2001 From: Alex Schapiro Date: Thu, 27 Aug 2026 18:52:26 +0000 Subject: [PATCH 19/28] fix(cloud): drop knowledge query and settings commands removed from the API --- strix/interface/cloud/spec.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/strix/interface/cloud/spec.py b/strix/interface/cloud/spec.py index d26a1cd3..bf7457ba 100644 --- a/strix/interface/cloud/spec.py +++ b/strix/interface/cloud/spec.py @@ -649,12 +649,6 @@ SPEC: dict[str, dict[str, Cmd]] = { ), ), "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", @@ -678,13 +672,6 @@ SPEC: dict[str, dict[str, Cmd]] = { "/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."), From a02c56423c14657e214357af9c1fdc96c040a3ca Mon Sep 17 00:00:00 2001 From: bearsyankees Date: Thu, 27 Aug 2026 15:53:56 -0400 Subject: [PATCH 20/28] fix(cloud): align agent commands with API contracts --- AGENTS.md | 4 +- README.md | 2 +- skills/managed-pentesting-with-strix/SKILL.md | 22 ++++- strix/interface/cloud/__init__.py | 6 +- strix/interface/cloud/runner.py | 10 ++- strix/interface/cloud/spec.py | 10 ++- strix/interface/platform_cli.py | 25 +++++- tests/test_cloud_cli.py | 90 ++++++++++++++++++- 8 files changed, 151 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 66b222d0..1ca24385 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,11 +44,11 @@ Target-specific workflows built on the same engine: strix cloud vulns list --severity critical strix cloud billing topup --credits 20 # buy credits when a scan returns exit code 5 ``` - - Account setup runs from the CLI too: `strix cloud workspaces list|create|use`, `strix cloud org members invite`, `strix cloud billing subscribe --plan strix_pro`, `strix cloud billing portal`, `strix cloud integrations install github`, and `strix cloud domains verify `. 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, the installation, or the DNS change for them. + - Account setup runs from the CLI too: `strix cloud workspaces list|create|use`, `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 `. 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, the installation, or the DNS change for them. - 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). +- 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 diff --git a/README.md b/README.md index 6b6d15f7..29cbbba2 100644 --- a/README.md +++ b/README.md @@ -350,7 +350,7 @@ Workspaces and account setup also work from the terminal: strix cloud workspaces list # your workspaces strix cloud workspaces create --name "My Team" strix cloud workspaces use "My Team" # store a token for another workspace -strix cloud billing subscribe --plan strix_pro # opens the hosted checkout page +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 # prints the DNS record to add diff --git a/skills/managed-pentesting-with-strix/SKILL.md b/skills/managed-pentesting-with-strix/SKILL.md index 2b46ec06..7dadccb2 100644 --- a/skills/managed-pentesting-with-strix/SKILL.md +++ b/skills/managed-pentesting-with-strix/SKILL.md @@ -14,7 +14,7 @@ Use this when you want Strix's autonomous pentesting **without running Docker or There are two equivalent interfaces. Prefer the CLI: - **`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`. +- **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)** · agent index: `https://docs.app.strix.ai/llms.txt` · OpenAPI: `https://docs.app.strix.ai/openapi.json`. 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. @@ -38,7 +38,7 @@ strix cloud login --scopes scans:read scans:write billing:read vulnerabilities:r 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. +- `strix cloud whoami` shows the active sign-in; add `--json` when another agent will parse it. `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: @@ -101,7 +101,7 @@ strix cloud org members invite --email dev@example.com --role analyst 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_pro # hosted checkout page for a plan +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 # DNS record to add, then run it again @@ -109,6 +109,22 @@ strix cloud domains verify # DNS record to add, then run i 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 --content "Staging uses SSO and TOTP." +strix cloud knowledge delete +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 Scans run against **registered assets**, not raw URLs. Register once, then reuse the returned UUID. diff --git a/strix/interface/cloud/__init__.py b/strix/interface/cloud/__init__.py index ce0b753d..c93d0c09 100644 --- a/strix/interface/cloud/__init__.py +++ b/strix/interface/cloud/__init__.py @@ -42,7 +42,11 @@ def run_cloud(argv: list[str]) -> int: group, rest = argv[0], argv[1:] if group in ("login", "logout", "whoami"): - session_argv = {"login": rest, "logout": ["logout"], "whoami": ["status"]} + session_argv = { + "login": rest, + "logout": ["logout"], + "whoami": ["status", *rest], + } return run_login(session_argv[group]) if group == "credits": group, rest = "billing", ["credits", *rest] diff --git a/strix/interface/cloud/runner.py b/strix/interface/cloud/runner.py index 57b7ced3..22af6f60 100644 --- a/strix/interface/cloud/runner.py +++ b/strix/interface/cloud/runner.py @@ -17,6 +17,7 @@ import time import webbrowser from pathlib import Path from typing import Any, cast +from urllib.parse import quote from rich.console import Console @@ -77,7 +78,8 @@ def run(group: str, verb_label: str, cmd: Cmd, argv: list[str]) -> int: path = cmd.path for name in _PLACEHOLDER.findall(cmd.path): - path = path.replace("{" + name + "}", str(getattr(args, _dest(name)))) + value = quote(str(getattr(args, _dest(name))), safe="") + path = path.replace("{" + name + "}", value) as_json = json_mode(flag=bool(getattr(args, "json", False))) token = getattr(args, "token", None) @@ -351,7 +353,11 @@ def _topup( challenge = http.parsed(response) if getattr(args, "no_pay", False): - emit(console, challenge, as_json=as_json) + emit( + console, + {"error": "Payment required", "challenge": challenge}, + as_json=as_json, + ) return http.EXIT_PAYMENT npx = shutil.which("npx") diff --git a/strix/interface/cloud/spec.py b/strix/interface/cloud/spec.py index bf7457ba..46da9d04 100644 --- a/strix/interface/cloud/spec.py +++ b/strix/interface/cloud/spec.py @@ -511,7 +511,7 @@ SPEC: dict[str, dict[str, Cmd]] = { "product", required=True, flag="plan", - help="Product to buy, for example strix_pro, strix_cloud, or strix_top_up.", + help="Product to buy: strix_cloud, strix_startup, or strix_top_up.", ), P("success_url", help="Page to open after the payment."), ), @@ -655,9 +655,11 @@ SPEC: dict[str, dict[str, Cmd]] = { "/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."), + P("policy_key", required=True, flag="key", help="Policy key."), + P("policy_value", required=True, flag="content", help="Policy content."), + P("policy_type", help="Policy type. Defaults to constraint."), + P("is_active", "bool", flag="enabled", help="Turn the policy on or off."), + P("metadata", "json", help="JSON metadata for the policy."), ), ), "policies delete": Cmd( diff --git a/strix/interface/platform_cli.py b/strix/interface/platform_cli.py index 586fcc3d..65dd067c 100644 --- a/strix/interface/platform_cli.py +++ b/strix/interface/platform_cli.py @@ -87,7 +87,7 @@ def run_login(argv: list[str]) -> int: console.print(_LOGIN_USAGE) return 0 if subcommand == "status": - return _status(console) + return _status(console, argv[1:]) if subcommand == "logout": return _logout(console) return _login(console, argv) @@ -468,14 +468,35 @@ def _print_success(console: Console, record: dict[str, Any]) -> None: ) -def _status(console: Console) -> int: +def _status(console: Console, argv: list[str]) -> int: + parser = argparse.ArgumentParser(prog="strix cloud whoami") + parser.add_argument("--json", action="store_true", help="Print the session as JSON.") + try: + args = parser.parse_args(argv) + except SystemExit as exc: + return exc.code if isinstance(exc.code, int) else 2 + record = read_record() if record is None: + if args.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 args.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, + } + sys.stdout.write(json.dumps(payload, indent=2, default=str) + "\n") + return 0 console.print(f"[green]Signed in[/] as [bold]{email}[/]") if organization: console.print(f" Workspace: {organization}") diff --git a/tests/test_cloud_cli.py b/tests/test_cloud_cli.py index 2f03d75b..fa03e8bf 100644 --- a/tests/test_cloud_cli.py +++ b/tests/test_cloud_cli.py @@ -102,6 +102,21 @@ def test_placeholder_substitution(monkeypatch: pytest.MonkeyPatch, capsys: Any) assert json.loads(capsys.readouterr().out) == {"id": "abc"} +def test_placeholder_substitution_percent_encodes_path_segments( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen: dict[str, Any] = {} + + def fake_request(_method: str, path: str, **_kwargs: Any) -> FakeResponse: + seen["path"] = path + return FakeResponse(payload={"entries": []}) + + monkeypatch.setattr(http, "request", fake_request) + code = cloud.run_cloud(["knowledge", "repos", "entries", "usestrix/.github", "--json"]) + assert code == 0 + assert seen["path"] == "/knowledge/repos/usestrix%2F.github/entries" + + def test_query_and_body_collection(monkeypatch: pytest.MonkeyPatch) -> None: seen: dict[str, Any] = {} @@ -282,6 +297,35 @@ def test_credits_alias_routes_to_billing(monkeypatch: pytest.MonkeyPatch) -> Non assert seen["path"] == "/billing/credits" +def test_whoami_json_is_machine_readable_and_omits_the_token( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: Any +) -> None: + monkeypatch.delenv("STRIX_API_TOKEN", raising=False) + monkeypatch.setattr(platform_cli, "AUTH_PATH", tmp_path / "platform-auth.json") + platform_cli.save_record( + { + "api_token": "strix_pat_secret", + "email": "agent@example.test", + "organization_id": "org_1", + "organization_name": "Example", + "scopes": ["scans:read"], + "expires_at": "2026-09-01T00:00:00Z", + } + ) + + assert cloud.run_cloud(["whoami", "--json"]) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload == { + "signed_in": True, + "email": "agent@example.test", + "organization_id": "org_1", + "organization_name": "Example", + "scopes": ["scans:read"], + "expires_at": "2026-09-01T00:00:00Z", + } + assert "api_token" not in payload + + def test_topup_no_pay_prints_challenge(monkeypatch: pytest.MonkeyPatch, capsys: Any) -> None: challenge = {"payment_requirements": [{"amount": 500}]} monkeypatch.setattr( @@ -289,7 +333,10 @@ def test_topup_no_pay_prints_challenge(monkeypatch: pytest.MonkeyPatch, capsys: ) code = cloud.run_cloud(["billing", "topup", "--credits", "5", "--no-pay", "--json"]) assert code == http.EXIT_PAYMENT - assert json.loads(capsys.readouterr().out) == challenge + assert json.loads(capsys.readouterr().out) == { + "error": "Payment required", + "challenge": challenge, + } def test_topup_success_without_payment(monkeypatch: pytest.MonkeyPatch, capsys: Any) -> None: @@ -393,14 +440,51 @@ def test_billing_subscribe_prints_checkout_url( return FakeResponse(status_code=200, payload={"checkout_url": "https://pay.test/session"}) monkeypatch.setattr(http, "request", fake_request) - code = cloud.run_cloud(["billing", "subscribe", "--plan", "strix_pro", "--json"]) + code = cloud.run_cloud(["billing", "subscribe", "--plan", "strix_cloud", "--json"]) assert code == 0 assert seen["method"] == "POST" assert seen["path"] == "/billing/checkout" - assert seen["body"] == {"product": "strix_pro"} + assert seen["body"] == {"product": "strix_cloud"} assert "https://pay.test/session" in capsys.readouterr().out +def test_knowledge_policy_flags_use_the_api_field_names( + 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={"success": True}) + + monkeypatch.setattr(http, "request", fake_request) + code = cloud.run_cloud( + [ + "knowledge", + "policies", + "add", + "--key", + "no-production-data", + "--content", + "Never test production data.", + "--policy-type", + "constraint", + "--no-enabled", + "--metadata", + '{"owner":"security"}', + "--json", + ] + ) + assert code == 0 + assert seen["body"] == { + "policy_key": "no-production-data", + "policy_value": "Never test production data.", + "policy_type": "constraint", + "is_active": False, + "metadata": {"owner": "security"}, + } + + def test_integration_install_url_does_not_open_browser( monkeypatch: pytest.MonkeyPatch, capsys: Any ) -> None: From c59e36d353c1a0fb0a39b336abfbf60d20774651 Mon Sep 17 00:00:00 2001 From: bearsyankees Date: Thu, 27 Aug 2026 15:58:42 -0400 Subject: [PATCH 21/28] fix(cloud): send required PR review integration fields --- skills/managed-pentesting-with-strix/SKILL.md | 8 ++- strix/interface/cloud/spec.py | 10 +++- tests/test_cloud_cli.py | 59 +++++++++++++++++++ 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/skills/managed-pentesting-with-strix/SKILL.md b/skills/managed-pentesting-with-strix/SKILL.md index 7dadccb2..464cf2f6 100644 --- a/skills/managed-pentesting-with-strix/SKILL.md +++ b/skills/managed-pentesting-with-strix/SKILL.md @@ -203,10 +203,14 @@ strix cloud scans report --format technical --type pdf --output strix- ## 6. PR reviews -Trigger an automated security review of a pull request (`pr_reviews:write`). The 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 -strix cloud pr-reviews start --repository-full-name org/app --pr-number 123 +strix cloud pr-reviews start \ + --provider github \ + --installation-id \ + --repository-full-name org/app \ + --pr-number 123 ``` 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`. diff --git a/strix/interface/cloud/spec.py b/strix/interface/cloud/spec.py index 46da9d04..fbbabbf1 100644 --- a/strix/interface/cloud/spec.py +++ b/strix/interface/cloud/spec.py @@ -476,6 +476,8 @@ SPEC: dict[str, dict[str, Cmd]] = { "/pr-reviews/start", "Start a PR review.", body=( + P("provider", required=True, help="Git provider: github, gitlab, or bitbucket."), + P("installation_id", "int", required=True, help="Provider installation ID."), P("repository_full_name", required=True, help="Repository full name."), P("pr_number", "int", required=True, help="Pull request number."), ), @@ -816,7 +818,13 @@ SPEC: dict[str, dict[str, Cmd]] = { "/llm-settings", "Update the LLM settings.", body=( - P("modelConfigs", "json", required=True, help="JSON list of model configurations."), + P( + "modelConfigs", + "json", + required=True, + flag="model-configs", + help="JSON list of model configurations.", + ), P("assignments", "json", required=True, help="JSON map of model assignments."), ), ), diff --git a/tests/test_cloud_cli.py b/tests/test_cloud_cli.py index fa03e8bf..03fbecf5 100644 --- a/tests/test_cloud_cli.py +++ b/tests/test_cloud_cli.py @@ -485,6 +485,65 @@ def test_knowledge_policy_flags_use_the_api_field_names( } +def test_pr_review_start_sends_provider_installation_and_pull_request( + 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={"review_id": "review-1", "status": "pending"}) + + monkeypatch.setattr(http, "request", fake_request) + code = cloud.run_cloud( + [ + "pr-reviews", + "start", + "--provider", + "github", + "--installation-id", + "123", + "--repository-full-name", + "org/app", + "--pr-number", + "42", + "--json", + ] + ) + assert code == 0 + assert seen["body"] == { + "provider": "github", + "installation_id": 123, + "repository_full_name": "org/app", + "pr_number": 42, + } + + +def test_llm_settings_uses_kebab_case_flag_for_camel_case_api_field( + 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( + [ + "llm-settings", + "update", + "--model-configs", + "[]", + "--assignments", + "{}", + "--json", + ] + ) + assert code == 0 + assert seen["body"] == {"modelConfigs": [], "assignments": {}} + + def test_integration_install_url_does_not_open_browser( monkeypatch: pytest.MonkeyPatch, capsys: Any ) -> None: From 1b9f41d24432f2f068df9560f2c9be4ba41a54a1 Mon Sep 17 00:00:00 2001 From: bearsyankees Date: Thu, 27 Aug 2026 17:30:16 -0400 Subject: [PATCH 22/28] fix(cloud): preserve scopes when switching workspaces --- strix/interface/cloud/__init__.py | 6 ++++-- strix/interface/cloud/workspaces.py | 13 +++++++++++-- tests/test_cloud_cli.py | 23 ++++++++++++++++++++++- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/strix/interface/cloud/__init__.py b/strix/interface/cloud/__init__.py index c93d0c09..f906a121 100644 --- a/strix/interface/cloud/__init__.py +++ b/strix/interface/cloud/__init__.py @@ -27,7 +27,8 @@ _USAGE_HEADER = """[bold]Usage:[/] strix cloud [arguments] [bold]Resource commands:[/]""" _USAGE_FOOTER = """ -Run [bold]strix cloud [/] without a verb to list its verbs. +Run [bold]strix cloud help[/] to list its verbs. Common read-only +commands may also run their default verb when no verb is given. 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""" @@ -57,7 +58,8 @@ def run_cloud(argv: list[str]) -> int: console.print(f"[red]Unknown command:[/] {group}") _print_usage(console) return 2 - resolved = resolve(group, rest) + group_help = bool(rest and rest[0] in ("-h", "--help", "help")) + resolved = None if group_help else 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 diff --git a/strix/interface/cloud/workspaces.py b/strix/interface/cloud/workspaces.py index 4958cd73..0ff23fe5 100644 --- a/strix/interface/cloud/workspaces.py +++ b/strix/interface/cloud/workspaces.py @@ -31,7 +31,10 @@ def run_workspace_use(argv: list[str]) -> int: nargs="+", metavar="SCOPE", default=None, - help="API scopes for the new token. Without this option the server grants the defaults.", + help=( + "API scopes for the new token. Without this option, preserve the stored token's " + "scopes." + ), ) parser.add_argument("--json", action="store_true", help="Print the raw JSON response.") parser.add_argument("--token", default=None, help="API token override.") @@ -58,9 +61,16 @@ def run_workspace_use(argv: list[str]) -> int: def _use(console: Console, args: argparse.Namespace, *, as_json: bool) -> int: workspace = _find_workspace(args.workspace, token=args.token) + record = read_record() or {} body: dict[str, Any] = {} if args.scopes: body["scopes"] = args.scopes + elif args.token is None: + stored_scopes = record.get("scopes") + if isinstance(stored_scopes, list) and stored_scopes and all( + isinstance(scope, str) for scope in stored_scopes + ): + body["scopes"] = stored_scopes minted = http.check( http.request( "POST", @@ -73,7 +83,6 @@ def _use(console: Console, args: argparse.Namespace, *, as_json: bool) -> int: raise http.CloudError("the platform did not return a token.") minted_record = cast("dict[str, Any]", minted) - record = read_record() or {} record.update( { "api_token": minted_record["api_token"], diff --git a/tests/test_cloud_cli.py b/tests/test_cloud_cli.py index 03fbecf5..09dcb743 100644 --- a/tests/test_cloud_cli.py +++ b/tests/test_cloud_cli.py @@ -571,17 +571,26 @@ def test_workspaces_use_switches_stored_token( auth_path = tmp_path / "platform-auth.json" monkeypatch.setattr(platform_cli, "AUTH_PATH", auth_path) monkeypatch.setattr(workspaces, "AUTH_PATH", auth_path) - platform_cli.save_record({"api_token": "old", "email": "a@b.test"}) + platform_cli.save_record( + { + "api_token": "old", + "email": "a@b.test", + "scopes": ["scans:read", "organizations:read", "tokens:write"], + } + ) calls: list[tuple[str, str]] = [] + token_body: dict[str, Any] | None = None def fake_request(method: str, path: str, **kwargs: Any) -> FakeResponse: + nonlocal token_body calls.append((method, path)) if path == "/workspaces": return FakeResponse( status_code=200, payload={"workspaces": [{"id": "org_1", "name": "Team One", "role": "admin"}]}, ) + token_body = kwargs.get("body") return FakeResponse( status_code=201, payload={ @@ -596,6 +605,9 @@ def test_workspaces_use_switches_stored_token( code = cloud.run_cloud(["workspaces", "use", "team one", "--json"]) assert code == 0 assert calls == [("GET", "/workspaces"), ("POST", "/workspaces/org_1/token")] + assert token_body == { + "scopes": ["scans:read", "organizations:read", "tokens:write"] + } record = platform_cli.read_record() assert record is not None assert record["api_token"] == "new-token" @@ -613,3 +625,12 @@ def test_workspaces_use_reports_unknown_workspace(monkeypatch: pytest.MonkeyPatc ), ) assert cloud.run_cloud(["workspaces", "use", "missing", "--json"]) == 1 + + +def test_group_help_lists_all_verbs_instead_of_default_verb_help(capsys: Any) -> None: + assert cloud.run_cloud(["workspaces", "-h"]) == 0 + output = capsys.readouterr().out + assert "workspaces verbs" in output + assert "list" in output + assert "create" in output + assert "use" in output From 4765838330dfc6725d4330021f1ea505d2cae74c Mon Sep 17 00:00:00 2001 From: bearsyankees Date: Thu, 27 Aug 2026 17:35:56 -0400 Subject: [PATCH 23/28] fix(cloud): make session command help non-destructive --- strix/interface/cloud/__init__.py | 21 +++++++++++++++------ tests/test_cloud_cli.py | 23 +++++++++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/strix/interface/cloud/__init__.py b/strix/interface/cloud/__init__.py index f906a121..2838e32b 100644 --- a/strix/interface/cloud/__init__.py +++ b/strix/interface/cloud/__init__.py @@ -43,12 +43,7 @@ def run_cloud(argv: list[str]) -> int: group, rest = argv[0], argv[1:] if group in ("login", "logout", "whoami"): - session_argv = { - "login": rest, - "logout": ["logout"], - "whoami": ["status", *rest], - } - return run_login(session_argv[group]) + return _run_session(console, group, rest) if group == "credits": group, rest = "billing", ["credits", *rest] if group == "workspaces" and rest and rest[0] == "use": @@ -68,6 +63,20 @@ def run_cloud(argv: list[str]) -> int: return run(group, verb_label, cmd, remaining) +def _run_session(console: Console, group: str, rest: list[str]) -> int: + if rest and rest[0] in ("-h", "--help", "help"): + return run_login(["--help"]) + if group == "logout" and rest: + console.print("[red]Unknown argument for logout:[/] " + " ".join(rest)) + return 2 + session_argv = { + "login": rest, + "logout": ["logout"], + "whoami": ["status", *rest], + } + return run_login(session_argv[group]) + + def _print_usage(console: Console) -> None: console.print(_USAGE_HEADER) for group in SPEC: diff --git a/tests/test_cloud_cli.py b/tests/test_cloud_cli.py index 09dcb743..d6dc8fc2 100644 --- a/tests/test_cloud_cli.py +++ b/tests/test_cloud_cli.py @@ -634,3 +634,26 @@ def test_group_help_lists_all_verbs_instead_of_default_verb_help(capsys: Any) -> assert "list" in output assert "create" in output assert "use" in output + + +def test_logout_help_does_not_remove_stored_auth( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: Any +) -> None: + auth_path = tmp_path / "platform-auth.json" + monkeypatch.setattr(platform_cli, "AUTH_PATH", auth_path) + platform_cli.save_record({"api_token": "keep-me"}) + + assert cloud.run_cloud(["logout", "--help"]) == 0 + assert platform_cli.read_record() == {"api_token": "keep-me"} + assert "Usage:" in capsys.readouterr().out + + +def test_logout_rejects_unknown_arguments_without_removing_stored_auth( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + auth_path = tmp_path / "platform-auth.json" + monkeypatch.setattr(platform_cli, "AUTH_PATH", auth_path) + platform_cli.save_record({"api_token": "keep-me"}) + + assert cloud.run_cloud(["logout", "--bogus"]) == 2 + assert platform_cli.read_record() == {"api_token": "keep-me"} From 9aa6b862a921003e2ba24ee9a1d5c8558d419488 Mon Sep 17 00:00:00 2001 From: bearsyankees Date: Thu, 27 Aug 2026 17:46:29 -0400 Subject: [PATCH 24/28] feat(cloud): improve human navigation and output --- strix/interface/cloud/__init__.py | 2 + strix/interface/cloud/http.py | 6 ++ strix/interface/cloud/render.py | 123 ++++++++++++++++++++++++-- strix/interface/cloud/runner.py | 14 ++- strix/interface/cloud/workspaces.py | 29 ++++++- tests/test_cloud_cli.py | 128 ++++++++++++++++++++++++++++ 6 files changed, 289 insertions(+), 13 deletions(-) diff --git a/strix/interface/cloud/__init__.py b/strix/interface/cloud/__init__.py index 2838e32b..7ce2ce81 100644 --- a/strix/interface/cloud/__init__.py +++ b/strix/interface/cloud/__init__.py @@ -42,6 +42,8 @@ def run_cloud(argv: list[str]) -> int: 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 == "credits": diff --git a/strix/interface/cloud/http.py b/strix/interface/cloud/http.py index 4c735dc0..f4e78328 100644 --- a/strix/interface/cloud/http.py +++ b/strix/interface/cloud/http.py @@ -98,6 +98,12 @@ def parsed(response: requests.Response) -> Any: def check(response: requests.Response) -> Any: data = parsed(response) if response.ok: + 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." + ) return data detail = "" if isinstance(data, dict): diff --git a/strix/interface/cloud/render.py b/strix/interface/cloud/render.py index a1c22f8d..e8fb6245 100644 --- a/strix/interface/cloud/render.py +++ b/strix/interface/cloud/render.py @@ -6,6 +6,7 @@ import json import sys from typing import TYPE_CHECKING, Any +from rich.markup import escape from rich.table import Table @@ -15,22 +16,49 @@ if TYPE_CHECKING: _MAX_TABLE_COLUMNS = 8 _MAX_CELL_LENGTH = 60 +_NARROW_TABLE_WIDTH = 120 _PREFERRED_KEYS = ( - "id", "name", "title", + "repository_full_name", + "pr_number", + "pr_title", + "head_branch", + "base_branch", + "verdict", "domain", + "target", + "default_branch", + "branch", + "display_number", "status", "state", "severity", + "cve", + "cvss", + "finding_type", + "findings_count", + "open_findings_count", "role", "email", + "firstName", + "lastName", "url", + "provider", + "secret_prefix", "scan_type", "engagement_type", + "estimated_credits", + "cron_expression", + "timezone", + "next_run_at", + "is_active", "created_at", "updated_at", + "expires_at", + "last_used_at", + "id", ) @@ -39,13 +67,27 @@ def json_mode(*, flag: bool) -> bool: return flag or not sys.stdout.isatty() -def emit(console: Console, data: Any, *, as_json: bool) -> None: +def emit( + console: Console, + data: Any, + *, + as_json: bool, + row_numbers: bool = False, + omit_columns: frozenset[str] = frozenset(), + hint: str | None = None, +) -> 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) + _print_table( + console, + rows, + row_numbers=row_numbers, + omit_columns=omit_columns, + hint=hint, + ) return if isinstance(data, str): console.print(data) @@ -66,28 +108,93 @@ def _list_of_dicts(data: Any) -> list[dict[str, Any]] | 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)] +def _print_table( + console: Console, + rows: list[dict[str, Any]], + *, + row_numbers: bool = False, + omit_columns: frozenset[str] = frozenset(), + hint: str | None = None, +) -> None: + columns: list[str] = [ + key + for key in _PREFERRED_KEYS + if key not in omit_columns and any(key in row for row in rows) + ] for row in rows: for key in row: if ( key not in columns + and key not in omit_columns and len(columns) < _MAX_TABLE_COLUMNS and not isinstance(row[key], dict | list) ): columns.append(key) + columns = columns[:_MAX_TABLE_COLUMNS] + if console.width < _NARROW_TABLE_WIDTH: + _print_cards(console, rows, columns, row_numbers=row_numbers) + console.print(f"[dim]{len(rows)} item(s). Use --json for the full records.[/]") + if hint: + console.print(f"[dim]{hint}[/]") + return table = Table(show_lines=False) - for column in columns[:_MAX_TABLE_COLUMNS]: + if row_numbers: + table.add_column("#", justify="right", style="cyan", no_wrap=True) + for column in columns: table.add_column(column) - for row in rows: - table.add_row(*[_cell(row.get(column)) for column in columns[:_MAX_TABLE_COLUMNS]]) + for index, row in enumerate(rows, start=1): + cells = [_cell(row.get(column)) for column in columns] + if row_numbers: + cells.insert(0, str(index)) + table.add_row(*cells) console.print(table) console.print(f"[dim]{len(rows)} item(s). Use --json for the full records.[/]") + if hint: + console.print(f"[dim]{hint}[/]") + + +def _print_cards( + console: Console, + rows: list[dict[str, Any]], + columns: list[str], + *, + row_numbers: bool, +) -> None: + """Render list rows legibly when a terminal is too narrow for a table.""" + for index, row in enumerate(rows, start=1): + parts = [ + f"[bold]{escape(_human_label(column))}:[/] {escape(_cell(row.get(column)))}" + for column in columns + if row.get(column) is not None + ] + prefix = f"[cyan]{index}.[/] " if row_numbers else "[cyan]•[/] " + console.print(prefix + " [dim]·[/] ".join(parts), soft_wrap=False) + + +def _human_label(column: str) -> str: + labels = { + "repository_full_name": "repo", + "pr_number": "PR", + "pr_title": "title", + "head_branch": "head", + "base_branch": "base", + "findings_count": "findings", + "open_findings_count": "open", + "display_number": "finding", + "created_at": "created", + "updated_at": "updated", + "expires_at": "expires", + "last_used_at": "last used", + "secret_prefix": "prefix", + } + return labels.get(column, column.replace("_", " ")) def _cell(value: Any) -> str: if value is None: return "" + if isinstance(value, bool): + return "yes" if value else "no" text = str(value) if len(text) > _MAX_CELL_LENGTH: return text[: _MAX_CELL_LENGTH - 1] + "…" diff --git a/strix/interface/cloud/runner.py b/strix/interface/cloud/runner.py index 22af6f60..ad697613 100644 --- a/strix/interface/cloud/runner.py +++ b/strix/interface/cloud/runner.py @@ -129,7 +129,19 @@ def _execute( result = _wait(console, cmd, result, token=token, as_json=as_json) if cmd.link: return _handoff_link(console, cmd, args, result, as_json=as_json) - emit(console, result, as_json=as_json) + workspace_list = cmd.method == "GET" and cmd.path == "/workspaces" + emit( + console, + result, + as_json=as_json, + row_numbers=workspace_list, + omit_columns=frozenset({"id"}) if workspace_list else frozenset(), + hint=( + "Switch with `strix cloud workspaces use NUMBER`." + if workspace_list + else None + ), + ) return http.EXIT_OK diff --git a/strix/interface/cloud/workspaces.py b/strix/interface/cloud/workspaces.py index 0ff23fe5..1ba67602 100644 --- a/strix/interface/cloud/workspaces.py +++ b/strix/interface/cloud/workspaces.py @@ -25,7 +25,11 @@ def run_workspace_use(argv: list[str]) -> int: prog="strix cloud workspaces use", description="Switch the stored API token to another workspace.", ) - parser.add_argument("workspace", metavar="WORKSPACE", help="Workspace ID or exact name.") + parser.add_argument( + "workspace", + metavar="WORKSPACE", + help="Workspace number from `workspaces list`, ID, or exact name.", + ) parser.add_argument( "--scopes", nargs="+", @@ -119,6 +123,14 @@ def _find_workspace(selector: str, *, token: str | None) -> dict[str, Any]: 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] @@ -126,7 +138,16 @@ def _find_workspace(selector: str, *, token: str | None) -> dict[str, Any]: if len(by_name) == 1: return by_name[0] if len(by_name) > 1: - ids = ", ".join(str(w.get("id")) for w in by_name) - raise http.CloudError(f"multiple workspaces are named {wanted!r}. Use an ID: {ids}") - names = ", ".join(str(w.get("name")) for w in workspaces) + 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}") diff --git a/tests/test_cloud_cli.py b/tests/test_cloud_cli.py index d6dc8fc2..f420852f 100644 --- a/tests/test_cloud_cli.py +++ b/tests/test_cloud_cli.py @@ -60,6 +60,22 @@ def test_unknown_verb_returns_usage_error() -> None: assert cloud.run_cloud(["scans", "bogus"]) == 2 +def test_successful_html_response_is_reported_without_dumping_html( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse(text="preview gate"), + ) + + assert cloud.run_cloud(["workspaces", "list", "--json"]) == 1 + output = capsys.readouterr().out + assert "non-JSON response" in output + assert "STRIX_APP_URL" in output + assert " None: assert cloud.run_cloud(["scans"]) == 0 @@ -636,6 +652,118 @@ def test_group_help_lists_all_verbs_instead_of_default_verb_help(capsys: Any) -> assert "use" in output +def test_workspace_alias_routes_to_workspaces(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, str] = {} + + def fake_request(method: str, path: str, **_kwargs: Any) -> FakeResponse: + seen.update(method=method, path=path) + return FakeResponse(payload={"workspaces": []}) + + monkeypatch.setattr(http, "request", fake_request) + assert cloud.run_cloud(["workspace", "list", "--json"]) == 0 + assert seen == {"method": "GET", "path": "/workspaces"} + + +def test_workspace_human_list_is_numbered_and_hides_ids( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True) + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse( + payload={ + "workspaces": [ + {"id": "org_secret", "name": "Team One", "role": "admin", "current": True} + ] + } + ), + ) + + assert cloud.run_cloud(["workspaces", "list"]) == 0 + output = capsys.readouterr().out + assert "1." in output + assert "Team One" in output + assert "yes" in output + assert "org_secret" not in output + assert "workspaces use NUMBER" in output + + +def test_pr_review_human_list_prioritizes_actionable_fields( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True) + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse( + payload={ + "items": [ + { + "id": "review-id", + "organization_id": "org-id", + "user_id": "user-id", + "installation_id": 42, + "repository_full_name": "usestrix/strix", + "pr_number": 1177, + "pr_title": "Improve cloud CLI", + "head_branch": "feature", + "base_branch": "main", + "verdict": "pass", + "status": "posted", + "findings_count": 0, + } + ], + "meta": {"total": 1}, + } + ), + ) + + assert cloud.run_cloud(["pr-reviews", "list"]) == 0 + output = capsys.readouterr().out + for value in ("usestrix/strix", "1177", "Improve cloud CLI", "feature", "main", "pass"): + assert value in output + for value in ("org-id", "user-id", "installation_id"): + assert value not in output + + +def test_workspace_use_accepts_list_number( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + auth_path = tmp_path / "platform-auth.json" + monkeypatch.setattr(platform_cli, "AUTH_PATH", auth_path) + monkeypatch.setattr(workspaces, "AUTH_PATH", auth_path) + platform_cli.save_record( + {"api_token": "old", "scopes": ["organizations:read", "tokens:write"]} + ) + called_paths: list[str] = [] + + def fake_request(_method: str, path: str, **_kwargs: Any) -> FakeResponse: + called_paths.append(path) + if path == "/workspaces": + return FakeResponse( + payload={ + "workspaces": [ + {"id": "org_1", "name": "One"}, + {"id": "org_2", "name": "Two"}, + ] + } + ) + return FakeResponse( + status_code=201, + payload={ + "api_token": "new", + "organization_id": "org_2", + "organization_name": "Two", + "scopes": ["organizations:read", "tokens:write"], + }, + ) + + monkeypatch.setattr(http, "request", fake_request) + assert cloud.run_cloud(["workspaces", "use", "2", "--json"]) == 0 + assert called_paths == ["/workspaces", "/workspaces/org_2/token"] + + def test_logout_help_does_not_remove_stored_auth( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: Any ) -> None: From cf7224923c5a09d1a43e04d43626eb044f8c4f01 Mon Sep 17 00:00:00 2001 From: bearsyankees Date: Thu, 27 Aug 2026 17:48:37 -0400 Subject: [PATCH 25/28] feat(cli): add native shell completions --- strix/interface/completions.py | 158 +++++++++++++++++++++++++++++++++ strix/interface/main.py | 6 ++ tests/test_completions.py | 49 ++++++++++ 3 files changed, 213 insertions(+) create mode 100644 strix/interface/completions.py create mode 100644 tests/test_completions.py diff --git a/strix/interface/completions.py b/strix/interface/completions.py new file mode 100644 index 00000000..013533e7 --- /dev/null +++ b/strix/interface/completions.py @@ -0,0 +1,158 @@ +"""Shell completion scripts and candidates for the Strix CLI.""" + +from __future__ import annotations + +import sys +from typing import Any + +from strix.interface.cloud.spec import SPEC, Cmd + + +_ROOT_COMMANDS = ("cloud", "auth", "view", "completions", "completion") +_SESSION_COMMANDS = ("login", "logout", "whoami", "credits") +_COMMON_FLAGS = ("--json", "--token", "--app-url", "--timeout", "--help") + + +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 \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: {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: + return _matching(_ROOT_COMMANDS, current) + if prior[0] != "cloud": + return [] + return _cloud_candidates(prior[1:], current) + + +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]: + 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 _matching(_session_flags(group), current) + commands = SPEC.get(group) + if commands is None: + return _matching(groups, current) + + verb_paths = [verb.split() for verb in commands] + 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)}) + exact_verbs = [" ".join(path) for path in matching_paths if len(path) == len(rest)] + candidates: list[str] = list(next_words) + for verb in exact_verbs: + if verb == "use" and group == "workspaces": + candidates.extend(_COMMON_FLAGS) + else: + candidates.extend(_command_flags(commands[verb])) + return _matching(candidates, current) + + +def _session_flags(group: str) -> tuple[str, ...]: + if group == "login": + return ("--no-browser", "--scopes", "--workspace", "--help") + if group == "whoami": + return ("--json", "--help") + return ("--help",) + + +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.binary: + flags.append("--output") + if cmd.link: + flags.append("--no-browser") + if cmd.wait_path or cmd.wait_self: + flags.append("--wait") + if cmd.path == "/billing/topup": + flags.extend(("--yes", "--no-pay", "--payment-method")) + if cmd.path == "/billing/auto-topup" and cmd.method == "PUT": + flags.append("--no-monthly-cap") + return tuple(dict.fromkeys(flags)) + + +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 + mapfile -t candidates < <(strix completions --candidates "${COMP_WORDS[@]:1:$COMP_CWORD}") + COMPREPLY=( $(compgen -W "${candidates[*]}" -- "${COMP_WORDS[$COMP_CWORD]}") ) +} +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)' +""" diff --git a/strix/interface/main.py b/strix/interface/main.py index 69a74b3b..e7dfc4ab 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -431,6 +431,12 @@ 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": diff --git a/tests/test_completions.py b/tests/test_completions.py new file mode 100644 index 00000000..373f4634 --- /dev/null +++ b/tests/test_completions.py @@ -0,0 +1,49 @@ +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 + + +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_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_completion_rejects_unknown_shell(capsys: Any) -> None: + assert run_completions(["powershell"]) == 2 + assert "Choose zsh, bash, or fish" in capsys.readouterr().err From bb32a7d4b0e72ae2608cb15cad3290f25a82ce6c Mon Sep 17 00:00:00 2001 From: bearsyankees Date: Thu, 27 Aug 2026 17:50:32 -0400 Subject: [PATCH 26/28] feat(cloud): tailor human list and detail views --- strix/interface/cloud/render.py | 36 +++++++++++++++++++++++++++++++++ tests/test_cloud_cli.py | 30 +++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/strix/interface/cloud/render.py b/strix/interface/cloud/render.py index e8fb6245..eb7bde66 100644 --- a/strix/interface/cloud/render.py +++ b/strix/interface/cloud/render.py @@ -17,6 +17,16 @@ if TYPE_CHECKING: _MAX_TABLE_COLUMNS = 8 _MAX_CELL_LENGTH = 60 _NARROW_TABLE_WIDTH = 120 +_INTERNAL_COLUMNS = frozenset( + { + "organization_id", + "user_id", + "userId", + "installation_id", + "created_by", + "avatarUrl", + } +) _PREFERRED_KEYS = ( "name", @@ -92,6 +102,9 @@ def emit( if isinstance(data, str): console.print(data) return + if isinstance(data, dict): + _print_detail(console, data) + return console.print_json(json.dumps(data, default=str)) @@ -116,6 +129,7 @@ def _print_table( omit_columns: frozenset[str] = frozenset(), hint: str | None = None, ) -> None: + omit_columns = omit_columns | _INTERNAL_COLUMNS columns: list[str] = [ key for key in _PREFERRED_KEYS @@ -171,6 +185,28 @@ def _print_cards( console.print(prefix + " [dim]·[/] ".join(parts), soft_wrap=False) +def _print_detail(console: Console, data: dict[str, Any]) -> None: + """Render one API record as a readable field/value view.""" + keys = [key for key in _PREFERRED_KEYS if key in data and key not in _INTERNAL_COLUMNS] + keys.extend( + key for key in data if key not in keys and key not in _INTERNAL_COLUMNS + ) + table = Table(show_header=False, show_edge=False, box=None, padding=(0, 2)) + table.add_column("field", style="bold cyan", no_wrap=True) + table.add_column("value", overflow="fold") + for key in keys: + value = data.get(key) + if value is None: + continue + if isinstance(value, (dict, list)): + rendered = json.dumps(value, indent=2, default=str) + else: + rendered = _cell(value) + table.add_row(_human_label(key), rendered) + console.print(table) + console.print("[dim]Use --json for the lossless machine-readable record.[/]") + + def _human_label(column: str) -> str: labels = { "repository_full_name": "repo", diff --git a/tests/test_cloud_cli.py b/tests/test_cloud_cli.py index f420852f..4723f9f0 100644 --- a/tests/test_cloud_cli.py +++ b/tests/test_cloud_cli.py @@ -727,6 +727,36 @@ def test_pr_review_human_list_prioritizes_actionable_fields( assert value not in output +def test_human_get_prioritizes_details_and_hides_internal_identity_fields( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True) + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse( + payload={ + "id": "review-id", + "organization_id": "org-id", + "user_id": "user-id", + "repository_full_name": "usestrix/strix", + "pr_number": 1177, + "pr_title": "Improve cloud CLI", + "verdict": "pass", + "findings": [{"severity": "high", "title": "Example"}], + } + ), + ) + + assert cloud.run_cloud(["pr-reviews", "get", "review-id"]) == 0 + output = capsys.readouterr().out + for value in ("usestrix/strix", "1177", "Improve cloud CLI", "pass", "Example"): + assert value in output + assert "org-id" not in output + assert "user-id" not in output + assert "lossless machine-readable" in output + + def test_workspace_use_accepts_list_number( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: From 7095d1dcaeb14d67f99c7d7ef1d4f54e66da14a1 Mon Sep 17 00:00:00 2001 From: bearsyankees Date: Thu, 27 Aug 2026 18:11:07 -0400 Subject: [PATCH 27/28] feat(cloud): upload local source for managed scans --- AGENTS.md | 3 + docs/cloud/overview.mdx | 16 + skills/managed-pentesting-with-strix/SKILL.md | 17 +- strix/interface/cloud/http.py | 33 +- strix/interface/cloud/render.py | 4 +- strix/interface/cloud/runner.py | 220 +++++++++- strix/interface/cloud/source_upload.py | 382 ++++++++++++++++++ strix/interface/cloud/workspaces.py | 12 +- strix/interface/completions.py | 13 + tests/test_cloud_cli.py | 12 +- tests/test_cloud_source_upload.py | 230 +++++++++++ tests/test_completions.py | 3 + 12 files changed, 906 insertions(+), 39 deletions(-) create mode 100644 strix/interface/cloud/source_upload.py create mode 100644 tests/test_cloud_source_upload.py diff --git a/AGENTS.md b/AGENTS.md index 1ca24385..fdaae975 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,11 +41,14 @@ Target-specific workflows built on the same engine: 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 scans start --source . --dry-run --show-files --json # review local upload + strix cloud scans start --source . --yes --engagement-type code_review --wait strix cloud vulns list --severity critical strix cloud billing topup --credits 20 # buy credits when a scan returns exit code 5 ``` - Account setup runs from the CLI too: `strix cloud workspaces list|create|use`, `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 `. 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, the installation, or the DNS change for them. - 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. + - Local source uploads require `uploads:write`. Review with `scans start --source . --dry-run --show-files --json`, then approve with `--yes`. 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. - 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). Managed API docs for LLMs: https://docs.app.strix.ai/llms.txt. diff --git a/docs/cloud/overview.mdx b/docs/cloud/overview.mdx index 8b6e584d..2429c7e2 100644 --- a/docs/cloud/overview.mdx +++ b/docs/cloud/overview.mdx @@ -35,6 +35,22 @@ 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 first. Nothing is uploaded. +strix cloud scans start --source . --dry-run --show-files --json + +# Approve the reviewed selection, upload it, and wait for the scan. +strix cloud scans start --source . --yes --engagement-type code_review --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. Non-interactive environments must pass `--yes`, so an agent cannot upload a workspace without an explicit approval flag. + Run your first pentest in minutes. diff --git a/skills/managed-pentesting-with-strix/SKILL.md b/skills/managed-pentesting-with-strix/SKILL.md index 464cf2f6..a8fb70a7 100644 --- a/skills/managed-pentesting-with-strix/SKILL.md +++ b/skills/managed-pentesting-with-strix/SKILL.md @@ -1,6 +1,6 @@ --- name: managed-pentesting-with-strix -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. +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 @@ -33,7 +33,7 @@ The platform enforces plan and role limits, and the CLI passes the platform mess 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 +strix cloud login --scopes scans:read scans:write uploads: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). @@ -168,6 +168,19 @@ Useful flags (each maps to a `CreateScanRequest` field): The response is `{ scan_id, title, status }` with `status` = `pending`. +### Scan a local workspace in the cloud + +Review the exact local upload before sending it. An agent or CI process must never skip this review merely because it can pass `--yes`: + +```bash +strix cloud scans start --source . --dry-run --show-files --json +strix cloud scans start --source . --yes --engagement-type code_review --wait +``` + +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, and enforces file-count, per-file, expanded-size, and compressed-size limits. Add project exclusions to `.strixignore` (one exclude glob per line) or repeat `--exclude GLOB`. + +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`. Non-interactive uploads require `--yes`; interactive terminals show one summary and confirmation prompt. The CLI deletes its temporary archive after the request and best-effort deletes the uploaded object if scan creation fails. + ## 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 ` (`scans:read`). Status flow: `pending → running → completed` (or `failed` / `cancelled`). Scans take minutes to hours — poll on an interval, do not block. diff --git a/strix/interface/cloud/http.py b/strix/interface/cloud/http.py index f4e78328..14560350 100644 --- a/strix/interface/cloud/http.py +++ b/strix/interface/cloud/http.py @@ -3,7 +3,7 @@ from __future__ import annotations import os -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast import requests @@ -11,6 +11,10 @@ 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 _app_url_override: str | None = None _timeout_s: float = _DEFAULT_TIMEOUT_S @@ -85,6 +89,33 @@ def request( return response +def upload_file(signed_url: str, upload_token: str, path: Path) -> None: + """Stream a file to a platform-issued storage URL.""" + 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, + ) + except (OSError, requests.RequestException) as exc: + raise CloudError(f"source upload failed: {exc}") from exc + 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})") + + def parsed(response: requests.Response) -> Any: content_type = response.headers.get("content-type", "") if "application/json" in content_type: diff --git a/strix/interface/cloud/render.py b/strix/interface/cloud/render.py index eb7bde66..484ae4fe 100644 --- a/strix/interface/cloud/render.py +++ b/strix/interface/cloud/render.py @@ -188,9 +188,7 @@ def _print_cards( def _print_detail(console: Console, data: dict[str, Any]) -> None: """Render one API record as a readable field/value view.""" keys = [key for key in _PREFERRED_KEYS if key in data and key not in _INTERNAL_COLUMNS] - keys.extend( - key for key in data if key not in keys and key not in _INTERNAL_COLUMNS - ) + keys.extend(key for key in data if key not in keys and key not in _INTERNAL_COLUMNS) table = Table(show_header=False, show_edge=False, box=None, padding=(0, 2)) table.add_column("field", style="bold cyan", no_wrap=True) table.add_column("value", overflow="fold") diff --git a/strix/interface/cloud/runner.py b/strix/interface/cloud/runner.py index ad697613..14ac705e 100644 --- a/strix/interface/cloud/runner.py +++ b/strix/interface/cloud/runner.py @@ -15,6 +15,7 @@ import subprocess import sys import time import webbrowser +from contextlib import suppress from pathlib import Path from typing import Any, cast from urllib.parse import quote @@ -23,6 +24,7 @@ from rich.console import Console from strix.interface.cloud import http from strix.interface.cloud.render import emit, json_mode +from strix.interface.cloud.source_upload import SourceBundle, prepare_source, remove_bundle from strix.interface.cloud.spec import DEFAULT_VERBS, SPEC, Cmd, P @@ -99,7 +101,7 @@ def run(group: str, verb_label: str, cmd: Cmd, argv: list[str]) -> int: return exc.exit_code -def _execute( +def _execute( # noqa: PLR0912 console: Console, cmd: Cmd, args: argparse.Namespace, @@ -112,21 +114,63 @@ def _execute( ) -> 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, - ) + source_bundle: SourceBundle | None = None + source_upload_id: str | None = None + try: + if cmd.path == "/scans" and cmd.method == "POST": + source_bundle = _prepare_scan_source(console, args, as_json=as_json) + if source_bundle is not None and getattr(args, "dry_run", False): + emit( + console, + { + "source": source_bundle.summary( + show_files=getattr(args, "show_files", False) + ) + }, + as_json=as_json, + ) + return http.EXIT_OK + if source_bundle is not None: + source_upload_id = _upload_scan_source(source_bundle, token=token) + existing = body.get("upload_ids") + body["upload_ids"] = [ + *(existing if isinstance(existing, list) else []), + source_upload_id, + ] + + response = http.request( + cmd.method, + path, + token=token, + query=query or None, + body=body if cmd.method in ("POST", "PUT", "PATCH") else None, + ) + except BaseException: + if source_upload_id is not None: + _delete_upload(source_upload_id, token=token) + raise + finally: + if source_bundle is not None: + remove_bundle(source_bundle) if cmd.binary: return _emit_binary(console, response, getattr(args, "output", None)) - result = http.check(response) + try: + result = http.check(response) + except BaseException: + if source_upload_id is not None: + _delete_upload(source_upload_id, token=token) + raise 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) + if source_bundle is not None: + result = { + "source": source_bundle.summary(show_files=getattr(args, "show_files", False)), + "upload_id": source_upload_id, + "scan": result, + } if cmd.link: return _handoff_link(console, cmd, args, result, as_json=as_json) workspace_list = cmd.method == "GET" and cmd.path == "/workspaces" @@ -136,11 +180,7 @@ def _execute( as_json=as_json, row_numbers=workspace_list, omit_columns=frozenset({"id"}) if workspace_list else frozenset(), - hint=( - "Switch with `strix cloud workspaces use NUMBER`." - if workspace_list - else None - ), + hint=("Switch with `strix cloud workspaces use NUMBER`." if workspace_list else None), ) return http.EXIT_OK @@ -149,7 +189,8 @@ def _handoff_link( console: Console, cmd: Cmd, args: argparse.Namespace, result: Any, *, as_json: bool ) -> int: """Print a hosted URL a person must open, and open the browser when interactive.""" - url = result.get(cmd.link) if isinstance(result, dict) else None + fields = cast("dict[str, Any]", result) if isinstance(result, dict) else {} + url = fields.get(cmd.link) if cmd.link else None if not isinstance(url, str) or not url: emit(console, result, as_json=as_json) return http.EXIT_OK @@ -243,9 +284,151 @@ def _build_parser(group: str, verb_label: str, cmd: Cmd) -> argparse.ArgumentPar "Defaults to MPPX_STRIPE_PAYMENT_METHOD." ), ) + if cmd.path == "/scans" and cmd.method == "POST": + parser.add_argument( + "--source", + default=None, + metavar="DIRECTORY", + help="Package a local directory, upload it, and attach it to this scan.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Build and print the source manifest without uploading or starting a scan.", + ) + parser.add_argument( + "--yes", + action="store_true", + help="Approve the displayed source upload without an interactive prompt.", + ) + parser.add_argument( + "--show-files", + action="store_true", + help="Include every selected relative path in the source manifest.", + ) + parser.add_argument( + "--exclude", + action="append", + default=[], + metavar="GLOB", + help="Exclude a path glob from the upload. May be repeated.", + ) + parser.add_argument( + "--include-hidden", + action="store_true", + help="Include hidden files except .git and secret-like filenames.", + ) + parser.add_argument( + "--include-sensitive", + action="store_true", + help="Include files with secret-like names. Use only after reviewing --dry-run.", + ) + parser.add_argument( + "--include-archives", + action="store_true", + help="Include nested archives. Use only when they are required source inputs.", + ) return parser +def _prepare_scan_source( + console: Console, args: argparse.Namespace, *, as_json: bool +) -> SourceBundle | None: + source = getattr(args, "source", None) + source_flags = ( + "dry_run", + "show_files", + "include_hidden", + "include_sensitive", + "include_archives", + ) + 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", [])), + ) + if getattr(args, "dry_run", False): + return bundle + if getattr(args, "yes", False): + return bundle + if as_json or not (sys.stdin.isatty() and sys.stdout.isatty()): + remove_bundle(bundle) + raise http.CloudError( + "source upload requires explicit approval in non-interactive mode. " + "Review with --dry-run --show-files, then rerun with --yes." + ) + 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." + ) + answer = console.input("Upload this source and start the scan? [y/N]: ").strip().lower() + if answer not in ("y", "yes"): + remove_bundle(bundle) + raise http.CloudError("source upload cancelled.") + return bundle + + +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)): + raise http.CloudError("the platform did not return complete source upload credentials.") + try: + http.upload_file(cast("str", signed_url), cast("str", upload_token), bundle.archive_path) + http.check( + http.request( + "POST", + "/uploads/complete", + token=token, + body={"upload_id": upload_id}, + ) + ) + except BaseException: + _delete_upload(cast("str", upload_id), token=token) + raise + return cast("str", upload_id) + + +def _delete_upload(upload_id: str, *, token: str | None) -> None: + with suppress(http.CloudError): + http.request("DELETE", f"/uploads/{quote(upload_id, safe='')}", token=token) + + +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" + + def _add_option(parser: argparse.ArgumentParser, param: P) -> None: flag = "--" + (param.flag or param.name.replace("_", "-")) if param.kind == "bool": @@ -342,10 +525,11 @@ def _poll(console: Console, path: str, *, token: str | None, as_json: bool) -> A """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 "" + current: Any = http.check(http.request("GET", path, token=token)) + fields = cast("dict[str, Any]", current) if isinstance(current, dict) else {} + status = str(fields.get("status", "")) if status.lower() in _TERMINAL_STATUSES: - return current + return fields if isinstance(current, dict) else current if not as_json: console.print(f"[dim] status: {status or 'unknown'}[/]") diff --git a/strix/interface/cloud/source_upload.py b/strix/interface/cloud/source_upload.py new file mode 100644 index 00000000..dc9e801c --- /dev/null +++ b/strix/interface/cloud/source_upload.py @@ -0,0 +1,382 @@ +"""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 strix.interface.cloud import http + + +MAX_FILES = 20_000 +MAX_FILE_BYTES = 25 * 1024 * 1024 +MAX_TOTAL_BYTES = 250 * 1024 * 1024 +MAX_ARCHIVE_BYTES = 50 * 1024 * 1024 + +_ALWAYS_EXCLUDED_DIRS = frozenset( + { + ".git", + ".hg", + ".svn", + "node_modules", + "vendor", + "venv", + "__pycache__", + "dist", + "build", + "coverage", + "target", + } +) +_SENSITIVE_NAMES = frozenset( + { + "id_rsa", + "id_dsa", + "id_ecdsa", + "id_ed25519", + "credentials.json", + "service-account.json", + "service_account.json", + ".env", + ".npmrc", + ".pypirc", + ".netrc", + } +) +_SENSITIVE_PATTERNS = ( + "*.pem", + "*.key", + "*.p12", + "*.pfx", + "*.keystore", + "*.jks", + "secrets.*", + "secret.*", + ".env.*", +) +_ARCHIVE_SUFFIXES = ( + ".zip", + ".tar", + ".tgz", + ".tar.gz", + ".tar.bz2", + ".tar.xz", + ".7z", + ".rar", + ".gz", + ".bz2", + ".xz", +) + + +@dataclass(frozen=True) +class SelectedFile: + path: Path + archive_name: str + size: 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 [])] + total_bytes = 0 + for relative in _candidate_paths(source): + 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 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, archive_name, info.st_size)) + 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) -> list[Path]: + git_root = _git_root(source) + if git_root is not None: + git = shutil.which("git") + if git is None: + return [path.relative_to(source) for path in source.rglob("*")] + 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()) + result = subprocess.run( # noqa: S603 # nosec B603 + command, check=False, capture_output=True + ) + if result.returncode == 0: + paths: list[Path] = [] + for raw in result.stdout.split(b"\0"): + if not raw: + continue + repo_relative = Path(os.fsdecode(raw)) + try: + paths.append(repo_relative.relative_to(relative_source)) + except ValueError: + continue + return paths + return [path.relative_to(source) for path in source.rglob("*")] + + +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 + if any(part == ".git" for part in parts): + return "git_metadata" + if any(part in _ALWAYS_EXCLUDED_DIRS for part in parts[:-1]): + return "dependency_or_build_output" + if not include_hidden and any(part.startswith(".") for part in parts): + return "hidden" + posix = PurePosixPath(relative.as_posix()) + if any( + posix.match(pattern) or fnmatch.fnmatch(relative.as_posix(), 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) + ): + return "sensitive_filename" + if not include_archives and name.endswith(_ARCHIVE_SUFFIXES): + return "nested_archive" + return None + + +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: + 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: + shutil.copyfileobj(source_file, target, length=1024 * 1024) + + +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 _load_ignore_patterns(source: Path) -> list[str]: + path = source / ".strixignore" + try: + lines = path.read_text(encoding="utf-8").splitlines() + except FileNotFoundError: + return [] + except OSError as exc: + raise http.CloudError(f"could not read {path}: {exc}") 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) + return patterns diff --git a/strix/interface/cloud/workspaces.py b/strix/interface/cloud/workspaces.py index 1ba67602..6df14a5a 100644 --- a/strix/interface/cloud/workspaces.py +++ b/strix/interface/cloud/workspaces.py @@ -36,8 +36,7 @@ def run_workspace_use(argv: list[str]) -> int: metavar="SCOPE", default=None, help=( - "API scopes for the new token. Without this option, preserve the stored token's " - "scopes." + "API scopes for the new token. Without this option, preserve the stored token's scopes." ), ) parser.add_argument("--json", action="store_true", help="Print the raw JSON response.") @@ -71,8 +70,10 @@ def _use(console: Console, args: argparse.Namespace, *, as_json: bool) -> int: body["scopes"] = args.scopes elif args.token is None: stored_scopes = record.get("scopes") - if isinstance(stored_scopes, list) and stored_scopes and all( - isinstance(scope, str) for scope in stored_scopes + if ( + isinstance(stored_scopes, list) + and stored_scopes + and all(isinstance(scope, str) for scope in stored_scopes) ): body["scopes"] = stored_scopes minted = http.check( @@ -147,7 +148,6 @@ def _find_workspace(selector: str, *, token: str | None) -> dict[str, Any]: 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) + 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}") diff --git a/strix/interface/completions.py b/strix/interface/completions.py index 013533e7..83a53026 100644 --- a/strix/interface/completions.py +++ b/strix/interface/completions.py @@ -108,6 +108,19 @@ def _command_flags(cmd: Cmd) -> tuple[str, ...]: flags.append("--wait") if cmd.path == "/billing/topup": flags.extend(("--yes", "--no-pay", "--payment-method")) + if cmd.path == "/scans" and cmd.method == "POST": + flags.extend( + ( + "--source", + "--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)) diff --git a/tests/test_cloud_cli.py b/tests/test_cloud_cli.py index 4723f9f0..46dc88c6 100644 --- a/tests/test_cloud_cli.py +++ b/tests/test_cloud_cli.py @@ -621,9 +621,7 @@ def test_workspaces_use_switches_stored_token( code = cloud.run_cloud(["workspaces", "use", "team one", "--json"]) assert code == 0 assert calls == [("GET", "/workspaces"), ("POST", "/workspaces/org_1/token")] - assert token_body == { - "scopes": ["scans:read", "organizations:read", "tokens:write"] - } + assert token_body == {"scopes": ["scans:read", "organizations:read", "tokens:write"]} record = platform_cli.read_record() assert record is not None assert record["api_token"] == "new-token" @@ -757,15 +755,11 @@ def test_human_get_prioritizes_details_and_hides_internal_identity_fields( assert "lossless machine-readable" in output -def test_workspace_use_accepts_list_number( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: +def test_workspace_use_accepts_list_number(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: auth_path = tmp_path / "platform-auth.json" monkeypatch.setattr(platform_cli, "AUTH_PATH", auth_path) monkeypatch.setattr(workspaces, "AUTH_PATH", auth_path) - platform_cli.save_record( - {"api_token": "old", "scopes": ["organizations:read", "tokens:write"]} - ) + platform_cli.save_record({"api_token": "old", "scopes": ["organizations:read", "tokens:write"]}) called_paths: list[str] = [] def fake_request(_method: str, path: str, **_kwargs: Any) -> FakeResponse: diff --git a/tests/test_cloud_source_upload.py b/tests/test_cloud_source_upload.py new file mode 100644 index 00000000..3c099383 --- /dev/null +++ b/tests/test_cloud_source_upload.py @@ -0,0 +1,230 @@ +"""Local-source packaging and scan upload tests.""" + +from __future__ import annotations + +import json +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 + + +@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_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_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_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 + assert "requires explicit approval" in capsys.readouterr().out + + +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"]["upload_ids"] == ["upload-1"] + assert uploaded_path is not None and not uploaded_path.exists() + + +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 diff --git a/tests/test_completions.py b/tests/test_completions.py index 373f4634..67c67c6f 100644 --- a/tests/test_completions.py +++ b/tests/test_completions.py @@ -28,6 +28,9 @@ def test_cloud_leaf_flag_candidates_come_from_command_spec() -> None: assert "--domain-ids" in candidates assert "--json" in candidates assert "--wait" in candidates + assert "--source" in candidates + assert "--dry-run" in candidates + assert "--include-hidden" in candidates def test_boolean_completion_includes_positive_and_negative_flags() -> None: From 1465b57a782950397162e401be0eedfd6e6a1d75 Mon Sep 17 00:00:00 2001 From: bearsyankees Date: Thu, 27 Aug 2026 18:42:57 -0400 Subject: [PATCH 28/28] fix(cloud): infer scan type from local targets --- strix/interface/cloud/runner.py | 18 +++++++++++ tests/test_cloud_source_upload.py | 54 ++++++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/strix/interface/cloud/runner.py b/strix/interface/cloud/runner.py index 14ac705e..4b32629c 100644 --- a/strix/interface/cloud/runner.py +++ b/strix/interface/cloud/runner.py @@ -118,6 +118,10 @@ def _execute( # noqa: PLR0912 source_upload_id: str | None = None try: if cmd.path == "/scans" and cmd.method == "POST": + _set_default_scan_engagement( + body, + has_local_source=getattr(args, "source", None) is not None, + ) source_bundle = _prepare_scan_source(console, args, as_json=as_json) if source_bundle is not None and getattr(args, "dry_run", False): emit( @@ -185,6 +189,20 @@ def _execute( # noqa: PLR0912 return http.EXIT_OK +def _set_default_scan_engagement( + body: dict[str, Any], *, has_local_source: bool = False +) -> None: + """Infer the scan type from its targets when the caller did not choose one.""" + if body.get("engagement_type"): + return + if body.get("internal_targets"): + body["engagement_type"] = "internal_infra" + elif body.get("domain_ids"): + body["engagement_type"] = "live_test" + elif has_local_source or body.get("repository_ids") or body.get("upload_ids"): + body["engagement_type"] = "code_review" + + def _handoff_link( console: Console, cmd: Cmd, args: argparse.Namespace, result: Any, *, as_json: bool ) -> int: diff --git a/tests/test_cloud_source_upload.py b/tests/test_cloud_source_upload.py index 3c099383..eeba69a6 100644 --- a/tests/test_cloud_source_upload.py +++ b/tests/test_cloud_source_upload.py @@ -196,10 +196,62 @@ def test_source_upload_is_completed_and_attached_to_scan( 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"]["upload_ids"] == ["upload-1"] + 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: