From de730119f0e20104f04a26c75c910edea9ca4eae Mon Sep 17 00:00:00 2001 From: alex s <46074070+bearsyankees@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:39:34 -0400 Subject: [PATCH] =?UTF-8?q?feat(cli):=20strix=20cloud=20=E2=80=94=20manage?= =?UTF-8?q?d=20platform=20CLI=20(login,=20scans,=20billing,=20and=20the=20?= =?UTF-8?q?rest=20of=20the=20API)=20(#1177)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): add strix login for managed platform sign-in (device flow) * feat(cli): add --scopes flag to strix login * docs: document strix login and managed billing in README, AGENTS, docs, and managed skill * fix(cli): handle malformed login responses and credential file failures * fix(cli): reject sign-in responses without an API token * feat(login): interactive workspace and scope selection with presets * fix(login): reject malformed API token values in sign-in responses * fix(login): skip the scope prompt when stdin is not a terminal * fix(login): tolerate malformed selection containers and remove unreadable credential files on logout * fix(login): treat overflowing timing values as invalid * fix(login): show the configured platform host in the sign-in banner * fix(login): bound device flow timing values and clean up unreplaced secret temp files * feat(cli): add the strix cloud command surface for the managed platform * feat(cli): manage workspaces and hosted onboarding links from strix cloud * fix(cli): report a leftover temporary secret file instead of hiding it * feat(cli): pass a Stripe payment method to the top-up wallet client * docs(cloud): recommend the Stripe agent wallet as the default payment path * fix(cloud): preserve API auth during MPP payment * fix(cloud): drop knowledge query and settings commands removed from the API * fix(cloud): align agent commands with API contracts * fix(cloud): send required PR review integration fields * fix(cloud): preserve scopes when switching workspaces * fix(cloud): make session command help non-destructive * feat(cloud): improve human navigation and output * feat(cli): add native shell completions * feat(cloud): tailor human list and detail views * feat(cloud): upload local source for managed scans * fix(cloud): infer scan type from local targets * Add agent-friendly managed cloud CLI * Harden cloud CLI type boundaries * Clarify cloud test user MFA options * Correct cloud vulnerability status guidance * Clarify chat file path handling * Allow signed storage upload URLs * Fix provider token request handling * Improve cloud CLI human list views * Make cloud CLI workflows actionable and safe * Make cloud workspace switching session-safe * Preserve CLI session metadata in JSON output * Remove preview protection bypass plumbing from cloud CLI --- AGENTS.md | 22 +- README.md | 102 + docs/cloud/overview.mdx | 19 + docs/integrations/coding-agents.mdx | 3 +- pyproject.toml | 1 + skills/managed-pentesting-with-strix/SKILL.md | 293 +- strix/interface/cloud/__init__.py | 169 + strix/interface/cloud/arguments.py | 18 + strix/interface/cloud/billing.py | 374 ++ strix/interface/cloud/http.py | 361 ++ strix/interface/cloud/payment_proxy.py | 280 ++ strix/interface/cloud/render.py | 1759 ++++++++ strix/interface/cloud/runner.py | 1127 ++++++ strix/interface/cloud/session.py | 167 + strix/interface/cloud/source_scan.py | 403 ++ strix/interface/cloud/source_upload.py | 703 ++++ strix/interface/cloud/spec.py | 1147 ++++++ strix/interface/cloud/workspaces.py | 291 ++ strix/interface/completions.py | 373 ++ strix/interface/main.py | 28 + strix/interface/platform_cli.py | 798 ++++ strix/interface/platform_identity.py | 46 + strix/interface/terminal_text.py | 21 + strix/interface/url_safety.py | 85 + strix/utils/secret_files.py | 25 +- tests/test_cli_target_list.py | 5 +- tests/test_cloud_cli.py | 3569 +++++++++++++++++ tests/test_cloud_cli_runtime.py | 1119 ++++++ tests/test_cloud_idempotency.py | 203 + tests/test_cloud_payment_proxy.py | 136 + tests/test_cloud_session.py | 165 + tests/test_cloud_source_upload.py | 657 +++ tests/test_completions.py | 164 + tests/test_pricing.py | 5 +- 34 files changed, 14564 insertions(+), 74 deletions(-) create mode 100644 strix/interface/cloud/__init__.py create mode 100644 strix/interface/cloud/arguments.py create mode 100644 strix/interface/cloud/billing.py create mode 100644 strix/interface/cloud/http.py create mode 100644 strix/interface/cloud/payment_proxy.py create mode 100644 strix/interface/cloud/render.py create mode 100644 strix/interface/cloud/runner.py create mode 100644 strix/interface/cloud/session.py create mode 100644 strix/interface/cloud/source_scan.py create mode 100644 strix/interface/cloud/source_upload.py create mode 100644 strix/interface/cloud/spec.py create mode 100644 strix/interface/cloud/workspaces.py create mode 100644 strix/interface/completions.py create mode 100644 strix/interface/platform_cli.py create mode 100644 strix/interface/platform_identity.py create mode 100644 strix/interface/terminal_text.py create mode 100644 strix/interface/url_safety.py create mode 100644 tests/test_cloud_cli.py create mode 100644 tests/test_cloud_cli_runtime.py create mode 100644 tests/test_cloud_idempotency.py create mode 100644 tests/test_cloud_payment_proxy.py create mode 100644 tests/test_cloud_session.py create mode 100644 tests/test_cloud_source_upload.py create mode 100644 tests/test_completions.py diff --git a/AGENTS.md b/AGENTS.md index b347278b..e9039195 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,13 +38,25 @@ Target-specific workflows built on the same engine: - **Managed cloud (app.strix.ai):** no Docker, no LLM key, no local install; adds team dashboards, scheduling, PR reviews, and downloadable PDF/DOCX reports (Enterprise plan). Best in sandboxed/CI environments and for teams. Use it when local infra isn't available. ```bash - # token from Settings → API Access; register the target as an asset, then: - curl -sS https://app.strix.ai/api/v1/scans -H "Authorization: Bearer $STRIX_API_TOKEN" \ - -H "Content-Type: application/json" -d '{"engagement_type":"live_test","domain_ids":[""]}' + strix cloud login --scopes scans:read scans:write uploads:write billing:read + strix cloud domains add --domain example.com --asset-type web_app + strix cloud scans start --engagement-type live_test --domain-ids --wait + strix cloud scans start --source . --dry-run --show-files --json # review + capture source.archive_sha256 + SOURCE_SHA256="" + strix cloud scans start --source . --approve-sha256 "$SOURCE_SHA256" --wait + strix cloud vulns list --severity critical + strix cloud billing topup --credits 20 --yes # explicit approval after exit code 5 ``` - - API docs: https://docs.app.strix.ai (OpenAPI: https://docs.app.strix.ai/openapi.json). + - Account setup runs from the CLI too: `strix cloud workspaces list|create|use` (`workspace` is an alias and `use` accepts a displayed number, name, or ID), `strix cloud session scopes|scopes set`, `strix cloud org members invite`, `strix cloud billing subscribe --plan strix_cloud`, `strix cloud billing portal`, `strix cloud integrations install github`, and `strix cloud domains verify `. Workspace switching preserves the server-side profile and can never widen past the login ceiling; ordinary switches do not reprompt. The last four end at a person: the command prints a link or a DNS record for the user to open or add, and it never completes the payment, installation, or DNS change for them. + - Every REST operation has a `strix cloud ` command. Run `strix cloud` to list them. Output is JSON when stdout is not a terminal (or with `--json`), and there are no prompts without a TTY. Binary downloads are the exception: redirect raw bytes intentionally, or combine `--output FILE --json` for structured download metadata. Exit codes: `0` success, `1` error, `2` usage, `4` auth or plan limit, `5` payment required. `--token` or `STRIX_API_TOKEN` is a stateless override and never replaces stored auth; set `--workspace-id`/`STRIX_WORKSPACE_ID` for an override CLI session. `--data` adds extra request fields as JSON, and accepts `@file` or `-` for standard input. + - Local source uploads require `uploads:write`. For an agent/CI handoff, review `scans start --source . --dry-run --show-files --json`, capture `source.archive_sha256`, then rerun with the same `--source`, `--exclude`, and `--include-*` selection flags plus `--approve-sha256 HASH`. A changed snapshot is rejected. `--yes` approves only the snapshot built in that invocation, so reserve it for a deliberate human or one-shot approval rather than a digest-bound two-step handoff. + - Git ignores, hidden files, `.git`, symlinks, dependency/build output, secret-like filenames, and nested archives are excluded by default; `.strixignore` and `--exclude` narrow the manifest further (a trailing `/` excludes a directory subtree). Limits: 20,000 files, 25 MiB/file, 250 MiB expanded, 50 MiB compressed. Source-only infers `code_review`; source plus a domain infers `live_test`. + - The temporary local archive is always removed. A staged upload is deleted after a definitive rejection, but retained when a network error, `5xx`, malformed success response, or interruption leaves the scan launch ambiguous. JSON reports its `upload_id` with `launch_outcome_unknown: true`, or with `cleanup_unknown: true` when automatic deletion cannot be confirmed. Check `scans list` before retrying; if no scan is linked, run `uploads delete UPLOAD_ID`. + - Non-Enterprise scans consume the scope estimate (a default-tier source-only review currently starts at 60 credits); Enterprise scans are plan-included. A rejected launch does not consume credits. + - Human output is compact and numbered; non-TTY output and `--json` retain full records. Enable tab completion with `source <(strix completions zsh)` (or `bash`), or `strix completions fish | source`. + - The REST API works directly too: https://docs.app.strix.ai (OpenAPI: https://docs.app.strix.ai/openapi.json). -- CLI docs index for LLMs: https://docs.strix.ai/llms.txt (full: https://docs.strix.ai/llms-full.txt). +- CLI docs index for LLMs: https://docs.strix.ai/llms.txt (full: https://docs.strix.ai/llms-full.txt). Managed API docs for LLMs: https://docs.app.strix.ai/llms.txt. - Only scan targets the user is authorized to test. ## Contributing to this repo diff --git a/README.md b/README.md index 99c235af..6cb6aaac 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,108 @@ strix auth status # show the active sign-in strix auth logout # forget the sign-in ``` +#### Use the managed platform: `strix cloud` + +The `strix cloud` commands drive the managed platform ([app.strix.ai](https://app.strix.ai)) from the terminal. Sign in once with the device flow. The sign-in creates your account and workspace on first use and stores a personal API token in `~/.strix/platform-auth.json`: + +```bash +strix cloud login # browser approval, then workspace + scope profile +strix cloud login --workspace "My Team" # select a workspace by name or ID +strix cloud whoami # fast local account/workspace status +strix cloud session # verify remote session + consent ceiling +strix cloud logout # revoke remotely, then remove locally +``` + +The default **Recommended** scope preset supports normal scan work, local source uploads, +workspace switching, and user-approved credit top-ups. It excludes credential creation; +request `tokens:write` explicitly (or choose Full) when needed. For strict least privilege, pass an explicit list such as +`--scopes scans:read scans:write uploads:write billing:read`. Named automation +profiles are also available with `--scope-profile minimal|recommended|full`. + +Every operation of the [REST API](https://docs.app.strix.ai) has a matching command in the form `strix cloud `: + +```bash +strix cloud # list all resources +strix cloud scans # run the safe default (`scans list`) +strix cloud scans help # list the verbs of a resource +strix cloud domains add --domain example.com --asset-type web_app +strix cloud scans start --engagement-type live_test --domain-ids --wait +strix cloud scans start --source . --dry-run --show-files --json # review + capture source.archive_sha256 +SOURCE_SHA256="" +strix cloud scans start --source . --approve-sha256 "$SOURCE_SHA256" --wait +strix cloud vulns list --severity critical +strix cloud credits # credit balance +strix cloud billing topup --credits 20 --yes # explicitly approve agent payment after HTTP 402 +``` + +Workspaces and account setup also work from the terminal: + +```bash +strix cloud workspaces list # numbered list; `workspace` is also accepted +strix cloud workspaces create --name "My Team" # admin + organizations:write +strix cloud workspaces use 2 # switch by list number, exact name, or ID +strix cloud session scopes # granted scopes + login ceiling +strix cloud session scopes set minimal # narrow without another browser sign-in +strix cloud billing subscribe --plan strix_cloud # opens the hosted checkout page +strix cloud billing portal # opens the billing portal +strix cloud integrations install github # opens the app installation page +strix cloud domains verify # prints the DNS record to add +``` + +The last four commands end at a person. Strix creates the link, opens the browser for an interactive terminal, and always prints the URL. The user enters the card, approves the installation, or adds the DNS record. Pass `--no-browser` to print the URL only. + +The commands work for humans and agents: terminal output favors names, branches, lifecycle states, and numbered selectors, while redirected output (or `--json`) preserves complete machine-readable records and IDs. Human lists retain the selectors needed by follow-up commands but omit internal organization/user IDs; a selector too long for the compact table is repeated losslessly in a copyable block. Paginated lists print the next `--page` or `--offset`, and detail views preserve useful prose within a safe terminal bound; use `--json` for the complete record. Token lists distinguish API keys from named CLI device sessions. Binary downloads are the exception: intentionally redirect their raw bytes, or use `--output FILE --json` to write the file and receive structured download metadata. There are no prompts when stdin is not a terminal. Exit codes: `0` success, `1` error, `2` invalid usage, `4` authentication or plan limit, `5` payment required. `--token` and `STRIX_API_TOKEN` are stateless per-command overrides and never replace the stored sign-in; pair a CLI-session override with `--workspace-id` or `STRIX_WORKSPACE_ID`. + +A browser sign-in creates one reusable credential per CLI installation. Logging in again on the +same installation replaces its secret instead of accumulating keys. Workspace switches keep that +credential and expiry, preserve the server-side scope preference, cap access by the target role, +and can never exceed the login consent ceiling. Each process pins its starting workspace, so a +concurrent switch fails safely instead of sending a stale command to another organization. +`strix cloud logout` revokes the server session before deleting the local token; use +`--local-only` only when you deliberately cannot reach the server. + +Write commands take request fields as flags, and every write command also accepts one JSON object with `--data`: + +```bash +strix cloud scans start --data '{"engagement_type":"code_review"}' # literal JSON +strix cloud scans start --data @request.json # read a file +cat request.json | strix cloud scans start --data - # read standard input +``` + +For an agent or CI local-source scan, run `--dry-run --show-files --json`, review the manifest, +and capture `source.archive_sha256`. Rerun with the same `--source`, every `--exclude`, and any +`--include-*` selection flags, replacing `--dry-run` with `--approve-sha256 HASH`; Strix +rebuilds the archive and refuses to upload it if the digest changed. `--yes` instead approves +only the snapshot built in that one invocation. It is suitable for a deliberate human or +one-shot approval, not as a digest-bound two-step agent/CI handoff. + +The safe default honors `.gitignore` and `.strixignore` and excludes hidden paths, secret-like +files, VCS metadata, dependencies/build output, symlinks, and nested archives. Opt in +separately with `--include-hidden`, `--include-sensitive`, or `--include-archives`. The client +caps a bundle at 20,000 files, 25 MiB per file, 250 MiB expanded, and 50 MiB compressed, and +the service independently validates the archive. Source alone infers a code review; adding a +domain infers a live test. You can always pass `--engagement-type` explicitly. + +Strix removes the temporary local archive after every invocation. It deletes a staged remote +upload after a definitive scan rejection. If a network error, `5xx` response, malformed +success response, or interruption makes the launch outcome ambiguous, it retains the upload and reports its `upload_id` with +`launch_outcome_unknown: true`; if automatic deletion cannot be confirmed, it reports the ID +with `cleanup_unknown: true`. Check `strix cloud scans list` before retrying. If no scan is +linked to the retained upload, delete it with `strix cloud uploads delete UPLOAD_ID`. + +Non-Enterprise scans consume the deterministic estimate shown for their scope (a source-only +code review at the default `ultra` tier currently starts at 60 credits). Enterprise scans are +plan-included and do not consume the credit wallet. Report downloads need Enterprise, +schedules need Pro, and billing writes need an admin token. Plan blocks exit `4`; an +insufficient credit wallet exits `5` without creating or charging a scan. + +Enable native tab completion once per shell session: + +```bash +source <(strix completions zsh) # use bash instead of zsh when appropriate +strix completions fish | source +``` + #### Connect your own MCP servers Strix can connect to Model Context Protocol (MCP) servers you list and expose their tools to the agent during a run. Create `~/.strix/mcp-servers.json` with a JSON list of servers. Each entry is either a local `stdio` server that Strix launches as a subprocess, or a remote `http` server: diff --git a/docs/cloud/overview.mdx b/docs/cloud/overview.mdx index 8b6e584d..1dc5c69b 100644 --- a/docs/cloud/overview.mdx +++ b/docs/cloud/overview.mdx @@ -35,6 +35,25 @@ Skip the setup. Run Strix in the cloud at [app.strix.ai](https://app.strix.ai). 2. Connect your repository or enter a target URL 3. Launch your first scan +## Scan Local Source + +Send a local working tree to the managed white-box scanner without connecting a source-control provider: + +```bash +# Review the exact file manifest and capture source.archive_sha256. Nothing is uploaded. +strix cloud scans start --source . --dry-run --show-files --json +SOURCE_SHA256="" + +# Repeat the same source-selection flags and approve that exact snapshot. +strix cloud scans start --source . --approve-sha256 "$SOURCE_SHA256" --wait +``` + +In a Git repository, Strix includes tracked files and untracked files that are not ignored. Hidden files, `.git`, symlinks, dependencies and build output, secret-like filenames, and nested archives are excluded by default. Use `.strixignore` or repeat `--exclude GLOB` for project-specific exclusions. `--include-hidden`, `--include-sensitive`, and `--include-archives` are explicit opt-ins. + +The CLI limits individual files, total expanded bytes, archive bytes, and file count. For an agent or CI handoff, repeat the same `--source`, `--exclude`, and `--include-*` flags with `--approve-sha256`; Strix refuses the upload if the rebuilt archive differs from the reviewed digest. `--yes` is a one-invocation approval for the snapshot built at that moment, not a digest-bound two-step approval. + +The temporary local archive is always removed. After a definitive launch rejection, Strix also deletes the staged remote upload. If a network error, server error, or interruption makes the launch outcome ambiguous, it retains the upload and reports its ID; check `strix cloud scans list` before retrying, then delete an unlinked upload with `strix cloud uploads delete UPLOAD_ID`. + Run your first pentest in minutes. diff --git a/docs/integrations/coding-agents.mdx b/docs/integrations/coding-agents.mdx index 59e598f1..80f24597 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. Create an API token under **Settings → API Access**; the `managed-pentesting-with-strix` skill has the full flow. +- **Managed cloud** — runs on Strix's infrastructure. Drive it with the `strix cloud` CLI (every REST operation has a `strix cloud ` command) or the [app.strix.ai REST API](https://docs.app.strix.ai) directly. No Docker, no LLM key; adds team dashboards, scheduling, PR reviews, and downloadable PDF/DOCX reports (Enterprise plan). Best in sandboxed/CI environments and for teams. Sign in with `strix cloud login` (browser device sign-in, account created on first use) or create a token in the dashboard under **Settings → API Access**. The `managed-pentesting-with-strix` skill has the full flow. ## Agent-Friendly Interfaces Everything an agent needs is machine-readable: - **Headless CLI** — `strix -n` runs without the TUI and exits with `0` (clean), `1` (error), or `2` (vulnerabilities found). +- **Cloud CLI** — `strix cloud` prints JSON when stdout is not a terminal (or with `--json`), never prompts without a TTY, and exits with `0` (success), `1` (error), `2` (usage), `4` (authentication required), or `5` (payment required). Credit top-ups pay the Stripe machine-payment challenge with an agent wallet (`strix cloud billing topup --credits N --yes`). Account setup also runs from the CLI: `strix cloud workspaces list|create|use`, `strix cloud org members invite`, `strix cloud billing subscribe`, `strix cloud billing portal`, and `strix cloud integrations install github`. The last three print a hosted link the user opens to finish the payment or approve the installation. - **REST API** — the managed platform exposes a documented [OpenAPI](https://docs.app.strix.ai/openapi.json) at `https://app.strix.ai/api/v1` (scans, vulnerabilities, assets, PR reviews, schedules, webhooks) with bearer tokens and scopes. - **Structured results** — every run writes `vulnerabilities.json`, `vulnerabilities.csv`, `findings.sarif` (SARIF 2.1.0), and per-finding Markdown under `strix_runs//`; 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 ab4127e7..326f7c8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -230,6 +230,7 @@ ignore = [ # Test doubles use fixture tokens/passwords and match a callee signature whose # args they intentionally ignore. "tests/test_viewer_auth.py" = ["S105", "S106", "ARG001"] +"tests/test_cloud_cli.py" = ["S105", "ARG001"] "tests/test_codex_auth.py" = ["S105", "S106", "SLF001"] # Hatchling loads the build hook by path, not as an importable package. "scripts/tui_sidecar_hook.py" = ["INP001"] diff --git a/skills/managed-pentesting-with-strix/SKILL.md b/skills/managed-pentesting-with-strix/SKILL.md index 08feeb36..14ff6f52 100644 --- a/skills/managed-pentesting-with-strix/SKILL.md +++ b/skills/managed-pentesting-with-strix/SKILL.md @@ -1,23 +1,59 @@ --- name: managed-pentesting-with-strix -description: Run a managed pentest of a web app or API through the app.strix.ai REST API — no local Docker, LLM key, or install needed. Create an API token, register domain/repository assets, launch and poll scans, triage vulnerabilities, export SARIF, download PDF/DOCX pentest reports for SOC 2 and other compliance evidence (Enterprise plan), start PR reviews, and set up schedules and webhooks. Use when the user wants continuous or scheduled pentesting-as-a-service, an auditor-ready pentest report, scans tracked in a team dashboard, or security testing from a sandboxed agent/CI environment with no infrastructure. +description: Run a managed pentest of a web app, API, repository, or local workspace on the app.strix.ai platform with the `strix cloud` CLI or REST API — no local Docker or LLM key needed. Safely review and upload local source, register assets, launch and poll scans, triage vulnerabilities, export SARIF, download compliance reports, start PR reviews, buy credits, and set up schedules or webhooks. Use for managed, continuous, scheduled, team-tracked, or sandboxed-agent security testing. license: Apache-2.0 metadata: author: usestrix homepage: https://docs.app.strix.ai --- -# Strix Cloud API (managed, no local infra) +# Strix Cloud (managed, no local infra) Use this when you want Strix's autonomous pentesting **without running Docker or an LLM yourself** — the scan runs on Strix's infrastructure and results are tracked in a team dashboard. This is the right choice in sandboxed/hosted agent and CI environments, for teams, and for scheduled/continuous testing (downloadable PDF/DOCX reports are an Enterprise-plan feature). For fully local, free, air-gapped, or BYO-LLM runs, use the open-source CLI in the **penetration-testing-with-strix** skill instead — both share the same engine and SARIF output, so you can mix them. -Full reference: **[docs.app.strix.ai](https://docs.app.strix.ai)** · OpenAPI: `https://docs.app.strix.ai/openapi.json` +There are two equivalent interfaces. Prefer the CLI: -## Setup +- **`strix cloud` CLI** — every REST operation has a command in the form `strix cloud `. Install with `curl -sSL https://strix.ai/install | bash`. Run `strix cloud` to list all resources and `strix cloud help` (or `-h`) to list a resource's verbs; a bare resource with a safe read operation runs its documented default. +- **REST API** — base URL `https://app.strix.ai/api/v1`, `Authorization: Bearer ` on every request. Full reference: **[docs.app.strix.ai](https://docs.app.strix.ai)** · agent index: `https://docs.app.strix.ai/llms.txt` · OpenAPI: `https://docs.app.strix.ai/openapi.json`. -- **Base URL:** `https://app.strix.ai/api/v1` -- **Auth:** every request sends `Authorization: Bearer `. Tokens are **org-scoped**. -- **Get a token:** the user creates one in the dashboard at **Settings → API Access** (app.strix.ai). Ask them for it; never hardcode, log, or commit it. Store it in an env var or the CI secret store. +The CLI is equally usable by agents and people. Output is complete JSON when stdout is not a terminal, or when you pass `--json`; terminal tables favor names, branches, lifecycle states, and numbered selectors. Human lists retain the selectors needed by follow-up commands but omit internal organization/user IDs; a selector too long for the compact table is repeated losslessly in a copyable block. Paginated lists print the next `--page` or `--offset`, and detail views preserve useful prose within a safe terminal bound; use `--json` for the complete record. Token lists label credentials as active, expired, or revoked. Binary downloads are the exception: redirect raw bytes intentionally, or use `--output FILE --json` to write the file and receive structured metadata. There are no interactive prompts when stdin is not a terminal. Exit codes: `0` success, `1` request/runtime error, `2` invalid usage, `4` authentication or plan limit, `5` payment required. + +Every resource group with a safe read operation has a useful default action, and `-h` or `help` always shows its verbs. Native tab completion includes resources, verbs, flags, workspace commands, and local paths: + +```bash +source <(strix completions zsh) # current zsh session +source <(strix completions bash) # current bash session +strix completions fish | source # current fish session +``` + +Write commands take request fields as flags. Every write command also accepts one JSON object with `--data`, which is the way to send fields that have no flag: + +```bash +strix cloud scans start --data '{"engagement_type":"code_review"}' # literal JSON +strix cloud scans start --data @request.json # read a file +cat request.json | strix cloud scans start --data - # read standard input +``` + +The platform enforces plan and role limits, and the CLI passes the platform message through. Report downloads need the Enterprise plan. Schedules need the Pro plan. Billing writes need an admin token. A blocked command exits with code `4`. + +## Setup: sign in + +Run the device sign-in. It creates the user's account and workspace on first use and stores a personal API token in `~/.strix/platform-auth.json`: + +```bash +strix cloud login +# Non-interactive least-privilege example: +strix cloud login --scopes scans:read scans:write uploads:write billing:read vulnerabilities:read assets:read assets:write +# Or use a stable named profile: +strix cloud login --scope-profile recommended +``` + +The user approves the sign-in in the browser. With `--scopes` (and optionally `--workspace `) there are no terminal prompts, so the command works from a non-interactive agent shell. In an interactive terminal without flags, the CLI offers a workspace picker and scope presets (Recommended, Full access, Minimal, Custom). Recommended covers ordinary scans, source uploads, workspace switching, and user-approved credit top-ups; it excludes `tokens:write`, which must be requested explicitly when credential management is required. Use explicit scopes for a narrower automation token. + +- `strix cloud whoami` is the fast local status. `strix cloud session --json` verifies the remote device session; `strix cloud session scopes` shows both effective access and the immutable login ceiling. +- `strix cloud logout` revokes the remote session before removing the local token. On a network or server failure it keeps the token so the user can retry; `--local-only` deliberately skips revocation. +- Every other `strix cloud` command uses the stored token automatically. `--token ` or `STRIX_API_TOKEN` is a stateless per-command override and never overwrites the stored account. For an override that is itself a CLI session, also pass `--workspace-id` or set `STRIX_WORKSPACE_ID`. +- Never hardcode, log, or commit the token. Store it in an env var or the CI secret store. - **Scopes (least-privilege):** assign only what the integration needs and rotate regularly: | Scope | Grants | @@ -28,15 +64,105 @@ Full reference: **[docs.app.strix.ai](https://docs.app.strix.ai)** · OpenAPI: ` | `schedules:read` / `:write` | read schedules · create/trigger recurring scans | | `pr_reviews:write` | trigger PR security reviews | | `webhooks:read` / `:write` | manage webhook subscriptions | - | `tokens:write` | create/revoke API tokens | + | `uploads:write` | upload local source or documents for a scan | + | `organizations:read` | read organization details (listing/switching the signed-in user's workspaces needs no API scope) | + | `organizations:write` | create/update workspaces (admin) | + | `tokens:write` | create/revoke ordinary API tokens (not needed to manage the current CLI session) | + | `knowledge:read` / `:write` | read/update organization knowledge | + | `audit:read` | read/export the Enterprise audit log | + | `billing:read` / `billing:write` | read credit balance & auto top-up settings · buy credits (admin) | + +HTTP errors map to messages and exit codes: `401` bad/expired token (exit `4`), `402` out of credits (exit `5`), `403` scope/plan-tier limit (exit `4`), `422` validation error (exit `1`). + +Create a time-limited automation token with `strix cloud tokens create`. Use +`--rbac-scopes` to restrict it to target IDs, tags, or business units; the value is a +JSON array of `{ "type": "target|tag|business_unit", "value": "..." }` objects: ```bash -export STRIX_API_TOKEN="" -BASE=https://app.strix.ai/api/v1 -auth=(-H "Authorization: Bearer $STRIX_API_TOKEN") +strix cloud tokens create --type service --name staging-ci \ + --expires-at 2026-12-31T23:59:59Z \ + --scopes scans:read scans:write \ + --rbac-scopes '[{"type":"tag","value":"staging"}]' ``` -All examples use `jq` to parse JSON. Handle HTTP errors: `401` bad/expired token, `402` out of credits, `403` scope/plan-tier limit, `422` validation error. +The token secret is returned once. Store it directly in a secret manager and do not +print or commit it. `--expires-at` and `--expires-in-days` are mutually exclusive. + +## 0. Credits & top-ups + +Non-Enterprise scans consume org credits. Enterprise engagements are plan-included and do not debit the wallet. Check the balance before a scan (`billing:read`): + +```bash +strix cloud credits +``` + +When the balance is too low, buy credits with `strix cloud billing topup` (`billing:write`, admin token). The server answers the first request with **HTTP 402 and a machine-payment challenge** (Stripe Machine Payments Protocol). The CLI pays the challenge with the `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. + +A default-tier source-only code review currently starts at 60 credits. Source uploads are not free: they launch an ordinary `code_review` and use the same deterministic scope estimator. The service checks the full balance before launch, reserves credits atomically only after validation succeeds, and does not create or charge a rejected scan. Retests and Enterprise scans are exempt. + +```bash +strix cloud billing topup --credits 20 --yes # explicit approval; skips the TTY prompt +strix cloud billing topup --credits 20 --no-pay # print the 402 challenge without paying +``` + +The default payment path is the Stripe 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. + +In a non-interactive agent or CI process, payment never proceeds unless the command includes `--yes`. Show the challenge or estimated spend to the user and obtain approval before adding it. `--no-pay` always stops after printing the challenge. + +If the user does not want a wallet, create a hosted checkout link with `strix cloud billing subscribe --plan strix_top_up` and give the link to the user. The user pays in the browser. + +Automatic top-ups (admin): `strix cloud billing auto-topup` shows the setting. Enable it with: + +```bash +strix cloud billing auto-topup update --enabled --topup-credits 20 --monthly-cap-credits 200 +``` + +An omitted `--monthly-cap-credits` keeps the stored cap. Pass `--no-monthly-cap` to remove the cap. + +### Workspaces and account setup + +Manage workspaces with a personal token from `strix cloud login`: + +```bash +strix cloud workspaces list # numbered name/role/current list +strix cloud workspaces create --name "My Team" # admin + organizations:write +strix cloud workspaces use 2 # displayed number, exact name, or ID +strix cloud workspace use "My Team" # singular `workspace` alias also works +strix cloud session scopes # effective scopes + consent ceiling +strix cloud session scopes set minimal # narrow the session +strix cloud org members invite --email dev@example.com --role analyst +``` + +`workspaces use` retargets the current personal token to a workspace the user already belongs to and stores the updated workspace metadata; the bearer secret and expiry stay unchanged. It does not reprompt during ordinary switches: the server preserves the chosen profile, enforces the immutable login ceiling, and caps effective scopes by the target role. Use `--scope-profile` or `--scopes` to narrow within that ceiling; broader consent requires `strix cloud login` again. The CLI pins each process to the workspace it started in, so concurrent shells fail with a recoverable conflict instead of silently crossing organizations. + +### Handoffs a person must finish + +Four steps end at the user. The command creates the link or the record and prints it. Strix opens the browser only in an interactive terminal. Pass `--no-browser` to print the URL only. + +```bash +strix cloud billing subscribe --plan strix_cloud # hosted checkout page for the Cloud plan +strix cloud billing portal # billing portal for the card and the plan +strix cloud integrations install github # GitHub App or Slack installation page +strix cloud domains verify # DNS record to add, then run it again +``` + +Give the printed URL or DNS record to the user and wait. Do not claim that the payment, the installation, or the DNS change is complete. Confirm the result afterwards with `strix cloud credits`, `strix cloud integrations list`, or `strix cloud domains list`. All four commands need an admin token, except `domains verify`, which needs `assets:write`. + +### Organization knowledge + +Agents can manage the organization knowledge base without the dashboard (`knowledge:read` / `knowledge:write`): + +```bash +strix cloud knowledge list --search authentication +strix cloud knowledge add --title "Authentication" --content "Staging uses SSO." +strix cloud knowledge update --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 @@ -44,109 +170,152 @@ Scans run against **registered assets**, not raw URLs. Register once, then reuse ```bash # Domain (black-box / live target). Requires domain verification before external scanning. -# asset_type must be one of: web_app | api | attack_surface. -curl -sS "$BASE/domains" "${auth[@]}" -H "Content-Type: application/json" \ - -d '{"domain":"staging.example.com","asset_type":"web_app"}' | jq '{id:.domain.id, status, reachable, verification}' +# --asset-type must be one of: web_app | api | attack_surface. +strix cloud domains add --domain staging.example.com --asset-type web_app # Repository (white-box / code review). `full_name` is "owner/name". -# Send one repository object, or a bare JSON array for several — not an object -# wrapping a "repositories" key (that is rejected with 400). -curl -sS "$BASE/repositories" "${auth[@]}" -H "Content-Type: application/json" \ - -d '[{"full_name":"org/app","provider":"github"}]' | jq '.repositories[] | {id, full_name}' +strix cloud repos add --data '{"full_name":"org/app","provider":"github"}' ``` -Look up existing assets instead of re-adding: `GET /domains`, `GET /repositories` (both `assets:read`, paginated with `?page=&limit=`). +Look up existing assets instead of re-adding: `strix cloud domains list`, `strix cloud repos list` (both `assets:read`). ## 2. Launch a scan -`POST /scans` (`scans:write`). Provide at least one target via `domain_ids`, `repository_ids`, or `internal_targets` (internal infra needs a network connector — see docs). +`strix cloud scans start` (`scans:write`). Provide at least one target with `--domain-ids`, `--repository-ids`, or `--internal-targets` (internal infra needs a network connector — see docs). ```bash -scan_id=$(curl -sS "$BASE/scans" "${auth[@]}" -H "Content-Type: application/json" -d '{ - "engagement_type": "live_test", - "domain_ids": [""], - "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 target HTTP headers as a JSON array of header objects | +| `--focus` / `--concerns` / `--context` | free-form strings that steer the agents | +| `--upload-ids` | attach uploaded source/docs archives for white-box context | +| `--notify-on-completion` / `--notification-emails` | email when done | -Response is `{ scan_id, title, status }` with `status` = `pending`. +Without `--source`, the response is `{ scan_id, title, status }` with `status` = `pending`. +Local-source success wraps that platform response as +`{ source, upload_id, scan: { scan_id, title, status } }`, so automation can retain the exact +approved manifest and staged-upload identifier alongside the created scan. -## 3. Poll to completion +### Scan a local workspace in the cloud -`GET /scans/{scanId}` (`scans:read`). Status flow: `pending → running → completed` (or `failed` / `cancelled`). Poll on an interval — scans take minutes to hours. Do not block. +For an agent or CI workflow, bind approval to the exact source snapshot that was reviewed. Run +the dry run with the intended source-selection flags, review the manifest and selected paths, +and capture `source.archive_sha256`. Then repeat the same `--source`, every `--exclude`, and +any `--include-hidden`, `--include-sensitive`, or `--include-archives` flags with +`--approve-sha256`: ```bash -while :; do - s=$(curl -sS "$BASE/scans/$scan_id" "${auth[@]}" | jq -r .status) - echo "status=$s"; [[ "$s" =~ ^(completed|failed|cancelled)$ ]] && break - sleep 60 -done +strix cloud scans start --source . --exclude 'private/' --dry-run --show-files --json +# After reviewing the output, capture its source.archive_sha256 value: +SOURCE_SHA256="" +# Repeat every source-selection flag unchanged; a source-only scan infers code_review. +strix cloud scans start --source . --exclude 'private/' \ + --approve-sha256 "$SOURCE_SHA256" --wait ``` +The CLI rebuilds the archive and refuses the upload if its SHA-256 no longer matches. `--yes` +has deliberately narrower semantics: it approves only the snapshot built during that one +invocation. Use it for a deliberate human or one-shot approval, not as the second half of a +digest-bound agent/CI review. Without a TTY, a source upload requires either matching +`--approve-sha256` approval or `--yes`; an interactive terminal can instead show the summary, +the selected filenames when `--show-files` is set, and a `[y/N]` confirmation for its current +snapshot. + +The default selection is privacy-conscious: in a Git worktree it includes tracked files plus untracked files that are not ignored; it honors `.gitignore`, excludes every hidden path component, always excludes `.git`, symlinks, dependencies/build output, secret-like filenames, and nested archives. Add project exclusions to `.strixignore` (one exclude glob per line) or repeat `--exclude GLOB`; a trailing slash such as `private/` excludes that directory subtree. + +The client refuses more than 20,000 files, a file over 25 MiB, more than 250 MiB expanded, or a ZIP over 50 MiB. The service then stream-inflates the ZIP and independently rejects malformed or unsupported entries, unsafe paths, too many entries, oversized entries, excessive expanded data, and oversized compressed input, so an untrusted client cannot bypass the ZIP-bomb controls by forging metadata. + +Only use `--include-hidden`, `--include-sensitive`, or `--include-archives` after the dry-run manifest shows that the scan needs them. Hidden and sensitive files are separate opt-ins: for example, including `.env` requires both `--include-hidden` and `--include-sensitive`. + +The CLI removes its private temporary local archive after every invocation. Once a remote +upload is staged, a definitive scan rejection causes the CLI to delete it. A network failure, +`5xx` response, malformed success response, or interruption after scan launch begins is +ambiguous—the platform may have accepted the scan—so the CLI retains the upload and returns +its `upload_id` with `launch_outcome_unknown: true`. If an automatic deletion attempt cannot +be confirmed, it instead returns the retained `upload_id` with `cleanup_unknown: true`. +Before retrying, run `strix cloud scans list` to avoid a duplicate scan or charge. If no scan +is linked to the retained upload, remove it with `strix cloud uploads delete UPLOAD_ID`; +linked uploads cannot be deleted. + +With no explicit type, source alone infers `code_review`. Any domain target wins and infers `live_test`, so source plus a deployed domain is the normal white-box live-test workflow. Pass `--engagement-type` when you need to override the inference. + +## 3. Wait for completion + +Pass `--wait` to `scans start` to poll until the scan reaches a final state, or poll yourself with `strix cloud scans get ` (`scans:read`). Bound automation with `--wait-timeout SECONDS`; timeout exits cleanly without cancelling the remote scan. Status flow: `pending → running → completed` (or `failed` / `cancelled`). Scans take minutes to hours — poll on an interval, do not block indefinitely. + ## 4. Read findings The scan-detail response includes `executive_summary`, `methodology`, `recommendations`, a `findings` severity roll-up, and a `vulnerabilities[]` array. Each vulnerability carries `title, severity, status, cvss, cwe, endpoint, method, impact, technical_analysis, poc_description, poc_script_code`, and (for code findings) `code_file`/`code_diff`/`code_before`/`code_after`. ```bash -curl -sS "$BASE/scans/$scan_id" "${auth[@]}" \ +strix cloud scans get --json \ | jq '["critical","high","medium","low","info"] as $order | .vulnerabilities | sort_by(.severity as $s | $order | index($s)) | .[] | {title, severity, endpoint, cwe}' ``` -Cloud severities are `critical | high | medium | low` and statuses are `open | in_progress | fixed | ignored`. Sort by an explicit severity order rather than `sort_by(.severity)`, which sorts alphabetically (critical, high, low, medium). +Cloud severities are `critical | high | medium | low` and statuses are `open | in_progress | snoozed | fixed | ignored | not_affected`. Sort by an explicit severity order rather than `sort_by(.severity)`, which sorts alphabetically (critical, high, low, medium). -Org-wide triage across scans: `GET /vulnerabilities` (`vulnerabilities:read`; filter by severity/status). Update triage state with the vulnerabilities `:write` endpoints. To remediate, hand off to the **fix-security-vulnerabilities-with-strix** skill. +Org-wide triage across scans: `strix cloud vulns list --severity critical` (`vulnerabilities:read`, and it also filters by `--status`, `--scan-id`, and more). Update triage state with `strix cloud vulns update --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 +``` + +Downloads refuse to replace a file unless `--force` is explicit. Enterprise audit logs can be streamed as JSON or exported without trying to JSON-decode the body: + +```bash +strix cloud audit list --format csv --all --output audit.csv +strix cloud audit list --format ndjson --all --output audit.ndjson ``` ## 6. PR reviews -Trigger an automated security review of a pull request (`pr_reviews:write`); results appear as PR comments and in the dashboard: +Trigger an automated security review of a pull request (`pr_reviews:write`). Read the repository's `provider` and `installation_id` with `strix cloud repos list`; both identify the installed source-control integration. The results appear as PR comments and in the dashboard: ```bash -curl -sS "$BASE/pr-reviews/start" "${auth[@]}" -H "Content-Type: application/json" \ - -d '{"repository_full_name":"org/app","pr_number":123}' +strix cloud pr-reviews start \ + --provider github \ + --installation-id \ + --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. +Network connectors are Enterprise-only. `strix cloud connectors create` may return a one-time enrollment command containing credentials; do not paste it into logs, and request it with `--include-command` only when the user is ready to install it. Browser checkout, source-control installation, DNS verification, connector installation, chat sharing, and publishing SARIF to an external provider are user handoffs or explicit external mutations—prepare the command/link, then obtain the appropriate approval before completing them. + ## Safety Only scan assets the user's organization owns or is authorized to test. External domain scans require verification (DNS/file/meta-tag) enforced by the platform — do not try to bypass it. diff --git a/strix/interface/cloud/__init__.py b/strix/interface/cloud/__init__.py new file mode 100644 index 00000000..37250e9e --- /dev/null +++ b/strix/interface/cloud/__init__.py @@ -0,0 +1,169 @@ +"""`strix cloud` — the managed Strix platform (app.strix.ai) from the terminal. + +Every command maps to one operation of the public REST API. Output is JSON +when stdout is not a terminal, so agents can parse every result. Exit codes: +0 success, 1 error, 2 invalid usage, 4 authentication required, 5 payment +required. +""" + +from __future__ import annotations + +import json +import sys + +from rich.console import Console +from rich.markup import escape + +import strix.interface.cloud.http as http # noqa: PLR0402 +from strix.interface.cloud.render import json_mode +from strix.interface.cloud.runner import resolve, run +from strix.interface.cloud.session import run_session +from strix.interface.cloud.spec import DEFAULT_VERBS, GROUP_HELP, SPEC +from strix.interface.cloud.workspaces import run_workspace_use +from strix.interface.platform_cli import run_login +from strix.interface.terminal_text import sanitize_terminal_text + + +_USAGE_HEADER = """[bold]Usage:[/] strix cloud [arguments] + +[bold]Session commands:[/] + login Sign in to the managed platform and store an API token + logout Remove the stored API token + whoami Show the stored account, workspace, and token state + session Inspect or narrow the remote CLI session + credits Show the credit balance of the workspace + +[bold]Resource commands:[/]""" + +_USAGE_FOOTER = """ +Run [bold]strix cloud help[/] to list its verbs. Common read-only +commands may also run their default verb when no verb is given. +Every REST resource command accepts [bold]--json[/] and [bold]--token[/]. Write +commands accept [bold]--data[/] with a JSON object of extra request fields. +Login is an interactive device flow; [bold]whoami[/] and [bold]logout[/] also +produce JSON automatically when output is redirected. +API reference: https://docs.app.strix.ai""" + +_HELP_TOKENS = frozenset({"-h", "--help", "help"}) + + +def _is_help_request(argv: list[str]) -> bool: + """Recognize a help token with an optional JSON-output flag in either order.""" + return sum(argument in _HELP_TOKENS for argument in argv) == 1 and all( + argument in _HELP_TOKENS or argument == "--json" for argument in argv + ) + + +def run_cloud(argv: list[str]) -> int: + """Run a managed-cloud command without ever leaking a Ctrl-C traceback.""" + try: + return _run_cloud(argv) + except KeyboardInterrupt: + if json_mode(flag="--json" in argv): + sys.stdout.write(json.dumps({"error": "Interrupted.", "interrupted": True}) + "\n") + else: + Console(stderr=True).print("[yellow]Interrupted.[/]") + return 130 + + +def _run_cloud(argv: list[str]) -> int: # noqa: PLR0911, PLR0912 + """Entry point for ``strix cloud …``. Returns a process exit code.""" + console = Console() + as_json = json_mode(flag="--json" in argv) + if not argv or _is_help_request(argv): + if as_json: + _print_usage_json() + else: + _print_usage(console) + return 0 + if argv == ["--json"]: + _print_usage_json() + return 0 + + group, rest = argv[0], argv[1:] + if group == "workspace": + group = "workspaces" + if group in ("login", "logout", "whoami"): + return _run_session(console, group, rest) + if group == "session": + return run_session(rest) + if group == "credits": + group, rest = "billing", ["credits", *rest] + if group == "workspaces" and rest and rest[0] == "use": + try: + return run_workspace_use(rest[1:]) + except http.CloudError as exc: + if "--json" in rest: + sys.stdout.write(json.dumps({"error": str(exc)}) + "\n") + else: + console.print(f"[red]Error:[/] {escape(sanitize_terminal_text(exc))}") + return exc.exit_code + + if group not in SPEC: + if as_json: + sys.stdout.write(json.dumps({"error": f"unknown command: {group}"}) + "\n") + return 2 + console.print(f"[red]Unknown command:[/] {escape(sanitize_terminal_text(group))}") + _print_usage(console) + return 2 + group_help = _is_help_request(rest) + resolved = None if group_help else resolve(group, rest) + if resolved is None: + help_tokens: set[str] = set(_HELP_TOKENS) if group_help else set() + invalid = [arg for arg in rest if arg != "--json" and arg not in help_tokens] + _print_verbs(console, group, as_json=as_json, error="unknown verb" if invalid else None) + return 2 if invalid else 0 + cmd, remaining = resolved + verb_label = " ".join(rest[: len(rest) - len(remaining)]) or DEFAULT_VERBS.get(group, "") + return run(group, verb_label, cmd, remaining) + + +def _run_session(_console: Console, group: str, rest: list[str]) -> int: + if rest and rest[0] == "help": + rest = ["--help", *rest[1:]] + session_argv = { + "login": rest, + "logout": ["logout", *rest], + "whoami": ["status", *rest], + } + return run_login(session_argv[group]) + + +def _print_usage(console: Console) -> None: + console.print(_USAGE_HEADER) + for group in SPEC: + console.print(f" {group:<14}{GROUP_HELP.get(group, '')}") + console.print(_USAGE_FOOTER) + + +def _print_verbs( + console: Console, group: str, *, as_json: bool = False, error: str | None = None +) -> None: + if as_json: + verbs: list[dict[str, str]] = [ + {"name": verb, "help": command.help} for verb, command in SPEC[group].items() + ] + if group == "workspaces": + verbs.append({"name": "use", "help": "Switch the stored token to another workspace."}) + payload: dict[str, object] = { + "command": f"strix cloud {group}", + "verbs": verbs, + } + if error: + payload["error"] = error + sys.stdout.write(json.dumps(payload, indent=2) + "\n") + return + console.print(f"[bold]strix cloud {group}[/] verbs:") + for verb, cmd in SPEC[group].items(): + console.print(f" {verb:<28}{cmd.help}") + if group == "workspaces": + console.print(f" {'use':<28}Switch the stored token to another workspace.") + + +def _print_usage_json() -> None: + payload = { + "command": "strix cloud", + "session_commands": ["login", "logout", "whoami", "session", "credits"], + "resource_commands": [{"name": group, "help": GROUP_HELP.get(group, "")} for group in SPEC], + } + sys.stdout.write(json.dumps(payload, indent=2) + "\n") diff --git a/strix/interface/cloud/arguments.py b/strix/interface/cloud/arguments.py new file mode 100644 index 00000000..d1924572 --- /dev/null +++ b/strix/interface/cloud/arguments.py @@ -0,0 +1,18 @@ +"""Argument parsing that reports managed-cloud usage errors through one contract.""" + +from __future__ import annotations + +import argparse +from typing import NoReturn + +import strix.interface.cloud.http as http # noqa: PLR0402 + + +class CloudArgumentParser(argparse.ArgumentParser): + """Raise a typed usage error instead of printing argparse prose and exiting.""" + + def error(self, message: str) -> NoReturn: + raise http.CloudError( + f"invalid arguments for {self.prog}: {message}", + exit_code=http.EXIT_USAGE, + ) diff --git a/strix/interface/cloud/billing.py b/strix/interface/cloud/billing.py new file mode 100644 index 00000000..a3392d5a --- /dev/null +++ b/strix/interface/cloud/billing.py @@ -0,0 +1,374 @@ +"""Billing top-up and agent-wallet execution for ``strix cloud``.""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any, cast + +import strix.interface.cloud.http as http # noqa: PLR0402 +from strix.interface.cloud.payment_proxy import WalletUpstreamResponse, wallet_payment_bridge +from strix.interface.cloud.render import emit +from strix.interface.terminal_text import sanitize_terminal_text + + +if TYPE_CHECKING: + import argparse + + from rich.console import Console + + +_MAX_WALLET_DETAIL_CHARS = 2_000 +# Keep the wallet client on the exact protocol implementation used by the +# platform. This version is also old enough to remain installable in npm +# environments that apply a short package-publication safety window. +_MPPX_PACKAGE = "mppx@0.8.17" +_NPM_REGISTRY = "https://registry.npmjs.org" +_WALLET_ENV_NAMES = frozenset( + { + "ALL_PROXY", + "APPDATA", + "COLORTERM", + "COMSPEC", + "FORCE_COLOR", + "HOME", + "HTTPS_PROXY", + "HTTP_PROXY", + "LANG", + "LC_ALL", + "LC_CTYPE", + "LOCALAPPDATA", + "NO_COLOR", + "NO_PROXY", + "PATH", + "PATHEXT", + "SSL_CERT_DIR", + "SSL_CERT_FILE", + "SYSTEMROOT", + "TEMP", + "TERM", + "TMP", + "TMPDIR", + "USERPROFILE", + "XDG_CONFIG_HOME", + "all_proxy", + "http_proxy", + "https_proxy", + "no_proxy", + } +) +_AUTHORIZATION_SECRET = re.compile(r"(?i)((?:bearer|payment)\s+)[^\s\"']+") +_LOOPBACK_NO_PROXY = ("127.0.0.1", "localhost", "::1") + + +@dataclass(frozen=True) +class _WalletClientResult: + process: subprocess.CompletedProcess[str] + upstream_responses: tuple[WalletUpstreamResponse, ...] + + +def run_topup( # noqa: PLR0911, PLR0912 + console: Console, + args: argparse.Namespace, + body: dict[str, Any], + *, + as_json: bool, + token: str | None, +) -> int: + """Handle the HTTP 402 challenge and optional agent-wallet payment.""" + response = http.request("POST", "/billing/topup", token=token, body=body) + if response.status_code != 402: + emit(console, http.check(response), as_json=as_json) + return http.EXIT_OK + + challenge = http.parsed(response) + if getattr(args, "no_pay", False): + emit( + console, + {"error": "Payment required", "challenge": challenge}, + as_json=as_json, + ) + return http.EXIT_PAYMENT + + credit_count = body.get("credits") + if not getattr(args, "yes", False): + if as_json or not (sys.stdin.isatty() and sys.stdout.isatty()): + emit( + console, + { + "error": ( + "Payment requires explicit approval in non-interactive mode. " + "Review the challenge, then re-run with --yes to authorize payment." + ), + "challenge": challenge, + }, + as_json=as_json, + ) + return http.EXIT_PAYMENT + answer = console.input(f"Buy {credit_count} credit(s) now? [y/N]: ").strip().lower() + if answer not in ("y", "yes"): + console.print("[yellow]Payment cancelled.[/]") + return http.EXIT_PAYMENT + + npx = shutil.which("npx") + if npx is None: + message = ( + "Payment requires a wallet client. Install Node.js and run the command again, " + "or pay the challenge with an MPP wallet client." + ) + if as_json: + emit( + console, + {"error": message, "challenge": challenge}, + as_json=True, + ) + else: + emit(console, challenge, as_json=False) + console.print(f"[yellow]Payment required.[/] {message}") + return http.EXIT_PAYMENT + + payment_method = getattr(args, "payment_method", None) or os.environ.get( + "MPPX_STRIPE_PAYMENT_METHOD" + ) + if not payment_method and ( + not as_json + and not os.environ.get("MPPX_ACCOUNT") + and not os.environ.get("MPPX_STRIPE_SECRET_KEY") + ): + console.print( + "[dim]Tip: payments need a wallet. Set up a Stripe agent wallet at " + "https://link.com/agents, and the user approves each payment in the Link app. " + "If the user does not want a wallet, run " + "`strix cloud billing subscribe --plan strix_top_up` for a hosted checkout link.[/]" + ) + try: + wallet_result = _run_wallet_client( + npx, + args, + body, + token=token, + payment_method=payment_method, + capture_output=as_json, + ) + except KeyboardInterrupt: + emit( + console, + { + "error": ( + "Payment was interrupted after the wallet started. The outcome is unknown; " + "run `strix cloud billing credits` and check the balance before retrying." + ), + "interrupted": True, + "payment_outcome_unknown": True, + }, + as_json=as_json, + ) + return 130 + except OSError: + emit( + console, + { + "error": "Could not start the wallet client securely.", + "challenge": challenge, + }, + as_json=as_json, + ) + return http.EXIT_PAYMENT + + result = wallet_result.process + confirmed_receipt = _confirmed_topup_receipt(wallet_result.upstream_responses) + if confirmed_receipt is not None: + if as_json: + emit(console, confirmed_receipt, as_json=True) + return http.EXIT_OK + + if not as_json: + console.print( + "[yellow]The wallet exited without a confirmed receipt. The payment outcome is " + "unknown; run `strix cloud billing credits` before retrying.[/]" + ) + return http.EXIT_PAYMENT + + stdout = str(getattr(result, "stdout", "") or "").strip() + stderr = str(getattr(result, "stderr", "") or "").strip() + if result.returncode == 0: + try: + receipt = json.loads(stdout) + except (TypeError, ValueError): + emit( + console, + { + "error": ( + "The wallet reported success but did not return JSON. Check the credit " + "balance before retrying payment." + ), + "detail": _wallet_detail(stdout or stderr or "No wallet output was returned."), + "payment_outcome_unknown": True, + }, + as_json=True, + ) + return http.EXIT_PAYMENT + if not _valid_topup_receipt(receipt): + emit( + console, + { + "error": ( + "The wallet returned an invalid top-up receipt. Check the credit balance " + "before retrying payment." + ), + "detail": _wallet_detail(stdout), + "payment_outcome_unknown": True, + }, + as_json=True, + ) + return http.EXIT_PAYMENT + emit( + console, + { + "error": ( + "The wallet returned a receipt, but the Strix billing endpoint did not " + "confirm it. Check the credit balance before retrying payment." + ), + "detail": _wallet_detail(stdout), + "payment_outcome_unknown": True, + }, + as_json=True, + ) + return http.EXIT_PAYMENT + + emit( + console, + { + "error": ( + "The wallet exited without a confirmed receipt. The payment outcome is unknown; " + "run `strix cloud billing credits` and check the balance before retrying." + ), + "detail": _wallet_detail( + stderr or stdout or f"Wallet client exited with status {result.returncode}." + ), + "wallet_exit_code": result.returncode, + "payment_outcome_unknown": True, + }, + as_json=True, + ) + return http.EXIT_PAYMENT + + +def _run_wallet_client( + npx: str, + args: argparse.Namespace, + body: dict[str, Any], + *, + token: str | None, + payment_method: str | None, + capture_output: bool, +) -> _WalletClientResult: + """Run mppx through the loopback bridge without exposing the API token to it.""" + upstream_url = f"{http.app_url()}/api/v1/billing/topup" + body_json = json.dumps(body) + wallet_env = _wallet_environment() + upstream_responses: list[WalletUpstreamResponse] = [] + with tempfile.TemporaryDirectory(prefix="strix-wallet-") as wallet_cwd: + wallet_root = Path(wallet_cwd) + user_config = wallet_root / "user.npmrc" + global_config = wallet_root / "global.npmrc" + user_config.touch(mode=0o600) + global_config.touch(mode=0o600) + with wallet_payment_bridge( + upstream_url=upstream_url, + api_token=http.api_token(token), + expected_body=body_json.encode(), + timeout=getattr(args, "timeout", None), + response_observer=upstream_responses.append, + ) as wallet_url: + command = [ + npx, + "--yes", + f"--registry={_NPM_REGISTRY}", + "--ignore-scripts", + f"--userconfig={user_config}", + f"--globalconfig={global_config}", + f"--cache={wallet_root / 'npm-cache'}", + _MPPX_PACKAGE, + wallet_url, + "--fail", + "-J", + body_json, + ] + if payment_method: + command += ["-M", f"paymentMethod={payment_method}"] + process = subprocess.run( # noqa: S603 + command, + check=False, + capture_output=capture_output, + text=True, + env=wallet_env, + cwd=wallet_root, + ) + return _WalletClientResult(process=process, upstream_responses=tuple(upstream_responses)) + + +def _wallet_environment() -> dict[str, str]: + """Pass only platform essentials and explicit wallet variables to npm/mppx.""" + environment = { + name: value + for name, value in os.environ.items() + if name in _WALLET_ENV_NAMES or name.startswith("MPPX_") + } + for name in ("NO_PROXY", "no_proxy"): + entries = [entry.strip() for entry in environment.get(name, "").split(",") if entry.strip()] + normalized = {entry.lower().strip("[]") for entry in entries} + entries.extend(host for host in _LOOPBACK_NO_PROXY if host not in normalized) + environment[name] = ",".join(entries) + return environment + + +def _wallet_detail(value: str) -> str: + """Bound and redact third-party wallet diagnostics before returning JSON.""" + redacted = _AUTHORIZATION_SECRET.sub(r"\1[redacted]", sanitize_terminal_text(value)) + if len(redacted) <= _MAX_WALLET_DETAIL_CHARS: + return redacted + return redacted[: _MAX_WALLET_DETAIL_CHARS - 1] + "…" + + +def _valid_topup_receipt(value: Any) -> bool: + """Require the documented success shape before reporting a paid top-up.""" + if not isinstance(value, dict): + return False + fields = cast("dict[str, Any]", value) + credits_granted = fields.get("credits_granted") + balance = fields.get("balance") + return ( + isinstance(credits_granted, int) + and not isinstance(credits_granted, bool) + and credits_granted >= 0 + and isinstance(fields.get("duplicate"), bool) + and isinstance(fields.get("reference"), str) + and bool(fields["reference"]) + and isinstance(balance, int) + and not isinstance(balance, bool) + and balance >= 0 + ) + + +def _confirmed_topup_receipt( + responses: tuple[WalletUpstreamResponse, ...], +) -> dict[str, Any] | None: + """Return a receipt only when the trusted bridge observed its successful response.""" + for response in reversed(responses): + if not 200 <= response.status_code < 300: + continue + try: + receipt = json.loads(response.body) + except (TypeError, ValueError): + continue + if _valid_topup_receipt(receipt): + return cast("dict[str, Any]", receipt) + return None diff --git a/strix/interface/cloud/http.py b/strix/interface/cloud/http.py new file mode 100644 index 00000000..7b57f80a --- /dev/null +++ b/strix/interface/cloud/http.py @@ -0,0 +1,361 @@ +"""HTTP client for the managed Strix platform API (app.strix.ai).""" + +from __future__ import annotations + +import ipaddress +import math +import os +import re +from typing import TYPE_CHECKING, Any, cast +from urllib.parse import SplitResult, urlsplit + +import requests + +from strix.config import load_settings +from strix.interface.platform_cli import read_record + + +if TYPE_CHECKING: + from pathlib import Path + + +_DEFAULT_TIMEOUT_S = 120 +_SUPABASE_STORAGE_HOST = re.compile(r"^[a-z0-9-]+\.supabase\.co$") +_STORAGE_PATH_PREFIX = "/storage/v1/" +_app_url_override: str | None = None +_token_override_active = False +_workspace_id_override: str | None = None +_timeout_s: float = _DEFAULT_TIMEOUT_S + +EXIT_OK = 0 +EXIT_ERROR = 1 +EXIT_USAGE = 2 +EXIT_AUTH = 4 +EXIT_PAYMENT = 5 + + +class CloudError(Exception): + """A failed cloud command. Carries the process exit code.""" + + def __init__(self, message: str, *, exit_code: int = EXIT_ERROR, payload: Any = None) -> None: + super().__init__(message) + self.exit_code = exit_code + self.payload = payload + + +class CloudTransportError(CloudError): + """A request may have reached the platform, but no response was received.""" + + +def configure( + *, + base_url: str | None = None, + timeout: float | None = None, + token_override: bool = False, + workspace_id: str | None = None, +) -> None: + """Set the platform URL and the request timeout for this process.""" + global _app_url_override, _timeout_s, _token_override_active # noqa: PLW0603 + global _workspace_id_override # noqa: PLW0603 + _app_url_override = base_url.rstrip("/") if base_url else None + _token_override_active = token_override + explicit_workspace = workspace_id or os.environ.get("STRIX_WORKSPACE_ID") + if explicit_workspace: + _workspace_id_override = explicit_workspace.strip() + elif not token_override and not os.environ.get("STRIX_API_TOKEN"): + record = read_record() + stored_workspace = record.get("organization_id") if record is not None else None + _workspace_id_override = ( + stored_workspace.strip() + if isinstance(stored_workspace, str) and stored_workspace.strip() + else None + ) + else: + _workspace_id_override = None + if timeout is not None: + if not math.isfinite(timeout) or timeout <= 0: + raise CloudError( + "request timeout must be a finite number greater than 0.", + exit_code=EXIT_USAGE, + ) + _timeout_s = timeout + + +def app_url() -> str: + if _app_url_override: + return _app_url_override + viewer = load_settings().viewer + configured = viewer.app_url.rstrip("/") + explicitly_configured = bool(os.environ.get("STRIX_APP_URL")) or "app_url" in getattr( + viewer, "model_fields_set", set[str]() + ) + if explicitly_configured or _token_override_active or os.environ.get("STRIX_API_TOKEN"): + return configured + record = read_record() + stored = record.get("app_url") if record is not None else None + if isinstance(stored, str) and stored: + try: + _parse_origin_url(stored, label="stored platform URL") + except CloudError: + pass + else: + return stored.rstrip("/") + return configured + + +def api_token(override: str | None = None) -> str: + token = override or os.environ.get("STRIX_API_TOKEN") + if not token: + record = read_record() + if record is not None: + stored = record.get("api_token") + if isinstance(stored, str): + _validate_stored_token_origin(record) + token = stored + if not token or not token.strip(): + raise CloudError( + "not signed in. Run `strix cloud login`, or set STRIX_API_TOKEN.", + exit_code=EXIT_AUTH, + ) + return token.strip() + + +def _validate_stored_token_origin(record: dict[str, Any]) -> None: + """Never send a stored bearer token to an origin other than its issuer.""" + stored_url = record.get("app_url") + if not isinstance(stored_url, str) or not stored_url: + raise CloudError( + "the stored sign-in is not bound to a trusted platform. Run `strix cloud login` " + "again before using it.", + exit_code=EXIT_AUTH, + ) + try: + stored_origin = _origin(_parse_origin_url(stored_url, label="stored platform URL")) + active_origin = _origin(_parse_origin_url(app_url(), label="configured platform URL")) + except CloudError as exc: + raise CloudError( + "the stored sign-in has an invalid platform binding. Run `strix cloud login` again.", + exit_code=EXIT_AUTH, + ) from exc + if stored_origin != active_origin: + raise CloudError( + "the stored sign-in belongs to a different platform. Refusing to send its token; " + "run `strix cloud login` for the configured platform or supply an explicit token.", + exit_code=EXIT_AUTH, + ) + + +def request( + method: str, + path: str, + *, + token: str | None = None, + query: dict[str, Any] | None = None, + body: dict[str, Any] | None = None, + stream: bool = False, + idempotency_key: str | None = None, +) -> requests.Response: + url = f"{app_url()}/api/v1{path}" + headers = { + "Authorization": f"Bearer {api_token(token)}", + } + workspace_id = _expected_workspace_id(token_override=token is not None) + if workspace_id: + headers["X-Strix-Workspace"] = workspace_id + if idempotency_key is not None: + headers["Idempotency-Key"] = idempotency_key + try: + response = requests.request( + method, + url, + headers=headers, + params={ + key: ("true" if value else "false") if isinstance(value, bool) else value + for key, value in (query or {}).items() + if value is not None + } + or None, + json=body, + timeout=_timeout_s, + stream=stream, + allow_redirects=False, + ) + except requests.RequestException as exc: + raise CloudTransportError(f"could not reach {app_url()}: {exc}") from exc + return response + + +def _expected_workspace_id(*, token_override: bool) -> str | None: + """Pin every request in this process to the workspace selected at startup.""" + if _workspace_id_override: + return _workspace_id_override + if token_override or _token_override_active or os.environ.get("STRIX_API_TOKEN"): + return None + return None + + +def upload_file(signed_url: str, upload_token: str, path: Path) -> None: + """Stream a file to a platform-issued storage URL.""" + _validate_upload_url(signed_url) + response: requests.Response | None = None + try: + with path.open("rb") as stream: + response = requests.put( + signed_url, + data=stream, + headers={ + "Authorization": f"Bearer {upload_token}", + "Content-Type": "application/zip", + }, + timeout=_timeout_s, + allow_redirects=False, + ) + except (OSError, requests.RequestException) as exc: + raise CloudError(f"source upload failed: {exc}") from exc + try: + if 300 <= response.status_code < 400: + raise CloudError("source upload refused an unexpected redirect") + if not response.ok: + detail = "" + try: + payload = response.json() + if isinstance(payload, dict): + fields = cast("dict[str, Any]", payload) + detail = str(fields.get("message") or fields.get("error") or "") + except ValueError: + pass + raise CloudError(detail or f"source upload failed (HTTP {response.status_code})") + finally: + response.close() + + +def _validate_upload_url(signed_url: str) -> None: + """Allow uploads only to the trusted app origin or managed Supabase storage.""" + # Supabase signed upload URLs carry their signature in the query string. + # Keep every origin/path restriction below, but allow that opaque query on + # this one platform-issued URL type. + target = _parse_origin_url( + signed_url, + label="source upload URL", + allow_query=True, + ) + if not target.path.startswith(_STORAGE_PATH_PREFIX): + raise CloudError("source upload refused a URL outside the storage API") + + configured_app = _parse_origin_url(app_url(), label="configured platform URL") + if _origin(target) == _origin(configured_app): + return + if _is_loopback_host(configured_app.hostname or "") and _is_loopback_host( + target.hostname or "" + ): + return + + hostname = target.hostname or "" + if ( + target.scheme == "https" + and target.port in (None, 443) + and _SUPABASE_STORAGE_HOST.fullmatch(hostname) + ): + return + raise CloudError( + "source upload refused an untrusted storage origin; only the configured platform " + "origin and managed Supabase storage are allowed" + ) + + +def _parse_origin_url( + value: str, + *, + label: str, + allow_query: bool = False, +) -> SplitResult: + try: + parsed = urlsplit(value) + port = parsed.port + except (TypeError, ValueError) as exc: + raise CloudError(f"{label} is invalid") from exc + hostname = parsed.hostname + if ( + parsed.scheme not in {"http", "https"} + or not hostname + or parsed.username is not None + or parsed.password is not None + or (parsed.query and not allow_query) + or parsed.fragment + or "\\" in value + or any(character.isspace() for character in value) + or "%" in parsed.netloc + ): + raise CloudError(f"{label} is invalid") + try: + hostname.encode("ascii") + except UnicodeEncodeError as exc: + raise CloudError(f"{label} contains a non-ASCII hostname") from exc + if port is not None and not 1 <= port <= 65535: + raise CloudError(f"{label} is invalid") + return parsed + + +def _origin(parsed: SplitResult) -> tuple[str, str, int]: + default_port = 443 if parsed.scheme == "https" else 80 + return parsed.scheme, (parsed.hostname or "").lower(), parsed.port or default_port + + +def _is_loopback_host(hostname: str) -> bool: + normalized = hostname.lower().rstrip(".") + if normalized == "localhost" or normalized.endswith(".localhost"): + return True + try: + return ipaddress.ip_address(normalized).is_loopback + except ValueError: + return False + + +def parsed(response: requests.Response) -> Any: + content_type = response.headers.get("content-type", "") + if "application/json" in content_type: + try: + return response.json() + except ValueError: + return response.text + return response.text + + +def check(response: requests.Response) -> Any: + data = parsed(response) + if 200 <= response.status_code < 300: + content_type = response.headers.get("content-type", "").lower() + if "application/json" not in content_type: + raise CloudError( + "the server returned a non-JSON response. Check STRIX_APP_URL and preview " + "access, then retry." + ) + try: + return response.json() + except ValueError as exc: + raise CloudError( + "the server returned malformed JSON. Check STRIX_APP_URL and preview " + "access, then retry." + ) from exc + detail = "" + error_code = "" + if isinstance(data, dict): + raw = cast("dict[str, Any]", data) + detail = str(raw.get("detail") or raw.get("error") or "") + error_code = str(raw.get("code") or raw.get("error_code") or "") + nested_error = raw.get("error") + if isinstance(nested_error, dict): + nested = cast("dict[str, Any]", nested_error) + error_code = error_code or str(nested.get("code") or "") + detail = str(nested.get("message") or detail) + message = detail or f"HTTP {response.status_code}" + if error_code == "scan_credit_limit_reached": + raise CloudError(message, exit_code=EXIT_PAYMENT, payload=data) + if response.status_code in (401, 403): + raise CloudError(message, exit_code=EXIT_AUTH, payload=data) + if response.status_code == 402: + hint = detail or ( + "not enough credits. Run `strix cloud billing topup --credits N` to buy credits." + ) + raise CloudError(hint, exit_code=EXIT_PAYMENT, payload=data) + raise CloudError(message, exit_code=EXIT_ERROR, payload=data) diff --git a/strix/interface/cloud/payment_proxy.py b/strix/interface/cloud/payment_proxy.py new file mode 100644 index 00000000..eada8787 --- /dev/null +++ b/strix/interface/cloud/payment_proxy.py @@ -0,0 +1,280 @@ +"""Loopback bridge for wallet clients that only accept secrets in argv. + +The ``mppx`` CLI accepts custom HTTP headers through ``-H`` only. Passing a +Strix API token that way exposes it to process-listing tools. This module keeps +the token in the Strix process and injects it while forwarding the wallet's two +requests (challenge and paid retry) to the fixed billing endpoint. +""" + +from __future__ import annotations + +import secrets +import threading +from contextlib import contextmanager, suppress +from dataclasses import dataclass, field +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import TYPE_CHECKING, Any + +import requests + + +if TYPE_CHECKING: + from collections.abc import Callable, Generator + + +_DEFAULT_REQUEST_TIMEOUT_S = 120.0 +_MAX_REQUEST_BODY_BYTES = 64 * 1024 +_MAX_UPSTREAM_RESPONSE_BYTES = 1024 * 1024 +_MAX_WALLET_REQUESTS = 2 +_HOP_BY_HOP_HEADERS = frozenset( + { + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade", + } +) + + +@dataclass +class _BridgeState: + upstream_url: str + authorization: str + expected_body: bytes + path: str + timeout: float + response_observer: Callable[[WalletUpstreamResponse], None] | None = None + request_count: int = 0 + lock: threading.Lock = field(default_factory=threading.Lock) + + def claim_request(self) -> bool: + """Allow only the challenge request and its paid retry.""" + with self.lock: + if self.request_count >= _MAX_WALLET_REQUESTS: + return False + self.request_count += 1 + return True + + +class _ResponseTooLargeError(Exception): + """The fixed billing endpoint returned more data than a wallet needs.""" + + +@dataclass(frozen=True) +class WalletUpstreamResponse: + """A bounded upstream response observed by the trusted loopback bridge.""" + + status_code: int + body: bytes + + +def _bounded_response_body(response: requests.Response) -> bytes: + content_length = response.headers.get("Content-Length") + if content_length: + try: + if int(content_length) > _MAX_UPSTREAM_RESPONSE_BYTES: + raise _ResponseTooLargeError + except ValueError: + pass + + chunks: list[bytes] = [] + total = 0 + for chunk in response.iter_content(chunk_size=64 * 1024): + if not chunk: + continue + total += len(chunk) + if total > _MAX_UPSTREAM_RESPONSE_BYTES: + raise _ResponseTooLargeError + chunks.append(chunk) + return b"".join(chunks) + + +def _connection_header_names(handler: BaseHTTPRequestHandler) -> set[str]: + value = handler.headers.get("Connection", "") + return {item.strip().lower() for item in value.split(",") if item.strip()} + + +def _forward_request_headers(handler: BaseHTTPRequestHandler) -> dict[str, str]: + blocked = { + *_HOP_BY_HOP_HEADERS, + *_connection_header_names(handler), + "content-length", + "forwarded", + "host", + "true-client-ip", + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-proto", + "x-real-ip", + "x-strix-authorization", + "x-vercel-forwarded-for", + } + return {name: value for name, value in handler.headers.items() if name.lower() not in blocked} + + +def _send_json_error(handler: BaseHTTPRequestHandler, status: int, message: str) -> None: + body = f'{{"error": "{message}"}}'.encode() + handler.close_connection = True + handler.send_response(status) + handler.send_header("Content-Type", "application/json") + handler.send_header("Content-Length", str(len(body))) + handler.send_header("Cache-Control", "no-store") + handler.send_header("Connection", "close") + handler.end_headers() + with suppress(BrokenPipeError, ConnectionResetError): + handler.wfile.write(body) + + +def _make_handler(state: _BridgeState) -> type[BaseHTTPRequestHandler]: + class WalletBridgeHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, format: str, *args: Any) -> None: # noqa: A002 + """Do not write wallet request metadata to stderr.""" + del format, args + + def do_POST(self) -> None: # noqa: PLR0911, PLR0912 + if self.path != state.path: + _send_json_error(self, 404, "Not found") + return + if self.headers.get("Transfer-Encoding"): + _send_json_error(self, 400, "Chunked request bodies are not supported") + return + try: + content_length = int(self.headers.get("Content-Length", "")) + except ValueError: + _send_json_error(self, 411, "A valid Content-Length is required") + return + if content_length < 0 or content_length > _MAX_REQUEST_BODY_BYTES: + _send_json_error(self, 413, "Request body is too large") + return + body = self.rfile.read(content_length) + if body != state.expected_body: + _send_json_error(self, 403, "Request body did not match the approved top-up") + return + if not state.claim_request(): + _send_json_error(self, 429, "Wallet request limit reached") + return + + headers = _forward_request_headers(self) + headers["X-Strix-Authorization"] = state.authorization + try: + response = requests.request( + "POST", + state.upstream_url, + headers=headers, + data=body, + timeout=state.timeout, + allow_redirects=False, + stream=True, + ) + try: + response_body = _bounded_response_body(response) + response_status = response.status_code + response_headers = dict(response.headers) + finally: + response.close() + except _ResponseTooLargeError: + _send_json_error(self, 502, "Strix billing response was too large") + return + except requests.RequestException: + _send_json_error(self, 502, "Could not reach the Strix billing endpoint") + return + + if state.response_observer is not None: + with suppress(Exception): + state.response_observer( + WalletUpstreamResponse(status_code=response_status, body=response_body) + ) + + if 300 <= response_status < 400: + _send_json_error(self, 502, "Strix billing refused an unexpected redirect") + return + + self.send_response(response_status) + response_connection_headers = { + item.strip().lower() + for item in response_headers.get("Connection", "").split(",") + if item.strip() + } + blocked_response_headers = { + *_HOP_BY_HOP_HEADERS, + *response_connection_headers, + "cache-control", + "content-encoding", + "content-length", + "location", + } + for name, value in response_headers.items(): + if ( + name.lower() not in blocked_response_headers + and "\r" not in value + and "\n" not in value + ): + self.send_header(name, value) + self.send_header("Content-Length", str(len(response_body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + with suppress(BrokenPipeError, ConnectionResetError): + self.wfile.write(response_body) + + def do_GET(self) -> None: + _send_json_error(self, 405, "Method not allowed") + + def do_PUT(self) -> None: + _send_json_error(self, 405, "Method not allowed") + + def do_PATCH(self) -> None: + _send_json_error(self, 405, "Method not allowed") + + def do_DELETE(self) -> None: + _send_json_error(self, 405, "Method not allowed") + + return WalletBridgeHandler + + +@contextmanager +def wallet_payment_bridge( + *, + upstream_url: str, + api_token: str, + expected_body: bytes, + timeout: float | None = None, + response_observer: Callable[[WalletUpstreamResponse], None] | None = None, +) -> Generator[str]: + """Yield a one-run loopback URL that injects the Strix API token upstream. + + The random path prevents accidental cross-process requests and limits local + denial-of-service races. It is not an authentication boundary against a + same-user process that can inspect another process's argv. + """ + capability = secrets.token_urlsafe(32) + path = f"/topup/{capability}" + state = _BridgeState( + upstream_url=upstream_url, + authorization=f"Bearer {api_token}", + expected_body=expected_body, + path=path, + timeout=timeout or _DEFAULT_REQUEST_TIMEOUT_S, + response_observer=response_observer, + ) + server = ThreadingHTTPServer(("127.0.0.1", 0), _make_handler(state)) + server.daemon_threads = True + thread = threading.Thread( + target=server.serve_forever, + kwargs={"poll_interval": 0.05}, + name="strix-wallet-bridge", + daemon=True, + ) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}{path}" + finally: + server.shutdown() + server.server_close() + thread.join(timeout=1) diff --git a/strix/interface/cloud/render.py b/strix/interface/cloud/render.py new file mode 100644 index 00000000..69a497f3 --- /dev/null +++ b/strix/interface/cloud/render.py @@ -0,0 +1,1759 @@ +"""Output rendering for `strix cloud` commands.""" + +from __future__ import annotations + +import json +import re +import sys +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any, TypeGuard + +from rich.markup import escape +from rich.table import Table + +from strix.interface.terminal_text import sanitize_terminal_text + + +if TYPE_CHECKING: + from collections.abc import Iterable + + from rich.console import Console + + +_MAX_TABLE_COLUMNS = 8 +_MAX_CELL_LENGTH = 60 +_MAX_DETAIL_CELL_LENGTH = 2000 +_MAX_DETAIL_FIELDS = 36 +_MAX_NESTED_PREVIEW = 5 +_NARROW_TABLE_WIDTH = 120 +_CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])") +_COPYABLE_SELECTOR_COLUMNS = frozenset( + { + "event_id", + "id", + "installation_id", + "parent_id", + "path", + "policy_key", + "repo_key", + "retest_scan_id", + "scan_id", + "test_user_id", + "vulnerability_id", + } +) +_SELECTOR_NO_WRAP_MAX = 40 +_INTERNAL_COLUMNS = frozenset( + { + "organization_id", + "user_id", + "userId", + "installation_id", + "added_by", + "created_by", + "connected_by", + "invited_by", + "uploaded_by", + "avatarUrl", + } +) +_LOSSLESS_DETAIL_KEYS = frozenset( + { + "api_token", + "command", + "docker_command", + "enrollment_command", + "secret", + "signing_secret", + "token", + "webhook_secret", + } +) + +_PREFERRED_KEYS = ( + "name", + "title", + "repository_full_name", + "pr_number", + "pr_title", + "head_branch", + "base_branch", + "verdict", + "domain", + "target", + "default_branch", + "branch", + "display_number", + "status", + "state", + "workspace_state", + "severity", + "cve", + "cvss", + "finding_type", + "findings_count", + "open_findings_count", + "role", + "email", + "firstName", + "lastName", + "url", + "provider", + "secret_prefix", + "events", + "action", + "resource_type", + "response_status", + "attempts", + "scan_type", + "engagement_type", + "estimated_credits", + "cron_expression", + "timezone", + "next_run_at", + "is_active", + "created_at", + "updated_at", + "expires_at", + "last_used_at", + "id", +) + +_DETAIL_PRIORITY_KEYS = ( + "id", + "display_number", + "title", + "name", + "status", + "state", + "severity", + "finding_type", + "cve", + "cwe", + "cvss", + "filed_at", + "target", + "location_meta", + "urls", + "repositories", + "internal_targets", + "endpoint", + "method", + "url", + "events", + "business_unit", + "is_active", + "secret_prefix", + "last_success_at", + "last_failure_at", + "run_id", + "sandbox_attached", + "description", + "impact", + "technical_analysis", + "evidence", + "assumptions", + "remediation_steps", + "fix_pr_eligible", + "fix_pr_reason", + "fix_pr_url", + "poc_description", + "poc_script_code", + "code_file", + "code_locations", + "code_diff", + "code_before", + "code_after", + "dependency_metadata", + "fix_effort", + "executive_summary", + "methodology", + "recommendations", + "auth_status", + "auth_failure_code", + "auth_detail", + "scan_scope", + "findings", + "duration", + "created_at", + "updated_at", +) + +_LIST_ENVELOPE_KEYS = frozenset( + { + "items", + "data", + "scans", + "agents", + "chats", + "vulnerabilities", + "findings", + "files", + "messages", + "runs", + "steps", + "components", + "domains", + "repositories", + "repos", + "schedules", + "reviews", + "pr_reviews", + "workspaces", + "members", + "invitations", + "integrations", + "connectors", + "webhooks", + "deliveries", + "entries", + "documents", + "docs", + "policies", + "tokens", + "uploads", + "events", + "audit_logs", + "logs", + } +) +_ENVELOPE_METADATA_KEYS = frozenset( + { + "total", + "total_count", + "totalCount", + "count", + "page", + "limit", + "page_size", + "pageSize", + "has_more", + "hasMore", + "next_cursor", + "nextCursor", + "meta", + "pagination", + "summary", + "stats", + "scansThisMonth", + "organization_id", + } +) + +_VIEW_COLUMNS: dict[str, tuple[str, ...]] = { + "GET /scans": ( + "title", + "target", + "engagement_type", + "scan_type", + "status", + "findings_count", + "created_at", + "id", + ), + "GET /vulnerabilities": ( + "display_number", + "title", + "severity", + "status", + "location", + "cvss", + "finding_type", + "id", + ), + "GET /pr-reviews": ( + "repository", + "pull_request", + "branches", + "status", + "verdict", + "findings", + "updated_at", + "id", + ), + "GET /integrations": ( + "provider", + "account_login", + "installation_id", + "instance_url", + "status", + "repository_selection", + "default_collection_name", + "connected_at", + ), + "GET /domains": ( + "domain", + "asset_type", + "verified", + "last_scan_at", + "context", + "tags", + "business_unit", + "id", + ), + "GET /repositories": ( + "full_name", + "provider", + "pr_review_enabled", + "last_scan_at", + "business_unit", + "tags", + "id", + ), + "GET /knowledge": ( + "title", + "source_type", + "source_id", + "tags", + "severity", + "status", + "updated_at", + "id", + ), + "GET /knowledge/repos/{repo}/entries": ( + "title", + "source_type", + "source_id", + "tags", + "severity", + "status", + "updated_at", + "id", + ), + "GET /knowledge/repos": ("repo_key", "docs_count", "last_updated_at"), + "GET /knowledge/policies": ( + "policy_key", + "policy_type", + "is_active", + "policy_value", + "updated_at", + "created_at", + "id", + ), + "GET /domains/{domainId}/test-users": ( + "label", + "username", + "password", + "mfa", + "verification", + "login_url", + "updated_at", + "id", + ), + "GET /tokens": ( + "name", + "type", + "status", + "scopes", + "access", + "expires_at", + "last_used_at", + "id", + ), + "GET /chat": ("title", "status", "last_message_at", "created_at", "id"), + "GET /chat/{chatId}/files": ("path", "size"), + "GET /chat/{chatId}/findings": ( + "title", + "severity", + "status", + "location", + "cvss", + "filed_at", + "created_at", + "id", + ), + "GET /domains/{domainId}/test-users/{userId}/inbox": ( + "from", + "subject", + "detected_code", + "timestamp", + "preview", + "id", + ), + "GET /scans/{scanId}/agents": ( + "name", + "status", + "task", + "finding_count", + "parent_id", + "created_at", + "id", + ), + "GET /scans/{scanId}/trace": ( + "timestamp", + "kind", + "tool_name", + "status", + "summary", + "event_id", + ), + "GET /scans/{scanId}/retests": ( + "title", + "severity", + "issue_status", + "retest_status", + "created_at", + "vulnerability_id", + "retest_scan_id", + ), + "GET /pr-reviews/findings": ( + "repository", + "pull_request", + "pr_state", + "title", + "severity", + "status", + "created_at", + "id", + ), + "GET /vulnerabilities/{vulnerabilityId}/history": ( + "created_at", + "previous_status", + "new_status", + "snooze", + "previous_severity", + "new_severity", + "note", + "reason", + ), + "GET /repositories/{repositoryId}/supply-chain/findings": ( + "title", + "package", + "severity", + "status", + "fixed_version", + "manifest_path", + "direct", + "id", + ), + "GET /repositories/{repositoryId}/supply-chain/components": ( + "name", + "version", + "ecosystem", + "relationship", + "status", + "highest_open_severity", + "manifest_path", + "id", + ), + "GET /schedules": ( + "name", + "target", + "cron_expression", + "timezone", + "state", + "last_run_status", + "next_run_at", + "id", + ), + "GET /connectors": ("name", "last_status", "last_status_checked_at", "created_at", "id"), + "GET /organization/members": ( + "email", + "firstName", + "lastName", + "role", + "access", + "status", + "joinedAt", + "id", + ), + "GET /organization/invitations": ( + "email", + "role", + "access", + "state", + "expiresAt", + "createdAt", + "id", + ), + "GET /webhooks": ( + "url", + "events", + "is_active", + "business_unit", + "last_success_at", + "last_failure_at", + "created_at", + "id", + ), + "GET /webhooks/{webhookId}/deliveries": ( + "event_type", + "status", + "response_status", + "last_error", + "attempts", + "sent_at", + "next_attempt_at", + "id", + ), + "GET /audit": ( + "action", + "resource_type", + "resource_id", + "actor_email", + "ip_address", + "created_at", + ), + "supply_chain_totals": ( + "repositories", + "components", + "findings", + "open_issues", + "malicious", + "suspicious", + "vulnerable", + ), + "supply_chain_repositories": ( + "repository", + "components", + "findings", + "severity", + "risks", + "latest_scan", + "policy", + "id", + ), + "chat_credentials_attached": ( + "label", + "username", + "login_url", + "mfa_method", + "password", + "totp", + "test_user_id", + ), + "chat_credentials_test_users": ( + "label", + "username", + "domain", + "login_url", + "mfa_method", + "password", + "totp", + "id", + ), + "chat_credentials_scans": ( + "scan_title", + "username", + "login_url", + "mfa_method", + "password", + "totp", + "scan_id", + ), +} + +_VIEW_LIST_KEYS: dict[str, str] = { + "GET /chat": "chats", + "GET /chat/{chatId}/files": "files", + "GET /chat/{chatId}/findings": "findings", + "GET /domains/{domainId}/test-users/{userId}/inbox": "messages", + "GET /scans/{scanId}/agents": "agents", + "GET /scans/{scanId}/trace": "steps", + "GET /scans/{scanId}/retests": "runs", + "GET /pr-reviews/findings": "items", + "GET /vulnerabilities/{vulnerabilityId}/history": "items", + "GET /knowledge/repos/{repo}/entries": "docs", + "GET /repositories/{repositoryId}/supply-chain/findings": "findings", + "GET /repositories/{repositoryId}/supply-chain/components": "components", + "GET /schedules": "schedules", + "GET /organization/members": "members", + "GET /organization/invitations": "invitations", +} + +_DETAIL_ENVELOPE_KEYS: dict[str, str] = { + "GET /chat/{chatId}": "chat", + "GET /webhooks/{webhookId}": "webhook", +} + + +def _is_record(value: object) -> TypeGuard[dict[str, Any]]: + return isinstance(value, dict) + + +def _is_list(value: object) -> TypeGuard[list[Any]]: + return isinstance(value, list) + + +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( # noqa: PLR0911, PLR0912, PLR0915 + console: Console, + data: Any, + *, + as_json: bool, + row_numbers: bool = False, + omit_columns: frozenset[str] = frozenset(), + hint: str | None = None, + view: str | None = None, + warning: str | None = None, +) -> None: + if as_json: + sys.stdout.write(json.dumps(data, indent=2, default=str) + "\n") + return + if warning: + console.print(f"[bold yellow]Save this now:[/] {escape(sanitize_terminal_text(warning))}") + hint = _combine_hints(hint, _pagination_hint(data)) + if view == "source_manifest" and _is_record(data): + _print_source_manifest(console, data) + return + if view == "GET /analytics/scan-frequency": + _print_scan_frequency(console, data) + return + if view in {"GET /analytics/overview", "GET /analytics/stats"} and _is_record(data): + _print_analytics(console, data) + return + if view == "GET /supply-chain/summary" and _is_record(data): + _print_supply_chain_summary(console, data) + return + if view == "GET /chat/{chatId}/credentials" and _is_record(data): + _print_chat_credentials(console, data) + return + detail_key = _DETAIL_ENVELOPE_KEYS.get(view or "") + if detail_key and _is_record(data): + detail = data.get(detail_key) + if _is_record(detail): + _print_detail(console, _detail_envelope_record(detail, view)) + return + if view == "GET /integrations": + integration_rows = _integration_rows(data) + if integration_rows is not None: + _print_table( + console, + integration_rows, + row_numbers=row_numbers, + omit_columns=omit_columns, + hint=hint, + view=view, + ) + return + if view == "GET /tokens": + token_rows = _token_rows(data) + if token_rows is not None: + _print_table( + console, + token_rows, + row_numbers=row_numbers, + omit_columns=omit_columns, + hint=hint, + view=view, + ) + return + if view == "GET /scans": + scan_rows = _scan_rows(data) + if scan_rows is not None: + _print_table( + console, + scan_rows, + row_numbers=False, + omit_columns=omit_columns, + hint=_combine_hints("Inspect one scan with `strix cloud scans get ID`.", hint), + view=view, + ) + return + if view == "GET /vulnerabilities": + vulnerability_rows = _finding_location_rows(data) + if vulnerability_rows is not None: + _print_table( + console, + vulnerability_rows, + row_numbers=False, + omit_columns=omit_columns | frozenset({"scan_id"}), + hint=_combine_hints("Inspect one finding with `strix cloud vulns get ID`.", hint), + view=view, + ) + return + if view == "GET /pr-reviews": + review_rows = _pr_review_rows(data) + if review_rows is not None: + _print_table( + console, + review_rows, + row_numbers=False, + omit_columns=omit_columns, + hint=_combine_hints( + "Use `strix cloud pr-reviews get ID` for one review.", + _view_hint(data, view), + hint, + ), + view=view, + ) + return + view_rows = _rows_for_view(data, view) + if view_rows is not None: + _print_table( + console, + view_rows, + row_numbers=row_numbers, + omit_columns=omit_columns, + hint=_combine_hints(_view_hint(data, view), hint), + view=view, + ) + return + rows = _list_of_dicts(data) + if rows is not None: + _print_table( + console, + rows, + row_numbers=row_numbers, + omit_columns=omit_columns, + hint=hint, + view=view, + ) + return + if isinstance(data, str): + console.print(sanitize_terminal_text(data), markup=False) + return + if _is_record(data): + _print_detail(console, data) + return + console.print_json(json.dumps(data, default=str)) + + +def _list_of_dicts(data: Any) -> list[dict[str, Any]] | None: + """Extract a record list from a raw list or a common paginated envelope.""" + if _is_record(data): + # Some endpoints wrap the actual envelope in a top-level ``data`` or + # ``result`` object. Only recurse through an object wrapper; a list in + # ``data`` is handled with the other named envelope keys below. + for wrapper in ("data", "result"): + nested = data.get(wrapper) + if _is_record(nested): + nested_rows = _list_of_dicts(nested) + if nested_rows is not None: + return nested_rows + candidates = [ + (key, value) + for key, value in data.items() + if key in _LIST_ENVELOPE_KEYS + and _is_list(value) + and all(_is_record(item) for item in value) + ] + if len(candidates) == 1: + list_key, records = candidates[0] + other_keys = set(data) - {list_key} + if list_key in {"items", "data"} or other_keys <= _ENVELOPE_METADATA_KEYS: + data = records + if not _is_list(data): + return None + if not data: + return [] + records = [item for item in data if _is_record(item)] + if len(records) != len(data): + return None + return records + + +def _records_at_key(data: Any, key: str) -> list[dict[str, Any]] | None: + """Extract one deliberate collection even when an envelope has other lists.""" + if _is_list(data): + return _list_of_dicts(data) + if not _is_record(data): + return None + value = data.get(key) + if not _is_list(value) or not all(_is_record(item) for item in value): + return None + return list(value) + + +def _detail_envelope_record(data: dict[str, Any], view: str | None) -> dict[str, Any]: + record = dict(data) + if view == "GET /webhooks/{webhookId}" and not record.get("business_unit"): + record["business_unit"] = "all organization" + if view == "GET /chat/{chatId}": + record["sandbox_attached"] = bool(record.pop("sandbox_api_url", False)) + return record + + +def _rows_for_view(data: Any, view: str | None) -> list[dict[str, Any]] | None: + """Shape non-standard list envelopes into compact, actionable rows.""" + if view == "GET /domains/{domainId}/test-users": + return _test_user_rows(data) + key = _VIEW_LIST_KEYS.get(view or "") + if key is None: + return None + records = _records_at_key(data, key) + if records is None: + return None + transforms = { + "GET /scans/{scanId}/trace": _trace_rows, + "GET /chat/{chatId}/findings": _finding_location_rows, + "GET /pr-reviews/findings": _pr_finding_rows, + "GET /vulnerabilities/{vulnerabilityId}/history": _vulnerability_history_rows, + "GET /repositories/{repositoryId}/supply-chain/findings": (_supply_chain_finding_rows), + "GET /schedules": _schedule_rows, + "GET /organization/members": _access_rows, + "GET /organization/invitations": _access_rows, + } + transform = transforms.get(view or "") + return transform(records) if transform else records + + +def _view_hint(data: Any, view: str | None) -> str | None: + hint: str | None = None + if view == "GET /scans/{scanId}/trace" and _is_record(data): + hint = _trace_view_hint(data) + elif view == "GET /scans/{scanId}/retests" and _is_record(data): + total = data.get("total") + completed = data.get("completed") + running = data.get("running") + if all(isinstance(value, int) for value in (total, completed, running)): + hint = f"{completed}/{total} retest(s) complete; {running} running." + elif view == "GET /knowledge/repos/{repo}/entries" and _is_record(data): + hint = _knowledge_repo_hint(data) + elif view == "GET /pr-reviews" and _is_record(data): + hint = _metric_hint( + "Review counts", + data.get("counts"), + ("all", "open", "attention", "merged_open", "passed", "running"), + ) + elif view == "GET /pr-reviews/findings" and _is_record(data): + hint = _metric_hint( + "Impact", + data.get("stats"), + ("prs_reviewed", "issues_found", "critical_high_found", "merges_blocked"), + ) + elif view == "GET /domains/{domainId}/test-users/{userId}/inbox" and _is_record(data): + address = data.get("address") + if isinstance(address, str) and address.strip(): + hint = f"Inbox: {sanitize_terminal_text(address.strip())}." + elif view == "GET /schedules": + hint = "Inspect one schedule with `strix cloud schedules get ID`." + return hint + + +def _trace_view_hint(data: dict[str, Any]) -> str | None: + scan_id = data.get("scan_id") + suffix = f" {scan_id}" if scan_id else " SCAN_ID" + parts = [ + f"Inspect a complete event with `strix cloud scans trace-event{suffix} EVENT_ID`; " + "use --json for full tool arguments and results." + ] + cursor = data.get("cursor") + if data.get("has_more") and isinstance(cursor, str) and cursor: + parts.append( + f"Continue the same trace command with `--cursor {cursor}`; keep its " + "--agent-id, --tool-name, and --limit options." + ) + note = data.get("note") + if isinstance(note, str) and note.strip(): + parts.append(note.strip()) + return _combine_hints(*parts) + + +def _knowledge_repo_hint(data: dict[str, Any]) -> str | None: + parts: list[str] = [] + profile = data.get("profile") + if _is_record(profile): + title = sanitize_terminal_text(str(profile.get("title") or "present")) + parts.append(f"Repository profile: {title}.") + policies = data.get("policies") + if _is_list(policies): + noun = "policy" if len(policies) == 1 else "policies" + parts.append(f"{len(policies)} {noun} apply.") + if parts: + parts.append("Use --json to view the profile and policy metadata.") + return _combine_hints(*parts) + + +def _metric_hint(label: str, value: Any, keys: tuple[str, ...]) -> str | None: + if not _is_record(value): + return None + metrics = [ + f"{_human_label(key)} {value[key]}" for key in keys if isinstance(value.get(key), int) + ] + return f"{label}: {', '.join(metrics)}." if metrics else None + + +def _combine_hints(*hints: str | None) -> str | None: + combined = " ".join(hint.strip() for hint in hints if hint and hint.strip()) + return combined or None + + +def _pagination_hint(data: Any) -> str | None: + """Explain how to continue a paginated human list without hiding API metadata.""" + if not _is_record(data): + return None + candidates = [data.get(key) for key in ("meta", "pagination")] + for pagination in candidates: + if not _is_record(pagination): + continue + message = _pagination_message(pagination) + if message: + return message + for wrapper in ("data", "result"): + nested = data.get(wrapper) + if _is_record(nested): + hint = _pagination_hint(nested) + if hint: + return hint + return None + + +def _pagination_message(pagination: dict[str, Any]) -> str | None: + page = pagination.get("page") + total_pages = pagination.get("total_pages") + total = pagination.get("total_items", pagination.get("total")) + has_next = pagination.get("has_next") + if isinstance(page, int) and isinstance(total_pages, int): + return _page_pagination_message(page, total_pages, total, has_next=has_next) + + offset = pagination.get("offset") + limit = pagination.get("limit") + if not isinstance(offset, int) or not isinstance(limit, int) or not isinstance(total, int): + return None + if total <= 0: + return "0 total." + if offset >= total: + last_offset = max(0, ((total - 1) // max(1, limit)) * max(1, limit)) + return f"No items at offset {offset}; {total} total. Retry with `--offset {last_offset}`." + shown_through = min(offset + limit, total) + message = f"Showing {offset + 1}-{shown_through} of {total}." + if offset + limit < total: + message += f" Continue with `--offset {offset + limit}`." + return message + + +def _page_pagination_message(page: int, total_pages: int, total: Any, *, has_next: Any) -> str: + if total == 0: + return "0 total." + last_page = max(1, total_pages) + if page > last_page: + total_note = f"; {total} total" if isinstance(total, int) else "" + return f"No items on page {page}{total_note}. Retry with `--page {last_page}`." + parts = [f"Page {page}/{last_page}"] + if isinstance(total, int): + parts.append(f"{total} total") + message = " · ".join(parts) + "." + if (has_next is True or page < total_pages) and page >= 0: + message += f" Continue with `--page {page + 1}`." + return message + + +def _test_user_rows(data: Any) -> list[dict[str, Any]] | None: + records = _records_at_key(data, "items") + if records is None: + return None + checks = data.get("auth_checks") if _is_record(data) else None + rows: list[dict[str, Any]] = [] + for record in records: + row = dict(record) + row["password"] = "set" if record.get("has_password") else "not set" + method = str(record.get("mfa_method") or "none") + if method == "totp": + row["mfa"] = "totp (secret set)" if record.get("has_totp_secret") else "totp (missing)" + elif method in {"email_otp", "magic_link"}: + address = str(record.get("mfa_email") or "address missing") + row["mfa"] = f"{method}: {address}" + elif record.get("has_totp_secret"): + row["mfa"] = "none (TOTP secret stored)" + else: + row["mfa"] = "none" + + check: Any = None + if _is_record(checks): + check = checks.get(str(record.get("id") or "")) + elif _is_list(checks): + check = next( + ( + candidate + for candidate in checks + if _is_record(candidate) and candidate.get("test_user_id") == record.get("id") + ), + None, + ) + if _is_record(check): + status = str(check.get("status") or "unknown") + failure = str(check.get("failure_code") or "").strip() + row["verification"] = f"{status}: {failure}" if failure else status + else: + row["verification"] = "not checked" + rows.append(row) + return rows + + +def _trace_rows(records: list[dict[str, Any]]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for record in records: + row = dict(record) + finding = record.get("finding") + if _is_record(finding): + title = str(finding.get("title") or "finding") + severity = str(finding.get("severity") or "").strip() + row["summary"] = f"{severity}: {title}" if severity else title + elif record.get("result") is not None: + row["summary"] = _trace_payload_shape("result", record["result"]) + elif record.get("args") is not None: + row["summary"] = _trace_payload_shape("arguments", record["args"]) + elif record.get("content") is not None: + row["summary"] = _trace_payload_shape("message", record["content"]) + rows.append(row) + return rows + + +def _trace_payload_shape(label: str, value: Any) -> str: + """Describe trace payload structure without leaking credentials or response bodies.""" + if _is_record(value): + return f"{label}: {len(value)} field(s)" + if _is_list(value): + return f"{label}: {len(value)} item(s)" + if value is None: + return f"{label}: empty" + text = str(value) + kind = "text" if isinstance(value, str) else type(value).__name__ + return f"{label}: {kind} ({len(text)} character(s))" + + +def _pr_finding_rows(records: list[dict[str, Any]]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for record in records: + row = dict(record) + row["repository"] = record.get("repository_full_name") + number = record.get("pr_number") + title = str(record.get("pr_title") or "").strip() + row["pull_request"] = " ".join( + part for part in (f"#{number}" if number is not None else "", title) if part + ) + rows.append(row) + return rows + + +def _finding_location_rows(data: Any) -> list[dict[str, Any]] | None: + records = _list_of_dicts(data) + if records is None: + return None + rows: list[dict[str, Any]] = [] + for record in records: + row = dict(record) + endpoint = str(record.get("endpoint") or "").strip() + method = str(record.get("method") or "").strip().upper() + target = str(record.get("target") or "").strip() + row["location"] = f"{method} {endpoint}".strip() if endpoint else target + rows.append(row) + return rows + + +def _supply_chain_finding_rows(records: list[dict[str, Any]]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for record in records: + row = dict(record) + name = str(record.get("package_name") or "").strip() + version = str(record.get("package_version") or "").strip() + row["package"] = f"{name}@{version}" if name and version else name or version + rows.append(row) + return rows + + +def _schedule_rows(records: list[dict[str, Any]]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for record in records: + row = dict(record) + row["cron_expression"] = record.get("cron") or record.get("cron_expression") + row["state"] = "paused" if record.get("isPaused") else "active" + targets: list[str] = [] + if record.get("supply_chain") is True: + targets.append("supply chain") + for key, singular in (("domain_ids", "domain"), ("repository_ids", "repo")): + values = record.get(key) + if _is_list(values) and values: + noun = singular if len(values) == 1 else f"{singular}s" + targets.append(f"{len(values)} {noun}") + internal_targets = record.get("internal_targets") + if _is_list(internal_targets) and internal_targets: + first = sanitize_terminal_text(str(internal_targets[0])) + suffix = f" (+{len(internal_targets) - 1} more)" if len(internal_targets) > 1 else "" + targets.append(f"{first}{suffix}") + if record.get("connector_id"): + targets.append("network connector") + row["target"] = " · ".join(targets) if targets else "no targets" + rows.append(row) + return rows + + +def _access_rows(records: list[dict[str, Any]]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for record in records: + row = dict(record) + row["access"] = _scope_summary(record.get("scopes")) + rows.append(row) + return rows + + +def _scope_summary(scopes: Any) -> str: + if not _is_list(scopes) or not scopes: + return "all assets" + labels: list[str] = [] + for scope in scopes[:2]: + if _is_record(scope): + scope_type = str(scope.get("type") or "scope").strip() + value = str(scope.get("value") or "").strip() + labels.append(f"{scope_type}:{value}" if value else scope_type) + else: + labels.append(str(scope)) + suffix = f" (+{len(scopes) - len(labels)} more)" if len(scopes) > len(labels) else "" + return ", ".join(labels) + suffix + + +def _credential_summary_rows(data: dict[str, Any], key: str) -> list[dict[str, Any]]: + records = _records_at_key(data, key) or [] + rows: list[dict[str, Any]] = [] + for record in records: + row = dict(record) + row["password"] = "set" if record.get("has_password") else "not set" + row["totp"] = "set" if record.get("has_totp_secret") else "not set" + rows.append(row) + return rows + + +def _print_chat_credentials(console: Console, data: dict[str, Any]) -> None: + """Render attached and attachable credential metadata without exposing secrets.""" + sections = ( + ( + "Attached credentials", + _credential_summary_rows(data, "credentials"), + "chat_credentials_attached", + None, + ), + ( + "Available saved test users", + _credential_summary_rows(data, "available_test_users"), + "chat_credentials_test_users", + "Attach one with `strix cloud chat credentials set CHAT_ID --test-user-ids ID`.", + ), + ( + "Credentials from requested scans", + _credential_summary_rows(data, "available_scan_credentials"), + "chat_credentials_scans", + ( + "Discover these with `strix cloud chat credentials CHAT_ID --scan-ids SCAN_ID`; " + "attach them with `strix cloud chat credentials set CHAT_ID --scan-ids SCAN_ID`." + ), + ), + ) + for title, rows, section_view, section_hint in sections: + console.print(f"[bold]{title}[/]") + _print_table( + console, + rows, + view=section_view, + hint=section_hint, + show_json_hint=False, + ) + console.print("[dim]Use --json for the complete credential metadata.[/]") + + +def _integration_rows(data: Any) -> list[dict[str, Any]] | None: + """Flatten the two integration collections into one compact human view.""" + if not _is_record(data): + return _list_of_dicts(data) + rows: list[dict[str, Any]] = [] + found_collection = False + for key in ("integrations", "merge_accounts"): + collection = data.get(key) + if not _is_list(collection): + continue + found_collection = True + for item in collection: + if not _is_record(item): + continue + row = dict(item) + if not row.get("account_login") and row.get("account_email"): + row["account_login"] = row["account_email"] + rows.append(row) + return rows if found_collection else None + + +def _token_rows(data: Any) -> list[dict[str, Any]] | None: + """Add an explicit lifecycle state to token rows for the human view.""" + records = _list_of_dicts(data) + if records is None: + return None + rows: list[dict[str, Any]] = [] + for record in records: + row = dict(record) + row["access"] = _scope_summary(record.get("rbac_scopes")) + if record.get("revoked_at"): + row["status"] = "revoked" + elif _timestamp_has_passed(record.get("expires_at")): + row["status"] = "expired" + else: + row["status"] = "active" + rows.append(row) + return rows + + +def _timestamp_has_passed(value: Any) -> bool: + if not isinstance(value, str) or not value.strip(): + return False + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return False + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=UTC) + return parsed <= datetime.now(UTC) + + +def _scan_rows(data: Any) -> list[dict[str, Any]] | None: + """Flatten the nested target and finding summaries returned by scan lists.""" + records = _list_of_dicts(data) + if records is None: + return None + rows: list[dict[str, Any]] = [] + for record in records: + row = dict(record) + if not row.get("title") and row.get("name"): + row["title"] = row["name"] + if not row.get("id") and isinstance(row.get("scan_id"), str): + row["id"] = row["scan_id"] + + targets = _scan_targets(record) + if targets: + visible_targets = targets[:2] + summary = " | ".join(visible_targets) + if len(targets) > len(visible_targets): + summary += f" (+{len(targets) - len(visible_targets)} more)" + row["target"] = summary + + findings = record.get("findings") + if _is_record(findings) and findings.get("total") is not None: + row["findings_count"] = findings["total"] + rows.append(row) + return rows + + +def _scan_targets(record: dict[str, Any]) -> list[str]: + targets: list[str] = [] + urls = record.get("urls") + if _is_list(urls): + targets.extend(url.strip() for url in urls if isinstance(url, str) and url.strip()) + repositories = record.get("repositories") + if _is_list(repositories): + for repository in repositories: + if not _is_record(repository): + continue + identifier = str( + repository.get("full_name") or repository.get("name") or repository.get("url") or "" + ).strip() + branch = str(repository.get("branch") or "").strip() + if identifier: + targets.append(f"{identifier} @ {branch}" if branch else identifier) + internal_targets = record.get("internal_targets") + if _is_list(internal_targets): + targets.extend( + target.strip() + for target in internal_targets + if isinstance(target, str) and target.strip() + ) + if record.get("has_code_upload") is True: + targets.append("uploaded source") + return targets + + +def _pr_review_rows(data: Any) -> list[dict[str, Any]] | None: + """Collapse related PR fields into an eight-column, action-oriented human view.""" + records = _list_of_dicts(data) + if records is None: + return None + rows: list[dict[str, Any]] = [] + for record in records: + row = dict(record) + number = record.get("pr_number") + title = str(record.get("pr_title") or "").strip() + row["repository"] = record.get("repository_full_name") or record.get("repository") + pull_request = " ".join( + part for part in (f"#{number}" if number is not None else "", title) if part + ) + pr_state = str(record.get("pr_state") or "").strip() + row["pull_request"] = f"{pull_request} [{pr_state}]" if pr_state else pull_request + head = str(record.get("head_branch") or "").strip() + base = str(record.get("base_branch") or "").strip() + row["branches"] = f"{head} → {base}" if head and base else head or base + findings = record.get("findings") + total = findings.get("total") if _is_record(findings) else None + unresolved = findings.get("unresolved") if _is_record(findings) else None + opened = unresolved.get("total") if _is_record(unresolved) else None + if not isinstance(total, int): + total = record.get("findings_count") + if not isinstance(opened, int): + opened = record.get("open_findings_count") + if isinstance(total, int) and isinstance(opened, int): + row["findings"] = f"{opened} open / {total} total" + elif isinstance(total, int): + row["findings"] = total + rows.append(row) + return rows + + +def _vulnerability_history_rows(records: list[dict[str, Any]]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for record in records: + row = dict(record) + previous = record.get("previous_snoozed_until") + current = record.get("new_snoozed_until") + if previous != current: + if previous and current: + row["snooze"] = f"{previous} → {current}" + elif current: + row["snooze"] = f"set until {current}" + else: + row["snooze"] = f"cleared (was {previous})" + rows.append(row) + return rows + + +def _print_table( + console: Console, + rows: list[dict[str, Any]], + *, + row_numbers: bool = False, + omit_columns: frozenset[str] = frozenset(), + hint: str | None = None, + view: str | None = None, + show_json_hint: bool = True, +) -> None: + if not rows: + console.print("[dim]No items.[/]") + if hint: + console.print(f"[dim]{escape(sanitize_terminal_text(hint))}[/]") + return + integration_view = view == "GET /integrations" + visible_internal: set[str] = {"installation_id"} if integration_view else set() + view_omissions: set[str] = {"id"} if integration_view else set() + omit_columns = omit_columns | (_INTERNAL_COLUMNS - visible_internal) | view_omissions + preferred = _VIEW_COLUMNS.get(view or "", _PREFERRED_KEYS) + columns: list[str] = [ + key + for key in preferred + if key not in omit_columns and any(_meaningful(row.get(key)) for row in rows) + ] + if view not in _VIEW_COLUMNS: + _append_fallback_columns(rows, columns, omit_columns) + columns = columns[:_MAX_TABLE_COLUMNS] + if console.width < _NARROW_TABLE_WIDTH: + _print_cards(console, rows, columns, row_numbers=row_numbers) + _print_copyable_selectors(console, rows, columns) + footer = f"{len(rows)} item(s)." + if show_json_hint: + footer += " Use --json for the full records." + console.print(f"[dim]{footer}[/]") + if hint: + console.print(f"[dim]{escape(sanitize_terminal_text(hint))}[/]") + return + table = Table(show_lines=False) + if row_numbers: + table.add_column("#", justify="right", style="cyan", no_wrap=True) + for column in columns: + table.add_column( + escape(_human_label(column)), + no_wrap=_selector_can_no_wrap(column, rows), + ) + for index, row in enumerate(rows, start=1): + cells = [escape(_cell(row.get(column))) for column in columns] + if row_numbers: + cells.insert(0, str(index)) + table.add_row(*cells) + console.print(table) + _print_copyable_selectors(console, rows, columns) + footer = f"{len(rows)} item(s)." + if show_json_hint: + footer += " Use --json for the full records." + console.print(f"[dim]{footer}[/]") + if hint: + console.print(f"[dim]{escape(sanitize_terminal_text(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]•[/] " + if not parts: + console.print(prefix.rstrip()) + continue + console.print(prefix + parts[0], soft_wrap=True) + continuation = " " if row_numbers else " " + for part in parts[1:]: + console.print(continuation + part, soft_wrap=True) + + +def _append_fallback_columns( + rows: list[dict[str, Any]], + columns: list[str], + omit_columns: frozenset[str], +) -> None: + 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) + + +def _meaningful(value: Any) -> bool: + return value is not None and value not in ("", [], {}) + + +def _is_selector_column(column: str) -> bool: + return column in _COPYABLE_SELECTOR_COLUMNS + + +def _selector_can_no_wrap(column: str, rows: list[dict[str, Any]]) -> bool: + return _is_selector_column(column) and all( + len(str(row[column])) <= _SELECTOR_NO_WRAP_MAX + for row in rows + if row.get(column) is not None + ) + + +def _print_copyable_selectors( + console: Console, + rows: list[dict[str, Any]], + columns: list[str], +) -> None: + """Print command selectors losslessly when their compact cell is shortened.""" + selector_columns = [column for column in columns if _is_selector_column(column)] + selectors = [ + (index, row, column, str(row[column])) + for index, row in enumerate(rows, start=1) + for column in selector_columns + if row.get(column) is not None and len(str(row[column])) > _SELECTOR_NO_WRAP_MAX + ] + if not selectors: + return + console.print("[dim]Copyable selectors:[/]") + for index, row, column, value in selectors: + label = next( + ( + str(row[key]) + for key in ("title", "name", "label", "domain", "full_name") + if row.get(key) + ), + f"item {index}", + ) + console.print( + f" {index}. {sanitize_terminal_text(label)} ({_human_label(column)}): " + f"{sanitize_terminal_text(value)}", + markup=False, + soft_wrap=True, + ) + + +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 _DETAIL_PRIORITY_KEYS if key in data and key not in _INTERNAL_COLUMNS] + keys.extend( + key + for key in _PREFERRED_KEYS + if key in data and 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") + populated_keys = [key for key in keys if data.get(key) is not None] + visible_keys = populated_keys[:_MAX_DETAIL_FIELDS] + lossless_fields: list[tuple[str, Any]] = [] + for key in visible_keys: + value = data.get(key) + if _is_lossless_detail(key, value): + lossless_fields.append((key, value)) + continue + rendered = ( + _nested_summary(value) if _is_record(value) or _is_list(value) else _detail_cell(value) + ) + table.add_row(escape(_human_label(key)), escape(rendered)) + if table.row_count: + console.print(table) + for key, value in lossless_fields: + console.print(f"{_human_label(key)}:", style="bold cyan", markup=False) + console.print(_lossless_detail_value(value), markup=False, soft_wrap=True) + if len(populated_keys) > len(visible_keys): + console.print( + f"[dim]{len(populated_keys) - len(visible_keys)} additional field(s) omitted from " + "this view.[/]" + ) + console.print("[dim]Use --json for the lossless machine-readable record.[/]") + + +def _is_lossless_detail(key: str, value: Any) -> bool: + """Keep one-time credentials and enrollment commands complete and copyable.""" + sensitive_key = key in _LOSSLESS_DETAIL_KEYS or key.endswith(("_token", "_secret")) + return sensitive_key and not _is_record(value) and not _is_list(value) + + +def _lossless_detail_value(value: Any) -> str: + """Preserve structural newlines while making every other control byte visible.""" + return "\n".join(sanitize_terminal_text(line) for line in str(value).split("\n")) + + +def _nested_summary(value: dict[str, Any] | list[Any]) -> str: + """Bound nested records so one detail response cannot flood a terminal.""" + if _is_record(value): + scalar_items = [ + (nested_key, nested_value) + for nested_key, nested_value in value.items() + if not isinstance(nested_value, dict | list) and nested_value is not None + ] + lines = [ + f"{_human_label(str(nested_key))}: {_cell(nested_value)}" + for nested_key, nested_value in scalar_items[:_MAX_NESTED_PREVIEW] + ] + omitted = len(value) - len(lines) + if omitted > 0: + lines.append(f"… {omitted} more field(s)") + return "\n".join(lines) if lines else f"{len(value)} nested field(s)" + if not _is_list(value): + return "none" + if not value: + return "none" + if all(not isinstance(item, dict | list) for item in value): + preview = ", ".join(_cell(item) for item in value[:12]) + if len(value) > 12: + preview += f", … {len(value) - 12} more" + return preview + records = [item for item in value if _is_record(item)] + lines = [f"{len(value)} item(s)"] + for record in records[:_MAX_NESTED_PREVIEW]: + label = record.get("title") or record.get("name") or record.get("message") + severity = record.get("severity") + status = record.get("status") or record.get("state") + prefix = " / ".join(_cell(part) for part in (severity, status) if part) + summary = str(label or record.get("id") or "record") + lines.append(f"- {prefix + ': ' if prefix else ''}{_cell(summary)}") + if len(value) > len(records[:_MAX_NESTED_PREVIEW]): + lines.append(f"… {len(value) - len(records[:_MAX_NESTED_PREVIEW])} more; use --json") + return "\n".join(lines) + + +def _print_source_manifest(console: Console, data: dict[str, Any]) -> None: + source = data.get("source") + manifest = source if _is_record(source) else data + files = manifest.get("files") + summary = {key: value for key, value in manifest.items() if key != "files"} + _print_detail(console, summary) + if _is_list(files): + console.print(f"\n[bold]Selected files ({len(files):,})[/]") + for path in files: + console.print(f" {escape(sanitize_terminal_text(path))}", soft_wrap=True) + + +def _print_supply_chain_summary(console: Console, data: dict[str, Any]) -> None: + """Render organization totals and one actionable row per repository.""" + totals = data.get("totals") + console.print("[bold]Supply-chain totals[/]") + _print_table( + console, + [dict(totals)] if _is_record(totals) else [], + view="supply_chain_totals", + show_json_hint=False, + ) + + console.print("[bold]Repositories[/]") + _print_table( + console, + _supply_chain_repository_rows(data), + view="supply_chain_repositories", + hint="Inspect one repository with `strix cloud repos supply-chain summary ID`.", + show_json_hint=False, + ) + console.print("[dim]Use --json for complete totals and repository records.[/]") + + +def _supply_chain_repository_rows(data: dict[str, Any]) -> list[dict[str, Any]]: + entries = data.get("repositories") + if not _is_list(entries): + return [] + rows: list[dict[str, Any]] = [] + for entry in entries: + if not _is_record(entry): + continue + repository = entry.get("repository") + summary = entry.get("summary") + if not _is_record(repository) or not _is_record(summary): + continue + row: dict[str, Any] = { + "repository": repository.get("full_name") or repository.get("name"), + "components": summary.get("component_count", 0), + "findings": summary.get("finding_count", 0), + "severity": _supply_chain_severity_summary(summary.get("severity_counts")), + "risks": _supply_chain_risk_summary(summary), + "latest_scan": _supply_chain_scan_summary(entry.get("latest_supply_chain_scan")), + "policy": _supply_chain_policy_summary(summary.get("policy")), + "id": repository.get("id"), + } + rows.append(row) + return rows + + +def _supply_chain_risk_summary(summary: dict[str, Any]) -> str: + return " · ".join( + ( + f"{summary.get('malicious_count', 0)} malicious", + f"{summary.get('suspicious_count', 0)} suspicious", + f"{summary.get('vulnerable_count', 0)} vulnerable", + ) + ) + + +def _supply_chain_severity_summary(value: Any) -> str: + if not _is_record(value): + return "none" + ordered = ("critical", "high", "medium", "low", "info", "unknown") + counts = [f"{key} {value[key]}" for key in ordered if isinstance(value.get(key), int)] + return " · ".join(counts) if counts else "none" + + +def _supply_chain_scan_summary(value: Any) -> str: + if not _is_record(value): + return "not run" + status = str(value.get("status") or "unknown") + created_at = str(value.get("created_at") or "").strip() + return f"{status} · {created_at}" if created_at else status + + +def _supply_chain_policy_summary(value: Any) -> str: + if not _is_record(value): + return "unknown" + if value.get("enabled") is False: + return "disabled" + mode = str(value.get("mode") or "monitor") + if value.get("pr_checks_enabled") is False: + return f"{mode} · PR checks off" + return mode + + +def _print_analytics(console: Console, data: dict[str, Any]) -> None: + rows = list(_flatten_summary(data)) + table = Table(show_header=False, show_edge=False, box=None, padding=(0, 2)) + table.add_column("metric", style="bold cyan") + table.add_column("value", overflow="fold") + for label, value in rows[:_MAX_DETAIL_FIELDS]: + table.add_row(escape(label), escape(value)) + console.print(table) + if len(rows) > _MAX_DETAIL_FIELDS: + console.print( + f"[dim]Showing {_MAX_DETAIL_FIELDS} of {len(rows)} summary metrics. " + "Use --json for all data.[/]" + ) + else: + console.print("[dim]Use --json for the complete analytics record.[/]") + + +def _flatten_summary(value: Any, prefix: str = "", depth: int = 0) -> Iterable[tuple[str, str]]: + if _is_record(value) and depth < 4: + for key, nested in value.items(): + label = f"{prefix} / {_human_label(key)}" if prefix else _human_label(key) + yield from _flatten_summary(nested, label, depth + 1) + return + if _is_list(value): + if all(not isinstance(item, dict | list) for item in value): + yield prefix, _nested_summary(value) + else: + yield prefix, f"{len(value)} data point(s)" + return + yield prefix or "value", _cell(value) + + +def _print_scan_frequency(console: Console, data: Any) -> None: + rows = _find_record_series(data) + if rows is None: + if _is_record(data): + _print_analytics(console, data) + else: + console.print_json(json.dumps(data, default=str)) + return + nonzero = [row for row in rows if _row_has_activity(row)] + selected = (nonzero[-30:] if nonzero else rows[-14:]) if rows else [] + _print_table(console, selected, view="GET /analytics/scan-frequency") + if rows: + qualifier = "non-zero" if nonzero else "most recent" + console.print( + f"[dim]Showing {len(selected)} {qualifier} point(s) from {len(rows)} total. " + "Use --json for the full series.[/]" + ) + + +def _find_record_series(data: Any) -> list[dict[str, Any]] | None: + direct = _list_of_dicts(data) + if direct is not None: + return direct + if _is_record(data): + candidates = [ + series for value in data.values() if (series := _find_record_series(value)) is not None + ] + if candidates: + return max(candidates, key=len) + return None + + +def _row_has_activity(row: dict[str, Any]) -> bool: + count_keys = ("count", "scans", "scan_count", "total", "value") + return any(isinstance(row.get(key), int | float) and row[key] > 0 for key in count_keys) + + +def _human_label(column: str) -> str: + column = sanitize_terminal_text(column) + if column == "secret_prefix": + return "prefix" + 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", + } + return labels.get(column, _CAMEL_BOUNDARY.sub(" ", column).replace("_", " ").lower()) + + +def _cell(value: Any) -> str: + if value is None: + return "" + if isinstance(value, bool): + return "yes" if value else "no" + if _is_list(value) and all(not _is_record(item) and not _is_list(item) for item in value): + text = ", ".join(str(item) for item in value) + elif _is_record(value) or _is_list(value): + text = f"{len(value)} item(s)" + else: + text = str(value) + text = sanitize_terminal_text(text) + if len(text) > _MAX_CELL_LENGTH: + return text[: _MAX_CELL_LENGTH - 1] + "…" + return text + + +def _detail_cell(value: Any) -> str: + """Keep prose useful in a detail view while bounding hostile responses.""" + if value is None: + return "" + if isinstance(value, bool): + return "yes" if value else "no" + text = "\n".join(sanitize_terminal_text(line) for line in str(value).split("\n")) + if len(text) > _MAX_DETAIL_CELL_LENGTH: + suffix = "… [truncated; use --json]" + return text[: _MAX_DETAIL_CELL_LENGTH - len(suffix)] + suffix + return text diff --git a/strix/interface/cloud/runner.py b/strix/interface/cloud/runner.py new file mode 100644 index 00000000..db7169fc --- /dev/null +++ b/strix/interface/cloud/runner.py @@ -0,0 +1,1127 @@ +"""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 math +import os +import re +import sys +import tempfile +import time +import webbrowser +from contextlib import suppress +from pathlib import Path +from typing import TYPE_CHECKING, Any, cast +from urllib.parse import quote +from uuid import uuid4 + +import requests +from rich.console import Console +from rich.markup import escape + +import strix.interface.cloud.http as http # noqa: PLR0402 +from strix.interface.cloud.arguments import CloudArgumentParser +from strix.interface.cloud.billing import run_topup +from strix.interface.cloud.render import emit, json_mode +from strix.interface.cloud.source_scan import LocalSourceScan +from strix.interface.cloud.spec import DEFAULT_VERBS, SPEC, Cmd, P +from strix.interface.terminal_text import sanitize_terminal_text +from strix.interface.url_safety import is_safe_web_url + + +if TYPE_CHECKING: + from collections.abc import Iterator + + +_PLACEHOLDER = re.compile(r"\{([^{}]+)\}") +_CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])") +_WAIT_POLL_S = 15 +_DEFAULT_WAIT_TIMEOUT_S = 4 * 60 * 60 +_TERMINAL_STATUSES = frozenset( + { + "completed", + "failed", + "cancelled", + "canceled", + "stopped", + "error", + "expired", + "succeeded", + } +) +_DEFINITIVE_SCAN_REJECTION_STATUSES = frozenset({400, 401, 402, 403, 404, 409, 413, 415, 422}) +_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/=-]{0,199}$") +_IDEMPOTENCY_RETRY_DELAYS_S = (0.25, 1.0) +_RETRYABLE_IDEMPOTENCY_CODES = frozenset( + {"idempotency_request_in_progress", "idempotency_outcome_unknown"} +) + + +def _dest(name: str) -> str: + return _CAMEL_BOUNDARY.sub("_", name).lower() + + +def _metavar(name: str) -> str: + return _CAMEL_BOUNDARY.sub("_", name).upper() + + +def _positive_seconds(value: str) -> float: + try: + parsed = float(value) + except ValueError as exc: + raise argparse.ArgumentTypeError("must be a number greater than 0") from exc + if not math.isfinite(parsed) or parsed <= 0: + raise argparse.ArgumentTypeError("must be a finite number greater than 0") + return parsed + + +def _resolve_idempotency_key(cmd: Cmd, args: argparse.Namespace) -> str | None: + if not cmd.idempotent: + return None + supplied = getattr(args, "idempotency_key", None) + key = supplied if isinstance(supplied, str) else str(uuid4()) + if not _IDEMPOTENCY_KEY.fullmatch(key): + raise http.CloudError( + "--idempotency-key must be 1-200 characters, start with a letter or digit, and " + "contain only letters, digits, '.', '_', ':', '/', '=', or '-'.", + exit_code=http.EXIT_USAGE, + ) + return key + + +def _audit_export_format(cmd: Cmd, query: dict[str, Any]) -> str | None: + if cmd.method != "GET" or cmd.path != "/audit": + return None + value = query.get("format") + if not isinstance(value, str): + return None + normalized = value.strip().lower() + return normalized if normalized in {"csv", "ndjson", "jsonl", "snowflake", "splunk"} else None + + +def _contains_response_key(value: Any, keys: frozenset[str], *, depth: int = 0) -> bool: + if depth > 2: + return False + if isinstance(value, dict): + fields = cast("dict[str, Any]", value) + if any(key in fields and fields[key] not in (None, "") for key in keys): + return True + return any(_contains_response_key(item, keys, depth=depth + 1) for item in fields.values()) + return False + + +def _one_time_secret_warning(cmd: Cmd, args: argparse.Namespace, result: Any) -> str | None: + if ( + cmd.method == "POST" + and cmd.path == "/tokens" + and _contains_response_key(result, frozenset({"token", "api_token", "secret"})) + ): + return "This API token is shown only once. Store it securely before leaving this output." + if cmd.path.startswith("/webhooks") and _contains_response_key( + result, frozenset({"secret", "signing_secret", "webhook_secret"}) + ): + return ( + "This webhook signing secret is shown only once. Store it securely before leaving " + "this output." + ) + connector_command_requested = cmd.method == "POST" or bool( + getattr(args, "include_command", False) + ) + if ( + cmd.path.startswith("/connectors") + and connector_command_requested + and _contains_response_key( + result, frozenset({"command", "enrollment_command", "docker_command", "token"}) + ) + ): + return ( + "This connector enrollment command contains one-time credentials. Store it securely " + "and do not share it." + ) + return None + + +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) + as_json = json_mode(flag="--json" in argv) + raw_binary_stdout = _argv_uses_raw_binary_stdout(cmd, argv) + try: + args = parser.parse_args(argv) + except KeyboardInterrupt: + _emit_interrupted(console, as_json=as_json, to_stderr=raw_binary_stdout) + return 130 + except http.CloudError as exc: + _emit_error( + console, + exc, + as_json=as_json and not raw_binary_stdout, + to_stderr=raw_binary_stdout, + ) + return exc.exit_code + except SystemExit as exc: + return exc.code if isinstance(exc.code, int) else 2 + + path = cmd.path + for name in _PLACEHOLDER.findall(cmd.path): + value = quote(str(getattr(args, _dest(name))), safe="") + path = path.replace("{" + name + "}", value) + + as_json = json_mode(flag=bool(getattr(args, "json", False))) + raw_binary_stdout = _uses_raw_binary_stdout(cmd, args) + token = getattr(args, "token", None) + try: + http.configure( + base_url=getattr(args, "app_url", None), + timeout=getattr(args, "timeout", None), + token_override=bool(token), + workspace_id=getattr(args, "workspace_id", None), + ) + query = _collect(args, cmd.query) + body = _collect(args, cmd.body) + data = getattr(args, "data", None) + if data: + _merge_extra_body(body, _load_data(data)) + _validate_body(cmd, body) + 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 KeyboardInterrupt: + _emit_interrupted(console, as_json=as_json, to_stderr=raw_binary_stdout) + return 130 + except http.CloudError as exc: + _emit_error( + console, + exc, + as_json=as_json and not raw_binary_stdout, + to_stderr=raw_binary_stdout, + ) + return exc.exit_code + + +def _uses_raw_binary_stdout(cmd: Cmd, args: argparse.Namespace) -> bool: + if bool(getattr(args, "json", False)) or getattr(args, "output", None): + return False + format_value = str(getattr(args, "format", "") or "").strip().lower() + audit_export = ( + cmd.method == "GET" + and cmd.path == "/audit" + and format_value + in { + "csv", + "ndjson", + "jsonl", + "snowflake", + "splunk", + } + ) + return not sys.stdout.isatty() and bool(cmd.binary or audit_export) + + +def _argv_uses_raw_binary_stdout(cmd: Cmd, argv: list[str]) -> bool: + """Choose the error channel before argparse can reject a binary command.""" + if "--json" in argv or any(arg.startswith("--json=") for arg in argv): + return False + has_output = any( + (arg.startswith("--output=") and bool(arg.partition("=")[2])) + or (arg == "--output" and index + 1 < len(argv) and not argv[index + 1].startswith("-")) + for index, arg in enumerate(argv) + ) + if has_output: + return False + format_value = "" + for index, arg in enumerate(argv): + if arg.startswith("--format="): + format_value = arg.partition("=")[2] + elif arg == "--format" and index + 1 < len(argv): + format_value = argv[index + 1] + audit_export = ( + cmd.method == "GET" + and cmd.path == "/audit" + and format_value.lower() + in { + "csv", + "ndjson", + "jsonl", + "snowflake", + "splunk", + } + ) + return not sys.stdout.isatty() and bool(cmd.binary or audit_export) + + +def _request_with_idempotency( + cmd: Cmd, + path: str, + *, + token: str | None, + query: dict[str, Any], + body: dict[str, Any], + stream: bool, + idempotency_key: str | None, +) -> requests.Response: + """Retry only exact, caller-keyed mutations whose outcome may be ambiguous.""" + attempts = 1 + (len(_IDEMPOTENCY_RETRY_DELAYS_S) if idempotency_key else 0) + for attempt in range(attempts): + try: + response = http.request( + cmd.method, + path, + token=token, + query=query or None, + body=body if cmd.method in ("POST", "PUT", "PATCH") else None, + stream=stream, + idempotency_key=idempotency_key, + ) + except http.CloudTransportError: + if attempt + 1 >= attempts: + raise + else: + if attempt + 1 >= attempts or not _idempotency_response_is_retryable(response): + return response + response.close() + time.sleep(_IDEMPOTENCY_RETRY_DELAYS_S[attempt]) + raise AssertionError("idempotent request retry loop exhausted without returning") + + +def _idempotency_response_is_retryable(response: requests.Response) -> bool: + if 500 <= response.status_code < 600 or response.status_code == 429: + return True + if response.status_code != 409: + return False + payload = http.parsed(response) + if not isinstance(payload, dict): + return False + fields = cast("dict[str, Any]", payload) + return fields.get("retry_safe") is True or fields.get("code") in _RETRYABLE_IDEMPOTENCY_CODES + + +def _scan_rejection_is_definitive(response: requests.Response) -> bool: + payload = http.parsed(response) + if isinstance(payload, dict): + fields = cast("dict[str, Any]", payload) + if fields.get("retry_safe") is True: + return False + if fields.get("terminal") is True: + return True + return response.status_code in _DEFINITIVE_SCAN_REJECTION_STATUSES + + +def _execute( # noqa: PLR0912, PLR0915 + 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 run_topup(console, args, body, as_json=as_json, token=token) + audit_export = _audit_export_format(cmd, query) + output_path = getattr(args, "output", None) + explicit_json = bool(getattr(args, "json", False)) + binary_response = bool(cmd.binary or audit_export) + if binary_response and explicit_json and not output_path: + raise http.CloudError( + "--json for a binary response requires --output FILE; omit --json only when " + "intentionally redirecting the raw bytes.", + exit_code=http.EXIT_USAGE, + ) + binary_json_metadata = explicit_json or bool(output_path and not sys.stdout.isatty()) + if cmd.path == "/audit" and getattr(args, "output", None) and not audit_export: + raise http.CloudError( + "--output requires --format csv, ndjson, jsonl, snowflake, or splunk.", + exit_code=http.EXIT_USAGE, + ) + idempotency_key = _resolve_idempotency_key(cmd, args) + source_workflow = LocalSourceScan(idempotency_key=idempotency_key) + scan_request_started = False + try: + if cmd.path == "/scans" and cmd.method == "POST": + _set_default_scan_engagement( + body, + has_local_source=getattr(args, "source", None) is not None, + ) + if source_workflow.prepare_and_attach( + console, + args, + body, + as_json=as_json, + token=token, + ): + return http.EXIT_OK + + source_workflow.mark_launch_started() + # Every wait-path mutation creates a scan, even when the endpoint has + # not yet adopted idempotency keys (for example vulnerability retests). + # Once sent, transport and malformed-success failures are ambiguous. + scan_request_started = cmd.idempotent or cmd.wait_path is not None + response = _request_with_idempotency( + cmd, + path, + token=token, + query=query, + body=body, + stream=bool(cmd.binary or audit_export), + idempotency_key=idempotency_key, + ) + except BaseException as exc: + source_workflow.handle_request_failure(exc, token=token) + if scan_request_started: + if isinstance(exc, KeyboardInterrupt): + raise _interrupted_scan_launch_error(idempotency_key) from None + if isinstance(exc, Exception): + raise _ambiguous_scan_launch_error(exc, idempotency_key) from exc + raise + finally: + source_workflow.close() + if audit_export: + return _emit_binary( + console, + response, + output_path, + force=bool(getattr(args, "force", False)), + json_metadata=binary_json_metadata, + ) + if cmd.binary: + return _emit_binary( + console, + response, + output_path, + force=bool(getattr(args, "force", False)), + json_metadata=binary_json_metadata, + ) + try: + result = _validated_operation_result(http.check(response), cmd) + except BaseException as exc: + source_workflow.handle_response_failure( + exc, + definitive=_scan_rejection_is_definitive(response), + token=token, + ) + if ( + source_workflow.upload_id is None + and scan_request_started + and not _scan_rejection_is_definitive(response) + and isinstance(exc, Exception) + ): + raise _ambiguous_scan_launch_error(exc, idempotency_key) from exc + raise + if getattr(args, "wait", False): + wait_timeout = cast("float", getattr(args, "wait_timeout", _DEFAULT_WAIT_TIMEOUT_S)) + try: + if cmd.wait_self: + result = _poll( + console, + path, + token=token, + as_json=as_json, + wait_timeout=wait_timeout, + ) + elif cmd.wait_path: + result = _wait( + console, + cmd, + result, + token=token, + as_json=as_json, + wait_timeout=wait_timeout, + ) + except KeyboardInterrupt: + raise _wait_status_error(result, interrupted=True) from None + except http.CloudError as exc: + raise _wait_status_error(result, error=exc) from exc + result = source_workflow.wrap_result(result, args) + if cmd.link: + return _handoff_link(console, cmd, args, result, as_json=as_json) + workspace_list = cmd.method == "GET" and cmd.path == "/workspaces" + integration_list = cmd.method == "GET" and cmd.path == "/integrations" + emit( + console, + result, + as_json=as_json, + row_numbers=workspace_list or integration_list, + omit_columns=frozenset({"id"}) if workspace_list else frozenset(), + hint=( + "Switch with `strix cloud workspaces use NUMBER`." + if workspace_list + else ( + "For Git providers, disconnect with `strix cloud integrations disconnect " + "PROVIDER --installation-id INSTALLATION_ID`; omit the ID for Slack." + if integration_list + else None + ) + ), + view=f"{cmd.method} {cmd.path}", + warning=_one_time_secret_warning(cmd, args, result), + ) + 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 _validate_body(cmd: Cmd, body: dict[str, Any]) -> None: + missing = [ + "--" + (param.flag or param.name.replace("_", "-")) + for param in cmd.body + if param.required and body.get(param.name) is None + ] + if missing: + raise http.CloudError( + "missing required request field(s): " + + ", ".join(missing) + + ". Supply them as options or with --data @file/-.", + exit_code=http.EXIT_USAGE, + ) + if ( + cmd.method == "POST" + and cmd.path == "/tokens" + and body.get("expires_at") is not None + and body.get("expires_in_days") is not None + ): + raise http.CloudError( + "--expires-at and --expires-in-days are mutually exclusive.", + exit_code=http.EXIT_USAGE, + ) + + +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.""" + 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: + raise http.CloudError( + f"the platform response did not include the expected {cmd.link or 'continuation'} URL." + ) + if not is_safe_web_url(url, trusted_origin=http.app_url()): + raise http.CloudError("the platform returned an invalid continuation URL.") + interactive = ( + not as_json + and sys.stdin.isatty() + and sys.stdout.isatty() + and not getattr(args, "no_browser", False) + ) + if as_json: + emit(console, result, as_json=True) + else: + console.print("Open this URL to continue:") + console.print(f" {sanitize_terminal_text(url)}", markup=False, soft_wrap=True) + 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 == "-": + 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.", exit_code=http.EXIT_USAGE) from exc + if not isinstance(parsed_value, dict): + raise http.CloudError("--data must be a JSON object.", exit_code=http.EXIT_USAGE) + return cast("dict[str, Any]", parsed_value) + + +def _merge_extra_body(body: dict[str, Any], extra_body: dict[str, Any]) -> None: + collisions = sorted(body.keys() & extra_body.keys()) + if collisions: + flags = ", ".join(f"--{name.replace('_', '-')}" for name in collisions) + raise http.CloudError( + f"--data cannot override explicit option(s): {flags}", + exit_code=http.EXIT_USAGE, + ) + body.update(extra_body) + + +def _build_parser(group: str, verb_label: str, cmd: Cmd) -> argparse.ArgumentParser: + parser = CloudArgumentParser(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: + _add_option(parser, param, required=param.required) + for param in cmd.body: + # Required body fields may be supplied securely through --data @file/-; + # validate them only after the two body sources are merged. + _add_option(parser, param, required=False) + json_help = "Print the raw JSON response." + if cmd.binary: + json_help = "With --output, print structured download metadata as JSON." + elif cmd.path == "/audit": + json_help = "Print JSON results, or download metadata when exporting with --output." + parser.add_argument("--json", action="store_true", help=json_help) + parser.add_argument("--token", default=None, help="API token override.") + parser.add_argument( + "--workspace-id", + default=None, + metavar="ORG_ID", + help="Expected workspace for an override CLI token (or STRIX_WORKSPACE_ID).", + ) + parser.add_argument("--app-url", default=None, metavar="URL", help="Platform URL override.") + parser.add_argument( + "--timeout", + default=None, + type=_positive_seconds, + 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.", + ) + _add_idempotency_option(parser, cmd) + 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 or cmd.path == "/audit": + output_help = ( + "Write the CSV or NDJSON-compatible export to this file." + if cmd.path == "/audit" and not cmd.binary + else "Write to this file." + ) + parser.add_argument("--output", default=None, metavar="FILE", help=output_help) + parser.add_argument( + "--force", + action="store_true", + help="Replace --output if it already exists.", + ) + 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." + ) + parser.add_argument( + "--wait-timeout", + type=_positive_seconds, + default=float(_DEFAULT_WAIT_TIMEOUT_S), + metavar="SECONDS", + help=( + "Maximum total time to wait before returning an error " + f"(default: {_DEFAULT_WAIT_TIMEOUT_S})." + ), + ) + if cmd.path == "/billing/topup": + payment_mode = parser.add_mutually_exclusive_group() + payment_mode.add_argument( + "--yes", + action="store_true", + help=( + "Explicitly authorize payment without a TTY prompt. Required in " + "non-interactive mode." + ), + ) + payment_mode.add_argument( + "--no-pay", + 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. " + "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.", + ) + source_approval = parser.add_mutually_exclusive_group() + source_approval.add_argument( + "--yes", + action="store_true", + help="Approve the source snapshot built by this invocation without a prompt.", + ) + source_approval.add_argument( + "--approve-sha256", + default=None, + metavar="SHA256", + help=( + "Upload only if the archive exactly matches this --dry-run SHA-256 digest. " + "Best for agent and CI approval handoffs." + ), + ) + 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 _add_idempotency_option(parser: argparse.ArgumentParser, cmd: Cmd) -> None: + if not cmd.idempotent: + return + parser.add_argument( + "--idempotency-key", + default=None, + metavar="KEY", + help=( + "Stable key for an exact retry after an ambiguous response. A fresh UUID is " + "generated when omitted; never reuse a key for a different request." + ), + ) + + +def _wait_status_error( + result: Any, + *, + error: http.CloudError | None = None, + interrupted: bool = False, +) -> http.CloudError: + operation_id = _created_id(result) + suffix = f" Operation ID: {operation_id}." if operation_id else "" + prefix = ( + "Interrupted while waiting" + if interrupted + else f"Waiting for the remote operation failed: {error}" + ) + message = ( + f"{prefix}; the remote operation may still be running.{suffix} " + "Check its status before retrying." + ) + payload: dict[str, Any] = { + "error": message, + "status_unknown": True, + } + if interrupted: + payload["interrupted"] = True + if operation_id: + payload["operation_id"] = operation_id + return http.CloudError( + message, + exit_code=130 if interrupted else (error.exit_code if error else http.EXIT_ERROR), + payload=payload, + ) + + +def _interrupted_scan_launch_error(idempotency_key: str | None = None) -> http.CloudError: + retry_note = _idempotency_retry_note(idempotency_key) + message = ( + "Interrupted while starting the scan. The launch outcome is unknown; check " + f"`strix cloud scans list` before retrying.{retry_note}" + ) + payload: dict[str, Any] = { + "error": message, + "interrupted": True, + "launch_outcome_unknown": True, + } + _attach_idempotency_recovery(payload, idempotency_key) + return http.CloudError( + message, + exit_code=130, + payload=payload, + ) + + +def _ambiguous_scan_launch_error( + error: Exception, idempotency_key: str | None = None +) -> http.CloudError: + retry_note = _idempotency_retry_note(idempotency_key) + message = ( + f"{error} The scan launch outcome is unknown; check `strix cloud scans list` before " + f"retrying to avoid a duplicate scan or charge.{retry_note}" + ) + payload: dict[str, Any] = { + "error": message, + "launch_outcome_unknown": True, + } + exit_code = http.EXIT_ERROR + if isinstance(error, http.CloudError): + exit_code = error.exit_code + raw_payload: Any = error.payload + error_payload = cast("dict[str, Any]", raw_payload) + if isinstance(raw_payload, dict): + payload.update(error_payload) + payload["error"] = message + _attach_idempotency_recovery(payload, idempotency_key) + return http.CloudError(message, exit_code=exit_code, payload=payload) + + +def _idempotency_retry_note(idempotency_key: str | None) -> str: + if not idempotency_key: + return "" + return ( + f" An exact retry is safe with the same request and `--idempotency-key {idempotency_key}`." + ) + + +def _attach_idempotency_recovery(payload: dict[str, Any], idempotency_key: str | None) -> None: + if not idempotency_key: + return + payload.update( + { + "idempotency_key": idempotency_key, + "retry_safe": True, + "retry_same_request": True, + } + ) + + +def _add_option(parser: argparse.ArgumentParser, param: P, *, required: bool) -> 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=required, + help=param.help, + ) + elif param.kind == "list": + parser.add_argument( + flag, + dest=param.name, + nargs="+", + default=None, + required=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=required, + help=param.help, + ) + else: + parser.add_argument(flag, dest=param.name, default=None, required=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 in ("json", "json-list") and isinstance(value, str): + try: + value = json.loads(value) + except ValueError as exc: + raise http.CloudError( + f"--{param.name.replace('_', '-')} must be JSON", + exit_code=http.EXIT_USAGE, + ) from exc + if param.kind == "json-list" and not isinstance(value, list): + raise http.CloudError( + f"--{param.name.replace('_', '-')} must be a JSON array", + exit_code=http.EXIT_USAGE, + ) + values[param.name] = value + return values + + +def _emit_binary( + console: Console, + response: Any, + output: str | None, + *, + force: bool = False, + json_metadata: bool = False, +) -> int: + try: + if not 200 <= response.status_code < 300: + http.check(response) + if output: + return _write_binary_file( + console, + response, + Path(output).expanduser(), + force=force, + as_json=json_metadata, + ) + if sys.stdout.isatty(): + raise http.CloudError( + "binary responses require --output FILE when stdout is a terminal; " + "redirect stdout only when intentionally piping the bytes.", + exit_code=http.EXIT_USAGE, + ) + output_stream: Any = getattr(sys.stdout, "buffer", None) + try: + for chunk in _response_chunks(response): + if output_stream is not None: + output_stream.write(chunk) + else: + sys.stdout.write(chunk.decode("utf-8")) + except (OSError, UnicodeDecodeError, requests.RequestException) as exc: + raise http.CloudError(f"could not write the response to stdout: {exc}") from exc + return http.EXIT_OK + finally: + close = getattr(response, "close", None) + if callable(close): + with suppress(Exception): + close() + + +def _write_binary_file( + console: Console, response: Any, path: Path, *, force: bool, as_json: bool +) -> int: + if path.exists() and not force: + raise http.CloudError( + f"refusing to replace existing file {path}; pass --force to overwrite it." + ) + temporary: Path | None = None + bytes_written = 0 + try: + try: + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + mode="wb", + prefix=f".{path.name}.", + suffix=".tmp", + dir=path.parent, + delete=False, + ) as stream: + temporary = Path(stream.name) + for chunk in _response_chunks(response): + stream.write(chunk) + bytes_written += len(chunk) + except (OSError, requests.RequestException) as exc: + raise http.CloudError(f"could not write {path}: {exc}") from exc + + try: + if force: + temporary.replace(path) + else: + os.link(temporary, path) + temporary.unlink() + except FileExistsError as exc: + raise http.CloudError( + f"refusing to replace existing file {path}; pass --force to overwrite it." + ) from exc + except OSError as exc: + raise http.CloudError(f"could not write {path}: {exc}") from exc + + if as_json: + content_type = str(getattr(response, "headers", {}).get("content-type", "")) + emit( + console, + { + "output": str(path), + "bytes": bytes_written, + **({"content_type": content_type} if content_type else {}), + }, + as_json=True, + view="binary_download", + ) + else: + console.print("Saved to:") + console.print(sanitize_terminal_text(path), markup=False, soft_wrap=True) + return http.EXIT_OK + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +def _response_chunks(response: Any) -> Iterator[bytes]: + iter_content = getattr(response, "iter_content", None) + if callable(iter_content): + chunks = cast("Iterator[bytes]", iter_content(chunk_size=1024 * 1024)) + for chunk in chunks: + if chunk: + yield bytes(chunk) + return + content = getattr(response, "content", b"") + if content: + yield bytes(content) + + +def _emit_error( + console: Console, exc: http.CloudError, *, as_json: bool, to_stderr: bool = False +) -> None: + if as_json: + raw_payload: Any = exc.payload + error_payload = cast("dict[str, Any]", raw_payload) + if isinstance(raw_payload, dict): + payload = dict(error_payload) + payload.setdefault("error", str(exc)) + if payload.get("detail") == payload.get("error"): + payload.pop("detail", None) + else: + 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 + target = Console(stderr=True) if to_stderr else console + target.print(f"[red]Error:[/] {escape(sanitize_terminal_text(exc))}") + + +def _emit_interrupted(console: Console, *, as_json: bool, to_stderr: bool) -> None: + if as_json and not to_stderr: + sys.stdout.write(json.dumps({"error": "Interrupted.", "interrupted": True}) + "\n") + return + target = Console(stderr=True) if to_stderr else console + target.print("[yellow]Interrupted.[/]") + + +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) and value.strip(): + return value + return None + + +def _validated_operation_result(result: Any, cmd: Cmd) -> Any: + """Reject malformed success bodies for mutations that create a scan.""" + if cmd.wait_path and _created_id(result) is None: + raise http.CloudError( + "the platform returned a successful operation response without an operation ID." + ) + return result + + +def _wait( + console: Console, + cmd: Cmd, + created: Any, + *, + token: str | None, + as_json: bool, + wait_timeout: float, +) -> Any: + item_id = _created_id(created) + if not cmd.wait_path: + return created + if not item_id: + raise http.CloudError( + "cannot wait because the platform response did not include an operation ID." + ) + path = cmd.wait_path.replace("{id}", str(item_id)) + if not as_json: + console.print( + f"[dim]Waiting for {escape(sanitize_terminal_text(item_id))} to reach a final state…[/]" + ) + return _poll( + console, + path, + token=token, + as_json=as_json, + wait_timeout=wait_timeout, + ) + + +def _poll( + console: Console, + path: str, + *, + token: str | None, + as_json: bool, + wait_timeout: float, +) -> Any: + """Poll a GET path until its status is final. Returns the last response.""" + deadline = time.monotonic() + wait_timeout + while True: + 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 fields if isinstance(current, dict) else current + if not as_json: + console.print( + f"[dim] status: {escape(sanitize_terminal_text(status or 'unknown'))}[/]" + ) + remaining = deadline - time.monotonic() + if remaining <= 0: + raise http.CloudError( + f"wait timed out after {wait_timeout:g} seconds; the remote operation is still " + "running. Re-run its get command to check the status." + ) + time.sleep(min(_WAIT_POLL_S, remaining)) diff --git a/strix/interface/cloud/session.py b/strix/interface/cloud/session.py new file mode 100644 index 00000000..d2ec0861 --- /dev/null +++ b/strix/interface/cloud/session.py @@ -0,0 +1,167 @@ +"""Inspect and safely narrow a managed Strix CLI session.""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING, Any, cast + +from rich.console import Console +from rich.markup import escape + +import strix.interface.cloud.http as http # noqa: PLR0402 +from strix.interface.cloud.arguments import CloudArgumentParser +from strix.interface.cloud.render import emit, json_mode +from strix.interface.platform_cli import read_record, save_record +from strix.interface.terminal_text import sanitize_terminal_text + + +if TYPE_CHECKING: + import argparse + + +def run_session(argv: list[str]) -> int: + console = Console() + normalized = ["show", *argv] if not argv or argv[0].startswith("-") else list(argv) + if normalized[0] == "help": + normalized = ["--help", *normalized[1:]] + if normalized[0] in {"-h", "--help"}: + _print_help(console) + return 0 + verb = normalized.pop(0) + if verb == "scopes" and normalized and normalized[0] == "set": + normalized.pop(0) + return _run_scopes_set(console, normalized) + if verb not in {"show", "scopes"}: + console.print(f"[red]Unknown session command:[/] {escape(sanitize_terminal_text(verb))}") + _print_help(console) + return http.EXIT_USAGE + return _run_show(console, normalized, scopes_only=verb == "scopes") + + +def _common(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--json", action="store_true", help="Print the raw JSON response.") + parser.add_argument("--show-scopes", action="store_true", help="Print every granted scope.") + parser.add_argument("--token", default=None, help="API token override.") + parser.add_argument("--workspace-id", default=None, metavar="ORG_ID") + parser.add_argument("--app-url", default=None, metavar="URL") + parser.add_argument("--timeout", default=None, type=float, metavar="SECONDS") + + +def _configure(args: argparse.Namespace) -> bool: + external = args.token is not None or bool(os.environ.get("STRIX_API_TOKEN", "").strip()) + http.configure( + base_url=args.app_url, + timeout=args.timeout, + token_override=bool(args.token), + workspace_id=args.workspace_id, + ) + return external + + +def _run_show(console: Console, argv: list[str], *, scopes_only: bool) -> int: + parser = CloudArgumentParser(prog=f"strix cloud session {'scopes' if scopes_only else 'show'}") + _common(parser) + as_json = json_mode(flag="--json" in argv) + try: + args = parser.parse_args(argv) + _configure(args) + payload = http.check(http.request("GET", "/cli/session", token=args.token)) + except SystemExit as exc: + return int(exc.code or 0) + except http.CloudError as exc: + return _error(console, exc, as_json=as_json) + if not isinstance(payload, dict): + return _error(console, http.CloudError("invalid CLI session response"), as_json=as_json) + record = cast("dict[str, Any]", payload) + if as_json: + emit(console, record, as_json=True) + return http.EXIT_OK + scopes = _string_list(record.get("scopes")) + ceiling = _string_list(record.get("scope_ceiling")) + profile = str(record.get("scope_profile") or "custom").title() + if not scopes_only: + device_name = escape(str(record.get("device_name") or "this device")) + console.print(f"[green]Active CLI session[/] on [bold]{device_name}[/]") + console.print(f" Workspace: {escape(str(record.get('organization_id') or 'unknown'))}") + console.print(f" Access: {profile} · {len(scopes)} scopes granted · {len(ceiling)} maximum") + if args.show_scopes or scopes_only: + console.print(f" Granted: [dim]{escape(' '.join(scopes))}[/]") + console.print(f" Ceiling: [dim]{escape(' '.join(ceiling))}[/]") + return http.EXIT_OK + + +def _run_scopes_set(console: Console, argv: list[str]) -> int: + parser = CloudArgumentParser( + prog="strix cloud session scopes set", + description="Change scopes within the access approved at browser sign-in.", + ) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("profile", nargs="?", choices=("minimal", "recommended", "full")) + mode.add_argument("--scopes", nargs="+", metavar="SCOPE") + _common(parser) + as_json = json_mode(flag="--json" in argv) + try: + args = parser.parse_args(argv) + external = _configure(args) + body = ( + {"scope_profile": args.profile} + if args.profile + else {"scope_profile": "custom", "scopes": args.scopes} + ) + payload = http.check(http.request("PATCH", "/cli/session", token=args.token, body=body)) + except SystemExit as exc: + return int(exc.code or 0) + except http.CloudError as exc: + return _error(console, exc, as_json=as_json) + if not isinstance(payload, dict): + return _error(console, http.CloudError("invalid CLI session response"), as_json=as_json) + result = cast("dict[str, Any]", payload) + if not external: + stored = read_record() + if stored is not None: + stored.update( + { + key: result[key] + for key in ("scopes", "requested_scopes", "scope_ceiling", "scope_profile") + if key in result + } + ) + save_record(stored) + if as_json: + emit(console, result, as_json=True) + else: + scopes = _string_list(result.get("scopes")) + profile = str(result.get("scope_profile") or "custom").title() + console.print(f"[green]✓ CLI access updated.[/] {profile} · {len(scopes)} scopes granted") + if args.show_scopes: + console.print(f" Scopes: [dim]{escape(' '.join(scopes))}[/]") + return http.EXIT_OK + + +def _string_list(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + items = cast("list[Any]", cast("Any", value)) + return [str(item) for item in items] + + +def _error(console: Console, error: http.CloudError, *, as_json: bool) -> int: + if as_json: + raw_payload: Any = error.payload + error_payload = cast("dict[str, Any]", raw_payload) + payload = dict(error_payload) if isinstance(raw_payload, dict) else {} + payload["error"] = str(error) + if payload.get("detail") == payload.get("error"): + payload.pop("detail", None) + emit(console, payload, as_json=True) + else: + console.print(f"[red]Error:[/] {escape(sanitize_terminal_text(error))}") + return error.exit_code + + +def _print_help(console: Console) -> None: + console.print("[bold]strix cloud session[/] commands:") + console.print(" show Show the remote CLI session (default).") + console.print(" scopes Show granted scopes and consent ceiling.") + console.print(" scopes set PROFILE Use minimal, recommended, or full.") + console.print(" scopes set --scopes SCOPE… Use a custom set within the ceiling.") diff --git a/strix/interface/cloud/source_scan.py b/strix/interface/cloud/source_scan.py new file mode 100644 index 00000000..7893bf5a --- /dev/null +++ b/strix/interface/cloud/source_scan.py @@ -0,0 +1,403 @@ +"""Local-source approval, upload, and scan-launch lifecycle.""" + +from __future__ import annotations + +import re +import sys +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, cast +from urllib.parse import quote + +from rich.markup import escape + +import strix.interface.cloud.http as http # noqa: PLR0402 +from strix.interface.cloud.render import emit +from strix.interface.cloud.source_upload import prepare_source, remove_bundle +from strix.interface.terminal_text import sanitize_terminal_text + + +if TYPE_CHECKING: + import argparse + from typing import NoReturn + + from rich.console import Console + + from strix.interface.cloud.source_upload import SourceBundle + + +_SHA256 = re.compile(r"^[0-9a-fA-F]{64}$") + + +@dataclass +class LocalSourceScan: + """Own one local bundle and its staged upload through a scan launch.""" + + bundle: SourceBundle | None = None + upload_id: str | None = None + idempotency_key: str | None = None + _launch_started: bool = False + + def prepare_and_attach( + self, + console: Console, + args: argparse.Namespace, + body: dict[str, Any], + *, + as_json: bool, + token: str | None, + ) -> bool: + """Prepare source, emit a dry run, or upload and attach it to ``body``. + + Returns ``True`` when a dry run was emitted and request execution should stop. + """ + self.bundle = prepare_scan_source(console, args, as_json=as_json) + if self.bundle is None: + return False + if getattr(args, "dry_run", False): + emit( + console, + {"source": self.bundle.summary(show_files=getattr(args, "show_files", False))}, + as_json=as_json, + view="source_manifest", + ) + return True + self.upload_id = _upload_scan_source(self.bundle, token=token) + existing = body.get("upload_ids") + body["upload_ids"] = [ + *(existing if isinstance(existing, list) else []), + self.upload_id, + ] + return False + + def mark_launch_started(self) -> None: + """Record that the scan-creation request may have reached the platform.""" + self._launch_started = self.upload_id is not None + + def handle_request_failure(self, error: BaseException, *, token: str | None) -> None: + """Clean or retain a staged upload according to request ambiguity.""" + if self.upload_id is None: + return + if self._launch_started: + if isinstance(error, KeyboardInterrupt): + raise _interrupted_source_upload_error( + self.upload_id, self.idempotency_key + ) from None + if isinstance(error, Exception): + raise _retained_source_upload_error( + self.upload_id, error, self.idempotency_key + ) from error + return + try: + _delete_upload(self.upload_id, token=token) + except (http.CloudError, KeyboardInterrupt) as cleanup_error: + if isinstance(error, Exception): + raise _source_cleanup_error(self.upload_id, error, cleanup_error) from error + interrupted = http.CloudError("source upload interrupted.", exit_code=130) + raise _source_cleanup_error(self.upload_id, interrupted, cleanup_error) from None + + def handle_response_failure( + self, + error: BaseException, + *, + definitive: bool, + token: str | None, + ) -> None: + """Clean a rejected upload or retain one whose scan result is ambiguous.""" + if self.upload_id is None: + return + if definitive: + try: + _delete_upload(self.upload_id, token=token) + except (http.CloudError, KeyboardInterrupt) as cleanup_error: + if isinstance(error, Exception): + raise _source_cleanup_error(self.upload_id, error, cleanup_error) from error + interrupted = http.CloudError("source upload interrupted.", exit_code=130) + raise _source_cleanup_error(self.upload_id, interrupted, cleanup_error) from None + return + if isinstance(error, Exception): + raise _retained_source_upload_error( + self.upload_id, error, self.idempotency_key + ) from error + + def wrap_result(self, result: Any, args: argparse.Namespace) -> Any: + """Attach the approved source manifest to a successful scan response.""" + if self.bundle is None: + return result + return { + "source": self.bundle.summary(show_files=getattr(args, "show_files", False)), + "upload_id": self.upload_id, + "scan": result, + } + + def close(self) -> None: + """Remove the private temporary bundle, if one was built.""" + if self.bundle is not None: + remove_bundle(self.bundle) + + +def prepare_scan_source( + console: Console, args: argparse.Namespace, *, as_json: bool +) -> SourceBundle | None: + """Build and approve the exact local-source snapshot for one invocation.""" + source = getattr(args, "source", None) + source_flags = ( + "dry_run", + "show_files", + "include_hidden", + "include_sensitive", + "include_archives", + "approve_sha256", + ) + if source is None: + if any(getattr(args, name, False) for name in source_flags) or getattr(args, "exclude", []): + raise http.CloudError("source upload options require --source DIRECTORY.") + return None + bundle = prepare_source( + source, + include_hidden=bool(getattr(args, "include_hidden", False)), + include_sensitive=bool(getattr(args, "include_sensitive", False)), + include_archives=bool(getattr(args, "include_archives", False)), + exclude=cast("list[str]", getattr(args, "exclude", [])), + ) + keep_bundle = False + try: + approved_digest = _validate_source_digest_approval(args, bundle) + if getattr(args, "dry_run", False): + keep_bundle = True + return bundle + if getattr(args, "yes", False) or approved_digest is not None: + keep_bundle = True + return bundle + if as_json or not (sys.stdin.isatty() and sys.stdout.isatty()): + _source_approval_error( + "source upload requires explicit approval in non-interactive mode. " + "Review with --dry-run --show-files, then rerun with " + "--approve-sha256 ; use --yes only for a deliberate " + "one-shot approval of the snapshot built by that invocation." + ) + console.print( + "[bold]Local source upload[/]\n" + f" {len(bundle.manifest.files):,} file(s), " + f"{_format_bytes(bundle.manifest.total_bytes)} " + f"({_format_bytes(bundle.archive_bytes)} compressed)\n" + f" {sum(bundle.manifest.excluded.values()):,} path(s) excluded\n" + " Only the selected files will be sent to Strix Cloud." + ) + if getattr(args, "show_files", False): + console.print(f"\n[bold]Selected files ({len(bundle.manifest.files):,})[/]") + for selected in bundle.manifest.files: + console.print( + f" {escape(sanitize_terminal_text(selected.archive_name))}", soft_wrap=True + ) + answer = ( + console.input("Upload this source and start the scan? [y/N]: ", markup=False) + .strip() + .lower() + ) + if answer not in ("y", "yes"): + _source_approval_error("source upload cancelled.") + keep_bundle = True + return bundle + finally: + if not keep_bundle: + remove_bundle(bundle) + + +def _validate_source_digest_approval(args: argparse.Namespace, bundle: SourceBundle) -> str | None: + approved_digest = getattr(args, "approve_sha256", None) + if approved_digest is None: + return None + if not isinstance(approved_digest, str) or not _SHA256.fullmatch(approved_digest): + _source_approval_error("--approve-sha256 must be exactly 64 hexadecimal characters.") + if bundle.archive_sha256 != approved_digest.lower(): + _source_approval_error( + "source archive SHA-256 does not match --approve-sha256; review a fresh " + "--dry-run before uploading." + ) + return approved_digest + + +def _source_approval_error(message: str) -> NoReturn: + raise http.CloudError(message) + + +def _upload_scan_source(bundle: SourceBundle, *, token: str | None) -> str: + file_name = f"strix-source-{bundle.archive_sha256[:12]}.zip" + requested = http.check( + http.request( + "POST", + "/uploads/request", + token=token, + body={ + "file_name": file_name, + "file_size": bundle.archive_bytes, + "category": "repository", + }, + ) + ) + if not isinstance(requested, dict): + raise http.CloudError("the platform returned an invalid source upload response.") + fields = cast("dict[str, Any]", requested) + upload_id = fields.get("upload_id") + signed_url = fields.get("signed_url") + upload_token = fields.get("token") + if not all(isinstance(value, str) and value for value in (upload_id, signed_url, upload_token)): + error = http.CloudError("the platform did not return complete source upload credentials.") + if isinstance(upload_id, str) and upload_id: + try: + _delete_upload(upload_id, token=token) + except (http.CloudError, KeyboardInterrupt) as cleanup_error: + raise _source_cleanup_error(upload_id, error, cleanup_error) from error + raise error + try: + http.upload_file(cast("str", signed_url), cast("str", upload_token), bundle.archive_path) + completed = http.check( + http.request( + "POST", + "/uploads/complete", + token=token, + body={"upload_id": upload_id}, + ) + ) + _validate_completed_upload(completed, expected_id=cast("str", upload_id)) + except BaseException as error: + try: + _delete_upload(cast("str", upload_id), token=token) + except (http.CloudError, KeyboardInterrupt) as cleanup_error: + if isinstance(error, Exception): + raise _source_cleanup_error(cast("str", upload_id), error, cleanup_error) from error + interrupted = http.CloudError("source upload interrupted.", exit_code=130) + raise _source_cleanup_error( + cast("str", upload_id), interrupted, cleanup_error + ) from None + raise + return cast("str", upload_id) + + +def _validate_completed_upload(completed: Any, *, expected_id: str) -> None: + fields = cast("dict[str, Any]", completed) if isinstance(completed, dict) else {} + if fields.get("id") != expected_id: + raise http.CloudError("the platform returned an invalid source upload completion response.") + + +def _delete_upload(upload_id: str, *, token: str | None) -> None: + response = http.request("DELETE", f"/uploads/{quote(upload_id, safe='')}", token=token) + if response.status_code == 404 or 200 <= response.status_code < 300: + return + http.check(response) + + +def _source_cleanup_note(upload_id: str, cleanup_error: BaseException) -> str: + return ( + f"Cleanup of source upload {upload_id} could not be confirmed: {cleanup_error}. " + f"Retry with `strix cloud uploads delete {upload_id}`." + ) + + +def _source_cleanup_error( + upload_id: str, error: Exception, cleanup_error: BaseException +) -> http.CloudError: + """Report a staged source object whenever automatic deletion is uncertain.""" + message = f"{error} {_source_cleanup_note(upload_id, cleanup_error)}" + payload: dict[str, Any] = {} + exit_code = http.EXIT_ERROR + if isinstance(error, http.CloudError): + exit_code = error.exit_code + raw_payload: Any = error.payload + if isinstance(raw_payload, dict): + payload.update(cast("dict[str, Any]", raw_payload)) + elif raw_payload is not None: + payload["detail"] = raw_payload + payload.update( + { + "error": message, + "upload_id": upload_id, + "upload_retained": True, + "cleanup_unknown": True, + } + ) + return http.CloudError(message, exit_code=exit_code, payload=payload) + + +def _interrupted_source_upload_error( + upload_id: str, idempotency_key: str | None = None +) -> http.CloudError: + retry_note = _idempotency_retry_note(idempotency_key) + message = ( + "Interrupted while starting the scan. The launch outcome is unknown, so source upload " + f"{upload_id} was retained. Check `strix cloud scans list` before retrying; if no scan " + f"was created, run `strix cloud uploads delete {upload_id}`.{retry_note}" + ) + payload: dict[str, Any] = { + "error": message, + "interrupted": True, + "upload_id": upload_id, + "upload_retained": True, + "launch_outcome_unknown": True, + } + _attach_idempotency_recovery(payload, idempotency_key) + return http.CloudError(message, exit_code=130, payload=payload) + + +def _retained_source_upload_error( + upload_id: str, + error: Exception, + idempotency_key: str | None = None, +) -> http.CloudError: + """Preserve source when the platform may already have accepted its scan.""" + retry_note = _idempotency_retry_note(idempotency_key) + message = ( + f"{error} The scan launch outcome is unknown, so source upload {upload_id} was retained. " + "Check `strix cloud scans list` before retrying; if no scan was created, clean it up " + f"with `strix cloud uploads delete {upload_id}`. Linked uploads cannot be deleted." + f"{retry_note}" + ) + payload: dict[str, Any] = {} + exit_code = http.EXIT_ERROR + if isinstance(error, http.CloudError): + exit_code = error.exit_code + raw_payload: Any = error.payload + error_payload = cast("dict[str, Any]", raw_payload) + if isinstance(raw_payload, dict): + payload.update(error_payload) + elif raw_payload is not None: + payload["detail"] = raw_payload + payload.update( + { + "error": message, + "upload_id": upload_id, + "upload_retained": True, + "launch_outcome_unknown": True, + } + ) + _attach_idempotency_recovery(payload, idempotency_key) + return http.CloudError(message, exit_code=exit_code, payload=payload) + + +def _idempotency_retry_note(idempotency_key: str | None) -> str: + if not idempotency_key: + return "" + return ( + " An exact retry is safe only with the same request body and " + f"`--idempotency-key {idempotency_key}`." + ) + + +def _attach_idempotency_recovery(payload: dict[str, Any], idempotency_key: str | None) -> None: + if not idempotency_key: + return + payload.update( + { + "idempotency_key": idempotency_key, + "retry_safe": True, + "retry_same_request": True, + } + ) + + +def _format_bytes(value: int) -> str: + if value < 1024: + return f"{value} B" + if value < 1024 * 1024: + return f"{value / 1024:.1f} KB" + return f"{value / (1024 * 1024):.1f} MB" diff --git a/strix/interface/cloud/source_upload.py b/strix/interface/cloud/source_upload.py new file mode 100644 index 00000000..bda3a0c8 --- /dev/null +++ b/strix/interface/cloud/source_upload.py @@ -0,0 +1,703 @@ +"""Privacy-conscious local source packaging for managed scans.""" + +from __future__ import annotations + +import fnmatch +import hashlib +import os +import shutil +import stat +import subprocess # nosec B404 +import tempfile +import zipfile +from collections import Counter +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import TYPE_CHECKING + +import strix.interface.cloud.http as http # noqa: PLR0402 + + +if TYPE_CHECKING: + from collections.abc import Iterator + from typing import Protocol + + class _ScandirIterator(Iterator[os.DirEntry[str]], Protocol): + def close(self) -> None: ... + + +MAX_FILES = 20_000 +MAX_FILE_BYTES = 25 * 1024 * 1024 +MAX_TOTAL_BYTES = 250 * 1024 * 1024 +MAX_ARCHIVE_BYTES = 50 * 1024 * 1024 +MAX_CANDIDATE_PATHS = 200_000 +MAX_IGNORE_BYTES = 64 * 1024 +MAX_IGNORE_PATTERNS = 1_000 +MAX_IGNORE_PATTERN_CHARS = 1_024 + +_ALWAYS_EXCLUDED_DIRS = frozenset( + { + ".git", + ".hg", + ".svn", + "node_modules", + "vendor", + "venv", + ".venv", + "env", + "__pycache__", + ".tox", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + "dist", + "build", + "coverage", + "target", + ".next", + ".nuxt", + ".gradle", + } +) +_SENSITIVE_NAMES = frozenset( + { + "id_rsa", + "id_dsa", + "id_ecdsa", + "id_ed25519", + "credentials.json", + "service-account.json", + "service_account.json", + ".env", + ".npmrc", + ".pypirc", + ".netrc", + ".git-credentials", + "application_default_credentials.json", + } +) +_SENSITIVE_PATTERNS = ( + "*.pem", + "*.key", + "*.p12", + "*.pfx", + "*.keystore", + "*.jks", + "secrets.*", + "secret.*", + ".env.*", +) +_SENSITIVE_PATH_SUFFIXES = ( + (".aws", "credentials"), + (".aws", "config"), + (".docker", "config.json"), + (".config", "gcloud", "credentials.db"), + (".azure", "accesstokens.json"), + (".azure", "azureprofile.json"), + (".kube", "config"), +) +_ARCHIVE_SUFFIXES = ( + ".zip", + ".tar", + ".tgz", + ".tar.gz", + ".tar.bz2", + ".tar.xz", + ".7z", + ".rar", + ".gz", + ".bz2", + ".xz", + ".jar", + ".war", + ".whl", + ".nupkg", + ".apk", + ".ipa", +) +_ARCHIVE_MAGIC_PREFIXES = ( + b"PK\x03\x04", + b"PK\x05\x06", + b"PK\x07\x08", + b"\x1f\x8b", + b"BZh", + b"\xfd7zXZ\x00", + b"7z\xbc\xaf\x27\x1c", + b"Rar!\x1a\x07", +) + + +@dataclass(frozen=True) +class SelectedFile: + path: Path + archive_name: str + size: int + device: int + inode: int + mtime_ns: int + ctime_ns: int + + +@dataclass(frozen=True) +class SourceManifest: + source: Path + files: tuple[SelectedFile, ...] + excluded: Counter[str] + include_hidden: bool + include_sensitive: bool + include_archives: bool + + @property + def total_bytes(self) -> int: + return sum(item.size for item in self.files) + + def as_dict( + self, + *, + show_files: bool, + archive_bytes: int | None = None, + archive_sha256: str | None = None, + ) -> dict[str, object]: + result: dict[str, object] = { + "source": str(self.source), + "file_count": len(self.files), + "uncompressed_bytes": self.total_bytes, + "excluded_count": sum(self.excluded.values()), + "excluded_by_reason": dict(sorted(self.excluded.items())), + "include_hidden": self.include_hidden, + "include_sensitive": self.include_sensitive, + "include_archives": self.include_archives, + } + if archive_bytes is not None: + result["archive_bytes"] = archive_bytes + if archive_sha256 is not None: + result["archive_sha256"] = archive_sha256 + if show_files: + result["files"] = [item.archive_name for item in self.files] + return result + + +@dataclass(frozen=True) +class SourceBundle: + manifest: SourceManifest + archive_path: Path + archive_bytes: int + archive_sha256: str + + def summary(self, *, show_files: bool) -> dict[str, object]: + return self.manifest.as_dict( + show_files=show_files, + archive_bytes=self.archive_bytes, + archive_sha256=self.archive_sha256, + ) + + +def prepare_source( + value: str, + *, + include_hidden: bool, + include_sensitive: bool, + include_archives: bool, + exclude: list[str], +) -> SourceBundle: + """Select safe source files and build a bounded temporary ZIP archive.""" + source = Path(value).expanduser().resolve() + if not source.is_dir(): + raise http.CloudError(f"--source must be a directory: {source}") + manifest = select_source( + source, + include_hidden=include_hidden, + include_sensitive=include_sensitive, + include_archives=include_archives, + exclude=exclude, + ) + if not manifest.files: + raise http.CloudError("no files remain after applying source upload exclusions.") + + with tempfile.NamedTemporaryFile(prefix="strix-source-", suffix=".zip", delete=False) as handle: + archive_path = Path(handle.name) + try: + _write_archive(archive_path, manifest.files) + except BaseException: + archive_path.unlink(missing_ok=True) + raise + archive_bytes = archive_path.stat().st_size + if archive_bytes > MAX_ARCHIVE_BYTES: + archive_path.unlink(missing_ok=True) + raise http.CloudError( + "source archive is larger than the 50 MB upload limit; narrow --source or " + "add --exclude patterns." + ) + digest = _sha256(archive_path) + return SourceBundle(manifest, archive_path, archive_bytes, digest) + + +def select_source( + source: Path, + *, + include_hidden: bool = False, + include_sensitive: bool = False, + include_archives: bool = False, + exclude: list[str] | None = None, +) -> SourceManifest: + excluded: Counter[str] = Counter() + selected: list[SelectedFile] = [] + patterns = [*_load_ignore_patterns(source), *(exclude or [])] + _validate_patterns(patterns) + total_bytes = 0 + for relative in _candidate_paths( + source, + include_hidden=include_hidden, + patterns=patterns, + excluded=excluded, + ): + archive_name = relative.as_posix() + reason = _exclusion_reason( + relative, + include_hidden=include_hidden, + include_sensitive=include_sensitive, + include_archives=include_archives, + patterns=patterns, + ) + if reason: + excluded[reason] += 1 + continue + path = source / relative + try: + info = path.lstat() + except OSError: + excluded["unreadable"] += 1 + continue + if not stat.S_ISREG(info.st_mode): + excluded["symlink_or_non_file"] += 1 + continue + if not include_archives and _has_archive_magic(path): + excluded["nested_archive"] += 1 + continue + if info.st_size > MAX_FILE_BYTES: + raise http.CloudError( + f"{archive_name} is larger than the 25 MB per-file limit; exclude it explicitly." + ) + selected.append( + SelectedFile( + path=path, + archive_name=archive_name, + size=info.st_size, + device=info.st_dev, + inode=info.st_ino, + mtime_ns=info.st_mtime_ns, + ctime_ns=info.st_ctime_ns, + ) + ) + total_bytes += info.st_size + if len(selected) > MAX_FILES: + raise http.CloudError( + f"source contains more than {MAX_FILES:,} files; narrow --source or add exclusions." + ) + if total_bytes > MAX_TOTAL_BYTES: + raise http.CloudError( + "selected source is larger than the 250 MB expanded-size limit; narrow --source " + "or add --exclude patterns." + ) + selected.sort(key=lambda item: item.archive_name) + return SourceManifest( + source, + tuple(selected), + excluded, + include_hidden, + include_sensitive, + include_archives, + ) + + +def remove_bundle(bundle: SourceBundle) -> None: + bundle.archive_path.unlink(missing_ok=True) + + +def _candidate_paths( + source: Path, + *, + include_hidden: bool, + patterns: list[str], + excluded: Counter[str], +) -> Iterator[Path]: + git_root = _git_root(source) + if git_root is not None: + git = shutil.which("git") + if git is not None: + yield from _git_candidate_paths(git, git_root, source) + return + yield from _walk_candidate_paths( + source, + include_hidden=include_hidden, + patterns=patterns, + excluded=excluded, + ) + + +def _git_candidate_paths(git: str, git_root: Path, source: Path) -> Iterator[Path]: + """Stream Git's NUL-delimited manifest without buffering an unbounded repository.""" + relative_source = source.relative_to(git_root) + command = [ + git, + "-C", + str(git_root), + "ls-files", + "-z", + "--cached", + "--others", + "--exclude-standard", + "--", + ] + if relative_source != Path(): + command.append(relative_source.as_posix()) + try: + process = subprocess.Popen( # noqa: S603 # nosec B603 + command, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + except OSError as exc: + raise http.CloudError(f"could not enumerate Git source files: {exc}") from exc + assert process.stdout is not None + buffer = b"" + count = 0 + try: + while chunk := process.stdout.read(64 * 1024): + buffer += chunk + records = buffer.split(b"\0") + buffer = records.pop() + for raw in records: + relative = _git_relative_path(raw, relative_source) + if relative is None: + continue + count += 1 + _check_candidate_limit(count) + yield relative + if buffer: + raise http.CloudError("Git returned a malformed source file manifest.") + if process.wait() != 0: + raise http.CloudError("Git could not enumerate the source directory.") + finally: + process.stdout.close() + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=1) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + +def _git_relative_path(raw: bytes, relative_source: Path) -> Path | None: + repo_relative = Path(os.fsdecode(raw)) + try: + relative = repo_relative.relative_to(relative_source) + except ValueError: + return None + if relative.is_absolute() or ".." in relative.parts: + raise http.CloudError("Git returned an unsafe source path.") + return relative + + +def _walk_candidate_paths( + source: Path, + *, + include_hidden: bool, + patterns: list[str], + excluded: Counter[str], +) -> Iterator[Path]: + """Walk top-down so excluded dependency, VCS, and hidden trees are never traversed.""" + count = 0 + stack: list[tuple[Path, _ScandirIterator]] = [] + try: + stack.append((source, os.scandir(source))) + while stack: + root_path, entries = stack[-1] + try: + entry = next(entries) + except StopIteration: + entries.close() + stack.pop() + continue + count += 1 + _check_candidate_limit(count) + path = root_path / entry.name + relative = path.relative_to(source) + try: + is_directory = entry.is_dir(follow_symlinks=False) + is_symlink = entry.is_symlink() + except OSError: + excluded["unreadable"] += 1 + continue + if is_directory: + reason = _pruned_directory_reason( + relative, + include_hidden=include_hidden, + patterns=patterns, + ) + if reason: + excluded[reason] += 1 + continue + try: + stack.append((path, os.scandir(path))) + except OSError: + excluded["unreadable"] += 1 + continue + if is_symlink: + excluded["symlink_or_non_file"] += 1 + continue + yield relative + except OSError as exc: + raise http.CloudError(f"could not enumerate source directory {source}: {exc}") from exc + finally: + for _, entries in stack: + entries.close() + + +def _pruned_directory_reason( + relative: Path, + *, + include_hidden: bool, + patterns: list[str], +) -> str | None: + lower_parts = tuple(part.lower() for part in relative.parts) + if any(part == ".git" for part in lower_parts): + return "git_metadata" + if any(part in _ALWAYS_EXCLUDED_DIRS for part in lower_parts): + return "dependency_or_build_output" + if not include_hidden and any(part.startswith(".") for part in relative.parts): + return "hidden" + if any(_matches_user_pattern(relative, pattern) for pattern in patterns): + return "user_pattern" + return None + + +def _check_candidate_limit(count: int) -> None: + if count > MAX_CANDIDATE_PATHS: + raise http.CloudError( + f"source enumeration exceeded {MAX_CANDIDATE_PATHS:,} paths before filtering; " + "narrow --source or add directory exclusions." + ) + + +def _git_root(source: Path) -> Path | None: + git = shutil.which("git") + if git is None: + return None + result = subprocess.run( # noqa: S603 # nosec B603 + [git, "-C", str(source), "rev-parse", "--show-toplevel"], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + return None + try: + return Path(result.stdout.strip()).resolve() + except OSError: + return None + + +def _exclusion_reason( # noqa: PLR0911 + relative: Path, + *, + include_hidden: bool, + include_sensitive: bool, + include_archives: bool, + patterns: list[str], +) -> str | None: + parts = relative.parts + lower_parts = tuple(part.lower() for part in parts) + if any(part == ".git" for part in lower_parts): + return "git_metadata" + if any(part in _ALWAYS_EXCLUDED_DIRS for part in lower_parts[:-1]): + return "dependency_or_build_output" + if not include_hidden and any(part.startswith(".") for part in parts): + return "hidden" + if any(_matches_user_pattern(relative, pattern) for pattern in patterns): + return "user_pattern" + name = relative.name.lower() + if not include_sensitive and ( + name in _SENSITIVE_NAMES + or any(fnmatch.fnmatch(name, pattern) for pattern in _SENSITIVE_PATTERNS) + or any( + lower_parts[-len(suffix) :] == suffix + for suffix in _SENSITIVE_PATH_SUFFIXES + if len(lower_parts) >= len(suffix) + ) + ): + return "sensitive_filename" + if not include_archives and name.endswith(_ARCHIVE_SUFFIXES): + return "nested_archive" + return None + + +def _matches_user_pattern(relative: Path, pattern: str) -> bool: + """Match exclude globs, including intuitive trailing-slash directory rules.""" + relative_posix = relative.as_posix() + posix = PurePosixPath(relative_posix) + if pattern.endswith("/"): + directory_pattern = pattern.rstrip("/") + if not directory_pattern: + return False + return ( + posix.match(directory_pattern) + or fnmatch.fnmatch(relative_posix, directory_pattern) + or any( + PurePosixPath(parent.as_posix()).match(directory_pattern) + or fnmatch.fnmatch(parent.as_posix(), directory_pattern) + for parent in posix.parents + if parent != PurePosixPath(".") + ) + ) + return posix.match(pattern) or fnmatch.fnmatch(relative_posix, pattern) + + +def _write_archive(destination: Path, files: tuple[SelectedFile, ...]) -> None: + with zipfile.ZipFile( + destination, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6 + ) as archive: + for item in files: + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(item.path, flags) + except OSError as exc: + raise http.CloudError(f"could not safely read {item.archive_name}: {exc}") from exc + with os.fdopen(descriptor, "rb") as source_file: + current = os.fstat(source_file.fileno()) + if ( + not stat.S_ISREG(current.st_mode) + or current.st_size != item.size + or current.st_dev != item.device + or current.st_ino != item.inode + or current.st_mtime_ns != item.mtime_ns + or current.st_ctime_ns != item.ctime_ns + ): + raise http.CloudError( + f"{item.archive_name} changed while the source archive was being built; " + "retry." + ) + info = zipfile.ZipInfo(item.archive_name) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o100644 << 16 + with archive.open(info, "w", force_zip64=True) as target: + remaining = item.size + while remaining: + chunk = source_file.read(min(1024 * 1024, remaining)) + if not chunk: + raise http.CloudError( + f"{item.archive_name} changed while the source archive was being " + "built; retry." + ) + target.write(chunk) + remaining -= len(chunk) + final = os.fstat(source_file.fileno()) + if ( + source_file.read(1) + or not stat.S_ISREG(final.st_mode) + or final.st_size != item.size + or final.st_dev != item.device + or final.st_ino != item.inode + or final.st_mtime_ns != item.mtime_ns + or final.st_ctime_ns != item.ctime_ns + ): + raise http.CloudError( + f"{item.archive_name} changed while the source archive was being " + "built; retry." + ) + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _has_archive_magic(path: Path) -> bool: + """Recognize common archive containers even when their suffix is disguised.""" + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags) + with os.fdopen(descriptor, "rb") as stream: + header = stream.read(512) + except OSError: + return False + return header.startswith(_ARCHIVE_MAGIC_PREFIXES) or header[257:262] == b"ustar" + + +def _load_ignore_patterns(source: Path) -> list[str]: + path = source / ".strixignore" + raw_text = _read_ignore_file(path) + if raw_text is None: + return [] + if len(raw_text) > MAX_IGNORE_BYTES: + raise http.CloudError(f"{path} is larger than the {MAX_IGNORE_BYTES:,}-byte limit.") + try: + lines = raw_text.decode("utf-8").splitlines() + except UnicodeDecodeError as exc: + raise http.CloudError(f"{path} must be UTF-8 text.") from exc + patterns: list[str] = [] + for line_number, raw in enumerate(lines, start=1): + value = raw.strip() + if not value or value.startswith("#"): + continue + if value.startswith("!"): + raise http.CloudError( + f"{path}:{line_number}: negated patterns are not supported; use exclude-only globs." + ) + patterns.append(value) + if len(patterns) > MAX_IGNORE_PATTERNS: + raise http.CloudError( + f"{path} contains more than {MAX_IGNORE_PATTERNS:,} exclusion patterns." + ) + return patterns + + +def _read_ignore_file(path: Path) -> bytes | None: + """Read a bounded regular ignore file without blocking on a FIFO or device.""" + try: + descriptor = os.open( + path, + os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0), + ) + except FileNotFoundError: + return None + except OSError as exc: + raise http.CloudError(f"could not read {path}: {exc}") from exc + try: + info = os.fstat(descriptor) + except OSError as exc: + os.close(descriptor) + raise http.CloudError(f"could not inspect {path}: {exc}") from exc + if not stat.S_ISREG(info.st_mode): + os.close(descriptor) + raise http.CloudError(f"{path} must be a regular file.") + try: + stream = os.fdopen(descriptor, "rb") + except OSError as exc: + os.close(descriptor) + raise http.CloudError(f"could not read {path}: {exc}") from exc + try: + return stream.read(MAX_IGNORE_BYTES + 1) + except OSError as exc: + raise http.CloudError(f"could not read {path}: {exc}") from exc + finally: + stream.close() + + +def _validate_patterns(patterns: list[str]) -> None: + if len(patterns) > MAX_IGNORE_PATTERNS: + raise http.CloudError( + f"source upload accepts at most {MAX_IGNORE_PATTERNS:,} exclusion patterns." + ) + for pattern in patterns: + if len(pattern) > MAX_IGNORE_PATTERN_CHARS: + raise http.CloudError( + "source exclusion patterns must be at most " + f"{MAX_IGNORE_PATTERN_CHARS:,} characters each." + ) + if "\x00" in pattern: + raise http.CloudError("source exclusion patterns cannot contain NUL bytes.") diff --git a/strix/interface/cloud/spec.py b/strix/interface/cloud/spec.py new file mode 100644 index 00000000..5b64a120 --- /dev/null +++ b/strix/interface/cloud/spec.py @@ -0,0 +1,1147 @@ +"""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``, ``json``, + or ``json-list``. + """ + + 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 + # 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 + # Caller retries for this mutation must carry one stable opaque key. The + # platform binds it to the authenticated actor and exact request body. + idempotent: 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: code_review, live_test, internal_infra, or compliance_pentest."), + ), + 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 array of target header objects: [{"name":"...","value":"...","notes":"..."}].', + ), + P("concerns", help="Free-form security concerns to investigate."), + 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: lite, standard, or ultra (default). " + "Not used for Enterprise or self-hosted scans." + ), + ), + P("model_config_id", help="Self-hosted only: model configuration ID to run with."), + P("max_budget_usd", "float", help="Self-hosted only: 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: none, totp, email_otp, or magic_link."), + 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: none, totp, email_otp, or magic_link."), + 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("access_token", required=True, help="Provider access token.", flag="provider-token"), + P("instance_url", help="GitLab base URL, for example https://gitlab.com."), + P("account_email", help="Bitbucket account email address."), + P("installation_id", "int", help="Existing installation ID to update."), +) + + +SPEC: dict[str, dict[str, Cmd]] = { + "scans": { + "list": Cmd( + "GET", + "/scans", + "List scans.", + query=( + P("page", "int", help="Results page (starts at 1)."), + P("limit", "int", help="Results per page (1-100)."), + *_q( + "status", + "scan_type", + "date_from", + "date_to", + "domain_id", + "repository_id", + "search", + ), + P("include_retests", "bool", help="Include per-finding retest scans."), + P("sort_by", help="Sort key; currently created_at."), + P("sort_order", help="Sort order: asc or desc."), + ), + ), + "start": Cmd( + "POST", + "/scans", + "Start a scan.", + body=_SCAN_START_BODY, + wait_path="/scans/{id}", + idempotent=True, + ), + "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", + help="Message text for the agents. Required unless --cancel-current is used.", + ), + P("cancel_current", "bool", help="Cancel the current task before delivery."), + P("agent_id", help="Target one agent instead of the root agent."), + ), + ), + "report": Cmd( + "GET", + "/scans/{scanId}/report", + "Download the scan report.", + query=( + P( + "format", + help=( + "Report content: technical (default), retest, attestation, or " + "executive_summary. Advanced formats require Enterprise." + ), + ), + P( + "type", + help="Rendered file type: pdf (default) or docx. DOCX requires Enterprise.", + ), + P( + "providerName", + flag="provider-name", + help="Enterprise report-cover provider name (up to 80 characters).", + ), + P( + "memberName0", + flag="member-name-0", + help="First Enterprise report preparer's name (up to 120 characters).", + ), + P( + "memberEmail0", + flag="member-email-0", + help="First Enterprise report preparer's email address.", + ), + P( + "memberName1", + flag="member-name-1", + help="Second Enterprise report preparer's name (up to 120 characters).", + ), + P( + "memberEmail1", + flag="member-email-1", + help="Second Enterprise report preparer's email address.", + ), + ), + binary=True, + ), + "rerun": Cmd( + "POST", + "/scans/{scanId}/rerun", + "Run the scan again.", + wait_path="/scans/{id}", + idempotent=True, + ), + "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=( + P("page", "int", help="Results page (starts at 1)."), + P("limit", "int", help="Results per page (1-100)."), + *_q( + "scan_id", + "severity", + "status", + "search", + "from", + "to", + "domain_id", + "repository_id", + "finding_type", + "dependency_relation", + "reachability", + "sort_by", + ), + P("sort_order", help="Sort order: asc or desc."), + ), + ), + "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: open, in_progress, snoozed, fixed, ignored, or not_affected." + ), + ), + 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."),), + wait_path="/scans/{id}", + ), + "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=( + P("page", "int", help="Results page (starts at 1)."), + P("limit", "int", help="Results per page (1-100)."), + *_q("search", "verified", "business_unit", "tags", "sort_by"), + P("sort_order", help="Sort order: asc or desc."), + ), + ), + "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", + ( + "Provision a Strix-managed inbox for email OTP or magic-link MFA. " + "Returns an address; it does not create a test user." + ), + body=(P("label", help="Optional display label for the managed inbox."),), + ), + "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=( + P("page", "int", help="Results page (starts at 1)."), + P("limit", "int", help="Results per page (1-100)."), + *_q("search", "business_unit", "tags", "sort_by"), + P("sort_order", help="Sort order: asc or desc."), + ), + ), + "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", + ), + P("limit", "int", help="Maximum components to return."), + P("offset", "int", help="Number of components to skip."), + ), + ), + "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.", + idempotent=True, + ), + "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", + "float", + help=( + "Self-hosted only: budget limit per run in USD. " + "Use --data to set null and clear it." + ), + ), + P("scan_tier", help="Scan tier: lite, standard, or ultra."), + ), + ), + "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.", + idempotent=True, + ), + }, + "pr-reviews": { + "list": Cmd( + "GET", + "/pr-reviews", + "List PR reviews.", + query=( + P("page", "int", help="Results page (starts at 1)."), + P("limit", "int", help="Results per page (1-100)."), + *_q( + "search", + "status", + "group", + "pr_state", + "repository_full_name", + "date_from", + "date_to", + "sort_by", + "sort_order", + ), + P("include_counts", "bool", help="Include exact disposition counts."), + ), + ), + "get": Cmd("GET", "/pr-reviews/{prReviewId}", "Get one PR review."), + "findings": Cmd( + "GET", + "/pr-reviews/findings", + "List PR review findings.", + query=( + P("page", "int", help="Results page (starts at 1)."), + P("limit", "int", help="Results per page (1-100)."), + *_q("severity", "pr_state", "search", "repository_full_name"), + P("include_stats", "bool", help="Include all-time impact statistics."), + ), + ), + "start": Cmd( + "POST", + "/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."), + ), + ), + "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."),), + ), + "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: strix_cloud, strix_startup, 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", + "/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", + "json", + help='JSON array of repository refs: [{"repoId":"...","branch":"main"}].', + ), + 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", + help="Message text. Required unless --cancel-current or --stop-agent is used.", + ), + P("cancel_current", "bool", help="Cancel the in-flight agent turn first."), + P("stop_agent", "bool", help="Park the target agent and its descendants."), + P( + "repos", + "json", + help='JSON array of repository refs: [{"repoId":"...","branch":"main"}].', + ), + P("agent_id", help="Target one subagent instead of the root 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="Relative path in the session, or an absolute path under /workspace.", + ), + ), + 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"), + P("limit", "int", help="Maximum documents to return."), + ), + ), + "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."), + "policies": Cmd("GET", "/knowledge/policies", "List knowledge policies."), + "policies add": Cmd( + "POST", + "/knowledge/policies", + "Add a knowledge policy.", + body=( + 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( + "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.", + ), + }, + "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, + ), + "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.", + query=( + P( + "installation_id", + "int", + help=( + "Installation ID. Required for github, gitlab, and bitbucket; " + "unsupported for other providers." + ), + ), + ), + ), + }, + "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=( + P( + "include_command", + "bool", + help=( + "Include the one-time Docker enrollment command. " + "The command contains sensitive connector credentials." + ), + ), + ), + ), + "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.", + query=( + P("page", "int", help="Results page (starts at 1)."), + P("limit", "int", help="Results per page (1-100)."), + ), + ), + }, + "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=( + P("page", "int", help="Results page (starts at 1)."), + P("limit", "int", help="Results per page (1-1000)."), + *_q("action", "resource_type", "actor_id", "date_from", "date_to"), + P( + "format", + help="Output format: json, csv, ndjson, jsonl, snowflake, or splunk.", + ), + P("all", "bool", help="Stream all matches when exporting instead of one page."), + ), + ), + }, + "costs": { + "overview": Cmd( + "GET", + "/llm-costs", + "Self-hosted only: show the LLM cost overview.", + query=_q("range", "from", "to"), + ), + "run": Cmd( + "GET", + "/llm-costs/runs/{runType}/{runId}", + "Self-hosted only: get the LLM costs of one run.", + ), + }, + "llm-settings": { + "get": Cmd("GET", "/llm-settings", "Self-hosted only: get the LLM settings."), + "update": Cmd( + "PUT", + "/llm-settings", + "Self-hosted only: update the LLM settings.", + body=( + 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."), + ), + ), + }, + "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( + "rbac_scopes", + "json-list", + help=( + "JSON array of resource restrictions; each item has type " + "target, tag, or business_unit and a value." + ), + ), + P( + "expires_at", + help=( + "Absolute expiration date/time (ISO 8601; mutually exclusive " + "with --expires-in-days)." + ), + ), + 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."), + }, + "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] = { + "scans": "list", + "vulns": "list", + "domains": "list", + "repos": "list", + "workspaces": "list", + "schedules": "list", + "pr-reviews": "list", + "billing": "credits", + "chat": "list", + "knowledge": "list", + "org": "get", + "integrations": "list", + "connectors": "list", + "webhooks": "list", + "analytics": "overview", + "costs": "overview", + "audit": "list", + "llm-settings": "get", + "settings": "notifications", + "license": "show", + "tokens": "list", + "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 and other integrations", + "workspaces": "List, create, and switch workspaces", + "connectors": "Manage network connectors", + "webhooks": "Manage webhooks", + "analytics": "Read analytics data", + "audit": "Read the audit log", + "costs": "Self-hosted only: read LLM cost data", + "llm-settings": "Self-hosted only: 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/cloud/workspaces.py b/strix/interface/cloud/workspaces.py new file mode 100644 index 00000000..1d495357 --- /dev/null +++ b/strix/interface/cloud/workspaces.py @@ -0,0 +1,291 @@ +"""`strix cloud workspaces use` — switch the stored token to another workspace. + +The command lists the workspaces of the account, finds the requested one by +ID or by exact name, asks the platform to rotate that token in place, and +stores the returned workspace metadata. The bearer secret and expiry stay the +same; the account's role in the target workspace limits the granted scopes. +""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING, Any, cast + +from rich.console import Console +from rich.markup import escape + +import strix.interface.cloud.http as http # noqa: PLR0402 +from strix.interface.cloud.arguments import CloudArgumentParser +from strix.interface.cloud.render import emit, json_mode +from strix.interface.platform_cli import AUTH_PATH, read_record, save_record +from strix.interface.platform_identity import read_or_create_identity +from strix.interface.terminal_text import sanitize_terminal_text + + +if TYPE_CHECKING: + import argparse + + +def run_workspace_use(argv: list[str]) -> int: + """Entry point for ``strix cloud workspaces use``. Returns an exit code.""" + console = Console() + parser = CloudArgumentParser( + prog="strix cloud workspaces use", + description="Switch the stored API token to another workspace.", + ) + parser.add_argument( + "workspace", + metavar="WORKSPACE", + help="Workspace number from `workspaces list`, ID, or exact name.", + ) + scope_mode = parser.add_mutually_exclusive_group() + scope_mode.add_argument( + "--scopes", + nargs="+", + metavar="SCOPE", + default=None, + help=( + "Use a custom scope set within the login-approved ceiling. " + "Without this option, preserve the server-side scope preference." + ), + ) + scope_mode.add_argument( + "--scope-profile", + choices=("minimal", "recommended", "full"), + default=None, + help="Change to a profile within the authority approved at login.", + ) + parser.add_argument("--show-scopes", action="store_true", help="Print every granted scope.") + parser.add_argument("--json", action="store_true", help="Print the raw JSON response.") + parser.add_argument("--token", default=None, help="API token override.") + parser.add_argument( + "--workspace-id", + default=None, + metavar="ORG_ID", + help="Expected workspace for an override CLI token.", + ) + parser.add_argument("--app-url", default=None, metavar="URL", help="Platform URL override.") + parser.add_argument( + "--timeout", default=None, type=float, metavar="SECONDS", help="Request timeout in seconds." + ) + as_json = json_mode(flag="--json" in argv) + try: + args = parser.parse_args(argv) + except SystemExit as exc: + return exc.code if isinstance(exc.code, int) else 2 + except http.CloudError as exc: + _emit_cloud_error(console, exc, as_json=as_json) + return exc.exit_code + + as_json = json_mode(flag=bool(args.json)) + try: + http.configure( + base_url=args.app_url, + timeout=args.timeout, + token_override=bool(args.token), + workspace_id=args.workspace_id, + ) + return _use(console, args, as_json=as_json) + except http.CloudError as exc: + _emit_cloud_error(console, exc, as_json=as_json) + return exc.exit_code + + +def _use( # noqa: PLR0912, PLR0915 + console: Console, args: argparse.Namespace, *, as_json: bool +) -> int: + workspace = _find_workspace(args.workspace, token=args.token) + stored_record: dict[str, Any] = read_record() or {} + # An override token may belong to a different account. Never mix its new + # workspace state with identity or scope preferences from the stored sign-in. + external_token = args.token is not None or bool(os.environ.get("STRIX_API_TOKEN", "").strip()) + record: dict[str, Any] = {} if external_token else dict(stored_record) + body: dict[str, Any] = {} + if args.scopes: + body["scopes"] = args.scopes + body["scope_profile"] = "custom" + elif args.scope_profile: + body["scope_profile"] = args.scope_profile + if not external_token: + try: + body.update(read_or_create_identity()) + except (OSError, ValueError) as exc: + raise http.CloudError(f"could not load the CLI device identity: {exc}") from exc + switched = _switch_workspace_token( + str(workspace["id"]), + token=args.token, + body=body or None, + ) + if not isinstance(switched, dict): + raise _workspace_switch_unknown("the platform returned an invalid response") + switched_record = cast("dict[str, Any]", switched) + switched_token = switched_record.get("api_token") + if not isinstance(switched_token, str) or not switched_token.strip(): + raise _workspace_switch_unknown("the platform response omitted the token") + switched_scopes = switched_record.get("scopes") + switched_scope_items = cast("list[Any]", cast("Any", switched_scopes)) + if not isinstance(switched_scopes, list) or not all( + isinstance(scope, str) for scope in switched_scope_items + ): + raise _workspace_switch_unknown("the platform response contained invalid scopes") + validated_scopes = cast("list[str]", switched_scope_items) + + record.update( + { + "api_token": switched_token, + "organization_id": switched_record.get("organization_id", workspace["id"]), + "organization_name": switched_record.get( + "organization_name", workspace.get("name", "") + ), + "expires_at": switched_record.get("expires_at") or stored_record.get("expires_at"), + "scopes": validated_scopes, + "requested_scopes": switched_record.get("requested_scopes", validated_scopes), + "scope_ceiling": switched_record.get("scope_ceiling", []), + "scope_profile": switched_record.get("scope_profile", "custom"), + "token_id": switched_record.get("token_id"), + "credential_source": switched_record.get("credential_source", "api"), + "device_name": switched_record.get("device_name"), + "app_url": http.app_url(), + } + ) + if switched_record.get("email"): + record["email"] = switched_record["email"] + if not external_token: + try: + save_record(record) + except OSError as exc: + raise http.CloudError( + "the platform switched the token, but the local workspace metadata could not be " + f"stored in {AUTH_PATH}: {exc}. The bearer is still valid; fix the file and safely " + "rerun the same workspace use command.", + payload={ + "workspace_switched": True, + "local_record_updated": False, + "retry_safe": True, + }, + ) from exc + + result = { + "workspace_id": record["organization_id"], + "workspace_name": record["organization_name"], + "scopes": record["scopes"], + "requested_scopes": record.get("requested_scopes", record["scopes"]), + "scope_ceiling": record.get("scope_ceiling", []), + "scope_profile": record.get("scope_profile", "custom"), + "expires_at": record.get("expires_at"), + "token_id": record.get("token_id"), + "credential_source": record.get("credential_source", "api"), + "device_name": record.get("device_name"), + "stored": not external_token, + } + if as_json: + emit(console, result, as_json=True) + return http.EXIT_OK + workspace_name = escape(sanitize_terminal_text(record["organization_name"])) + console.print(f"[green]✓ Switched to workspace [bold]{workspace_name}[/].[/]") + scopes = record.get("scopes") + if isinstance(scopes, list) and scopes: + scope_items = cast("list[Any]", cast("Any", scopes)) + scope_names = [scope for scope in scope_items if isinstance(scope, str)] + if scope_names and args.show_scopes: + rendered_scopes = escape(sanitize_terminal_text(" ".join(scope_names))) + console.print(f" Scopes: [dim]{rendered_scopes}[/]") + elif scope_names: + profile = str(record.get("scope_profile") or "custom").title() + console.print(f" Access: [dim]{profile} · {len(scope_names)} scopes granted[/]") + if external_token: + console.print(" Token: [dim]override used for this command only; not stored[/]") + else: + console.print(f" Token: stored in [dim]{escape(sanitize_terminal_text(AUTH_PATH))}[/]") + return http.EXIT_OK + + +def _switch_workspace_token( + workspace_id: str, + *, + token: str | None, + body: dict[str, Any] | None, +) -> Any: + """Switch in place, distinguishing definitive rejections from lost outcomes.""" + try: + response = http.request( + "POST", + f"/workspaces/{workspace_id}/token", + token=token, + body=body, + ) + except http.CloudError as exc: + raise _workspace_switch_unknown(str(exc)) from exc + + # Client/auth/conflict responses prove the rotation did not return success. + # A 5xx or malformed success may arrive after the database commit, but the + # server preserves the bearer so replaying this exact command is safe. + if response.status_code in {400, 401, 403, 404, 409, 422}: + return http.check(response) + try: + return http.check(response) + except http.CloudError as exc: + raise _workspace_switch_unknown(str(exc)) from exc + + +def _workspace_switch_unknown(detail: str) -> http.CloudError: + return http.CloudError( + "workspace switch outcome is unknown: " + f"{sanitize_terminal_text(detail)}. The bearer secret is unchanged; safely rerun the " + "same workspace use command, or list workspaces to check the current one.", + payload={ + "switch_outcome_unknown": True, + "retry_safe": True, + }, + ) + + +def _emit_cloud_error(console: Console, error: http.CloudError, *, as_json: bool) -> None: + if as_json: + raw_payload: Any = error.payload + error_payload = cast("dict[str, Any]", raw_payload) + payload = dict(error_payload) if isinstance(raw_payload, dict) else {} + payload["error"] = str(error) + emit(console, payload, as_json=True) + return + console.print(f"[red]Error:[/] {escape(sanitize_terminal_text(error))}") + + +def _find_workspace(selector: str, *, token: str | None) -> dict[str, Any]: + listed = http.check(http.request("GET", "/workspaces", token=token)) + listed_record = cast("dict[str, Any]", listed) if isinstance(listed, dict) else {} + items = listed_record.get("workspaces") + item_values = cast("list[Any]", cast("Any", items)) if isinstance(items, list) else [] + workspaces = [ + cast("dict[str, Any]", cast("Any", item)) for item in item_values if isinstance(item, dict) + ] + if not workspaces: + raise http.CloudError("no workspaces found for this account.") + wanted = selector.strip() + if wanted.isdigit(): + index = int(wanted) + if 1 <= index <= len(workspaces): + return workspaces[index - 1] + raise http.CloudError( + f"workspace number must be between 1 and {len(workspaces)}. " + "Run `strix cloud workspaces` to see the numbered list." + ) + by_id = [w for w in workspaces if w.get("id") == wanted] + if by_id: + return by_id[0] + by_name = [w for w in workspaces if str(w.get("name", "")).casefold() == wanted.casefold()] + if len(by_name) == 1: + return by_name[0] + if len(by_name) > 1: + numbers = ", ".join( + str(index) + for index, workspace in enumerate(workspaces, start=1) + if workspace in by_name + ) + raise http.CloudError( + f"multiple workspaces are named {wanted!r}. Use its list number: {numbers}" + ) + names = ", ".join( + f"{index}: {workspace.get('name')}" for index, workspace in enumerate(workspaces, start=1) + ) + raise http.CloudError(f"no workspace matches {wanted!r}. Your workspaces: {names}") diff --git a/strix/interface/completions.py b/strix/interface/completions.py new file mode 100644 index 00000000..f188fc3e --- /dev/null +++ b/strix/interface/completions.py @@ -0,0 +1,373 @@ +"""Shell completion scripts and candidates for the Strix CLI.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +from strix.interface.cloud.spec import DEFAULT_VERBS, SPEC, Cmd +from strix.interface.terminal_text import has_terminal_control, sanitize_terminal_text + + +_ROOT_COMMANDS = ("cloud", "auth", "view", "completions", "completion") +_SESSION_COMMANDS = ("login", "logout", "whoami", "session", "credits") +_COMMON_FLAGS = ( + "--json", + "--token", + "--workspace-id", + "--app-url", + "--timeout", + "-h", + "--help", +) +_COMMON_VALUE_FLAGS = frozenset({"--token", "--workspace-id", "--app-url", "--timeout"}) +_WORKSPACE_USE_FLAGS = (*_COMMON_FLAGS, "--scopes", "--scope-profile", "--show-scopes") + + +def run_completions(argv: list[str]) -> int: + """Print a shell integration script or hidden completion candidates.""" + if argv and argv[0] == "--candidates": + for candidate in completion_candidates(argv[1:]): + sys.stdout.write(candidate + "\n") + return 0 + if not argv or argv[0] in ("-h", "--help", "help"): + sys.stdout.write( + "Usage: strix completions \n\n" + "Enable tab completion for the current shell:\n" + " zsh: source <(strix completions zsh)\n" + " bash: source <(strix completions bash)\n" + " fish: strix completions fish | source\n" + ) + return 0 + shell = argv[0].lower() + scripts = {"zsh": _zsh_script, "bash": _bash_script, "fish": _fish_script} + generator = scripts.get(shell) + if generator is None: + sys.stderr.write( + f"Unknown shell: {sanitize_terminal_text(shell)}. Choose zsh, bash, or fish.\n" + ) + return 2 + sys.stdout.write(generator()) + return 0 + + +def completion_candidates(words: list[str]) -> list[str]: + """Return candidates for words after the ``strix`` executable.""" + prior, current = _split_cursor(words) + if not prior: + candidates = _matching(_ROOT_COMMANDS, current) + elif prior[0] != "cloud": + candidates = [] + else: + candidates = _cloud_candidates(prior[1:], current) + # The line-oriented shell protocol cannot represent these names safely. + # Omitting them is preferable to returning a sanitized path that does not exist. + return [candidate for candidate in candidates if not has_terminal_control(candidate)] + + +def _split_cursor(words: list[str]) -> tuple[list[str], str]: + if not words: + return [], "" + return words[:-1], words[-1] + + +def _cloud_candidates(prior: list[str], current: str) -> list[str]: # noqa: PLR0911 + groups = (*_SESSION_COMMANDS, *SPEC, "workspace") + if not prior: + return _matching(groups, current) + group = "workspaces" if prior[0] == "workspace" else prior[0] + rest = prior[1:] + if group in _SESSION_COMMANDS: + return _session_candidates(group, rest, current) + commands = SPEC.get(group) + if commands is None: + return _matching(groups, current) + default_verb = DEFAULT_VERBS.get(group) + default_is_active = (rest and rest[0].startswith("-")) or (not rest and current.startswith("-")) + if default_verb is not None and default_is_active: + return _command_candidates(commands[default_verb], rest, current) + + command_paths = sorted( + ((verb.split(), cmd) for verb, cmd in commands.items()), + key=lambda item: len(item[0]), + reverse=True, + ) + for path, cmd in command_paths: + if rest[: len(path)] == path: + command_candidates = _command_candidates(cmd, rest[len(path) :], current) + if rest == path: + nested_words = { + candidate_path[len(path)] + for candidate_path, _candidate_cmd in command_paths + if len(candidate_path) > len(path) and candidate_path[: len(path)] == path + } + return sorted({*command_candidates, *_matching(nested_words, current)}) + return command_candidates + if group == "workspaces" and rest[:1] == ["use"]: + return _flag_candidates( + _WORKSPACE_USE_FLAGS, + rest[1:], + current, + value_flags=_COMMON_VALUE_FLAGS | {"--scopes"}, + ) + + verb_paths = [path for path, _cmd in command_paths] + if group == "workspaces": + verb_paths.append(["use"]) + matching_paths = [path for path in verb_paths if path[: len(rest)] == rest] + if not matching_paths: + return [] + next_words = sorted({path[len(rest)] for path in matching_paths if len(path) > len(rest)}) + return _matching(next_words, current) + + +def _session_candidates(group: str, prior: list[str], current: str) -> list[str]: + if group == "session": + if not prior: + return _matching(("show", "scopes", "help", *_COMMON_FLAGS, "--show-scopes"), current) + if prior[:1] == ["scopes"] and len(prior) == 1: + return _matching(("set", *_COMMON_FLAGS, "--show-scopes"), current) + if prior[:2] == ["scopes", "set"]: + return _matching( + ("minimal", "recommended", "full", "--scopes", *_COMMON_FLAGS, "--show-scopes"), + current, + ) + return _flag_candidates( + (*_COMMON_FLAGS, "--show-scopes"), + prior, + current, + value_flags=_COMMON_VALUE_FLAGS | {"--scopes"}, + ) + flags = _session_flags(group) + value_flags: frozenset[str] = frozenset() + if group == "login": + value_flags = frozenset({"--scopes", "--scope-profile", "--workspace", "--device-name"}) + elif group == "credits": + value_flags = _COMMON_VALUE_FLAGS + return _flag_candidates(flags, prior, current, value_flags=value_flags) + + +def _session_flags(group: str) -> tuple[str, ...]: + if group == "login": + return ( + "--no-browser", + "--scopes", + "--scope-profile", + "--workspace", + "--device-name", + "-h", + "--help", + ) + if group == "whoami": + return ("--json", "--show-scopes", "-h", "--help") + if group == "logout": + return ("--json", "--local-only", "-h", "--help") + if group == "credits": + return _COMMON_FLAGS + return ("-h", "--help") + + +def _command_candidates(cmd: Cmd, prior: list[str], current: str) -> list[str]: + filesystem = _filesystem_candidates(cmd, prior, current) + if filesystem is not None: + return filesystem + return _flag_candidates( + _command_flags(cmd), + prior, + current, + value_flags=_command_value_flags(cmd), + ) + + +def _flag_candidates( + flags: tuple[str, ...], + prior: list[str], + current: str, + *, + value_flags: frozenset[str], +) -> list[str]: + if prior and prior[-1] in value_flags and not current.startswith("-"): + return [] + return _matching(flags, current) + + +def _command_flags(cmd: Cmd) -> tuple[str, ...]: + flags: list[str] = list(_COMMON_FLAGS) + for param in cmd.query + cmd.body: + flag = "--" + (param.flag or _kebab(param.name)) + flags.append(flag) + if param.kind == "bool": + flags.append("--no-" + flag.removeprefix("--")) + if cmd.method in ("POST", "PUT", "PATCH"): + flags.append("--data") + if cmd.idempotent: + flags.append("--idempotency-key") + if cmd.binary or cmd.path == "/audit": + flags.extend(("--output", "--force")) + if cmd.link: + flags.append("--no-browser") + if cmd.wait_path or cmd.wait_self: + flags.extend(("--wait", "--wait-timeout")) + if cmd.path == "/billing/topup": + flags.extend(("--yes", "--no-pay", "--payment-method")) + if cmd.path == "/scans" and cmd.method == "POST": + flags.extend( + ( + "--source", + "--approve-sha256", + "--dry-run", + "--yes", + "--show-files", + "--exclude", + "--include-hidden", + "--include-sensitive", + "--include-archives", + ) + ) + if cmd.path == "/billing/auto-topup" and cmd.method == "PUT": + flags.append("--no-monthly-cap") + return tuple(dict.fromkeys(flags)) + + +def _command_value_flags(cmd: Cmd) -> frozenset[str]: + flags = set(_COMMON_VALUE_FLAGS) + for param in cmd.query + cmd.body: + if param.kind != "bool": + flags.add("--" + (param.flag or _kebab(param.name))) + if cmd.method in ("POST", "PUT", "PATCH"): + flags.add("--data") + if cmd.idempotent: + flags.add("--idempotency-key") + if cmd.binary or cmd.path == "/audit": + flags.add("--output") + if cmd.wait_path or cmd.wait_self: + flags.add("--wait-timeout") + if cmd.path == "/billing/topup": + flags.add("--payment-method") + if cmd.path == "/scans" and cmd.method == "POST": + flags.update(("--source", "--approve-sha256", "--exclude")) + return frozenset(flags) + + +def _filesystem_candidates( # noqa: PLR0911 + cmd: Cmd, prior: list[str], current: str +) -> list[str] | None: + inline = ( + ("--source=", True, ""), + ("--output=", False, ""), + ("--data=@", False, "@"), + ) + for option, directories_only, marker in inline: + if current.startswith(option): + value = current.removeprefix(option) + return [ + option + candidate.removeprefix(marker) + for candidate in _path_candidates( + marker + value, + directories_only=directories_only, + marker=marker, + ) + ] + + if not prior or current.startswith("-"): + return None + option = prior[-1] + if option == "--source" and cmd.path == "/scans" and cmd.method == "POST": + return _path_candidates(current, directories_only=True) + if option == "--output" and (cmd.binary or cmd.path == "/audit"): + return _path_candidates(current) + if option == "--data" and cmd.method in ("POST", "PUT", "PATCH"): + if not current: + return ["@"] + if current.startswith("@"): + return _path_candidates(current, marker="@") + return [] + return None + + +def _path_candidates( + value: str, + *, + directories_only: bool = False, + marker: str = "", +) -> list[str]: + raw = value.removeprefix(marker) if marker else value + ends_with_separator = raw.endswith(("/", "\\")) + expanded = Path(raw or ".").expanduser() + directory = expanded if ends_with_separator else expanded.parent + name_prefix = "" if ends_with_separator else expanded.name + raw_base = raw if ends_with_separator else raw[: len(raw) - len(name_prefix)] + try: + entries = directory.iterdir() + matches = [ + entry + for entry in entries + if entry.name.startswith(name_prefix) and (not directories_only or entry.is_dir()) + ] + except OSError: + return [] + + candidates: list[str] = [] + for entry in sorted(matches, key=lambda item: item.name.casefold()): + candidate = marker + raw_base + entry.name + if entry.is_dir(): + candidate += "/" + candidates.append(candidate) + return candidates + + +def _kebab(value: str) -> str: + output: list[str] = [] + for char in value: + if char.isupper(): + output.extend(("-", char.lower())) + else: + output.append("-" if char == "_" else char) + return "".join(output) + + +def _matching(candidates: Any, prefix: str) -> list[str]: + return sorted({str(candidate) for candidate in candidates if str(candidate).startswith(prefix)}) + + +def _zsh_script() -> str: + return r"""#compdef strix +_strix() { + local -a candidates + candidates=("${(@f)$($words[1] completions --candidates "${words[@]:2}")}") + _describe 'strix' candidates +} +compdef _strix strix +""" + + +def _bash_script() -> str: + return r"""_strix_completion() { + local -a candidates + local candidate + while IFS= read -r candidate; do + candidates+=("$candidate") + done < <(strix completions --candidates "${COMP_WORDS[@]:1:$COMP_CWORD}") + COMPREPLY=("${candidates[@]}") + for candidate in "${COMPREPLY[@]}"; do + if [[ $candidate == */ ]]; then + if type compopt >/dev/null 2>&1; then + compopt -o nospace + fi + break + fi + done +} +complete -F _strix_completion strix +""" + + +def _fish_script() -> str: + return r"""function __strix_candidates + set -l words (commandline -opc) + set -e words[1] + command strix completions --candidates $words (commandline -ct) +end +complete -c strix -f -a '(__strix_candidates)' +""" diff --git a/strix/interface/main.py b/strix/interface/main.py index d1c29ea0..107f1bdc 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -62,6 +62,14 @@ import logging # noqa: E402 logger = logging.getLogger(__name__) +_ROOT_SUBCOMMAND_HELP = """ +Additional commands: + strix cloud ... Use the managed Strix platform + strix auth ... Manage model-subscription sign-in + strix view [RUN] View a completed or running scan + strix completions SHELL Generate zsh, bash, or fish tab completion +""" + def _exception_messages(exc: BaseException) -> tuple[str, ...]: messages: list[str] = [] @@ -410,6 +418,13 @@ def main() -> None: if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + if len(sys.argv) == 2 and sys.argv[1] in ("-h", "--help"): + try: + parse_arguments() + except SystemExit as exc: + Console().print(_ROOT_SUBCOMMAND_HELP.strip(), markup=False) + raise SystemExit(exc.code) from None + # `strix view []` is a viewer-only subcommand, dispatched before the # scan argument parser (which requires a target) and before any scan setup. if len(sys.argv) > 1 and sys.argv[1] == "view": @@ -425,6 +440,19 @@ def main() -> None: sys.exit(run_auth(sys.argv[2:])) + # Generate native shell completion scripts before scan argument parsing. + if len(sys.argv) > 1 and sys.argv[1] in ("completion", "completions"): + from strix.interface.completions import run_completions + + sys.exit(run_completions(sys.argv[2:])) + + # `strix cloud …` drives the managed platform (app.strix.ai) and exits; + # it needs no target, Docker, or scan setup. + if len(sys.argv) > 1 and sys.argv[1] == "cloud": + from strix.interface.cloud import run_cloud + + sys.exit(run_cloud(sys.argv[2:])) + from strix.llm.warmup import start_import_warmup start_import_warmup() diff --git a/strix/interface/platform_cli.py b/strix/interface/platform_cli.py new file mode 100644 index 00000000..bf57e9cc --- /dev/null +++ b/strix/interface/platform_cli.py @@ -0,0 +1,798 @@ +"""`strix cloud login` — managed platform sign-in (app.strix.ai). + +Signing in runs an OAuth 2.0 device authorization flow in the browser, creates +the Strix account and workspace when they do not exist yet, and stores a +personal API token in ``~/.strix/platform-auth.json``. The token drives the +managed REST API (scans, credits, top-ups) without a dashboard visit. +""" + +from __future__ import annotations + +import argparse +import contextlib +import json +import sys +import time +import webbrowser +from pathlib import Path +from typing import Any, NoReturn, cast +from urllib.parse import urlparse, urlsplit, urlunsplit + +import requests +from rich.console import Console +from rich.markup import escape +from rich.panel import Panel +from rich.text import Text + +from strix.config import load_settings +from strix.interface.platform_identity import read_or_create_identity +from strix.interface.terminal_text import sanitize_terminal_text +from strix.interface.url_safety import is_safe_web_url +from strix.utils.secret_files import write_secret_text + + +AUTH_PATH = Path.home() / ".strix" / "platform-auth.json" + +_HTTP_TIMEOUT_S = 30 +_DEFAULT_POLL_INTERVAL_S = 5 +_MAX_POLL_INTERVAL_S = 60 +_MAX_EXPIRES_IN_S = 30 * 60 + +_ROLE_RANK = {"viewer": 0, "analyst": 1, "admin": 2} + + +class PlatformAuthError(Exception): + """Raised when the device authorization flow fails.""" + + +class _SessionUsageError(Exception): + """A session subcommand received invalid arguments.""" + + +class _SessionArgumentParser(argparse.ArgumentParser): + def error(self, message: str) -> NoReturn: + raise _SessionUsageError(f"invalid arguments for {self.prog}: {message}") + + +def _terminal_markup(value: object) -> str: + return escape(sanitize_terminal_text(value)) + + +def _app_url() -> str: + return load_settings().viewer.app_url.rstrip("/") + + +def read_record() -> dict[str, Any] | None: + try: + data = json.loads(AUTH_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + if not isinstance(data, dict): + return None + record = cast("dict[str, Any]", data) + if not record.get("api_token"): + return None + return record + + +def save_record(record: dict[str, Any]) -> None: + write_secret_text(AUTH_PATH, json.dumps(record, indent=2)) + + +def logout() -> bool: + try: + AUTH_PATH.unlink() + except FileNotFoundError: + return True + except OSError: + return False + return True + + +def run_login(argv: list[str]) -> int: + """Entry point for ``strix cloud login``. Returns a process exit code.""" + console = Console() + subcommand = argv[0] if argv else None + + if subcommand == "status": + return _status(console, argv[1:]) + if subcommand == "logout": + return _logout(console, argv[1:]) + return _login(console, argv) + + +def _login(console: Console, argv: list[str]) -> int: + parser = argparse.ArgumentParser(prog="strix cloud login", add_help=True) + parser.add_argument( + "--no-browser", + action="store_true", + help="Do not open the browser. Print the verification URL instead.", + ) + scope_mode = parser.add_mutually_exclusive_group() + scope_mode.add_argument( + "--scopes", + nargs="+", + metavar="SCOPE", + default=None, + help=( + "API scopes for the token, for example scans:read billing:write. " + "The server always includes a minimum scope set. " + "Without this option, an interactive picker opens after the browser step." + ), + ) + scope_mode.add_argument( + "--scope-profile", + choices=("minimal", "recommended", "full"), + default=None, + help="Scope profile to approve. Defaults to an interactive choice in a TTY.", + ) + parser.add_argument( + "--device-name", + default=None, + metavar="NAME", + help="Privacy-safe label shown for this CLI session in the dashboard.", + ) + parser.add_argument( + "--workspace", + metavar="WORKSPACE", + default=None, + help=( + "Workspace that receives the token, by ID or by exact name. " + "Without this option, an interactive picker opens when you have " + "more than one workspace." + ), + ) + previous_record = read_record() + try: + args = parser.parse_args(argv) + except SystemExit as exc: # argparse already printed the message + return exc.code if isinstance(exc.code, int) else 2 + + console.print() + host = urlparse(_app_url()).netloc or _app_url() + console.print(f"[bold]Signing in to the Strix platform[/] [dim]({_terminal_markup(host)})[/]") + console.print( + "[dim]This creates your account and workspace when needed, and stores an API token.[/]" + ) + console.print() + + try: + record = _run_device_flow( + console, + open_browser=not args.no_browser, + scopes=args.scopes, + scope_profile=args.scope_profile, + workspace=args.workspace, + device_name=args.device_name, + ) + except PlatformAuthError as exc: + console.print(f"[red]Sign-in failed:[/] {_terminal_markup(exc)}") + return 1 + except KeyboardInterrupt: + console.print("\n[yellow]Sign-in cancelled.[/]") + return 130 + + try: + save_record(record) + except OSError as exc: + console.print( + f"[red]Sign-in succeeded, but the token could not be stored:[/] {_terminal_markup(exc)}" + ) + console.print( + f"[dim]Check that {_terminal_markup(AUTH_PATH.parent)} is writable, " + "then run `strix cloud login` again.[/]" + ) + return 1 + _revoke_replaced_legacy_session(previous_record, record) + _print_success(console, record) + return 0 + + +def _run_device_flow( # noqa: PLR0912, PLR0915 + console: Console, + *, + open_browser: bool, + scopes: list[str] | None = None, + scope_profile: str | None = None, + workspace: str | None = None, + device_name: str | None = None, +) -> dict[str, Any]: + app_url = _app_url() + interactive = workspace is not None or ( + sys.stdin.isatty() and scopes is None and scope_profile is None + ) + try: + identity = read_or_create_identity(device_name=device_name) + except (OSError, ValueError) as exc: + raise PlatformAuthError(f"could not prepare the CLI device identity: {exc}") from exc + + try: + response = requests.post( + f"{app_url}/api/v1/cli/login", + timeout=_HTTP_TIMEOUT_S, + allow_redirects=False, + ) + except requests.RequestException as exc: + raise PlatformAuthError(f"could not reach {app_url}: {exc}") from exc + if not 200 <= response.status_code < 300: + raise PlatformAuthError(_error_detail(response)) + authorization = _json_object(response) + + user_code = str(authorization.get("user_code") or "") + verification_uri = str( + authorization.get("verification_uri_complete") + or authorization.get("verification_uri") + or "" + ) + device_code = str(authorization.get("device_code") or "") + expires_in = _as_positive_int( + authorization.get("expires_in"), default=300, maximum=_MAX_EXPIRES_IN_S + ) + interval = _as_positive_int( + authorization.get("interval"), + default=_DEFAULT_POLL_INTERVAL_S, + maximum=_MAX_POLL_INTERVAL_S, + ) + if not device_code or not verification_uri: + raise PlatformAuthError("the server returned an incomplete device authorization") + if not is_safe_web_url(verification_uri, trusted_origin=app_url): + raise PlatformAuthError("the server returned an invalid verification URL") + + console.print( + Panel.fit( + Text.assemble( + ("Confirmation code: ", "dim"), + (sanitize_terminal_text(user_code), "bold cyan"), + ), + title="Verify this device", + ) + ) + console.print("Open this URL in your browser and confirm the code:") + console.print(sanitize_terminal_text(verification_uri), markup=False, soft_wrap=True) + + if open_browser: + with contextlib.suppress(Exception): + webbrowser.open(verification_uri) + + console.print("[dim]Waiting for browser confirmation…[/]") + + poll_body: dict[str, Any] = {"device_code": device_code, **identity} + if interactive: + poll_body["interactive"] = True + elif scopes: + poll_body["scopes"] = scopes + elif scope_profile: + poll_body["scope_profile"] = scope_profile + + deadline = time.monotonic() + expires_in + while time.monotonic() < deadline: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + time.sleep(min(interval, remaining)) + try: + poll = requests.post( + f"{app_url}/api/v1/cli/login/poll", + json=poll_body, + timeout=_HTTP_TIMEOUT_S, + allow_redirects=False, + ) + except requests.RequestException: + continue + if 200 <= poll.status_code < 300: + return _finish_login( + console, + app_url, + poll, + scopes=scopes, + scope_profile=scope_profile, + workspace=workspace, + ) + delta = _handle_poll_error(poll) + if delta is None: + break + interval = min(interval + delta, _MAX_POLL_INTERVAL_S) + + raise PlatformAuthError("the sign-in request expired. Run `strix cloud login` again.") + + +def _handle_poll_error(poll: requests.Response) -> int | None: + """Return the interval increase, or None when the device code expired.""" + error = "" + with contextlib.suppress(ValueError, AttributeError): + error = str(poll.json().get("error", "")) + if error == "authorization_pending": + return 0 + if error == "slow_down": + return 5 + if error == "access_denied": + raise PlatformAuthError("the sign-in request was denied in the browser") + if error == "expired_token": + return None + raise PlatformAuthError(_error_detail(poll)) + + +def _finish_login( + console: Console, + app_url: str, + poll: requests.Response, + *, + scopes: list[str] | None, + scope_profile: str | None, + workspace: str | None, +) -> dict[str, Any]: + result = _json_object(poll) + if result.get("selection_required"): + return _complete_selection( + console, + app_url, + result, + scopes=scopes, + scope_profile=scope_profile, + workspace=workspace, + ) + return _bind_login_record(_require_api_token(result), app_url) + + +def _signed_in_record( + response: requests.Response, + *, + app_url: str, +) -> dict[str, Any]: + return _bind_login_record( + _require_api_token(_json_object(response)), + app_url, + ) + + +def _require_api_token(record: dict[str, Any]) -> dict[str, Any]: + api_token = record.get("api_token") + if not isinstance(api_token, str) or not api_token.strip(): + raise PlatformAuthError("the server returned a sign-in response without an API token") + return record + + +def _bind_login_record(record: dict[str, Any], app_url: str) -> dict[str, Any]: + """Bind a stored credential to its issuer and preserve its scope preference.""" + parsed = urlsplit(app_url) + if ( + parsed.scheme not in {"http", "https"} + or not parsed.netloc + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + or "\\" in app_url + or any(character.isspace() for character in app_url) + or "%" in parsed.netloc + ): + raise PlatformAuthError("the configured platform URL is invalid") + bound = dict(record) + bound["app_url"] = urlunsplit( + (parsed.scheme.lower(), parsed.netloc.lower(), parsed.path.rstrip("/"), "", "") + ) + preference: Any = record.get("requested_scopes", record.get("scopes")) + preference_items = cast("list[Any]", preference) + if isinstance(preference, list) and all(isinstance(scope, str) for scope in preference_items): + bound["requested_scopes"] = list(dict.fromkeys(cast("list[str]", preference_items))) + return bound + + +def _complete_selection( + console: Console, + app_url: str, + selection: dict[str, Any], + *, + scopes: list[str] | None, + scope_profile: str | None, + workspace: str | None, +) -> dict[str, Any]: + organizations = _dict_items(selection.get("organizations")) + catalog = _dict_items(selection.get("scopes")) + selection_token = str(selection.get("selection_token") or "") + if not selection_token or not organizations: + raise PlatformAuthError("the server returned an incomplete selection response") + + chosen_org = _choose_workspace(console, organizations, workspace) + role = str(chosen_org.get("role") or "admin") + chosen_scopes = scopes + chosen_profile = scope_profile + if chosen_scopes is None and chosen_profile is None and sys.stdin.isatty(): + chosen_profile, chosen_scopes = _choose_scopes(console, catalog, role) + + body: dict[str, Any] = { + "selection_token": selection_token, + "organization_id": chosen_org.get("id"), + } + if chosen_scopes is not None: + body["scopes"] = chosen_scopes + body["scope_profile"] = "custom" + elif chosen_profile is not None: + body["scope_profile"] = chosen_profile + try: + response = requests.post( + f"{app_url}/api/v1/cli/login/complete", + json=body, + timeout=_HTTP_TIMEOUT_S, + allow_redirects=False, + ) + except requests.RequestException as exc: + raise PlatformAuthError(f"could not reach {app_url}: {exc}") from exc + if not 200 <= response.status_code < 300: + raise PlatformAuthError(_error_detail(response)) + return _signed_in_record( + response, + app_url=app_url, + ) + + +def _dict_items(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, list): + return [] + items = cast("list[Any]", cast("Any", value)) + return [cast("dict[str, Any]", cast("Any", item)) for item in items if isinstance(item, dict)] + + +def _choose_workspace( + console: Console, organizations: list[dict[str, Any]], workspace: str | None +) -> dict[str, Any]: + if workspace is not None: + wanted = workspace.strip().casefold() + by_id = [org for org in organizations if str(org.get("id", "")).casefold() == wanted] + if by_id: + return by_id[0] + by_name = [ + org for org in organizations if str(org.get("name", "")).strip().casefold() == wanted + ] + if len(by_name) == 1: + return by_name[0] + if len(by_name) > 1: + matching_ids = ", ".join(str(org.get("id", "")) for org in by_name) + raise PlatformAuthError( + f"multiple workspaces are named {workspace!r}; use an exact workspace ID: " + f"{matching_ids}" + ) + names = ", ".join(str(org.get("name", "")) for org in organizations) + raise PlatformAuthError(f"no workspace matches {workspace!r}. Your workspaces: {names}") + if len(organizations) == 1: + return organizations[0] + if not sys.stdin.isatty(): + choices = ", ".join(f"{org.get('name', '')} ({org.get('id', '')})" for org in organizations) + raise PlatformAuthError( + "more than one workspace is available; rerun with --workspace NAME_OR_ID. " + f"Available workspaces: {choices}" + ) + + console.print() + console.print("[bold]Select a workspace for the API token:[/]") + for index, org in enumerate(organizations, start=1): + name = _terminal_markup(org.get("name", "")) + org_role = _terminal_markup(org.get("role", "")) + console.print(f" [cyan]{index}[/]. {name} [dim]({org_role})[/]") + while True: + answer = console.input(f"Workspace [1-{len(organizations)}] (1): ").strip() or "1" + if answer.isdigit() and 1 <= int(answer) <= len(organizations): + return organizations[int(answer) - 1] + console.print("[yellow]Enter a number from the list.[/]") + + +def _choose_scopes( + console: Console, catalog: list[dict[str, Any]], role: str +) -> tuple[str, list[str] | None]: + """Prompt for a named scope profile or a custom scope list.""" + rank = _ROLE_RANK.get(role, 2) + allowed = [ + item for item in catalog if _ROLE_RANK.get(str(item.get("min_role", "viewer")), 0) <= rank + ] + if not allowed: + return "recommended", None + + console.print() + console.print("[bold]Select token scopes:[/]") + console.print( + " [cyan]1[/]. Recommended [dim](scans, findings, schedules, assets, uploads, " + "workspace switching, billing/top-ups; no token creation)[/]" + ) + console.print(" [cyan]2[/]. Full access [dim](every scope your role allows)[/]") + console.print(" [cyan]3[/]. Minimal [dim](scan read/write and billing read)[/]") + console.print(" [cyan]4[/]. Custom [dim](pick individual scopes)[/]") + while True: + answer = console.input("Scopes [1-4] (1): ").strip() or "1" + if answer == "1": + return "recommended", None + if answer == "2": + return "full", None + if answer == "3": + return "minimal", None + if answer == "4": + return "custom", _choose_custom_scopes(console, allowed) + console.print("[yellow]Enter a number from 1 to 4.[/]") + + +def _choose_custom_scopes(console: Console, allowed: list[dict[str, Any]]) -> list[str]: + selected = { + str(item["scope"]) + for item in allowed + if item.get("scope") and (item.get("default") or item.get("minimum")) + } + while True: + console.print() + for index, item in enumerate(allowed, start=1): + scope = str(item.get("scope", "")) + mark = "[green]x[/]" if scope in selected else " " + required = " [dim](always included)[/]" if item.get("minimum") else "" + rendered_scope = _terminal_markup(scope) + description = _terminal_markup(item.get("description", "")) + console.print( + f" [{mark}] [cyan]{index:>2}[/]. {rendered_scope}{required}" + f"\n [dim]{description}[/]" + ) + answer = console.input( + "Toggle scopes by number (comma separated), or press Enter to confirm: " + ).strip() + if not answer: + return sorted(selected) + for part in answer.replace(",", " ").split(): + if not part.isdigit() or not 1 <= int(part) <= len(allowed): + console.print( + f"[yellow]Ignored {_terminal_markup(part)!r}: not a number from the list.[/]" + ) + continue + item = allowed[int(part) - 1] + scope = str(item.get("scope", "")) + if item.get("minimum"): + console.print(f"[yellow]{_terminal_markup(scope)} is always included.[/]") + continue + if scope in selected: + selected.discard(scope) + else: + selected.add(scope) + + +def _json_object(response: requests.Response) -> dict[str, Any]: + try: + data = response.json() + except ValueError as exc: + raise PlatformAuthError("the server returned a response that is not JSON") from exc + if not isinstance(data, dict): + raise PlatformAuthError("the server returned an unexpected response shape") + return cast("dict[str, Any]", data) + + +def _as_positive_int(value: Any, *, default: int, maximum: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError, OverflowError): + return default + if parsed <= 0: + return default + return min(parsed, maximum) + + +def _error_detail(response: requests.Response) -> str: + with contextlib.suppress(ValueError, AttributeError): + detail = response.json().get("detail") + if detail: + return str(detail) + return f"HTTP {response.status_code}" + + +def _session_headers(record: dict[str, Any]) -> dict[str, str]: + headers = {"Authorization": f"Bearer {record['api_token']}"} + workspace_id = record.get("organization_id") + if isinstance(workspace_id, str) and workspace_id: + headers["X-Strix-Workspace"] = workspace_id + return headers + + +def _revoke_stored_session(record: dict[str, Any]) -> tuple[bool, str | None]: + """Revoke one server session; return (definitively_inactive, error).""" + app_url = record.get("app_url") + if not isinstance(app_url, str) or not app_url: + return False, ( + "the stored sign-in has no trusted platform URL; use --local-only to remove it" + ) + try: + response = requests.delete( + f"{app_url.rstrip('/')}/api/v1/cli/session", + headers=_session_headers(record), + timeout=_HTTP_TIMEOUT_S, + allow_redirects=False, + ) + except requests.RequestException as exc: + return False, f"could not revoke the remote CLI session: {exc}" + if response.status_code in {200, 204, 401}: + return True, None + return False, f"could not revoke the remote CLI session: {_error_detail(response)}" + + +def _print_logout_failure(console: Console, message: str, *, as_json: bool) -> int: + if as_json: + sys.stdout.write(json.dumps({"error": message, "removed": False}) + "\n") + else: + console.print(f"[red]Sign-out failed:[/] {_terminal_markup(message)}") + console.print("[dim]The local token was kept so you can safely retry.[/]") + return 1 + + +def _revoke_replaced_legacy_session( + previous: dict[str, Any] | None, current: dict[str, Any] +) -> None: + """Best-effort cleanup when the first device-aware login replaces a legacy token.""" + if not previous or previous.get("api_token") == current.get("api_token"): + return + if previous.get("app_url") != current.get("app_url"): + return + with contextlib.suppress(KeyError, requests.RequestException): + requests.delete( + f"{previous['app_url']}/api/v1/cli/session", + headers=_session_headers(previous), + timeout=_HTTP_TIMEOUT_S, + allow_redirects=False, + ) + + +def _print_success(console: Console, record: dict[str, Any]) -> None: + email = record.get("email", "") + organization = record.get("organization_name") or record.get("organization_id", "") + console.print() + console.print("[green]✓ Signed in to the Strix platform.[/]") + if email: + console.print(f" Account: [bold]{_terminal_markup(email)}[/]") + if organization: + console.print(f" Workspace: [bold]{_terminal_markup(organization)}[/]") + scopes = record.get("scopes") + if isinstance(scopes, list) and scopes: + console.print(f" Access: [dim]{_terminal_markup(_scope_summary(record))}[/]") + console.print(f" Token: stored in [dim]{_terminal_markup(AUTH_PATH)}[/]") + console.print() + console.print( + "[dim]The managed platform is ready. Run `strix cloud` to list the commands. " + "See https://docs.app.strix.ai for the API reference.[/]" + ) + + +def _status(console: Console, argv: list[str]) -> int: # noqa: PLR0912 + parser = _SessionArgumentParser( + prog="strix cloud whoami", + description="Show the stored managed-platform account, workspace, scopes, and expiry.", + ) + parser.add_argument("--json", action="store_true", help="Print the session as JSON.") + parser.add_argument("--show-scopes", action="store_true", help="Print every granted scope.") + as_json = "--json" in argv or not sys.stdout.isatty() + try: + args = parser.parse_args(argv) + except _SessionUsageError as exc: + if as_json: + sys.stdout.write(json.dumps({"error": str(exc)}) + "\n") + else: + console.print(f"[red]Error:[/] {_terminal_markup(exc)}") + return 2 + except SystemExit as exc: + return exc.code if isinstance(exc.code, int) else 2 + + as_json = bool(args.json) or not sys.stdout.isatty() + record = read_record() + if record is None: + if as_json: + sys.stdout.write(json.dumps({"signed_in": False, "error": "Not signed in"}) + "\n") + return 1 + console.print("[yellow]Not signed in.[/] Run [bold]strix cloud login[/] to sign in.") + return 1 + email = record.get("email", "unknown") + organization = record.get("organization_name") or record.get("organization_id", "") + expires_at = record.get("expires_at", "") + if as_json: + payload = { + "signed_in": True, + "email": email, + "organization_id": record.get("organization_id"), + "organization_name": record.get("organization_name"), + "scopes": record.get("scopes", []), + "expires_at": expires_at or None, + **({"app_url": record["app_url"]} if record.get("app_url") else {}), + } + sys.stdout.write(json.dumps(payload, indent=2, default=str) + "\n") + return 0 + console.print(f"[green]Signed in[/] as [bold]{_terminal_markup(email)}[/]") + if organization: + console.print(f" Workspace: {_terminal_markup(organization)}") + if expires_at: + console.print(f" Token expires: {_terminal_markup(expires_at)}") + if record.get("app_url"): + console.print(f" Platform: {_terminal_markup(record['app_url'])}") + scopes = record.get("scopes") + if isinstance(scopes, list) and scopes: + scope_items = cast("list[Any]", cast("Any", scopes)) + if args.show_scopes: + console.print( + f" Scopes: {_terminal_markup(' '.join(str(scope) for scope in scope_items))}" + ) + else: + console.print(f" Access: {_terminal_markup(_scope_summary(record))}") + return 0 + + +def _scope_summary(record: dict[str, Any]) -> str: + scopes = record.get("scopes") + scope_items = cast("list[Any]", cast("Any", scopes)) if isinstance(scopes, list) else [] + count = len(scope_items) + profile = str(record.get("scope_profile") or "custom").replace("_", " ").title() + return f"{profile} · {count} scope{'s' if count != 1 else ''} granted" + + +def _logout(console: Console, argv: list[str]) -> int: # noqa: PLR0911, PLR0912 + parser = _SessionArgumentParser( + prog="strix cloud logout", + description="Revoke this CLI session and remove its token from this machine.", + ) + parser.add_argument("--json", action="store_true", help="Print the result as JSON.") + parser.add_argument( + "--local-only", + action="store_true", + help="Remove only the local token, leaving the remote session active.", + ) + as_json = "--json" in argv or not sys.stdout.isatty() + try: + args = parser.parse_args(argv) + except _SessionUsageError as exc: + if as_json: + sys.stdout.write(json.dumps({"error": str(exc)}) + "\n") + else: + console.print(f"[red]Error:[/] {_terminal_markup(exc)}") + return 2 + except SystemExit as exc: + return exc.code if isinstance(exc.code, int) else 2 + as_json = bool(args.json) or not sys.stdout.isatty() + if read_record() is None and not AUTH_PATH.exists(): + if as_json: + sys.stdout.write(json.dumps({"signed_in": False, "removed": False}) + "\n") + return 0 + console.print("[yellow]Not signed in.[/]") + return 0 + record = read_record() + remotely_revoked = False + if record is not None and not args.local_only: + remotely_revoked, revoke_error = _revoke_stored_session(record) + if revoke_error: + return _print_logout_failure(console, revoke_error, as_json=as_json) + + if not logout(): + if as_json: + sys.stdout.write( + json.dumps( + { + "error": "Could not remove the stored API token", + "signed_in": True, + "removed": False, + } + ) + + "\n" + ) + return 1 + console.print( + f"[red]Could not remove the stored API token.[/] Delete " + f"{_terminal_markup(AUTH_PATH)} manually." + ) + return 1 + if as_json: + sys.stdout.write( + json.dumps( + { + "signed_in": False, + "removed": True, + "remotely_revoked": remotely_revoked, + "local_only": bool(args.local_only), + } + ) + + "\n" + ) + return 0 + if args.local_only: + console.print( + "[yellow]Local sign-out only.[/] The remote CLI session is still active; " + "revoke it from API Access if needed." + ) + else: + console.print("[green]Signed out.[/] The CLI session was revoked and removed locally.") + return 0 diff --git a/strix/interface/platform_identity.py b/strix/interface/platform_identity.py new file mode 100644 index 00000000..508e7e39 --- /dev/null +++ b/strix/interface/platform_identity.py @@ -0,0 +1,46 @@ +"""Stable, privacy-safe identity for this Strix CLI installation.""" + +from __future__ import annotations + +import json +import platform +from pathlib import Path +from typing import Any, cast +from uuid import uuid4 + +from strix.utils.secret_files import write_secret_text + + +IDENTITY_PATH = Path.home() / ".strix" / "cli-identity.json" + + +def _default_device_name(instance_id: str) -> str: + system = {"Darwin": "macOS", "Windows": "Windows", "Linux": "Linux"}.get( + platform.system(), "Computer" + ) + return f"{system} CLI · {instance_id[:8]}" + + +def read_or_create_identity(*, device_name: str | None = None) -> dict[str, str]: + """Return one installation ID, optionally updating its user-facing label.""" + record: dict[str, Any] = {} + try: + raw = json.loads(IDENTITY_PATH.read_text(encoding="utf-8")) + if isinstance(raw, dict): + record = cast("dict[str, Any]", raw) + except (OSError, json.JSONDecodeError): + pass + + instance_id = record.get("client_instance_id") + if not isinstance(instance_id, str) or len(instance_id) < 8: + instance_id = str(uuid4()) + label = device_name.strip() if device_name is not None else record.get("device_name") + if not isinstance(label, str) or not label.strip(): + label = _default_device_name(instance_id) + label = " ".join(label.split()) + if not 1 <= len(label) <= 80: + raise ValueError("device name must be 1-80 printable characters") + + identity = {"client_instance_id": instance_id, "device_name": label} + write_secret_text(IDENTITY_PATH, json.dumps(identity, indent=2)) + return identity diff --git a/strix/interface/terminal_text.py b/strix/interface/terminal_text.py new file mode 100644 index 00000000..b0cc2b4b --- /dev/null +++ b/strix/interface/terminal_text.py @@ -0,0 +1,21 @@ +"""Safe rendering of untrusted text in a terminal.""" + +from __future__ import annotations + +import re + + +_TERMINAL_CONTROL = re.compile(r"[\x00-\x1f\x7f-\x9f]") + + +def has_terminal_control(value: object) -> bool: + """Return whether text contains bytes that can alter terminal state/protocols.""" + return _TERMINAL_CONTROL.search(str(value)) is not None + + +def sanitize_terminal_text(value: object) -> str: + """Make C0/C1 control bytes visible so they cannot operate a terminal.""" + return _TERMINAL_CONTROL.sub( + lambda match: f"\\x{ord(match.group()):02x}", + str(value), + ) diff --git a/strix/interface/url_safety.py b/strix/interface/url_safety.py new file mode 100644 index 00000000..b9a77165 --- /dev/null +++ b/strix/interface/url_safety.py @@ -0,0 +1,85 @@ +"""Validation for URLs printed or opened on behalf of a remote service.""" + +from __future__ import annotations + +import ipaddress +from urllib.parse import SplitResult, urlsplit + +from strix.interface.terminal_text import has_terminal_control + + +def is_safe_web_url( + value: object, + *, + trusted_origin: str | None = None, + require_trusted_origin: bool = False, +) -> bool: + """Accept a strict HTTP(S) URL, optionally only on a pre-trusted origin.""" + parsed = _parse(value) + if parsed is None: + return False + trusted = _parse(trusted_origin) if trusted_origin is not None else None + same_origin = trusted is not None and _origin(parsed) == _origin(trusted) + if require_trusted_origin: + return same_origin + if same_origin: + return True + return _is_safe_external_https(parsed) + + +def _is_safe_external_https(parsed: SplitResult) -> bool: + """Reject local, numeric-looking, or otherwise ambiguous external hosts.""" + hostname = (parsed.hostname or "").lower().rstrip(".") + if ( + parsed.scheme != "https" + or hostname == "localhost" + or hostname.endswith((".localhost", ".local")) + ): + return False + try: + return ipaddress.ip_address(hostname).is_global + except ValueError: + pass + labels = hostname.split(".") + return len(labels) >= 2 and not all(_looks_numeric(label) for label in labels) + + +def _parse(value: object) -> SplitResult | None: + if not isinstance(value, str) or not value or has_terminal_control(value): + return None + if "\\" in value or any(character.isspace() for character in value): + return None + try: + parsed = urlsplit(value) + port = parsed.port + except ValueError: + return None + hostname = parsed.hostname + if ( + parsed.scheme not in {"http", "https"} + or not hostname + or parsed.username is not None + or parsed.password is not None + or parsed.fragment + or "%" in parsed.netloc + ): + return None + try: + hostname.encode("ascii") + except UnicodeEncodeError: + return None + return parsed if port is None or 1 <= port <= 65535 else None + + +def _origin(parsed: SplitResult) -> tuple[str, str, int]: + default_port = 443 if parsed.scheme == "https" else 80 + return parsed.scheme, (parsed.hostname or "").lower().rstrip("."), parsed.port or default_port + + +def _looks_numeric(label: str) -> bool: + lowered = label.lower() + if lowered.startswith("0x"): + return len(lowered) > 2 and all( + character in "0123456789abcdef" for character in lowered[2:] + ) + return bool(lowered) and all(character.isdigit() for character in lowered) diff --git a/strix/utils/secret_files.py b/strix/utils/secret_files.py index b2170bf9..023aa87f 100644 --- a/strix/utils/secret_files.py +++ b/strix/utils/secret_files.py @@ -23,9 +23,26 @@ def write_secret_text(path: Path, text: str) -> None: try: with os.fdopen(fd, "w", encoding="utf-8") as handle: handle.write(text) - except BaseException: - with contextlib.suppress(OSError): - tmp.unlink() + except BaseException as exc: + _cleanup_tmp(tmp, exc) raise - tmp.replace(path) + try: + tmp.replace(path) + except BaseException as exc: + _cleanup_tmp(tmp, exc) + raise + + +def _cleanup_tmp(tmp: Path, cause: BaseException) -> None: + """Delete the temporary secret file. A failed delete must not stay silent.""" + try: + tmp.unlink() + except FileNotFoundError: + pass + except OSError: + message = ( + f"could not store the secret, and the temporary file {tmp} " + f"still holds it. Delete the file manually." + ) + raise OSError(message) from cause 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..f2c23435 --- /dev/null +++ b/tests/test_cloud_cli.py @@ -0,0 +1,3569 @@ +"""Tests for the `strix cloud` CLI: routing, request building, and output.""" + +from __future__ import annotations + +import io +import json +import shutil +import subprocess +import urllib.request +import webbrowser +from pathlib import Path +from typing import Any + +import pytest +import requests +from rich.console import Console + +from strix.interface import cloud, platform_cli +from strix.interface.cloud import billing, http, payment_proxy, render, runner, workspaces +from strix.interface.cloud.spec import GROUP_HELP, SPEC + + +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 + + def iter_content(self, chunk_size: int) -> Any: + for index in range(0, len(self.content), chunk_size): + yield self.content[index : index + chunk_size] + + def close(self) -> None: + pass + + +@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_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: + response = FakeResponse(text="accepted") + response.headers = {"content-type": "application/json"} + monkeypatch.setattr(http, "request", lambda *_a, **_k: response) + + assert cloud.run_cloud(["workspaces", "list", "--json"]) == 1 + output = capsys.readouterr().out + assert "malformed JSON" in output + assert "accepted" not in output + + +def test_group_without_safe_read_default_lists_verbs() -> None: + assert cloud.run_cloud(["uploads"]) == 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 == [] + + +@pytest.mark.parametrize( + ("group", "verb"), + [ + ("scans", "list"), + ("vulns", "list"), + ("domains", "list"), + ("repos", "list"), + ("schedules", "list"), + ("pr-reviews", "list"), + ("billing", "credits"), + ("chat", "list"), + ("knowledge", "list"), + ("org", "get"), + ("integrations", "list"), + ("connectors", "list"), + ("webhooks", "list"), + ("analytics", "overview"), + ("audit", "list"), + ("costs", "overview"), + ("llm-settings", "get"), + ("settings", "notifications"), + ("license", "show"), + ("tokens", "list"), + ("supply-chain", "summary"), + ("workspaces", "list"), + ], +) +def test_read_groups_have_safe_defaults(group: str, verb: str) -> None: + resolved = runner.resolve(group, []) + assert resolved is not None + command, remaining = resolved + assert command is runner.SPEC[group][verb] + assert 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_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] = {} + + def fake_request(method: str, path: str, **kwargs: Any) -> FakeResponse: + seen.update(query=kwargs.get("query"), body=kwargs.get("body")) + if method == "POST" and path == "/scans": + return FakeResponse(payload={"scan_id": "scan-1", "status": "pending"}) + 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={"scan_id": "scan-1", "status": "pending"}) + + 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_token_create_accepts_expiry_and_rbac_scope_flags( + 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={"id": "token-1", "token": "strix_pat_once"}) + + monkeypatch.setattr(http, "request", fake_request) + code = cloud.run_cloud( + [ + "tokens", + "create", + "--type", + "service", + "--name", + "ci", + "--expires-at", + "2026-09-30T12:00:00Z", + "--rbac-scopes", + '[{"type":"tag","value":"staging"}]', + "--json", + ] + ) + + assert code == 0 + assert seen["body"] == { + "type": "service", + "name": "ci", + "expires_at": "2026-09-30T12:00:00Z", + "rbac_scopes": [{"type": "tag", "value": "staging"}], + } + + +def test_token_create_rejects_non_array_rbac_scopes(capsys: Any) -> None: + code = cloud.run_cloud( + [ + "tokens", + "create", + "--type", + "service", + "--name", + "ci", + "--rbac-scopes", + '{"type":"tag","value":"staging"}', + "--json", + ] + ) + + assert code == http.EXIT_USAGE + assert json.loads(capsys.readouterr().out)["error"] == ("--rbac-scopes must be a JSON array") + + +def test_token_create_rejects_two_expiration_modes(capsys: Any) -> None: + code = cloud.run_cloud( + [ + "tokens", + "create", + "--type", + "personal", + "--name", + "local", + "--expires-at", + "2026-09-30T12:00:00Z", + "--expires-in-days", + "30", + "--json", + ] + ) + + assert code == http.EXIT_USAGE + assert json.loads(capsys.readouterr().out)["error"] == ( + "--expires-at and --expires-in-days are mutually exclusive." + ) + + +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={"scan_id": "scan-1", "status": "pending"}) + + 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={"scan_id": "scan-1", "status": "pending"}) + + 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_required_secret_body_field_can_come_from_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('{"access_token":"provider-secret"}')) + + assert cloud.run_cloud(["integrations", "connect", "gitlab", "--data", "-", "--json"]) == 0 + assert seen["body"] == {"access_token": "provider-secret"} + + +def test_provider_token_does_not_override_strix_api_auth( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen: dict[str, Any] = {} + + def fake_request(_method: str, _path: str, **kwargs: Any) -> FakeResponse: + seen.update(token=kwargs.get("token"), body=kwargs.get("body")) + return FakeResponse(payload={"ok": True}) + + monkeypatch.setattr(http, "request", fake_request) + + assert ( + cloud.run_cloud( + [ + "integrations", + "connect", + "gitlab", + "--provider-token", + "provider-secret", + "--instance-url", + "https://gitlab.com", + "--json", + ] + ) + == 0 + ) + assert seen == { + "token": None, + "body": { + "access_token": "provider-secret", + "instance_url": "https://gitlab.com", + }, + } + + +def test_required_body_field_is_validated_after_data_merge(capsys: Any) -> None: + assert cloud.run_cloud(["integrations", "connect", "gitlab", "--json"]) == http.EXIT_USAGE + assert "--provider-token" in json.loads(capsys.readouterr().out)["error"] + + +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_wait_failure_keeps_the_created_operation_id( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + def fake_request(method: str, _path: str, **_kwargs: Any) -> FakeResponse: + if method == "POST": + return FakeResponse(payload={"id": "scan-created", "status": "running"}) + return FakeResponse(status_code=503, payload={"detail": "temporarily unavailable"}) + + monkeypatch.setattr(http, "request", fake_request) + assert cloud.run_cloud(["scans", "start", "--domain-ids", "d1", "--wait", "--json"]) == 1 + payload = json.loads(capsys.readouterr().out) + assert payload["operation_id"] == "scan-created" + assert payload["status_unknown"] is True + + +def test_ambiguous_scan_request_warns_before_retry( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr( + http, + "request", + lambda *_args, **_kwargs: (_ for _ in ()).throw(http.CloudError("connection reset")), + ) + + assert cloud.run_cloud(["scans", "start", "--domain-ids", "d1", "--json"]) == 1 + payload = json.loads(capsys.readouterr().out) + assert payload["launch_outcome_unknown"] is True + assert "scans list" in payload["error"] + + +@pytest.mark.parametrize("response_payload", [{}, "accepted"]) +def test_malformed_scan_success_is_reported_as_ambiguous( + response_payload: Any, monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr( + http, + "request", + lambda *_args, **_kwargs: FakeResponse(payload=response_payload), + ) + + assert cloud.run_cloud(["scans", "start", "--domain-ids", "d1", "--json"]) == 1 + payload = json.loads(capsys.readouterr().out) + assert payload["launch_outcome_unknown"] is True + assert payload["retry_safe"] is True + assert payload["idempotency_key"] + assert "scans list" in payload["error"] + + +@pytest.mark.parametrize( + "command", + [ + ["scans", "rerun", "scan-1", "--wait", "--json"], + ["vulns", "retest", "vuln-1", "--wait", "--json"], + ], +) +def test_waitable_scan_mutation_requires_an_operation_id( + command: list[str], monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr(http, "request", lambda *_a, **_k: FakeResponse(payload={})) + + assert cloud.run_cloud(command) == 1 + payload = json.loads(capsys.readouterr().out) + assert payload["launch_outcome_unknown"] is True + assert "successful operation response without an operation ID" in payload["error"] + + +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]"]) == http.EXIT_USAGE + assert cloud.run_cloud(["scans", "start", "--data", "not json"]) == http.EXIT_USAGE + + +def test_typed_json_flag_parse_error_is_usage_error() -> None: + assert cloud.run_cloud(["scans", "start", "--domain-paths", "not-json"]) == http.EXIT_USAGE + + +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_stored_token_is_never_sent_to_a_different_platform_origin( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("STRIX_API_TOKEN", raising=False) + monkeypatch.setattr(http, "_app_url_override", "https://attacker.example") + monkeypatch.setattr( + http, + "read_record", + lambda: {"api_token": "stored-secret", "app_url": "https://app.strix.ai"}, + ) + monkeypatch.setattr( + http.requests, + "request", + lambda *_args, **_kwargs: pytest.fail("a mismatched origin must not receive the token"), + ) + + with pytest.raises(http.CloudError, match="different platform") as raised: + http.request("GET", "/billing/credits") + assert raised.value.exit_code == http.EXIT_AUTH + + +def test_stored_token_requires_an_issuer_binding(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("STRIX_API_TOKEN", raising=False) + monkeypatch.setattr(http, "_app_url_override", "https://app.strix.ai") + monkeypatch.setattr(http, "read_record", lambda: {"api_token": "legacy-secret"}) + monkeypatch.setattr( + http.requests, + "request", + lambda *_args, **_kwargs: pytest.fail("an unbound token must not be sent"), + ) + + with pytest.raises(http.CloudError, match="not bound") as raised: + http.request("GET", "/billing/credits") + assert raised.value.exit_code == http.EXIT_AUTH + + +def test_stored_token_is_sent_only_to_its_bound_platform( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("STRIX_API_TOKEN", raising=False) + monkeypatch.setattr(http, "_app_url_override", "https://preview.strix.ai") + monkeypatch.setattr( + http, + "read_record", + lambda: {"api_token": "stored-secret", "app_url": "https://preview.strix.ai"}, + ) + seen: dict[str, Any] = {} + + def request(_method: str, url: str, **kwargs: Any) -> FakeResponse: + seen.update(url=url, headers=kwargs["headers"]) + return FakeResponse(payload={"balance": 1}) + + monkeypatch.setattr(http.requests, "request", request) + response = http.request("GET", "/billing/credits") + + assert response.status_code == 200 + assert seen["url"] == "https://preview.strix.ai/api/v1/billing/credits" + assert seen["headers"]["Authorization"] == "Bearer stored-secret" + + +def test_explicit_token_can_target_an_explicit_platform( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("STRIX_API_TOKEN", raising=False) + monkeypatch.setattr(http, "_app_url_override", "https://preview.strix.ai") + monkeypatch.setattr( + http, + "read_record", + lambda: {"api_token": "stored-secret", "app_url": "https://app.strix.ai"}, + ) + seen: dict[str, Any] = {} + + def request(_method: str, url: str, **kwargs: Any) -> FakeResponse: + seen.update(url=url, headers=kwargs["headers"]) + return FakeResponse(payload={"balance": 1}) + + monkeypatch.setattr(http.requests, "request", request) + override_value = "explicit-preview-" + str(1) + response = http.request("GET", "/billing/credits", token=override_value) + + assert response.status_code == 200 + assert seen["url"] == "https://preview.strix.ai/api/v1/billing/credits" + assert seen["headers"]["Authorization"] == f"Bearer {override_value}" + + +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_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( + 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) == { + "error": "Payment required", + "challenge": challenge, + } + + +def test_topup_noninteractive_requires_explicit_payment_approval( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + challenge = {"payment_requirements": [{"amount": 500}]} + monkeypatch.setattr( + http, "request", lambda *_a, **_k: FakeResponse(status_code=402, payload=challenge) + ) + monkeypatch.setattr(runner.sys.stdin, "isatty", lambda: False) + monkeypatch.setattr( + billing.subprocess, + "run", + lambda *_a, **_k: pytest.fail("wallet must not run without --yes"), + ) + + code = cloud.run_cloud(["billing", "topup", "--credits", "5", "--json"]) + + assert code == http.EXIT_PAYMENT + payload = json.loads(capsys.readouterr().out) + assert "requires explicit approval" in payload["error"] + assert payload["challenge"] == challenge + + +@pytest.mark.parametrize("explicit_json,stdout_tty", [(True, True), (False, False)]) +def test_topup_machine_output_never_prompts_even_with_terminal_stdin( + explicit_json: bool, + stdout_tty: bool, + monkeypatch: pytest.MonkeyPatch, + capsys: Any, +) -> None: + challenge = {"payment_requirements": [{"amount": 500}]} + monkeypatch.setattr( + http, "request", lambda *_a, **_k: FakeResponse(status_code=402, payload=challenge) + ) + monkeypatch.setattr(runner.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(runner.sys.stdout, "isatty", lambda: stdout_tty) + monkeypatch.setattr( + runner.Console, + "input", + lambda *_a, **_k: pytest.fail("machine-readable top-up must not prompt"), + ) + + argv = ["billing", "topup", "--credits", "5"] + if explicit_json: + argv.append("--json") + assert cloud.run_cloud(argv) == http.EXIT_PAYMENT + + payload = json.loads(capsys.readouterr().out) + assert "requires explicit approval" in payload["error"] + assert payload["challenge"] == challenge + + +def test_topup_payment_flags_are_mutually_exclusive( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(http, "request", lambda *_a, **_k: pytest.fail("must not request")) + assert ( + cloud.run_cloud(["billing", "topup", "--credits", "5", "--yes", "--no-pay", "--json"]) + == http.EXIT_USAGE + ) + + +def test_data_cannot_override_an_explicit_payment_amount( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr(http, "request", lambda *_a, **_k: pytest.fail("must not request")) + + assert ( + cloud.run_cloud( + [ + "billing", + "topup", + "--credits", + "5", + "--data", + '{"credits": 500}', + "--yes", + "--json", + ] + ) + == http.EXIT_USAGE + ) + assert "cannot override explicit" in json.loads(capsys.readouterr().out)["error"] + + +def test_topup_missing_wallet_keeps_json_machine_readable( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + challenge = {"payment_requirements": [{"amount": 500}]} + monkeypatch.setattr( + http, "request", lambda *_a, **_k: FakeResponse(status_code=402, payload=challenge) + ) + monkeypatch.setattr(shutil, "which", lambda _name: None) + + code = cloud.run_cloud(["billing", "topup", "--credits", "5", "--yes", "--json"]) + + assert code == http.EXIT_PAYMENT + payload = json.loads(capsys.readouterr().out) + assert "wallet client" in payload["error"] + assert payload["challenge"] == 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_topup_keeps_token_out_of_wallet_process_and_forwards_payment( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + challenge = {"payment_requirements": [{"amount": 500}]} + receipt = { + "credits_granted": 5, + "duplicate": False, + "reference": "pay_test_1", + "balance": 10, + } + api_credential = "opaque-test-api-credential-value" + monkeypatch.chdir(tmp_path) + (tmp_path / ".npmrc").write_text("registry=https://malicious.invalid\n", encoding="utf-8") + monkeypatch.setenv("UNRELATED_CODING_AGENT_SECRET", "must-not-reach-wallet") + monkeypatch.setattr( + http, "request", lambda *_a, **_k: FakeResponse(status_code=402, payload=challenge) + ) + monkeypatch.setattr(http, "api_token", lambda *_a, **_k: api_credential) + monkeypatch.setattr(shutil, "which", lambda _name: "/usr/bin/npx") + commands: list[list[str]] = [] + child_envs: list[dict[str, str]] = [] + child_cwds: list[Path] = [] + upstream: dict[str, Any] = {} + + def fake_upstream_request(method: str, url: str, **kwargs: Any) -> FakeResponse: + upstream.update(method=method, url=url, **kwargs) + return FakeResponse(payload=receipt, content=json.dumps(receipt).encode()) + + monkeypatch.setattr(payment_proxy.requests, "request", fake_upstream_request) + + def fake_run(command: list[str], **kwargs: Any) -> Any: + commands.append(command) + child_envs.append(kwargs["env"]) + child_cwds.append(Path(kwargs["cwd"])) + wallet_url = next( + argument for argument in command if argument.startswith("http://127.0.0.1:") + ) + request = urllib.request.Request( # noqa: S310 + wallet_url, + data=json.dumps({"credits": 5}).encode(), + headers={ + "Authorization": "Payment wallet-credential", + "Content-Type": "application/json", + }, + method="POST", + ) + with urllib.request.urlopen(request, timeout=2) as response: # noqa: S310 + stdout = response.read().decode() + return type( + "Result", + (), + { + "returncode": 0, + "stdout": stdout, + "stderr": "", + }, + )() + + monkeypatch.setattr(subprocess, "run", fake_run) + code = cloud.run_cloud( + ["billing", "topup", "--credits", "5", "--yes", "--payment-method", "pm_card_visa"] + ) + assert code == 0 + assert all(api_credential not in argument for argument in commands[0]) + assert "mppx@0.8.17" in commands[0] + assert "--registry=https://registry.npmjs.org" in commands[0] + assert "--ignore-scripts" in commands[0] + assert "-H" not in commands[0] + assert "--fail" in commands[0] + assert child_envs[0].get("STRIX_API_TOKEN") is None + assert child_envs[0].get("UNRELATED_CODING_AGENT_SECRET") is None + for name in ("NO_PROXY", "no_proxy"): + bypasses = child_envs[0][name].split(",") + assert "127.0.0.1" in bypasses + assert "localhost" in bypasses + assert "::1" in bypasses + assert child_cwds[0] != tmp_path + assert upstream["method"] == "POST" + assert upstream["url"].endswith("/api/v1/billing/topup") + assert upstream["headers"]["X-Strix-Authorization"] == f"Bearer {api_credential}" + assert upstream["headers"]["Authorization"] == "Payment wallet-credential" + assert upstream["data"] == json.dumps({"credits": 5}).encode() + assert "-M" in commands[0] + assert "paymentMethod=pm_card_visa" in commands[0] + + +def test_topup_wallet_failure_is_one_redacted_json_object( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> 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") + monkeypatch.setattr( + subprocess, + "run", + lambda *_a, **_k: type( + "Result", + (), + { + "returncode": 9, + "stdout": "", + "stderr": ( + "failed with Bearer super-secret and " + "Authorization: Payment wallet-super-secret\x1b[2J" + ), + }, + )(), + ) + + assert cloud.run_cloud(["billing", "topup", "--credits", "5", "--yes", "--json"]) == 5 + payload = json.loads(capsys.readouterr().out) + assert payload["wallet_exit_code"] == 9 + assert payload["payment_outcome_unknown"] is True + assert "billing credits" in payload["error"] + assert "super-secret" not in payload["detail"] + assert "Bearer [redacted]" in payload["detail"] + assert "Payment [redacted]" in payload["detail"] + assert "\x1b" not in payload["detail"] + + +def test_topup_wallet_interruption_reports_unknown_payment_outcome( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse( + status_code=402, + payload={"payment_requirements": [{"amount": 500}]}, + ), + ) + monkeypatch.setattr(http, "api_token", lambda *_a, **_k: "tok") + monkeypatch.setattr(shutil, "which", lambda _name: "/usr/bin/npx") + monkeypatch.setattr( + subprocess, "run", lambda *_a, **_k: (_ for _ in ()).throw(KeyboardInterrupt) + ) + + assert cloud.run_cloud(["billing", "topup", "--credits", "5", "--yes", "--json"]) == 130 + payload = json.loads(capsys.readouterr().out) + assert payload["interrupted"] is True + assert payload["payment_outcome_unknown"] is True + assert "billing credits" in payload["error"] + assert "before retrying" in payload["error"] + + +def test_topup_non_json_wallet_success_requires_balance_verification( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse( + status_code=402, + payload={"payment_requirements": [{"amount": 500}]}, + ), + ) + monkeypatch.setattr(http, "api_token", lambda *_a, **_k: "tok") + monkeypatch.setattr(shutil, "which", lambda _name: "/usr/bin/npx") + monkeypatch.setattr( + subprocess, + "run", + lambda *_a, **_k: type("Result", (), {"returncode": 0, "stdout": "paid", "stderr": ""})(), + ) + + assert cloud.run_cloud(["billing", "topup", "--credits", "5", "--yes", "--json"]) == 5 + payload = json.loads(capsys.readouterr().out) + assert "did not return JSON" in payload["error"] + assert "before retrying" in payload["error"] + assert payload["payment_outcome_unknown"] is True + + +def test_topup_rejects_parseable_wallet_error_as_a_success( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse( + status_code=402, + payload={"payment_requirements": [{"amount": 500}]}, + ), + ) + monkeypatch.setattr(http, "api_token", lambda *_a, **_k: "tok") + monkeypatch.setattr(shutil, "which", lambda _name: "/usr/bin/npx") + monkeypatch.setattr( + subprocess, + "run", + lambda *_a, **_k: type( + "Result", + (), + { + "returncode": 0, + "stdout": '{"detail":"Failed to process the top-up payment"}', + "stderr": "", + }, + )(), + ) + + assert cloud.run_cloud(["billing", "topup", "--credits", "5", "--yes", "--json"]) == 5 + payload = json.loads(capsys.readouterr().out) + assert "invalid top-up receipt" in payload["error"] + assert payload["payment_outcome_unknown"] is True + + +def test_topup_does_not_trust_an_unobserved_wallet_receipt( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + receipt = { + "credits_granted": 5, + "duplicate": False, + "reference": "untrusted-wallet-output", + "balance": 10, + } + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse( + status_code=402, + payload={"payment_requirements": [{"amount": 500}]}, + ), + ) + monkeypatch.setattr(http, "api_token", lambda *_a, **_k: "tok") + monkeypatch.setattr(shutil, "which", lambda _name: "/usr/bin/npx") + monkeypatch.setattr( + subprocess, + "run", + lambda *_a, **_k: type( + "Result", + (), + {"returncode": 0, "stdout": json.dumps(receipt), "stderr": ""}, + )(), + ) + + assert cloud.run_cloud(["billing", "topup", "--credits", "5", "--yes", "--json"]) == 5 + payload = json.loads(capsys.readouterr().out) + assert "did not confirm" in payload["error"] + assert payload["payment_outcome_unknown"] is True + + +def test_topup_human_mode_requires_a_bridge_confirmed_receipt( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True) + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse( + status_code=402, + payload={"payment_requirements": [{"amount": 500}]}, + ), + ) + monkeypatch.setattr(http, "api_token", lambda *_a, **_k: "tok") + monkeypatch.setattr(shutil, "which", lambda _name: "/usr/bin/npx") + monkeypatch.setattr( + payment_proxy.requests, + "request", + lambda *_a, **_k: FakeResponse(status_code=200, content=b"not a receipt"), + ) + + def fake_run(command: list[str], **_kwargs: Any) -> Any: + wallet_url = next( + argument for argument in command if argument.startswith("http://127.0.0.1:") + ) + request = urllib.request.Request( # noqa: S310 + wallet_url, + data=json.dumps({"credits": 5}).encode(), + headers={"Authorization": "Payment wallet-credential"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=2) as response: # noqa: S310 + response.read() + return type("Result", (), {"returncode": 0, "stdout": None, "stderr": None})() + + monkeypatch.setattr(subprocess, "run", fake_run) + + assert cloud.run_cloud(["billing", "topup", "--credits", "5", "--yes"]) == 5 + output = capsys.readouterr().out + assert "without a confirmed receipt" in output + assert "outcome is unknown" in output + assert "billing credits" in output + + +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}) == [] + assert render._list_of_dicts( + {"organization_id": "org_1", "docs": [{"id": "doc_1"}], "total": 1} + ) == [{"id": "doc_1"}] + 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", + "json-list", + ), f"{group} {verb} {param.name}" + + +@pytest.mark.parametrize( + ("group", "verb"), + [ + ("scans", "list"), + ("vulns", "list"), + ("domains", "list"), + ("repos", "list"), + ("pr-reviews", "list"), + ("pr-reviews", "findings"), + ("webhooks", "deliveries"), + ("audit", "list"), + ], +) +def test_paginated_commands_expose_integer_page_and_limit(group: str, verb: str) -> None: + params = {param.name: param for param in SPEC[group][verb].query} + assert params["page"].kind == "int" + assert params["limit"].kind == "int" + + +def test_list_query_types_match_the_api_contract() -> None: + scans = {param.name: param for param in SPEC["scans"]["list"].query} + assert scans["include_retests"].kind == "bool" + assert {"sort_by", "sort_order"} <= scans.keys() + + vulnerabilities = {param.name: param for param in SPEC["vulns"]["list"].query} + assert "sort_order" in vulnerabilities + + for group in ("domains", "repos"): + params = {param.name: param for param in SPEC[group]["list"].query} + assert params["limit"].kind == "int" + assert "sort_order" in params + + reviews = {param.name: param for param in SPEC["pr-reviews"]["list"].query} + findings = {param.name: param for param in SPEC["pr-reviews"]["findings"].query} + audit = {param.name: param for param in SPEC["audit"]["list"].query} + assert reviews["include_counts"].kind == "bool" + assert findings["include_stats"].kind == "bool" + assert audit["all"].kind == "bool" + + components = {param.name: param for param in SPEC["repos"]["supply-chain components"].query} + knowledge = {param.name: param for param in SPEC["knowledge"]["list"].query} + assert components["limit"].kind == "int" + assert components["offset"].kind == "int" + assert knowledge["limit"].kind == "int" + + +def test_scan_creating_replay_commands_support_bounded_waits() -> None: + assert SPEC["scans"]["rerun"].wait_path == "/scans/{id}" + assert SPEC["vulns"]["retest"].wait_path == "/scans/{id}" + + +def test_scan_start_parameter_contract_and_help() -> None: + params = {param.name: param for param in SPEC["scans"]["start"].body} + assert params["headers"].kind == "json" + assert "array" in params["headers"].help.lower() + assert params["concerns"].kind == "str" + assert all(tier in params["scan_tier"].help for tier in ("lite", "standard", "ultra")) + assert "pro" not in params["scan_tier"].help + assert "max" not in params["scan_tier"].help + assert "self-hosted" in params["model_config_id"].help.lower() + assert "self-hosted" in params["max_budget_usd"].help.lower() + + +def test_report_branding_flags_preserve_the_api_query_names( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen: dict[str, Any] = {} + + def fake_request(_method: str, _path: str, **kwargs: Any) -> FakeResponse: + seen["query"] = kwargs.get("query") + return FakeResponse(content=b"report") + + monkeypatch.setattr(http, "request", fake_request) + output = tmp_path / "report.pdf" + assert ( + cloud.run_cloud( + [ + "scans", + "report", + "scan-1", + "--provider-name", + "Strix Partner", + "--member-name-0", + "Alex", + "--member-email-0", + "alex@example.test", + "--member-name-1", + "Sam", + "--member-email-1", + "sam@example.test", + "--output", + str(output), + ] + ) + == 0 + ) + assert output.read_bytes() == b"report" + assert seen["query"] == { + "providerName": "Strix Partner", + "memberName0": "Alex", + "memberEmail0": "alex@example.test", + "memberName1": "Sam", + "memberEmail1": "sam@example.test", + } + + +def test_scan_start_collects_header_array_and_string_concerns( + 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={"id": "scan-1"}) + + monkeypatch.setattr(http, "request", fake_request) + assert ( + cloud.run_cloud( + [ + "scans", + "start", + "--headers", + '[{"name":"X-Test","value":"one"}]', + "--concerns", + "authorization boundaries", + "--scan-tier", + "standard", + "--json", + ] + ) + == 0 + ) + assert seen["body"] == { + "headers": [{"name": "X-Test", "value": "one"}], + "concerns": "authorization boundaries", + "scan_tier": "standard", + } + + +def test_control_only_scan_and_chat_messages_do_not_require_message( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[str, dict[str, Any] | None]] = [] + + def fake_request(_method: str, path: str, **kwargs: Any) -> FakeResponse: + calls.append((path, kwargs.get("body"))) + return FakeResponse(payload={"success": True}) + + monkeypatch.setattr(http, "request", fake_request) + assert cloud.run_cloud(["scans", "message", "scan-1", "--cancel-current", "--json"]) == 0 + assert ( + cloud.run_cloud( + ["chat", "send", "chat-1", "--stop-agent", "--agent-id", "agent-1", "--json"] + ) + == 0 + ) + assert calls == [ + ("/scans/scan-1/message", {"cancel_current": True}), + ("/chat/chat-1/message", {"stop_agent": True, "agent_id": "agent-1"}), + ] + + +def test_chat_repositories_use_the_api_object_shape(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={"id": "chat-1"}) + + monkeypatch.setattr(http, "request", fake_request) + assert ( + cloud.run_cloud( + [ + "chat", + "start", + "--message", + "Review this repository", + "--repos", + '[{"repoId":"repo-1","branch":"main"}]', + "--json", + ] + ) + == 0 + ) + assert seen["body"] == { + "message": "Review this repository", + "repos": [{"repoId": "repo-1", "branch": "main"}], + } + + +def test_schedule_budget_accepts_fractional_usd(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={"id": "schedule-1"}) + + monkeypatch.setattr(http, "request", fake_request) + assert ( + cloud.run_cloud(["schedules", "update", "schedule-1", "--max-budget-usd", "1.5", "--json"]) + == 0 + ) + assert seen["body"] == {"max_budget_usd": 1.5} + + +def test_integration_disconnect_sends_installation_id_query( + monkeypatch: pytest.MonkeyPatch, +) -> 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={"success": True}) + + monkeypatch.setattr(http, "request", fake_request) + assert ( + cloud.run_cloud( + ["integrations", "disconnect", "github", "--installation-id", "42", "--json"] + ) + == 0 + ) + assert seen == { + "method": "DELETE", + "path": "/integrations/github", + "query": {"installation_id": 42}, + } + + +def test_connector_command_flag_is_boolean_and_warns_that_it_is_sensitive( + monkeypatch: pytest.MonkeyPatch, +) -> None: + param = next( + param for param in SPEC["connectors"]["get"].query if param.name == "include_command" + ) + assert param.kind == "bool" + assert "sensitive" in param.help.lower() + + seen: dict[str, Any] = {} + + def fake_request(_method: str, _path: str, **kwargs: Any) -> FakeResponse: + seen["query"] = kwargs.get("query") + return FakeResponse(payload={"id": "connector-1"}) + + monkeypatch.setattr(http, "request", fake_request) + assert cloud.run_cloud(["connectors", "get", "connector-1", "--include-command", "--json"]) == 0 + assert seen["query"] == {"include_command": True} + + +def test_corrected_help_distinguishes_inboxes_reports_and_self_hosted_commands() -> None: + inbox = SPEC["domains"]["test-users provision-inbox"] + assert "does not create a test user" in inbox.help + + for command_name in ("test-users add", "test-users update"): + parameters = {param.name: param.help for param in SPEC["domains"][command_name].body} + assert "email_otp" in parameters["mfa_method"] + assert "magic_link" in parameters["mfa_method"] + assert " or email." not in parameters["mfa_method"] + + vulnerability_update = {param.name: param.help for param in SPEC["vulns"]["update"].body} + assert "in_progress" in vulnerability_update["status"] + assert "not_affected" in vulnerability_update["status"] + assert "triaged" not in vulnerability_update["status"] + assert "false_positive" not in vulnerability_update["status"] + + chat_download = {param.name: param.help for param in SPEC["chat"]["files download"].query} + assert "Relative path" in chat_download["path"] + assert "/workspace" in chat_download["path"] + + report = {param.name: param.help for param in SPEC["scans"]["report"].query} + assert "Report content" in report["format"] + assert "file type" in report["type"] + + for command in (*SPEC["costs"].values(), *SPEC["llm-settings"].values()): + assert "self-hosted only" in command.help.lower() + assert "self-hosted only" in GROUP_HELP["costs"].lower() + assert "self-hosted only" in GROUP_HELP["llm-settings"].lower() + + +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 + assert runner._created_id({"scan_id": ""}) is None + assert runner._created_id({"id": " "}) 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_cloud", "--json"]) + assert code == 0 + assert seen["method"] == "POST" + assert seen["path"] == "/billing/checkout" + 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_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: + 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 + + +@pytest.mark.parametrize( + "argv,payload", + [ + ( + ["billing", "subscribe", "--plan", "strix_cloud"], + {"checkout_url": "file:///tmp/not-a-checkout"}, + ), + ( + ["integrations", "install", "github"], + {"url": "javascript:alert(1)"}, + ), + ], +) +def test_handoff_links_reject_non_http_schemes( + argv: list[str], + payload: dict[str, str], + monkeypatch: pytest.MonkeyPatch, + capsys: Any, +) -> None: + monkeypatch.setattr(runner.sys.stdout, "isatty", lambda: True) + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse(status_code=200, payload=payload), + ) + monkeypatch.setattr( + webbrowser, + "open", + lambda _url: pytest.fail("an untrusted URL must never be opened"), + ) + + assert cloud.run_cloud(argv) == http.EXIT_ERROR + output = capsys.readouterr().out + assert "invalid continuation URL" in output + assert next(iter(payload.values())) not in output + + +def test_handoff_missing_expected_url_is_an_error( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse(status_code=200, payload={"status": "created"}), + ) + assert cloud.run_cloud(["integrations", "install", "github", "--json"]) == 1 + assert "expected url URL" in json.loads(capsys.readouterr().out)["error"] + + +def test_workspaces_use_switches_stored_token( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: Any +) -> None: + monkeypatch.delenv("STRIX_API_TOKEN", raising=False) + 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", + "scopes": ["scans:read", "organizations:read", "tokens:write"], + "requested_scopes": [ + "scans:read", + "scans:write", + "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=200, + payload={ + "api_token": "old", + "organization_id": "org_1", + "organization_name": "Team One", + "scopes": ["scans:read"], + }, + ) + + monkeypatch.setattr(http, "request", fake_request) + monkeypatch.setattr( + workspaces, + "read_or_create_identity", + lambda: {"client_instance_id": "client-test", "device_name": "Test CLI"}, + ) + code = cloud.run_cloud(["workspaces", "use", "team one", "--json"]) + assert code == 0 + assert calls == [("GET", "/workspaces"), ("POST", "/workspaces/org_1/token")] + assert token_body == {"client_instance_id": "client-test", "device_name": "Test CLI"} + record = platform_cli.read_record() + assert record is not None + assert record["api_token"] == "old" + assert record["organization_name"] == "Team One" + assert record["email"] == "a@b.test" + output = json.loads(capsys.readouterr().out) + assert output["workspace_id"] == "org_1" + assert output["scope_profile"] == "custom" + assert output["stored"] is True + + +def test_workspace_use_explicit_token_starts_with_fresh_account_state( + 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": "account-a-token", + "email": "account-a@example.test", + "organization_id": "org_a", + "organization_name": "Account A", + "scopes": ["scans:read"], + "requested_scopes": ["scans:read", "tokens:write"], + } + ) + switch_body: dict[str, Any] | None = None + + def fake_request(method: str, path: str, **kwargs: Any) -> FakeResponse: + nonlocal switch_body + assert kwargs.get("token") == "account-b-token" + if method == "GET": + return FakeResponse(payload={"workspaces": [{"id": "org_b", "name": "Account B"}]}) + assert path == "/workspaces/org_b/token" + switch_body = kwargs.get("body") + return FakeResponse( + payload={ + "api_token": "account-b-token", + "organization_id": "org_b", + "organization_name": "Account B", + "scopes": ["scans:read", "organizations:read"], + } + ) + + monkeypatch.setattr(http, "request", fake_request) + assert ( + cloud.run_cloud(["workspaces", "use", "Account B", "--token", "account-b-token", "--json"]) + == 0 + ) + assert switch_body is None + record = platform_cli.read_record() + assert record is not None + assert record["api_token"] == "account-a-token" + assert record["organization_id"] == "org_a" + assert record["email"] == "account-a@example.test" + output = json.loads(capsys.readouterr().out) + assert output["workspace_id"] == "org_b" + assert output["stored"] is False + + +def test_workspace_use_environment_token_starts_with_fresh_account_state( + 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) + monkeypatch.setenv("STRIX_API_TOKEN", "account-b-token") + platform_cli.save_record( + { + "api_token": "account-a-token", + "email": "account-a@example.test", + "organization_id": "org_a", + "organization_name": "Account A", + "scopes": ["scans:read"], + "requested_scopes": ["scans:read", "tokens:write"], + } + ) + switch_body: dict[str, Any] | None = None + + def fake_request(method: str, path: str, **kwargs: Any) -> FakeResponse: + nonlocal switch_body + assert kwargs.get("token") is None + if method == "GET": + return FakeResponse(payload={"workspaces": [{"id": "org_b", "name": "Account B"}]}) + assert path == "/workspaces/org_b/token" + switch_body = kwargs.get("body") + return FakeResponse( + payload={ + "api_token": "account-b-token", + "organization_id": "org_b", + "organization_name": "Account B", + "email": "account-b@example.test", + "scopes": ["scans:read", "organizations:read"], + } + ) + + monkeypatch.setattr(http, "request", fake_request) + assert cloud.run_cloud(["workspaces", "use", "Account B", "--json"]) == 0 + assert switch_body is None + record = platform_cli.read_record() + assert record is not None + assert record["api_token"] == "account-a-token" + assert record["organization_id"] == "org_a" + assert record["email"] == "account-a@example.test" + + +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 + + +def test_workspaces_use_reports_auth_storage_failure( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.delenv("STRIX_API_TOKEN", raising=False) + + def fake_request(method: str, path: str, **_kwargs: Any) -> FakeResponse: + if method == "GET": + return FakeResponse(payload={"workspaces": [{"id": "org_1", "name": "Team One"}]}) + assert path == "/workspaces/org_1/token" + return FakeResponse( + payload={ + "api_token": "test-token", + "organization_id": "org_1", + "organization_name": "Team One", + "scopes": ["scans:read"], + } + ) + + monkeypatch.setattr(http, "request", fake_request) + monkeypatch.setattr( + workspaces, "save_record", lambda _record: (_ for _ in ()).throw(OSError("disk full")) + ) + + assert cloud.run_cloud(["workspaces", "use", "1", "--json"]) == http.EXIT_ERROR + payload = json.loads(capsys.readouterr().out) + assert "could not be stored" in payload["error"] + assert payload["workspace_switched"] is True + assert payload["local_record_updated"] is False + assert payload["retry_safe"] is True + + +@pytest.mark.parametrize( + "failure", + [ + requests.ConnectionError("connection reset"), + FakeResponse(status_code=503, text="temporarily unavailable"), + FakeResponse(status_code=200, text="not JSON"), + FakeResponse(status_code=200, payload={"organization_id": "org_1"}), + ], +) +def test_workspace_use_reports_retry_safe_unknown_outcomes( + failure: Exception | FakeResponse, + monkeypatch: pytest.MonkeyPatch, + capsys: Any, +) -> None: + def fake_request(method: str, path: str, **_kwargs: Any) -> FakeResponse: + if method == "GET": + return FakeResponse(payload={"workspaces": [{"id": "org_1", "name": "Team One"}]}) + assert path == "/workspaces/org_1/token" + if isinstance(failure, Exception): + raise http.CloudError(str(failure)) from failure + return failure + + monkeypatch.setattr(http, "request", fake_request) + + assert cloud.run_cloud(["workspaces", "use", "1", "--json"]) == http.EXIT_ERROR + payload = json.loads(capsys.readouterr().out) + assert payload["switch_outcome_unknown"] is True + assert payload["retry_safe"] is True + assert "safely rerun" in payload["error"] + + +def test_workspace_use_preserves_definitive_conflict( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + def fake_request(method: str, path: str, **_kwargs: Any) -> FakeResponse: + if method == "GET": + return FakeResponse(payload={"workspaces": [{"id": "org_1", "name": "Team One"}]}) + return FakeResponse( + status_code=409, + payload={"error": {"code": "token_conflict", "message": "token changed"}}, + ) + + monkeypatch.setattr(http, "request", fake_request) + + assert cloud.run_cloud(["workspaces", "use", "1", "--json"]) == http.EXIT_ERROR + payload = json.loads(capsys.readouterr().out) + assert "token changed" in payload["error"] + assert "switch_outcome_unknown" not in payload + + +def test_group_help_lists_all_verbs_instead_of_default_verb_help( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True) + 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 + + +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_integrations_human_list_exposes_installation_id_and_json_stays_full( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + payload = { + "integrations": [ + { + "id": "integration-uuid", + "organization_id": "org-secret", + "connected_by": "user-secret", + "provider": "github", + "installation_id": 154419799, + "account_login": "usestrix", + "repository_selection": "selected", + "connected_at": "2026-08-27T12:00:00Z", + } + ], + "merge_accounts": [ + { + "id": "merge-uuid", + "provider": "jira", + "status": "linked", + "default_collection_name": "Security", + } + ], + "bitbucket_oauth_enabled": True, + } + monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True) + monkeypatch.setattr(http, "request", lambda *_a, **_k: FakeResponse(payload=payload)) + + assert cloud.run_cloud(["integrations", "list"]) == 0 + output = capsys.readouterr().out + for value in ("1.", "2.", "github", "usestrix", "154419799", "jira", "Security"): + assert value in output + for value in ("integration-uuid", "merge-uuid", "org-secret", "user-secret"): + assert value not in output + assert "--installation-id INSTALLATION_ID" in output + + assert cloud.run_cloud(["integrations", "list", "--json"]) == 0 + assert json.loads(capsys.readouterr().out) == payload + + +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", + "pr_state": "merged", + "verdict": "request_changes", + "status": "posted", + "findings_count": 99, + "open_findings_count": 88, + "findings": { + "total": 7, + "critical": 1, + "high": 2, + "medium": 3, + "low": 1, + "unresolved": {"total": 2}, + "snoozed": 1, + "fixed": 4, + }, + } + ], + "meta": {"total": 1}, + "counts": { + "all": 12, + "open": 3, + "attention": 2, + "merged_open": 1, + "passed": 6, + "running": 1, + }, + } + ), + ) + + assert cloud.run_cloud(["pr-reviews", "list", "--include-counts"]) == 0 + output = capsys.readouterr().out + for value in ( + "usestrix/strix", + "1177", + "Improve cloud CLI", + "merged", + "feature", + "main", + "posted", + "request_changes", + "2 open / 7 total", + "Review counts", + "attention 2", + "passed 6", + "review-id", + ): + assert value in output + for value in ("org-id", "user-id", "installation_id"): + assert value not in output + + +@pytest.mark.parametrize("pr_state", ("open", "merged", "closed")) +def test_pr_review_human_list_shows_pull_request_state( + monkeypatch: pytest.MonkeyPatch, + capsys: Any, + pr_state: str, +) -> None: + monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True) + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse( + payload={ + "items": [ + { + "id": f"{pr_state}-review-id", + "repository_full_name": "usestrix/strix", + "pr_number": 1177, + "pr_title": "Renderer test", + "head_branch": "feature", + "base_branch": "main", + "pr_state": pr_state, + "status": "posted", + "findings": {"total": 0, "unresolved": {"total": 0}}, + } + ], + "meta": {"total": 1}, + } + ), + ) + + assert cloud.run_cloud(["pr-reviews", "list"]) == 0 + assert f"[{pr_state}]" in capsys.readouterr().out + + +@pytest.mark.parametrize( + ("record", "expected_targets"), + [ + ( + { + "id": "internal-scan-id", + "title": "Private network review", + "engagement_type": "internal_infra", + "scan_type": "blackbox", + "status": "running", + "internal_targets": ["10.24.0.0/16", "db.internal"], + "findings": {"total": 0}, + }, + ("10.24.0.0/16", "db.internal"), + ), + ( + { + "id": "upload-scan-id", + "title": "Local source review", + "engagement_type": "code_review", + "scan_type": "whitebox", + "status": "pending", + "has_code_upload": True, + "findings": {"total": 0}, + }, + ("uploaded source",), + ), + ], +) +def test_scan_human_list_identifies_internal_and_uploaded_targets( + monkeypatch: pytest.MonkeyPatch, + capsys: Any, + record: dict[str, Any], + expected_targets: tuple[str, ...], +) -> None: + monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True) + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse( + payload={ + "items": [record], + "meta": { + "page": 1, + "limit": 20, + "total_items": 1, + "total_pages": 1, + "has_next": False, + }, + } + ), + ) + + assert cloud.run_cloud(["scans", "list"]) == 0 + output = capsys.readouterr().out + for target in expected_targets: + assert target in output + + +@pytest.mark.parametrize( + ("command", "payload", "visible", "hidden"), + [ + ( + ["vulns", "list"], + { + "items": [ + { + "id": "vuln-id", + "scan_id": "scan-secret", + "display_number": 17, + "title": "Missing authorization", + "severity": "high", + "status": "open", + "target": None, + "method": "get", + "endpoint": "/api/admin", + "cvss": 8.2, + "finding_type": "dynamic", + } + ], + "meta": {"total_items": 1}, + }, + ("17", "Missing authorization", "GET /api/admin", "vuln-id"), + ("scan-secret",), + ), + ( + ["domains", "list"], + { + "items": [ + { + "id": "domain-id", + "organization_id": "org-secret", + "domain": "staging.example.com", + "asset_type": "web_app", + "verified": True, + "context": "staging", + "tags": ["customer-facing"], + "business_unit": "product", + "last_scan_at": "2026-08-27T12:00:00Z", + "added_by": "user-secret", + } + ], + "meta": {"total_items": 1}, + }, + ("staging.example.com", "web_app", "yes", "staging", "domain-id"), + ("org-secret", "user-secret", "added by"), + ), + ( + ["repos", "list"], + { + "items": [ + { + "id": "repo-id", + "organization_id": "org-secret", + "full_name": "usestrix/strix", + "provider": "github", + "pr_review_enabled": True, + "tags": ["core"], + "business_unit": "product", + "last_scan_at": "2026-08-27T12:00:00Z", + "added_by": "user-secret", + } + ], + "meta": {"total_items": 1}, + }, + ("usestrix/strix", "github", "yes", "repo-id"), + ("org-secret", "user-secret", "added by"), + ), + ( + ["knowledge", "list"], + { + "organization_id": "org-secret", + "docs": [ + { + "id": ( + "doc-id-that-is-deliberately-long-enough-to-require-a-lossless-" + "copyable-value" + ), + "organization_id": "org-secret", + "title": "Authentication", + "source_type": "manual", + "source_id": "dashboard/notes/auth.md", + "content": "Long private content should stay out of the list.", + "tags": ["auth"], + "severity": None, + "status": None, + "updated_at": "2026-08-27T12:00:00Z", + } + ], + "total": 1, + }, + ( + "Authentication", + "manual", + "dashboard/notes/auth.md", + "doc-id-that-is-deliberately-long-enough-to-require-a-lossless-copyable-value", + "Copyable selectors", + ), + ("org-secret", "Long private content"), + ), + ( + ["domains", "test-users", "list", "domain-id"], + { + "items": [ + { + "id": "test-user-id", + "organization_id": "org-secret", + "domain_id": "domain-id", + "label": "Staging admin", + "username": "admin@example.com", + "mfa_method": "email_otp", + "mfa_email": "inbox@security-mail.strix.ai", + "has_password": True, + "login_url": "https://staging.example.com/login", + "updated_at": "2026-08-27T12:00:00Z", + "created_by": "user-secret", + } + ], + "agentmail_configured": True, + }, + ( + "Staging admin", + "admin@example.com", + "email_otp", + "inbox@security-mail.strix.ai", + "test-user-id", + ), + ("org-secret", "user-secret", "domain id"), + ), + ], +) +def test_human_lists_prioritize_actionable_fields( + monkeypatch: pytest.MonkeyPatch, + capsys: Any, + command: list[str], + payload: dict[str, Any], + visible: tuple[str, ...], + hidden: tuple[str, ...], +) -> None: + monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True) + monkeypatch.setattr(http, "request", lambda *_a, **_k: FakeResponse(payload=payload)) + + assert cloud.run_cloud(command) == 0 + output = capsys.readouterr().out + for value in visible: + assert value in output + for value in hidden: + assert value not in output + + +def test_token_human_list_shows_lifecycle_status( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True) + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse( + payload={ + "tokens": [ + { + "id": "active-id", + "organization_id": "org-secret", + "name": "Active CI", + "type": "service", + "scopes": ["scans:read"], + "rbac_scopes": [], + "secret_prefix": "strix_svc_a", + "expires_at": "2099-01-01T00:00:00Z", + "last_used_at": None, + "revoked_at": None, + }, + { + "id": "revoked-id", + "organization_id": "org-secret", + "name": "Old CI", + "type": "service", + "scopes": ["scans:read"], + "rbac_scopes": [{"type": "tag", "value": "staging"}], + "secret_prefix": "strix_svc_r", + "expires_at": None, + "last_used_at": None, + "revoked_at": "2026-08-27T12:00:00Z", + }, + { + "id": "expired-id", + "organization_id": "org-secret", + "name": "Expired CI", + "type": "service", + "scopes": ["scans:read"], + "rbac_scopes": [{"type": "business_unit", "value": "payments"}], + "secret_prefix": "strix_svc_e", + "expires_at": "2000-01-01T00:00:00Z", + "last_used_at": None, + "revoked_at": None, + }, + ] + } + ), + ) + + assert cloud.run_cloud(["tokens", "list"]) == 0 + output = capsys.readouterr().out + for value in ( + "Active CI", + "active", + "all assets", + "Old CI", + "revoked", + "tag:staging", + "Expired CI", + "expired", + "business_unit:payments", + "scans:read", + ): + assert value in output + assert "org-secret" not in output + + +@pytest.mark.parametrize( + ("command", "payload", "visible", "hidden"), + [ + ( + ["chat", "list"], + { + "chats": [ + { + "id": "chat-id", + "title": "Investigate auth", + "status": "running", + "created_at": "2026-08-27T10:00:00Z", + "last_message_at": "2026-08-27T11:00:00Z", + } + ] + }, + ("Investigate auth", "running", "chat-id"), + (), + ), + ( + ["chat", "files", "chat-id"], + { + "files": [ + { + "path": "/workspace/" + "nested/" * 12 + "report.md", + "size": 42, + } + ] + }, + ( + "/workspace/" + "nested/" * 12 + "report.md", + "Copyable selectors", + "42", + ), + (), + ), + ( + ["chat", "findings", "chat-id"], + { + "findings": [ + { + "id": "finding-id", + "chat_id": "chat-secret", + "filed_by": "user-secret", + "title": "Broken access control", + "severity": "high", + "status": "open", + "target": None, + "method": "post", + "endpoint": "/admin/users", + "cvss": 8.1, + "filed_at": "2026-08-27T11:00:00Z", + "created_at": "2026-08-27T10:00:00Z", + } + ] + }, + ("Broken access control", "high", "POST /admin/users", "finding-id"), + ("chat-secret", "user-secret"), + ), + ( + ["scans", "agents", "scan-id"], + { + "scan_id": "scan-secret", + "agents": [ + { + "id": "agent-id", + "name": "Authorization tester", + "status": "completed", + "task": "Test object ownership", + "parent_id": None, + "created_at": "2026-08-27T10:00:00Z", + "finding_count": 2, + } + ], + }, + ("Authorization tester", "completed", "Test object ownership", "agent-id"), + ("scan-secret",), + ), + ( + ["scans", "retests", "scan-id"], + { + "runs": [ + { + "vulnerability_id": "vuln-id", + "title": "IDOR", + "severity": "high", + "issue_status": "open", + "retest_scan_id": "retest-id", + "retest_status": "running", + "created_at": "2026-08-27T10:00:00Z", + } + ], + "total": 1, + "completed": 0, + "running": 1, + }, + ("IDOR", "high", "vuln-id", "retest-id", "0/1 retest(s) complete"), + (), + ), + ( + ["pr-reviews", "findings", "--include-stats"], + { + "items": [ + { + "id": "pr-finding-id", + "pr_review_id": "review-secret", + "provider": "github", + "repository_full_name": "usestrix/strix", + "pr_number": 1177, + "pr_title": "Improve cloud CLI", + "pr_state": "open", + "title": "Unsafe redirect", + "severity": "medium", + "status": "open", + "created_at": "2026-08-27T10:00:00Z", + } + ], + "meta": {"total_items": 1}, + "stats": { + "prs_reviewed": 9, + "issues_found": 1, + "critical_high_found": 1, + "merges_blocked": 2, + }, + }, + ( + "usestrix/strix", + "1177", + "Improve cloud CLI", + "Unsafe redirect", + "pr-finding-id", + "Impact", + "prs reviewed 9", + "merges blocked 2", + ), + ("review-secret",), + ), + ( + ["vulns", "history", "vuln-id"], + [ + { + "id": "history-secret", + "vulnerability_id": "vuln-secret", + "previous_status": "snoozed", + "new_status": "snoozed", + "previous_severity": "high", + "new_severity": "medium", + "previous_snoozed_until": "2026-09-01T00:00:00Z", + "new_snoozed_until": "2026-09-15T00:00:00Z", + "changed_by": "user-secret", + "note": "Extended pending vendor fix", + "reason": "Vendor ETA changed", + "created_at": "2026-08-27T10:00:00Z", + }, + { + "id": "history-clear-secret", + "vulnerability_id": "vuln-secret", + "previous_status": "snoozed", + "new_status": "snoozed", + "previous_severity": "medium", + "new_severity": "medium", + "previous_snoozed_until": "2026-09-15T00:00:00Z", + "new_snoozed_until": None, + "changed_by": "user-secret", + "note": "Snooze removed", + "reason": "Fix available", + "created_at": "2026-08-28T10:00:00Z", + }, + ], + ( + "snoozed", + "high", + "medium", + "2026-09-01T00:00:00Z", + "2026-09-15T00:00:00Z", + "cleared", + "Extended pending vendor fix", + "Vendor ETA changed", + "Snooze removed", + "Fix available", + ), + ("history-secret", "history-clear-secret", "vuln-secret", "user-secret"), + ), + ( + ["repos", "supply-chain", "findings", "repo-id"], + { + "snapshot": {"id": "snapshot-secret"}, + "findings": [ + { + "id": "dependency-id", + "repository_id": "repo-secret", + "title": "Vulnerable package", + "package_name": "lodash", + "package_version": "1.0.0", + "severity": "high", + "status": "open", + "fixed_version": "4.17.21", + "manifest_path": "package-lock.json", + "direct": True, + } + ], + }, + ("Vulnerable package", "lodash@1.0.0", "4.17.21", "dependency-id"), + ("snapshot-secret", "repo-secret"), + ), + ( + ["repos", "supply-chain", "components", "repo-id"], + { + "snapshot": {"id": "snapshot-secret"}, + "components": [ + { + "id": "component-id", + "snapshot_id": "snapshot-secret", + "name": "requests", + "version": "2.0.0", + "ecosystem": "pypi", + "relationship": "direct", + "status": "active", + "highest_open_severity": "critical", + "manifest_path": "requirements.txt", + } + ], + "meta": {"total": 3, "limit": 1, "offset": 0}, + }, + ("requests", "2.0.0", "pypi", "critical", "component-id", "--offset 1"), + ("snapshot-secret",), + ), + ( + ["domains", "test-users", "inbox", "domain-id", "test-user-id"], + { + "address": "inbox@security-mail.strix.ai", + "messages": [ + { + "id": "message-id", + "from": "login@example.com", + "subject": "Your code", + "preview": "Code 123456", + "timestamp": "2026-08-27T10:00:00Z", + "detected_code": "123456", + } + ], + }, + ( + "login@example.com", + "Your code", + "123456", + "message-id", + "Inbox: inbox@security-mail.strix.ai", + ), + (), + ), + ( + ["knowledge", "repos", "entries", "usestrix/strix"], + { + "organization_id": "org-secret", + "repo_key": "usestrix/strix", + "profile": {"id": "profile-secret", "title": "Profile"}, + "docs": [ + { + "id": "doc-id", + "title": "Auth notes", + "source_type": "system", + "source_id": "repos/usestrix__strix/auth.md", + "tags": [], + "updated_at": "2026-08-27T10:00:00Z", + } + ], + "insights": [], + "policies": [{"id": "policy-secret", "policy_key": "no-prod"}], + "stats": {"docs_count": 1}, + }, + ( + "Auth notes", + "system", + "repos/usestrix__strix/auth.md", + "doc-id", + "Repository profile: Profile", + "1 policy apply", + ), + ("org-secret", "profile-secret", "policy-secret", "nested field"), + ), + ], +) +def test_nonstandard_human_list_envelopes_are_actionable( + monkeypatch: pytest.MonkeyPatch, + capsys: Any, + command: list[str], + payload: Any, + visible: tuple[str, ...], + hidden: tuple[str, ...], +) -> None: + monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True) + monkeypatch.setattr(http, "request", lambda *_a, **_k: FakeResponse(payload=payload)) + + assert cloud.run_cloud(command) == 0 + output = capsys.readouterr().out + for value in visible: + assert value in output + for value in hidden: + assert value not in output + + +def test_chat_credentials_human_view_separates_attached_and_available_sources( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + payload = { + "credentials": [ + { + "label": "Attached admin", + "username": "admin@example.com", + "login_url": "https://example.com/login", + "mfa_method": "totp", + "has_password": True, + "has_totp_secret": True, + "test_user_id": "attached-test-user-id", + } + ], + "available_test_users": [ + { + "id": "available-test-user-id", + "label": "Saved analyst", + "username": "analyst@example.com", + "domain": "example.com", + "login_url": "https://example.com/login", + "mfa_method": "email_otp", + "has_password": False, + "has_totp_secret": False, + } + ], + "available_scan_credentials": [ + { + "scan_id": "source-scan-id", + "scan_title": "August staging pentest", + "username": "scan-user@example.com", + "login_url": "https://staging.example.com/login", + "mfa_method": "none", + "has_password": True, + "has_totp_secret": False, + } + ], + } + monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True) + monkeypatch.setattr(http, "request", lambda *_a, **_k: FakeResponse(payload=payload)) + + command = ["chat", "credentials", "chat-id", "--scan-ids", "source-scan-id"] + assert cloud.run_cloud(command) == 0 + output = capsys.readouterr().out + for value in ( + "Attached credentials", + "Attached admin", + "attached-test-user-id", + "Available saved test users", + "Saved analyst", + "available-test-user-id", + "Credentials from requested scans", + "August staging pentest", + "source-scan-id", + "password: set", + "password: not set", + "--test-user-ids ID", + "--scan-ids SCAN_ID", + ): + assert value in output + + assert cloud.run_cloud([*command, "--json"]) == 0 + assert json.loads(capsys.readouterr().out) == payload + + +@pytest.mark.parametrize( + ("command", "payload", "visible", "hidden"), + [ + ( + ["schedules", "list"], + { + "schedules": [ + { + "id": "schedule-id", + "organization_id": "org-secret", + "created_by": "user-secret", + "name": "Weekly staging", + "cron": "0 9 * * 1", + "timezone": "America/New_York", + "isPaused": True, + "supply_chain": True, + "domain_ids": [], + "repository_ids": ["repo-id"], + "internal_targets": ["10.24.0.0/16"], + "connector_id": "connector-secret", + "last_run_status": "ok", + "next_run_at": "2026-08-31T13:00:00Z", + "run_count": 4, + } + ] + }, + ( + "Weekly staging", + "supply chain", + "1 repo", + "10.24.0.0/16", + "network connector", + "0 9 * * 1", + "paused", + "ok", + "schedule-id", + "schedules get ID", + ), + ("org-secret", "user-secret", "connector-secret"), + ), + ( + ["connectors", "list"], + [ + { + "id": "connector-id", + "name": "Private network", + "last_status": "healthy", + "last_status_checked_at": "2026-08-27T10:00:00Z", + "created_at": "2026-08-20T10:00:00Z", + "unexpected": "hidden", + } + ], + ("Private network", "healthy", "connector-id"), + ("unexpected", "hidden"), + ), + ( + ["org", "members"], + { + "members": [ + { + "id": "membership-id", + "userId": "user-secret", + "email": "analyst@example.com", + "firstName": "Ada", + "lastName": "Lovelace", + "role": "analyst", + "scopes": [ + {"type": "tag", "value": "production"}, + {"type": "business_unit", "value": "payments"}, + ], + "status": "active", + "joinedAt": "2026-08-20T10:00:00Z", + } + ] + }, + ( + "analyst@example.com", + "Ada", + "Lovelace", + "tag:production", + "business_unit:payments", + "active", + "membership-id", + ), + ("user-secret",), + ), + ( + ["org", "invitations"], + { + "invitations": [ + { + "id": "invitation-id", + "email": "invitee@example.com", + "role": "analyst", + "scopes": [], + "state": "pending", + "expiresAt": "2026-09-01T10:00:00Z", + "createdAt": "2026-08-27T10:00:00Z", + } + ] + }, + ("invitee@example.com", "analyst", "all assets", "pending", "invitation-id"), + (), + ), + ( + ["webhooks", "list"], + { + "webhooks": [ + { + "id": "webhook-id", + "organization_id": "org-secret", + "url": "https://example.com/hook", + "events": ["scan.completed"], + "business_unit": "product", + "is_active": True, + "last_success_at": "2026-08-27T10:00:00Z", + "last_failure_at": None, + "created_at": "2026-08-20T10:00:00Z", + } + ] + }, + ("https://example.com/hook", "scan.completed", "product", "webhook-id"), + ("org-secret", "last delivery"), + ), + ( + ["webhooks", "deliveries", "webhook-id"], + { + "items": [ + { + "id": "delivery-id", + "subscription_id": "subscription-secret", + "organization_id": "org-secret", + "event_type": "scan.completed", + "status": "delivered", + "response_status": 200, + "last_error": "temporary timeout", + "attempts": 1, + "sent_at": "2026-08-27T10:01:00Z", + "next_attempt_at": None, + "created_at": "2026-08-27T10:00:00Z", + } + ], + "meta": {"total_items": 1}, + }, + ("scan.completed", "delivered", "200", "temporary timeout", "delivery-id"), + ("subscription-secret", "org-secret"), + ), + ( + ["knowledge", "repos"], + { + "repos": [ + { + "repo_key": "usestrix/strix", + "docs_count": 4, + "last_updated_at": "2026-08-27T10:00:00Z", + "future_internal_field": "hidden", + } + ] + }, + ("usestrix/strix", "4", "2026-08-27"), + ("future_internal_field", "hidden"), + ), + ( + ["audit", "list"], + { + "data": [ + { + "id": "audit-row-secret", + "organization_id": "org-secret", + "actor_id": "actor-secret", + "actor_email": "ada@example.com", + "action": "scan.started", + "resource_type": "scan", + "resource_id": "scan-id", + "metadata": {"private": "details"}, + "ip_address": "192.0.2.1", + "created_at": "2026-08-27T10:00:00Z", + } + ], + "pagination": { + "page": 1, + "limit": 20, + "total": 41, + "total_pages": 3, + }, + }, + ("scan.started", "scan", "scan-id", "ada@example.com", "192.0.2.1", "--page 2"), + ("audit-row-secret", "org-secret", "actor-secret", "private"), + ), + ], +) +def test_named_human_list_views_match_api_fields( + monkeypatch: pytest.MonkeyPatch, + capsys: Any, + command: list[str], + payload: Any, + visible: tuple[str, ...], + hidden: tuple[str, ...], +) -> None: + monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True) + monkeypatch.setattr(http, "request", lambda *_a, **_k: FakeResponse(payload=payload)) + + assert cloud.run_cloud(command) == 0 + output = capsys.readouterr().out + for value in visible: + assert value in output + for value in hidden: + assert value not in output + + +def test_supply_chain_org_summary_human_view_shows_totals_and_repository_risk( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True) + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse( + payload={ + "totals": { + "repositories": 2, + "components": 145, + "findings": 9, + "open_issues": 4, + "malicious": 1, + "suspicious": 2, + "vulnerable": 6, + "ecosystems": {"npm": 100, "pypi": 45}, + "severities": {"critical": 1, "high": 3, "medium": 5}, + }, + "repositories": [ + { + "repository": { + "id": "repo-id", + "organization_id": "org-secret", + "full_name": "usestrix/strix", + "provider": "github", + }, + "summary": { + "component_count": 100, + "finding_count": 7, + "malicious_count": 1, + "suspicious_count": 2, + "vulnerable_count": 4, + "severity_counts": {"critical": 1, "high": 2, "medium": 4}, + "policy": { + "enabled": True, + "pr_checks_enabled": False, + "mode": "block", + }, + }, + "latest_supply_chain_scan": { + "id": "scan-secret", + "status": "completed", + "created_at": "2026-08-27T10:00:00Z", + }, + }, + { + "repository": { + "id": "repo-id-2", + "organization_id": "org-secret", + "full_name": "usestrix/sdk", + "provider": "github", + }, + "summary": { + "component_count": 45, + "finding_count": 2, + "malicious_count": 0, + "suspicious_count": 0, + "vulnerable_count": 2, + "severity_counts": {"high": 1, "medium": 1}, + "policy": {"enabled": False}, + }, + "latest_supply_chain_scan": None, + }, + ], + } + ), + ) + + assert cloud.run_cloud(["supply-chain", "summary"]) == 0 + output = capsys.readouterr().out + for value in ( + "Supply-chain totals", + "145", + "open issues", + "usestrix/strix", + "critical 1", + "1 malicious", + "completed", + "block", + "PR checks off", + "usestrix/sdk", + "not run", + "disabled", + "repo-id", + ): + assert value in output + for value in ("org-secret", "scan-secret", "ecosystems"): + assert value not in output + assert "Use --json for complete totals and repository records" in output + + +@pytest.mark.parametrize( + ("command", "payload", "visible", "hidden"), + [ + ( + ["webhooks", "get", "webhook-id"], + { + "webhook": { + "id": "webhook-id", + "organization_id": "org-secret", + "url": "https://example.com/hook", + "events": ["scan.completed", "scan.failed"], + "business_unit": None, + "secret_prefix": "whsec_1234", + "is_active": True, + "last_success_at": "2026-08-27T10:00:00Z", + "last_failure_at": "2026-08-26T10:00:00Z", + "created_by": "user-secret", + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-08-27T10:00:00Z", + } + }, + ( + "webhook-id", + "https://example.com/hook", + "scan.completed", + "scan.failed", + "all organization", + "whsec_1234", + "yes", + "2026-08-26T10:00:00Z", + ), + ("org-secret", "user-secret", "nested field"), + ), + ( + ["chat", "get", "chat-id"], + { + "chat": { + "workspace_state": "running", + "id": "chat-id", + "title": "Investigate auth", + "status": "active", + "run_id": "run-id", + "sandbox_api_url": True, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-08-27T10:00:00Z", + "last_message_at": "2026-08-27T09:59:00Z", + } + }, + ( + "chat-id", + "Investigate auth", + "active", + "workspace state", + "running", + "run-id", + "sandbox attached", + "yes", + "2026-08-27T09:59:00Z", + ), + ("sandbox api url", "nested field"), + ), + ], +) +def test_wrapped_detail_human_views_are_unwrapped_and_actionable( + monkeypatch: pytest.MonkeyPatch, + capsys: Any, + command: list[str], + payload: dict[str, Any], + visible: tuple[str, ...], + hidden: tuple[str, ...], +) -> None: + monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True) + monkeypatch.setattr(http, "request", lambda *_a, **_k: FakeResponse(payload=payload)) + + assert cloud.run_cloud(command) == 0 + output = capsys.readouterr().out + for value in visible: + assert value in output + for value in hidden: + assert value not in output + + +def test_trace_human_view_summarizes_events_and_preserves_selector( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True) + seen_query: dict[str, Any] = {} + + def fake_trace_request(_method: str, _path: str, **kwargs: Any) -> FakeResponse: + seen_query.update(kwargs.get("query") or {}) + return FakeResponse( + payload={ + "scan_id": "scan-id", + "agent_id": "agent-id", + "steps": [ + { + "timestamp": "2026-08-27T10:00:00Z", + "kind": "tool_call", + "event_id": "event-id", + "tool_name": "browser", + "args": { + "url": "https://example.com", + "password": "trace-password-must-not-render", + "headers": {"Authorization": "Bearer trace-token-must-not-render"}, + "sk_live_secret-as-dictionary-key": True, + }, + "truncated": True, + }, + { + "timestamp": "2026-08-27T10:01:00Z", + "kind": "finding", + "event_id": "finding-event-id", + "finding": {"title": "IDOR", "severity": "high"}, + }, + { + "timestamp": "2026-08-27T10:02:00Z", + "kind": "tool_result", + "event_id": "result-event-id", + "tool_name": "browser", + "status": "completed", + "result": "result-token-must-not-render", + }, + ], + "cursor": "next-secret", + "has_more": True, + "note": "Older trace events remain available.", + } + ) + + monkeypatch.setattr( + http, + "request", + fake_trace_request, + ) + + assert ( + cloud.run_cloud( + [ + "scans", + "trace", + "scan-id", + "--agent-id", + "agent-id", + "--tool-name", + "browser", + "--limit", + "25", + ] + ) + == 0 + ) + output = capsys.readouterr().out + for value in ( + "tool_call", + "browser", + "arguments: 4 field(s)", + "high: IDOR", + "event-id", + "tool_result", + "result: text (", + ): + assert value in output + assert seen_query == {"agent_id": "agent-id", "tool_name": "browser", "limit": 25} + for secret in ( + "trace-password-must-not-render", + "trace-token-must-not-render", + "result-token-must-not-render", + "password", + "sk_live_secret-as-dictionary-key", + ): + assert secret not in output + assert "scans trace-event scan-id EVENT_ID" in output + normalized_output = " ".join(output.replace("`", "").split()) + assert "same trace command with --cursor next-secret" in normalized_output + assert "keep its --agent-id, --tool-name, and --limit options" in normalized_output + assert "Older trace events remain available." in normalized_output + + +def test_paginated_human_list_shows_total_and_continuation_command( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True) + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse( + payload={ + "items": [ + { + "id": "domain-id", + "domain": "staging.example.com", + "asset_type": "web_app", + } + ], + "meta": { + "page": 1, + "limit": 20, + "total_items": 51, + "total_pages": 3, + "has_next": True, + }, + } + ), + ) + + assert cloud.run_cloud(["domains", "list"]) == 0 + output = capsys.readouterr().out + assert "Page 1/3" in output + assert "51 total" in output + assert "--page 2" in output + + +def test_empty_paginated_human_list_does_not_claim_page_one_of_zero() -> None: + stream = io.StringIO() + console = Console(file=stream, width=120, color_system=None, force_terminal=False) + + render.emit( + console, + { + "items": [], + "meta": { + "page": 1, + "limit": 25, + "total_items": 0, + "total_pages": 0, + "has_next": False, + }, + }, + as_json=False, + view="GET /domains", + ) + + output = stream.getvalue() + assert "0 total." in output + assert "Page 1/0" not in output + + +def test_offset_pagination_explains_an_out_of_range_page() -> None: + stream = io.StringIO() + console = Console(file=stream, width=120, color_system=None, force_terminal=False) + + render.emit( + console, + {"components": [], "meta": {"total": 3, "limit": 2, "offset": 4}}, + as_json=False, + view="GET /repositories/{repositoryId}/supply-chain/components", + ) + + output = stream.getvalue() + assert "No items at offset 4; 3 total." in output + assert "--offset 2" in output + assert "Showing 3-3" not in output + + +def test_page_pagination_explains_an_out_of_range_page() -> None: + stream = io.StringIO() + console = Console(file=stream, width=120, color_system=None, force_terminal=False) + + render.emit( + console, + { + "items": [], + "meta": { + "page": 4, + "limit": 20, + "total_items": 51, + "total_pages": 3, + "has_next": False, + }, + }, + as_json=False, + view="GET /domains", + ) + + output = stream.getvalue() + assert "No items on page 4; 51 total." in output + assert "--page 3" in output + assert "Page 4/3" not in output + + +def test_human_detail_preserves_long_prose_beyond_table_cell_limit( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True) + description = ( + " ".join(["authorization context"] * 12) + " final-description-marker\nsecond-line-marker" + ) + assert len(description) > 60 + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse( + payload={ + "id": "vuln-id", + "title": "Cross-tenant access", + "description": description, + "remediation_steps": "Validate tenant ownership before every object lookup.", + } + ), + ) + + assert cloud.run_cloud(["vulns", "get", "vuln-id"]) == 0 + output = capsys.readouterr().out + assert "final-description-marker" in output + assert "second-line-marker" in output + assert "\\x0a" not in output + assert "Validate tenant ownership" in output + + +def test_human_detail_bounds_extreme_scalar_values() -> None: + assert render._detail_cell("x" * 2500).endswith("… [truncated; use --json]") + assert len(render._detail_cell("x" * 2500)) == 2000 + assert render._detail_cell("first\nsecond") == "first\nsecond" + + +def test_large_vulnerability_detail_prioritizes_evidence_and_remediation() -> None: + stream = io.StringIO() + console = Console(file=stream, width=240, color_system=None, force_terminal=False) + payload: dict[str, Any] = {f"future_field_{index}": f"value-{index}" for index in range(45)} + payload.update( + { + "id": "vuln-id", + "title": "Cross-tenant access", + "status": "open", + "severity": "high", + "description": "A caller can read another tenant's object.", + "technical_analysis": "The object lookup omits the tenant predicate.", + "evidence": "GET /objects/other-tenant returned HTTP 200.", + "remediation_steps": "Bind every object lookup to the authenticated tenant.", + "cwe": ["CWE-639"], + "location_meta": {"path": "src/routes/objects.ts", "line": 42}, + "fix_pr_eligible": True, + "fix_pr_reason": "A repository and exact code location are available.", + "fix_pr_url": "https://github.com/example/app/pull/42", + "filed_at": "2026-08-28T12:00:00Z", + "dependency_metadata": {"package": "example", "installed_version": "1.0.0"}, + } + ) + + render.emit(console, payload, as_json=False, view="GET /vulnerabilities/{vulnerabilityId}") + + output = stream.getvalue() + for value in ( + "Cross-tenant access", + "The object lookup omits the tenant predicate.", + "GET /objects/other-tenant returned HTTP 200.", + "Bind every object lookup to the authenticated tenant.", + "CWE-639", + "src/routes/objects.ts", + "A repository and exact code location are available.", + "https://github.com/example/app/pull/42", + "2026-08-28T12:00:00Z", + "package: example", + ): + assert value in output + assert "additional field(s) omitted" in output + + +def test_test_user_human_view_joins_latest_verification( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True) + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse( + payload={ + "items": [ + { + "id": "test-user-id", + "label": "Admin", + "username": "admin@example.com", + "mfa_method": "totp", + "has_password": True, + "has_totp_secret": True, + "login_url": "https://example.com/login", + "updated_at": "2026-08-27T10:00:00Z", + } + ], + "auth_checks": { + "test-user-id": { + "status": "failed", + "failure_code": "invalid_credentials", + } + }, + } + ), + ) + + assert cloud.run_cloud(["domains", "test-users", "list", "domain-id"]) == 0 + output = capsys.readouterr().out + for value in ("password: set", "totp (secret set)", "failed: invalid_credentials"): + assert value in output + + +def test_explicit_human_view_is_an_allowlist_and_preserves_uuid() -> None: + stream = io.StringIO() + console = Console(file=stream, width=120, color_system=None, force_terminal=False) + uuid = "4d3a33cc-5c96-4e91-921c-682093efe780" + + render.emit( + console, + { + "items": [ + { + "id": uuid, + "domain": "staging.example.com", + "asset_type": "web_app", + "verified": False, + "unknown_internal_scalar": "must-not-render", + } + ], + "meta": {"total_items": 1}, + }, + as_json=False, + view="GET /domains", + ) + + output = stream.getvalue() + assert uuid in output + assert "must-not-render" not in output + + +def test_wide_knowledge_table_keeps_title_readable_with_long_identifiers() -> None: + stream = io.StringIO() + console = Console(file=stream, width=120, color_system=None, force_terminal=False) + document_id = "document-selector-" + "x" * 80 + source_id = "repos/usestrix__strix/" + "nested/" * 12 + "authentication.md" + + render.emit( + console, + { + "organization_id": "org-secret", + "docs": [ + { + "id": document_id, + "title": "Authentication guidance", + "source_type": "system", + "source_id": source_id, + "tags": ["auth"], + "updated_at": "2026-08-27T10:00:00Z", + } + ], + "total": 1, + }, + as_json=False, + view="GET /knowledge", + ) + + output = stream.getvalue() + assert "Authentication guidance" in output + assert document_id in output + assert "Copyable selectors" 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: + 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=200, + payload={ + "api_token": "old", + "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"] + record = platform_cli.read_record() + assert record is not None + assert record["api_token"] == "old" + + +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: strix cloud logout" 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"} diff --git a/tests/test_cloud_cli_runtime.py b/tests/test_cloud_cli_runtime.py new file mode 100644 index 00000000..2d6ccc93 --- /dev/null +++ b/tests/test_cloud_cli_runtime.py @@ -0,0 +1,1119 @@ +"""Focused regressions for managed-cloud CLI rendering and runtime safety.""" + +from __future__ import annotations + +import argparse +import io +import json +import sys +from typing import TYPE_CHECKING, Any + +import pytest +from rich.console import Console + +from strix.interface import cloud, platform_cli +from strix.interface.cloud import http, render, runner, source_scan +from strix.interface.main import main as interface_main + + +if TYPE_CHECKING: + from pathlib import Path + + +class FakeResponse: + def __init__( + self, + payload: Any = None, + *, + status_code: int = 200, + content: bytes | None = None, + content_type: str | None = None, + ) -> None: + self._payload = payload + self.status_code = status_code + self.ok = 200 <= status_code < 400 + self.content = content if content is not None else json.dumps(payload).encode() + self.text = self.content.decode("utf-8", errors="replace") + self.headers = { + "content-type": content_type + or ("application/json" if payload is not None else "application/octet-stream") + } + self.closed = False + + def json(self) -> Any: + if self._payload is None: + raise ValueError("not JSON") + return self._payload + + def close(self) -> None: + self.closed = True + + +@pytest.fixture(autouse=True) +def _token(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("STRIX_API_TOKEN", "runtime-test-token") + + +def _console_output(data: Any, *, view: str | None = None, width: int = 160) -> str: + output = io.StringIO() + render.emit(Console(file=output, width=width), data, as_json=False, view=view) + return output.getvalue() + + +def test_paginated_envelope_with_scalar_metadata_is_a_compact_list() -> None: + output = _console_output( + { + "items": [{"id": "scan-1", "status": "running", "title": "Acceptance"}], + "scansThisMonth": 42, + "total": 1, + }, + view="GET /scans", + ) + assert "Acceptance" in output + assert "running" in output + assert "scansThisMonth" not in output + assert "42" not in output + assert len(output.splitlines()) < 10 + + +@pytest.mark.parametrize("width", [80, 160]) +def test_scan_lists_flatten_actionable_targets_findings_and_ids(width: int) -> None: + output = _console_output( + { + "items": [ + { + "id": "scan-code-uuid", + "title": "Source review", + "engagement_type": "code_review", + "scan_type": "ultra", + "status": "running", + "repositories": [ + { + "url": "https://github.com/usestrix/strix", + "branch": "feature", + "provider": "github", + } + ], + "findings": {"total": 4, "critical": 1, "high": 3}, + "created_at": "2026-08-28T12:00:00Z", + }, + { + "id": "scan-live-uuid", + "title": "Staging pentest", + "engagement_type": "live_test", + "scan_type": "ultra", + "status": "completed", + "urls": ["https://staging.example.test"], + "findings": {"total": 2, "high": 2}, + "created_at": "2026-08-28T13:00:00Z", + }, + ], + "total": 2, + }, + view="GET /scans", + width=width, + ) + for value in ( + "https://github.com/usestrix/strix @ feature", + "https://staging.example.test", + "running", + "completed", + "scan-code-uuid", + "scan-live-uuid", + ): + assert value in output + assert "findings" in output + assert "4" in output + assert "2" in output + assert "scans get ID" in output + + +@pytest.mark.parametrize("width", [80, 160]) +def test_vulnerability_lists_always_include_the_actionable_uuid(width: int) -> None: + vulnerability_id = "12345678-1234-4321-8765-123456789abc" + output = _console_output( + { + "items": [ + { + "id": vulnerability_id, + "scan_id": "internal-scan-id", + "title": "SQL injection", + "target": "https://example.test/search", + "severity": "critical", + "cve": "CVE-2026-0001", + "cvss": 9.8, + "status": "open", + "created_at": "2026-08-28T12:00:00Z", + "dependency_metadata": {"package": "example"}, + "display_number": "VULN-42", + "finding_type": "dast", + } + ] + }, + view="GET /vulnerabilities", + width=width, + ) + assert vulnerability_id in output + assert "internal-scan-id" not in output + assert "SQL injection" in output + assert "critical" in output + assert "vulns get ID" in output + + +def test_connector_enrollment_command_is_complete_multiline_and_terminal_safe( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + command = ( + "docker run --rm \\\n" + " -e TS_AUTHKEY=tskey-" + "a" * 180 + " \\\n" + " -e LABEL=before\x1b]52;c;copied\x07after \\\n" + " ghcr.io/usestrix/connector:latest" + ) + monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True) + monkeypatch.setattr( + http, + "request", + lambda *_args, **_kwargs: FakeResponse( + {"id": "connector-1", "name": "Private network", "docker_command": command} + ), + ) + + assert cloud.run_cloud(["connectors", "get", "connector-1", "--include-command"]) == 0 + output = capsys.readouterr().out + assert "TS_AUTHKEY=tskey-" + "a" * 180 in output + assert "ghcr.io/usestrix/connector:latest" in output + assert "\\x0a" not in output + assert "\x1b" not in output + assert "\\x1b]52;c;copied\\x07" in output + + +def test_detail_field_cap_counts_only_populated_values() -> None: + payload = {**{f"unused_{index}": None for index in range(40)}, "result": "visible"} + output = _console_output(payload) + assert "visible" in output + assert "additional field" not in output + + +def test_empty_envelope_has_a_clear_empty_state() -> None: + output = _console_output({"webhooks": [], "total": 0}, view="GET /webhooks") + assert "No items." in output + assert "{}" not in output + + +def test_pr_detail_summarizes_nested_findings() -> None: + findings = [{"severity": "high", "title": f"Finding {index}"} for index in range(12)] + output = _console_output( + { + "id": "review-1", + "repository_full_name": "usestrix/strix", + "pr_number": 1177, + "findings": findings, + }, + view="GET /pr-reviews/{reviewId}", + ) + assert "usestrix/strix" in output + assert "Finding 0" in output + assert "7 more; use --json" in output + assert "Finding 11" not in output + assert len(output.splitlines()) < 25 + + +def test_analytics_views_are_bounded_and_frequency_prefers_activity() -> None: + overview = { + f"section_{index}": {f"metric_{inner}": inner for inner in range(10)} for index in range(20) + } + overview_output = _console_output(overview, view="GET /analytics/overview") + assert "Showing 36 of 200 summary metrics" in overview_output + assert len(overview_output.splitlines()) < 45 + + points = [{"date": f"day-{index}", "count": 0} for index in range(300)] + points[100]["count"] = 3 + points[250]["count"] = 7 + frequency_output = _console_output( + {"items": points, "total": 300}, view="GET /analytics/scan-frequency" + ) + assert "day-100" in frequency_output + assert "day-250" in frequency_output + assert "day-299" not in frequency_output + assert "2 non-zero point(s) from 300 total" in frequency_output + assert len(frequency_output.splitlines()) < 15 + + +def test_nested_webhook_envelope_and_events_render_cleanly() -> None: + output = _console_output( + { + "data": { + "webhooks": [ + { + "id": "hook-1", + "url": "https://example.test/hook", + "events": ["scan.completed", "finding.created"], + "is_active": True, + } + ], + "pagination": {"page": 1, "total": 1}, + } + }, + view="GET /webhooks", + ) + assert "https://example.test/hook" in output + assert "scan.completed, finding.created" in output + assert "pagination" not in output + + +def test_human_rendering_neutralizes_osc_and_csi_control_sequences() -> None: + dangerous = "before\x1b]52;c;copied\x07after\x1b[2J\x9b31m" + outputs = ( + _console_output(dangerous), + _console_output([{"name": dangerous}], view="GET /scans"), + _console_output({f"field{dangerous}": dangerous}), + _console_output({"source": {"files": [dangerous]}}, view="source_manifest"), + ) + + for output in outputs: + assert "\x1b" not in output + assert "\x07" not in output + assert "\x9b" not in output + assert "\\x1b]52;c;copied\\x07" in output + assert "\\x1b[2J\\x9b31m" in output + + +def test_source_prompt_shows_paths_and_literal_confirmation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + (tmp_path / "app.py").write_text("print('ok')\n", encoding="utf-8") + dangerous_name = "visible\x1b]52;c;copied\x07\x1b[2J.py" + (tmp_path / dangerous_name).write_text("print('safe')\n", encoding="utf-8") + output = io.StringIO() + console = Console(file=output, width=100) + prompts: list[tuple[str, bool]] = [] + + def answer(prompt: str, *, markup: bool = True, **_kwargs: Any) -> str: + prompts.append((prompt, markup)) + return "n" + + monkeypatch.setattr(console, "input", answer) + monkeypatch.setattr(source_scan.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(source_scan.sys.stdout, "isatty", lambda: True) + args = argparse.Namespace( + source=str(tmp_path), + dry_run=False, + show_files=True, + include_hidden=False, + include_sensitive=False, + include_archives=False, + exclude=[], + yes=False, + ) + with pytest.raises(http.CloudError, match="cancelled"): + source_scan.prepare_scan_source(console, args, as_json=False) + rendered = output.getvalue() + assert "app.py" in rendered + assert "\x1b" not in rendered + assert "\x07" not in rendered + assert "visible\\x1b]52;c;copied\\x07\\x1b[2J.py" in rendered + assert prompts == [("Upload this source and start the scan? [y/N]: ", False)] + + +@pytest.mark.parametrize("failure", [KeyboardInterrupt(), EOFError()]) +def test_source_prompt_interruption_removes_temporary_archive( + failure: BaseException, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + (tmp_path / "app.py").write_text("print('ok')\n", encoding="utf-8") + console = Console(file=io.StringIO(), width=100) + archive_paths: list[Path] = [] + original_prepare = source_scan.prepare_source + + def capture_bundle(*args: Any, **kwargs: Any) -> Any: + bundle = original_prepare(*args, **kwargs) + archive_paths.append(bundle.archive_path) + return bundle + + def interrupt(*_args: Any, **_kwargs: Any) -> str: + raise failure + + monkeypatch.setattr(source_scan, "prepare_source", capture_bundle) + monkeypatch.setattr(console, "input", interrupt) + monkeypatch.setattr(source_scan.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(source_scan.sys.stdout, "isatty", lambda: True) + args = argparse.Namespace( + source=str(tmp_path), + dry_run=False, + show_files=False, + include_hidden=False, + include_sensitive=False, + include_archives=False, + exclude=[], + yes=False, + ) + + with pytest.raises(type(failure)): + source_scan.prepare_scan_source(console, args, as_json=False) + + assert archive_paths + assert all(not path.exists() for path in archive_paths) + + +def test_human_error_neutralizes_terminal_control_sequences() -> None: + output = io.StringIO() + console = Console(file=output, width=100) + runner._emit_error( + console, + http.CloudError("failed\x1b]52;c;copied\x07\x1b[2J"), + as_json=False, + ) + rendered = output.getvalue() + assert "\x1b" not in rendered + assert "\x07" not in rendered + assert "failed\\x1b]52;c;copied\\x07\\x1b[2J" in rendered + + +def test_session_human_output_neutralizes_server_control_sequences() -> None: + dangerous = "value\x1b]52;c;copied\x07\x1b[2J" + output = io.StringIO() + console = Console(file=output, width=100) + + platform_cli._print_success( + console, + { + "email": dangerous, + "organization_name": dangerous, + "scopes": [dangerous], + }, + ) + + rendered = output.getvalue() + assert "\x1b" not in rendered + assert "\x07" not in rendered + assert rendered.count("\\x1b]52;c;copied\\x07\\x1b[2J") == 2 + + +def test_device_login_rejects_non_http_verification_url( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + platform_cli.requests, + "post", + lambda *_a, **_k: FakeResponse( + { + "device_code": "device-1", + "user_code": "ABCD", + "verification_uri": "javascript:alert(1)", + "expires_in": 300, + } + ), + ) + + with pytest.raises(platform_cli.PlatformAuthError, match="invalid verification URL"): + platform_cli._run_device_flow(Console(file=io.StringIO()), open_browser=False) + + +@pytest.mark.parametrize("value", ["0", "-1", "nan", "inf"]) +def test_invalid_timeout_is_a_usage_error_without_request( + value: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(http, "request", lambda *_a, **_k: pytest.fail("must not request")) + assert cloud.run_cloud(["scans", "list", "--timeout", value, "--json"]) == 2 + + +def test_boolean_query_values_are_lowercase_for_url_search_params( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen: dict[str, Any] = {} + + def fake_request(_method: str, _url: str, **kwargs: Any) -> FakeResponse: + seen["params"] = kwargs.get("params") + return FakeResponse({"items": []}) + + monkeypatch.setattr(http.requests, "request", fake_request) + http.request("GET", "/test", query={"enabled": True, "disabled": False}) + assert seen["params"] == {"enabled": "true", "disabled": "false"} + + +@pytest.mark.parametrize("value", ["0", "-1", "nan", "inf"]) +def test_workspace_use_invalid_timeout_is_clean( + value: str, monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr(http, "request", lambda *_a, **_k: pytest.fail("must not request")) + assert cloud.run_cloud(["workspaces", "use", "1", "--timeout", value, "--json"]) == 2 + assert "greater than 0" in json.loads(capsys.readouterr().out)["error"] + + +def test_json_argument_errors_do_not_leak_argparse_prose( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr(http, "request", lambda *_a, **_k: pytest.fail("must not request")) + + assert cloud.run_cloud(["scans", "get", "--json"]) == http.EXIT_USAGE + captured = capsys.readouterr() + payload = json.loads(captured.out) + assert "SCAN_ID" in payload["error"] + assert captured.err == "" + + assert cloud.run_cloud(["workspaces", "use", "--json"]) == http.EXIT_USAGE + captured = capsys.readouterr() + payload = json.loads(captured.out) + assert "WORKSPACE" in payload["error"] + assert captured.err == "" + + +@pytest.mark.parametrize("command", ["whoami", "logout"]) +def test_redirected_session_argument_errors_are_json_only(command: str, capsys: Any) -> None: + assert cloud.run_cloud([command, "--bogus"]) == http.EXIT_USAGE + captured = capsys.readouterr() + payload = json.loads(captured.out) + assert f"strix cloud {command}" in payload["error"] + assert captured.err == "" + + +@pytest.mark.parametrize( + "content_type,payload", + [ + ("application/pdf", b"%PDF-1.7\n\x1b]52;c;copied\x07"), + ("application/zip", b"PK\x03\x04\x1b[2J"), + ], +) +def test_binary_response_refuses_to_write_to_a_terminal( + content_type: str, + payload: bytes, + monkeypatch: pytest.MonkeyPatch, + capsys: Any, +) -> None: + monkeypatch.setattr(runner.sys.stdout, "isatty", lambda: True) + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse(content=payload, content_type=content_type), + ) + + assert cloud.run_cloud(["scans", "report", "scan-1"]) == http.EXIT_USAGE + output = capsys.readouterr().out + assert "binary responses" in output + assert "--output FILE" in output + assert "\x1b" not in output + + +def test_binary_response_can_be_intentionally_redirected( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class RedirectedStdout: + def __init__(self) -> None: + self.buffer = io.BytesIO() + + @staticmethod + def isatty() -> bool: + return False + + def write(self, value: str) -> int: + return len(value) + + def flush(self) -> None: + return None + + redirected = RedirectedStdout() + monkeypatch.setattr(runner.sys, "stdout", redirected) + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse( + content=b"PK\x03\x04archive", content_type="application/zip" + ), + ) + + assert cloud.run_cloud(["chat", "files", "archive", "chat-1"]) == 0 + assert redirected.buffer.getvalue() == b"PK\x03\x04archive" + + +@pytest.mark.parametrize("failure", ["rejected", "interrupted"]) +def test_redirected_binary_errors_never_append_diagnostics_to_stdout( + failure: str, monkeypatch: pytest.MonkeyPatch, capsysbinary: Any +) -> None: + class InterruptedResponse(FakeResponse): + def iter_content(self, *, chunk_size: int) -> Any: + assert chunk_size == 1024 * 1024 + yield b"%PDF-partial" + raise http.requests.ConnectionError("connection lost") + + response = ( + FakeResponse({"detail": "report rejected"}, status_code=500) + if failure == "rejected" + else InterruptedResponse(content=b"unused", content_type="application/pdf") + ) + monkeypatch.setattr(http, "request", lambda *_a, **_k: response) + + assert cloud.run_cloud(["scans", "report", "scan-1"]) == http.EXIT_ERROR + captured = capsysbinary.readouterr() + assert captured.out == (b"" if failure == "rejected" else b"%PDF-partial") + assert b"Error:" in captured.err + expected = b"report rejected" if failure == "rejected" else b"connection lost" + assert expected in captured.err + + +def test_redirected_binary_parse_errors_go_only_to_stderr( + monkeypatch: pytest.MonkeyPatch, capsysbinary: Any +) -> None: + monkeypatch.setattr( + http, "request", lambda *_a, **_k: pytest.fail("invalid usage must not request a report") + ) + + assert cloud.run_cloud(["scans", "report", "scan-1", "--not-an-option"]) == http.EXIT_USAGE + captured = capsysbinary.readouterr() + assert captured.out == b"" + assert b"invalid arguments" in captured.err + + +def test_explicit_json_binary_response_requires_an_output_file( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: pytest.fail("usage must be rejected before downloading"), + ) + + assert cloud.run_cloud(["scans", "report", "scan-1", "--json"]) == http.EXIT_USAGE + payload = json.loads(capsys.readouterr().out) + assert "requires --output FILE" in payload["error"] + + +def test_binary_output_can_return_json_download_metadata( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse(content=b"%PDF-report", content_type="application/pdf"), + ) + target = tmp_path / "report.pdf" + + assert ( + cloud.run_cloud(["scans", "report", "scan-1", "--output", str(target), "--json"]) + == http.EXIT_OK + ) + payload = json.loads(capsys.readouterr().out) + assert payload == { + "output": str(target), + "bytes": len(b"%PDF-report"), + "content_type": "application/pdf", + } + assert target.read_bytes() == b"%PDF-report" + + +def test_binary_download_creates_parents_and_requires_force( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + contents = iter((b"first", b"second", b"third")) + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse(content=next(contents), content_type="application/pdf"), + ) + target = tmp_path / "nested" / "report.pdf" + command = ["scans", "report", "scan-1", "--output", str(target)] + assert cloud.run_cloud(command) == 0 + assert target.read_bytes() == b"first" + assert cloud.run_cloud(command) == 1 + assert target.read_bytes() == b"first" + assert cloud.run_cloud([*command, "--force"]) == 0 + assert target.read_bytes() == b"third" + + +def test_binary_download_bad_parent_is_a_clean_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse(content=b"report", content_type="application/pdf"), + ) + blocker = tmp_path / "not-a-directory" + blocker.write_text("x", encoding="utf-8") + assert ( + cloud.run_cloud(["scans", "report", "scan-1", "--output", str(blocker / "report.pdf")]) == 1 + ) + assert "could not write" in capsys.readouterr().out + + +def test_binary_download_streams_and_preserves_existing_file_on_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + class InterruptedResponse(FakeResponse): + closed = False + + def iter_content(self, *, chunk_size: int) -> Any: + assert chunk_size == 1024 * 1024 + yield b"partial" + raise http.requests.ConnectionError("connection lost") + + def close(self) -> None: + self.closed = True + + response = InterruptedResponse(content=b"must not be buffered", content_type="application/pdf") + seen: dict[str, Any] = {} + + def fake_request(_method: str, _path: str, **kwargs: Any) -> FakeResponse: + seen["stream"] = kwargs.get("stream") + return response + + monkeypatch.setattr(http, "request", fake_request) + target = tmp_path / "report.pdf" + target.write_bytes(b"original") + assert ( + cloud.run_cloud(["scans", "report", "scan-1", "--output", str(target), "--force", "--json"]) + == 1 + ) + assert seen["stream"] is True + assert target.read_bytes() == b"original" + assert response.closed is True + assert list(tmp_path.iterdir()) == [target] + + +def test_audit_csv_downloads_while_json_remains_parsed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + responses = iter( + ( + FakeResponse(content=b"action,actor\nlogin,alex\n", content_type="text/csv"), + FakeResponse(payload={"items": [{"action": "login"}], "total": 1}), + ) + ) + monkeypatch.setattr(http, "request", lambda *_a, **_k: next(responses)) + target = tmp_path / "exports" / "audit.csv" + assert cloud.run_cloud(["audit", "list", "--format", "csv", "--output", str(target)]) == 0 + assert target.read_text(encoding="utf-8") == "action,actor\nlogin,alex\n" + capsys.readouterr() + assert cloud.run_cloud(["audit", "list", "--format", "json", "--json"]) == 0 + assert json.loads(capsys.readouterr().out)["items"][0]["action"] == "login" + + +@pytest.mark.parametrize("format_name", ["ndjson", "jsonl", "snowflake", "splunk"]) +def test_audit_ndjson_compatible_formats_download_raw( + format_name: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse( + content=b'{"action":"login"}\n', content_type="application/x-ndjson" + ), + ) + target = tmp_path / f"audit-{format_name}.ndjson" + assert cloud.run_cloud(["audit", "list", "--format", format_name, "--output", str(target)]) == 0 + assert target.read_bytes() == b'{"action":"login"}\n' + + +def test_credit_limit_payload_code_maps_to_payment_exit() -> None: + response = FakeResponse( + {"code": "scan_credit_limit_reached", "detail": "monthly scan credits exhausted"}, + status_code=403, + ) + with pytest.raises(http.CloudError) as raised: + http.check(response) # type: ignore[arg-type] + assert raised.value.exit_code == http.EXIT_PAYMENT + + +def test_wait_timeout_is_bounded(monkeypatch: pytest.MonkeyPatch, capsys: Any) -> None: + def response(method: str, _path: str, **_kwargs: Any) -> FakeResponse: + if method == "POST": + return FakeResponse({"id": "scan-1", "status": "pending"}) + return FakeResponse({"id": "scan-1", "status": "running"}) + + monkeypatch.setattr(http, "request", response) + assert ( + cloud.run_cloud( + [ + "scans", + "start", + "--domain-ids", + "domain-1", + "--wait", + "--wait-timeout", + "0.000001", + "--json", + ] + ) + == 1 + ) + assert "wait timed out" in json.loads(capsys.readouterr().out)["error"] + + +def test_wait_interruption_returns_130_with_remote_operation_id( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + def response(method: str, _path: str, **_kwargs: Any) -> FakeResponse: + if method == "POST": + return FakeResponse({"id": "scan-interrupted", "status": "pending"}) + raise KeyboardInterrupt + + monkeypatch.setattr(http, "request", response) + assert ( + cloud.run_cloud(["scans", "start", "--domain-ids", "domain-1", "--wait", "--json"]) == 130 + ) + payload = json.loads(capsys.readouterr().out) + assert payload["interrupted"] is True + assert payload["status_unknown"] is True + assert payload["operation_id"] == "scan-interrupted" + + +def test_session_help_is_specific_and_human_whoami_shows_scopes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.delenv("STRIX_API_TOKEN", raising=False) + monkeypatch.setattr(platform_cli, "AUTH_PATH", tmp_path / "auth.json") + platform_cli.save_record( + { + "api_token": "secret", + "email": "alex@example.test", + "organization_name": "Demo", + "scopes": ["scans:read", "organizations:read"], + } + ) + monkeypatch.setattr(platform_cli.sys.stdout, "isatty", lambda: True) + assert cloud.run_cloud(["whoami", "--help"]) == 0 + who_help = capsys.readouterr().out + assert "strix cloud whoami" in who_help + assert "--no-browser" not in who_help + assert cloud.run_cloud(["whoami", "--show-scopes"]) == 0 + assert "scans:read organizations:read" in capsys.readouterr().out + + +def test_non_tty_whoami_and_logout_emit_json( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.delenv("STRIX_API_TOKEN", raising=False) + monkeypatch.setattr(platform_cli, "AUTH_PATH", tmp_path / "auth.json") + monkeypatch.setattr(platform_cli.sys.stdout, "isatty", lambda: False) + platform_cli.save_record( + { + "api_token": "secret", + "email": "agent@example.test", + "organization_name": "Demo", + "scopes": ["scans:read"], + "app_url": "https://app.example.test", + } + ) + + assert cloud.run_cloud(["whoami"]) == 0 + assert json.loads(capsys.readouterr().out)["email"] == "agent@example.test" + + monkeypatch.setattr( + platform_cli.requests, + "delete", + lambda *_args, **_kwargs: type("Response", (), {"status_code": 200})(), + ) + assert cloud.run_cloud(["logout"]) == 0 + assert json.loads(capsys.readouterr().out) == { + "signed_in": False, + "removed": True, + "remotely_revoked": True, + "local_only": False, + } + assert not platform_cli.AUTH_PATH.exists() + + +def test_scope_picker_labels_match_the_server_presets() -> None: + output = io.StringIO() + console = Console(file=output, width=120) + console.input = lambda *_args, **_kwargs: "1" # type: ignore[method-assign] + assert platform_cli._choose_scopes( + console, + [{"scope": "scans:read", "min_role": "viewer", "minimum": True}], + "admin", + ) == ("recommended", None) + rendered = output.getvalue() + assert "uploads" in rendered + assert "workspace switching" in rendered + assert "scan read/write and billing read" in rendered + + +def test_noninteractive_login_never_prompts_for_workspace( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(platform_cli.sys.stdin, "isatty", lambda: False) + console = Console(file=io.StringIO()) + console.input = lambda *_args, **_kwargs: pytest.fail("must not prompt") # type: ignore[method-assign] + + with pytest.raises(platform_cli.PlatformAuthError, match="--workspace NAME_OR_ID"): + platform_cli._choose_workspace( + console, + [ + {"id": "org-1", "name": "One"}, + {"id": "org-2", "name": "Two"}, + ], + None, + ) + + +def test_login_workspace_selector_prefers_ids_and_rejects_duplicate_names() -> None: + console = Console(file=io.StringIO()) + organizations = [ + {"id": "org_1", "name": "org_2"}, + {"id": "org_2", "name": "Strix"}, + {"id": "org_3", "name": "Strix"}, + ] + + assert platform_cli._choose_workspace(console, organizations, "org_2")["id"] == "org_2" + with pytest.raises(platform_cli.PlatformAuthError, match="org_2, org_3"): + platform_cli._choose_workspace(console, organizations, "Strix") + + +def test_device_flow_slow_down_never_exceeds_the_poll_interval_cap( + monkeypatch: pytest.MonkeyPatch, +) -> None: + authorization = FakeResponse( + { + "user_code": "ABCD-EFGH", + "verification_uri": "https://example.test/device", + "device_code": "device-1", + "expires_in": 1000, + "interval": 60, + } + ) + polls = iter( + [ + FakeResponse({"error": "slow_down"}, status_code=400), + FakeResponse({"error": "slow_down"}, status_code=400), + FakeResponse({"error": "expired_token"}, status_code=400), + ] + ) + calls = 0 + + def post(*_args: Any, **_kwargs: Any) -> FakeResponse: + nonlocal calls + calls += 1 + return authorization if calls == 1 else next(polls) + + now = 0.0 + sleeps: list[float] = [] + + def monotonic() -> float: + return now + + def sleep(seconds: float) -> None: + nonlocal now + sleeps.append(seconds) + now += seconds + + monkeypatch.setattr(platform_cli.requests, "post", post) + monkeypatch.setattr(platform_cli, "_app_url", lambda: "https://example.test") + monkeypatch.setattr(platform_cli.time, "monotonic", monotonic) + monkeypatch.setattr(platform_cli.time, "sleep", sleep) + + with pytest.raises(platform_cli.PlatformAuthError, match="expired"): + platform_cli._run_device_flow( + Console(file=io.StringIO()), + open_browser=False, + scopes=["scans:read"], + ) + assert sleeps == [60, 60, 60] + + +def test_device_flow_accepts_external_authkit_url_and_binds_token_origin( + monkeypatch: pytest.MonkeyPatch, +) -> None: + responses = iter( + [ + FakeResponse( + { + "user_code": "ABCD-EFGH", + "verification_uri": "https://auth.example-workos.com/device?code=ABCD", + "device_code": "device-1", + "expires_in": 300, + "interval": 1, + } + ), + FakeResponse( + { + "api_token": "strix_pat_test", + "organization_id": "org-1", + "scopes": ["scans:read"], + } + ), + ] + ) + monkeypatch.setattr(platform_cli.requests, "post", lambda *_a, **_k: next(responses)) + monkeypatch.setattr(platform_cli, "_app_url", lambda: "https://preview.strix.ai") + monkeypatch.setattr(platform_cli.time, "sleep", lambda _seconds: None) + + record = platform_cli._run_device_flow( + Console(file=io.StringIO()), + open_browser=False, + scopes=["scans:read"], + ) + + assert record["app_url"] == "https://preview.strix.ai" + assert record["requested_scopes"] == ["scans:read"] + + +def test_missing_verb_json_is_structured(capsys: Any) -> None: + assert cloud.run_cloud(["uploads", "--json"]) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["command"] == "strix cloud uploads" + assert any(item["name"] == "request" for item in payload["verbs"]) + + +@pytest.mark.parametrize( + "argv", + [ + ["pr-reviews", "--json", "-h"], + ["pr-reviews", "--help", "--json"], + ["workspaces", "--json", "help"], + ], +) +def test_group_help_accepts_json_and_help_in_either_order(argv: list[str], capsys: Any) -> None: + assert cloud.run_cloud(argv) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["verbs"] + assert "error" not in payload + + +def test_root_help_accepts_json_before_help_and_leaf_help_stays_specific( + capsys: Any, +) -> None: + assert cloud.run_cloud(["--json", "--help"]) == 0 + assert json.loads(capsys.readouterr().out)["command"] == "strix cloud" + + assert cloud.run_cloud(["scans", "get", "scan-1", "-h"]) == 0 + leaf_help = capsys.readouterr().out + assert "strix cloud scans get" in leaf_help + assert "scans verbs" not in leaf_help + + +def test_non_tty_dispatcher_always_emits_structured_json( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr(render.sys.stdout, "isatty", lambda: False) + + assert cloud.run_cloud([]) == 0 + assert json.loads(capsys.readouterr().out)["command"] == "strix cloud" + + assert cloud.run_cloud(["uploads"]) == 0 + assert json.loads(capsys.readouterr().out)["command"] == "strix cloud uploads" + + assert cloud.run_cloud(["does-not-exist"]) == http.EXIT_USAGE + assert json.loads(capsys.readouterr().out) == {"error": "unknown command: does-not-exist"} + + assert cloud.run_cloud(["scans", "does-not-exist"]) == http.EXIT_USAGE + payload = json.loads(capsys.readouterr().out) + assert payload["command"] == "strix cloud scans" + assert payload["error"] == "unknown verb" + + +@pytest.mark.parametrize( + "signed_url", + [ + "http://project.supabase.co/storage/v1/object/upload/sign/bucket/file", + "https://127.0.0.1/storage/v1/object/upload/sign/bucket/file", + "https://10.0.0.1/storage/v1/object/upload/sign/bucket/file", + "https://app.strix.ai@127.0.0.1/storage/v1/object/upload/sign/bucket/file", + "https://evil.example/storage/v1/object/upload/sign/bucket/file", + "https://project.supabase.co.evil/storage/v1/object/upload/sign/bucket/file", + "https://project.supabase.co/not-storage/file", + ], +) +def test_source_upload_rejects_untrusted_destinations_before_reading_file( + signed_url: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = tmp_path / "approved.zip" + source.write_bytes(b"approved source") + monkeypatch.setattr(http, "_app_url_override", "https://app.strix.ai") + monkeypatch.setattr( + http.requests, + "put", + lambda *_args, **_kwargs: pytest.fail("an untrusted URL must not receive source bytes"), + ) + + with pytest.raises(http.CloudError, match=r"(untrusted storage origin|storage API|invalid)"): + http.upload_file(signed_url, "upload-token", source) + + +@pytest.mark.parametrize( + "app_url,signed_url", + [ + ( + "https://app.strix.ai", + "https://project-ref.supabase.co/storage/v1/object/upload/sign/bucket/file?token=signed%2Fvalue", + ), + ( + "https://strix.corp.internal", + "https://strix.corp.internal/storage/v1/object/upload/sign/bucket/file", + ), + ( + "http://127.0.0.1:3000", + "http://127.0.0.1:3000/storage/v1/object/upload/sign/bucket/file", + ), + ], +) +def test_source_upload_allows_only_managed_or_same_origin_storage( + app_url: str, signed_url: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = tmp_path / "approved.zip" + source.write_bytes(b"approved source") + response = FakeResponse({"ok": True}) + request_options: dict[str, Any] = {} + + def put(*_args: Any, **kwargs: Any) -> FakeResponse: + request_options.update(kwargs) + return response + + monkeypatch.setattr(http, "_app_url_override", app_url) + monkeypatch.setattr(http.requests, "put", put) + http.upload_file(signed_url, "upload-token", source) + + assert request_options["allow_redirects"] is False + assert response.closed is True + + +def test_source_upload_refuses_redirects_without_following_them( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = tmp_path / "approved.zip" + source.write_bytes(b"approved source") + response = FakeResponse(None, status_code=307) + response.headers["location"] = "http://169.254.169.254/latest/meta-data" + request_options: dict[str, Any] = {} + + def put(*_args: Any, **kwargs: Any) -> FakeResponse: + request_options.update(kwargs) + return response + + monkeypatch.setattr(http, "_app_url_override", "https://app.strix.ai") + monkeypatch.setattr(http.requests, "put", put) + + with pytest.raises(http.CloudError, match="unexpected redirect"): + http.upload_file( + "https://project-ref.supabase.co/storage/v1/object/upload/sign/bucket/file", + "upload-token", + source, + ) + assert request_options["allow_redirects"] is False + assert response.closed is True + + +def test_one_time_api_token_has_save_now_warning( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True) + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse({"id": "token-1", "token": "strix_pat_once"}), + ) + assert cloud.run_cloud(["tokens", "create", "--type", "personal", "--name", "test"]) == 0 + output = capsys.readouterr().out + assert "Save this now" in output + assert "shown only once" in output + + +def test_root_help_advertises_cloud_and_completions( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr(sys, "argv", ["strix", "--help"]) + with pytest.raises(SystemExit) as raised: + interface_main() + assert raised.value.code == 0 + output = capsys.readouterr().out + assert "strix cloud" in output + assert "strix completions" in output diff --git a/tests/test_cloud_idempotency.py b/tests/test_cloud_idempotency.py new file mode 100644 index 00000000..80246912 --- /dev/null +++ b/tests/test_cloud_idempotency.py @@ -0,0 +1,203 @@ +"""Durable retry behavior for managed scan-launch commands.""" + +from __future__ import annotations + +import json +from typing import Any + +import pytest + +from strix.interface import cloud +from strix.interface.cloud import http, runner +from strix.interface.completions import completion_candidates + + +class FakeResponse: + def __init__(self, payload: Any, *, status_code: int = 200) -> None: + self._payload = payload + self.status_code = status_code + self.headers = {"content-type": "application/json"} + self.text = json.dumps(payload) + self.closed = False + + def json(self) -> Any: + return self._payload + + def close(self) -> None: + self.closed = True + + +@pytest.fixture(autouse=True) +def _token(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("STRIX_API_TOKEN", "idempotency-test-token") + monkeypatch.setattr(runner.time, "sleep", lambda _seconds: None) + + +def test_scan_start_generates_and_sends_one_stable_key( + monkeypatch: pytest.MonkeyPatch, + capsys: Any, +) -> None: + seen: list[dict[str, Any]] = [] + monkeypatch.setattr(runner, "uuid4", lambda: "generated-key") + + def request(_method: str, _path: str, **kwargs: Any) -> FakeResponse: + seen.append(kwargs) + return FakeResponse({"scan_id": "scan-1", "status": "running"}) + + monkeypatch.setattr(http, "request", request) + assert cloud.run_cloud(["scans", "start", "--domain-ids", "domain-1", "--json"]) == 0 + assert json.loads(capsys.readouterr().out)["scan_id"] == "scan-1" + assert len(seen) == 1 + assert seen[0]["idempotency_key"] == "generated-key" + assert seen[0]["body"]["engagement_type"] == "live_test" + + +def test_exact_transport_retry_reuses_key_and_body( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen: list[tuple[str, dict[str, Any]]] = [] + + def request(_method: str, _path: str, **kwargs: Any) -> FakeResponse: + seen.append((kwargs["idempotency_key"], kwargs["body"])) + if len(seen) == 1: + raise http.CloudTransportError("response lost") + return FakeResponse({"scan_id": "scan-1", "status": "running"}) + + monkeypatch.setattr(http, "request", request) + command = [ + "scans", + "start", + "--domain-ids", + "domain-1", + "--idempotency-key", + "retry-key", + "--json", + ] + assert cloud.run_cloud(command) == 0 + assert len(seen) == 2 + assert seen[0] == seen[1] + assert seen[0][0] == "retry-key" + + +@pytest.mark.parametrize( + "payload,status", + [ + ({"code": "idempotency_request_in_progress", "retry_safe": True}, 409), + ({"code": "idempotency_outcome_unknown", "retry_safe": True}, 503), + ({"detail": "gateway unavailable"}, 502), + ({"detail": "rate limited"}, 429), + ], +) +def test_retryable_responses_are_closed_and_replayed( + payload: dict[str, Any], + status: int, + monkeypatch: pytest.MonkeyPatch, +) -> None: + first = FakeResponse(payload, status_code=status) + responses = iter((first, FakeResponse({"scan_id": "scan-1", "status": "running"}))) + keys: list[str] = [] + + def request(_method: str, _path: str, **kwargs: Any) -> FakeResponse: + keys.append(kwargs["idempotency_key"]) + return next(responses) + + monkeypatch.setattr(http, "request", request) + assert ( + cloud.run_cloud( + [ + "scans", + "rerun", + "scan-old", + "--idempotency-key", + "same-key", + "--json", + ] + ) + == 0 + ) + assert keys == ["same-key", "same-key"] + assert first.closed is True + + +def test_terminal_key_conflict_is_not_retried( + monkeypatch: pytest.MonkeyPatch, + capsys: Any, +) -> None: + calls = 0 + + def request(_method: str, _path: str, **_kwargs: Any) -> FakeResponse: + nonlocal calls + calls += 1 + return FakeResponse( + { + "detail": "key belongs to another request", + "code": "idempotency_key_conflict", + "terminal": True, + }, + status_code=409, + ) + + monkeypatch.setattr(http, "request", request) + assert ( + cloud.run_cloud(["scans", "rerun", "scan-old", "--idempotency-key", "conflict", "--json"]) + == http.EXIT_ERROR + ) + assert calls == 1 + assert json.loads(capsys.readouterr().out)["code"] == "idempotency_key_conflict" + + +def test_exhausted_ambiguous_launch_reports_safe_recovery_key( + monkeypatch: pytest.MonkeyPatch, + capsys: Any, +) -> None: + monkeypatch.setattr( + http, + "request", + lambda *_args, **_kwargs: (_ for _ in ()).throw(http.CloudTransportError("response lost")), + ) + assert ( + cloud.run_cloud(["scans", "rerun", "scan-old", "--idempotency-key", "recover-me", "--json"]) + == http.EXIT_ERROR + ) + payload = json.loads(capsys.readouterr().out) + assert payload["idempotency_key"] == "recover-me" + assert payload["retry_safe"] is True + assert payload["retry_same_request"] is True + assert "--idempotency-key recover-me" in payload["error"] + + +@pytest.mark.parametrize("key", ["", " white", "bad key", "x\nheader", "x" * 201]) +def test_invalid_idempotency_key_is_usage_error_before_request( + key: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(http, "request", lambda *_a, **_k: pytest.fail("must not request")) + assert ( + cloud.run_cloud(["scans", "rerun", "scan-old", "--idempotency-key", key, "--json"]) + == http.EXIT_USAGE + ) + + +def test_idempotency_flag_is_completed_only_for_keyed_commands() -> None: + assert "--idempotency-key" in completion_candidates(["cloud", "scans", "start", "--idemp"]) + assert "--idempotency-key" in completion_candidates( + ["cloud", "scans", "rerun", "scan-1", "--idemp"] + ) + assert "--idempotency-key" not in completion_candidates(["cloud", "scans", "list", "--idemp"]) + assert "--idempotency-key" in completion_candidates(["cloud", "schedules", "create", "--idemp"]) + assert "--idempotency-key" in completion_candidates( + ["cloud", "schedules", "trigger", "schedule-1", "--idemp"] + ) + + +def test_http_client_places_key_in_the_header(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + + def request(_method: str, _url: str, **kwargs: Any) -> FakeResponse: + seen.update(kwargs) + return FakeResponse({"ok": True}) + + monkeypatch.setattr(http.requests, "request", request) + http.request("POST", "/scans", body={}, idempotency_key="header-key") + assert seen["headers"]["Idempotency-Key"] == "header-key" + assert seen["headers"]["Authorization"] == "Bearer idempotency-test-token" diff --git a/tests/test_cloud_payment_proxy.py b/tests/test_cloud_payment_proxy.py new file mode 100644 index 00000000..c6fb6a79 --- /dev/null +++ b/tests/test_cloud_payment_proxy.py @@ -0,0 +1,136 @@ +"""Security tests for the wallet payment loopback bridge.""" + +from __future__ import annotations + +import urllib.error +import urllib.request +from typing import TYPE_CHECKING, Any + +import pytest + +from strix.interface.cloud import payment_proxy + + +if TYPE_CHECKING: + from collections.abc import Iterator + + +class _StreamingResponse: + status_code = 200 + + def __init__(self, chunks: list[bytes]) -> None: + self.chunks = chunks + self.closed = False + self.headers = {"Content-Type": "application/json"} + + def iter_content(self, *, chunk_size: int) -> Iterator[bytes]: + assert chunk_size > 0 + yield from self.chunks + + def close(self) -> None: + self.closed = True + + +def _post(url: str, body: bytes, headers: dict[str, str] | None = None) -> bytes: + request = urllib.request.Request( # noqa: S310 + url, + data=body, + headers={"Content-Type": "application/json", **(headers or {})}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=2) as response: # noqa: S310 + return response.read() + + +def test_bridge_bounds_decompressed_upstream_response(monkeypatch: pytest.MonkeyPatch) -> None: + response = _StreamingResponse([b"1234", b"5"]) + + def fake_request(*_args: Any, **kwargs: Any) -> _StreamingResponse: + assert kwargs["stream"] is True + return response + + monkeypatch.setattr(payment_proxy, "_MAX_UPSTREAM_RESPONSE_BYTES", 4) + monkeypatch.setattr(payment_proxy.requests, "request", fake_request) + + with payment_proxy.wallet_payment_bridge( + upstream_url="https://app.example.test/api/v1/billing/topup", + api_token="strix-secret", # noqa: S106 + expected_body=b"{}", + ) as wallet_url: + request = urllib.request.Request( # noqa: S310 + wallet_url, + data=b"{}", + headers={"Content-Type": "application/json"}, + method="POST", + ) + with pytest.raises(urllib.error.HTTPError) as exc_info: + urllib.request.urlopen(request, timeout=2) # noqa: S310 + + assert exc_info.value.code == 502 + assert response.closed is True + + +def test_bridge_forwards_only_the_approved_request_and_protected_headers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: list[dict[str, Any]] = [] + observed: list[payment_proxy.WalletUpstreamResponse] = [] + + def fake_request(*_args: Any, **kwargs: Any) -> _StreamingResponse: + captured.append(kwargs) + return _StreamingResponse([b'{"ok":true}']) + + monkeypatch.setattr(payment_proxy.requests, "request", fake_request) + with payment_proxy.wallet_payment_bridge( + upstream_url="https://app.example.test/api/v1/billing/topup", + api_token="strix-secret", # noqa: S106 + expected_body=b'{"credits":5}', + response_observer=observed.append, + ) as wallet_url: + result = _post( + wallet_url, + b'{"credits":5}', + { + "Authorization": "Payment wallet-proof", + "Proxy-Authorization": "Basic drop-me", + "X-Strix-Authorization": "Bearer attacker", + }, + ) + + assert result == b'{"ok":true}' + headers = captured[0]["headers"] + assert headers["Authorization"] == "Payment wallet-proof" + assert headers["X-Strix-Authorization"] == "Bearer strix-secret" + assert "Proxy-Authorization" not in headers + assert not any(name.lower() in {"host", "content-length"} for name in headers) + assert observed == [ + payment_proxy.WalletUpstreamResponse(status_code=200, body=b'{"ok":true}') + ] + + with pytest.raises(urllib.error.HTTPError) as wrong_body: + _post(wallet_url, b'{"credits":500}') + assert wrong_body.value.code == 403 + assert len(captured) == 1 + + +def test_bridge_allows_only_two_valid_wallet_attempts(monkeypatch: pytest.MonkeyPatch) -> None: + calls = 0 + + def fake_request(*_args: Any, **_kwargs: Any) -> _StreamingResponse: + nonlocal calls + calls += 1 + return _StreamingResponse([b"{}"]) + + monkeypatch.setattr(payment_proxy.requests, "request", fake_request) + with payment_proxy.wallet_payment_bridge( + upstream_url="https://app.example.test/api/v1/billing/topup", + api_token="strix-secret", # noqa: S106 + expected_body=b"{}", + ) as wallet_url: + assert _post(wallet_url, b"{}") == b"{}" + assert _post(wallet_url, b"{}") == b"{}" + with pytest.raises(urllib.error.HTTPError) as third_request: + _post(wallet_url, b"{}") + + assert third_request.value.code == 429 + assert calls == 2 diff --git a/tests/test_cloud_session.py b/tests/test_cloud_session.py new file mode 100644 index 00000000..e9ccc862 --- /dev/null +++ b/tests/test_cloud_session.py @@ -0,0 +1,165 @@ +"""CLI-session lifecycle, scope, and workspace-race behavior.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +import pytest +from rich.console import Console + +from strix.interface import cloud, platform_cli, platform_identity +from strix.interface.cloud import http +from strix.interface.cloud import session as cloud_session + + +if TYPE_CHECKING: + from pathlib import Path + + +class Response: + def __init__(self, payload: Any = None, status_code: int = 200) -> None: + self._payload = payload + self.status_code = status_code + self.ok = 200 <= status_code < 400 + self.text = json.dumps(payload) if payload is not None else "" + self.headers = {"content-type": "application/json"} + + def json(self) -> Any: + return self._payload + + +@pytest.fixture +def auth_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + path = tmp_path / "platform-auth.json" + monkeypatch.setattr(platform_cli, "AUTH_PATH", path) + monkeypatch.delenv("STRIX_API_TOKEN", raising=False) + monkeypatch.delenv("STRIX_WORKSPACE_ID", raising=False) + return path + + +def test_http_workspace_pin_is_captured_once( + auth_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + platform_cli.save_record( + { + "api_token": "secret", + "organization_id": "org_start", + "app_url": "https://app.example.test", + } + ) + assert auth_path.exists() + sent: list[dict[str, str]] = [] + + def fake_request(*_args: Any, **kwargs: Any) -> Response: + sent.append(dict(kwargs["headers"])) + return Response({}) + + monkeypatch.setattr(http.requests, "request", fake_request) + http.configure() + platform_cli.save_record( + { + "api_token": "secret", + "organization_id": "org_changed_elsewhere", + "app_url": "https://app.example.test", + } + ) + http.request("GET", "/scans") + assert sent[0]["X-Strix-Workspace"] == "org_start" + + +def test_session_scope_update_persists_only_for_stored_session( + auth_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + platform_cli.save_record( + { + "api_token": "secret", + "organization_id": "org_1", + "app_url": "https://app.example.test", + "scopes": ["scans:read"], + } + ) + monkeypatch.setattr( + http, + "request", + lambda *_args, **_kwargs: Response( + { + "scopes": ["scans:read", "scans:write", "billing:read"], + "requested_scopes": ["scans:read", "scans:write", "billing:read"], + "scope_ceiling": ["scans:read", "scans:write", "billing:read"], + "scope_profile": "minimal", + } + ), + ) + assert cloud.run_cloud(["session", "scopes", "set", "minimal", "--json"]) == 0 + stored = platform_cli.read_record() + assert stored is not None + assert stored["scope_profile"] == "minimal" + assert json.loads(capsys.readouterr().out)["scope_profile"] == "minimal" + + before = auth_path.read_text(encoding="utf-8") + assert ( + cloud.run_cloud(["session", "scopes", "set", "minimal", "--token", "override", "--json"]) + == 0 + ) + assert auth_path.read_text(encoding="utf-8") == before + + +def test_logout_keeps_local_token_when_remote_outcome_is_not_definitive( + auth_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + platform_cli.save_record( + { + "api_token": "secret", + "organization_id": "org_1", + "app_url": "https://app.example.test", + } + ) + monkeypatch.setattr( + platform_cli.requests, + "delete", + lambda *_args, **_kwargs: Response({"detail": "unavailable"}, 503), + ) + assert cloud.run_cloud(["logout", "--json"]) == 1 + assert auth_path.exists() + assert json.loads(capsys.readouterr().out)["removed"] is False + + +def test_local_only_logout_is_explicit_and_recoverable(auth_path: Path, capsys: Any) -> None: + platform_cli.save_record({"api_token": "secret"}) + assert cloud.run_cloud(["logout", "--local-only", "--json"]) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["local_only"] is True + assert payload["remotely_revoked"] is False + assert not auth_path.exists() + + +def test_session_json_errors_preserve_machine_readable_server_details(capsys: Any) -> None: + error = http.CloudError( + "workspace changed", + payload={ + "detail": "workspace changed", + "code": "workspace_session_changed", + "current_organization_id": "org_current", + }, + ) + + assert cloud_session._error(Console(), error, as_json=True) == http.EXIT_ERROR + payload = json.loads(capsys.readouterr().out) + assert payload == { + "code": "workspace_session_changed", + "current_organization_id": "org_current", + "error": "workspace changed", + } + + +def test_cli_device_identity_is_stable_and_privacy_safe( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path = tmp_path / "cli-identity.json" + monkeypatch.setattr(platform_identity, "IDENTITY_PATH", path) + first = platform_identity.read_or_create_identity() + second = platform_identity.read_or_create_identity(device_name=" Build laptop ") + assert second["client_instance_id"] == first["client_instance_id"] + assert second["device_name"] == "Build laptop" + assert path.stat().st_mode & 0o777 == 0o600 diff --git a/tests/test_cloud_source_upload.py b/tests/test_cloud_source_upload.py new file mode 100644 index 00000000..24004cd8 --- /dev/null +++ b/tests/test_cloud_source_upload.py @@ -0,0 +1,657 @@ +"""Local-source packaging and scan upload tests.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import zipfile +from typing import TYPE_CHECKING, Any + +import pytest + +from strix.interface import cloud +from strix.interface.cloud import http, source_upload + + +if TYPE_CHECKING: + from pathlib import Path + + +class FakeResponse: + def __init__(self, payload: Any, status_code: int = 200) -> None: + self.status_code = status_code + self._payload = payload + self.text = json.dumps(payload) + self.content = b"" + self.ok = 200 <= status_code < 400 + self.headers = {"content-type": "application/json"} + + def json(self) -> Any: + return self._payload + + +class MalformedJsonResponse(FakeResponse): + def json(self) -> Any: + raise ValueError("malformed JSON") + + +@pytest.fixture(autouse=True) +def _token_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("STRIX_API_TOKEN", "test-token") + + +def _git_source(tmp_path: Path) -> Path: + git = shutil.which("git") + assert git is not None + subprocess.run([git, "init", "-q", str(tmp_path)], check=True) # noqa: S603 + (tmp_path / "app.py").write_text("print('hello')\n", encoding="utf-8") + (tmp_path / "README.md").write_text("hello\n", encoding="utf-8") + (tmp_path / ".gitignore").write_text("ignored.log\n", encoding="utf-8") + (tmp_path / "ignored.log").write_text("ignored\n", encoding="utf-8") + subprocess.run( # noqa: S603 + [git, "-C", str(tmp_path), "add", "app.py", ".gitignore"], check=True + ) + return tmp_path + + +def test_source_defaults_are_private_and_git_aware(tmp_path: Path) -> None: + source = _git_source(tmp_path) + (source / ".hidden.py").write_text("hidden\n", encoding="utf-8") + (source / ".env").write_text("TOKEN=secret\n", encoding="utf-8") + (source / "private.pem").write_text("secret\n", encoding="utf-8") + (source / "fixture.zip").write_bytes(b"not really a zip") + (source / "node_modules").mkdir() + (source / "node_modules" / "dep.js").write_text("dep\n", encoding="utf-8") + (source / "linked.py").symlink_to(source / "app.py") + + bundle = source_upload.prepare_source( + str(source), + include_hidden=False, + include_sensitive=False, + include_archives=False, + exclude=[], + ) + try: + names = [item.archive_name for item in bundle.manifest.files] + assert names == ["README.md", "app.py"] + assert bundle.manifest.total_bytes > 0 + assert bundle.archive_bytes <= source_upload.MAX_ARCHIVE_BYTES + assert bundle.manifest.excluded["hidden"] == 3 + assert bundle.manifest.excluded["sensitive_filename"] == 1 + assert bundle.manifest.excluded["nested_archive"] == 1 + assert bundle.manifest.excluded["dependency_or_build_output"] == 1 + assert bundle.manifest.excluded["symlink_or_non_file"] == 1 + with zipfile.ZipFile(bundle.archive_path) as archive: + assert archive.namelist() == names + finally: + source_upload.remove_bundle(bundle) + + +def test_hidden_and_sensitive_files_need_separate_opt_ins(tmp_path: Path) -> None: + source = _git_source(tmp_path) + (source / ".env").write_text("TOKEN=secret\n", encoding="utf-8") + (source / ".github").mkdir() + (source / ".github" / "workflow.yml").write_text("name: test\n", encoding="utf-8") + + hidden = source_upload.select_source(source, include_hidden=True) + hidden_names = {item.archive_name for item in hidden.files} + assert ".github/workflow.yml" in hidden_names + assert ".env" not in hidden_names + + sensitive = source_upload.select_source(source, include_hidden=True, include_sensitive=True) + assert ".env" in {item.archive_name for item in sensitive.files} + assert all( + not name.startswith(".git/") for name in (item.archive_name for item in sensitive.files) + ) + + +def test_hidden_opt_in_still_excludes_common_credential_paths(tmp_path: Path) -> None: + paths = [ + ".aws/credentials", + ".git-credentials", + ".docker/config.json", + ".config/gcloud/application_default_credentials.json", + ".config/gcloud/credentials.db", + ".azure/accessTokens.json", + ".kube/config", + ] + for relative in paths: + path = tmp_path / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("credential material\n", encoding="utf-8") + + hidden_only = source_upload.select_source(tmp_path, include_hidden=True) + assert not ({item.archive_name for item in hidden_only.files} & set(paths)) + assert hidden_only.excluded["sensitive_filename"] == len(paths) + + explicitly_sensitive = source_upload.select_source( + tmp_path, include_hidden=True, include_sensitive=True + ) + assert set(paths) <= {item.archive_name for item in explicitly_sensitive.files} + + +def test_hidden_opt_in_cannot_reenable_dependency_cache_or_build_dirs(tmp_path: Path) -> None: + excluded_dirs = [ + ".venv", + "env", + ".tox", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + ".next", + ".nuxt", + ".gradle", + ] + for directory in excluded_dirs: + path = tmp_path / directory / "artifact.txt" + path.parent.mkdir(parents=True) + path.write_text("generated\n", encoding="utf-8") + (tmp_path / ".github" / "workflow.yml").parent.mkdir() + (tmp_path / ".github" / "workflow.yml").write_text("name: test\n", encoding="utf-8") + + manifest = source_upload.select_source(tmp_path, include_hidden=True) + + names = {item.archive_name for item in manifest.files} + assert ".github/workflow.yml" in names + assert not any(name.split("/", 1)[0] in excluded_dirs for name in names) + assert manifest.excluded["dependency_or_build_output"] == len(excluded_dirs) + + +def test_strixignore_and_cli_excludes_are_applied(tmp_path: Path) -> None: + (tmp_path / "keep.py").write_text("keep\n", encoding="utf-8") + (tmp_path / "generated.py").write_text("generated\n", encoding="utf-8") + (tmp_path / "test_app.py").write_text("test\n", encoding="utf-8") + (tmp_path / ".strixignore").write_text("generated.py\n", encoding="utf-8") + + manifest = source_upload.select_source(tmp_path, exclude=["test_*.py"]) + assert [item.archive_name for item in manifest.files] == ["keep.py"] + assert manifest.excluded["user_pattern"] == 2 + + +def test_strixignore_trailing_slash_excludes_the_whole_directory(tmp_path: Path) -> None: + (tmp_path / "keep.py").write_text("keep\n", encoding="utf-8") + private = tmp_path / "private" / "nested" + private.mkdir(parents=True) + (private / "secret.txt").write_text("do not upload\n", encoding="utf-8") + cache = tmp_path / "packages" / "cache" + cache.mkdir(parents=True) + (cache / "artifact.txt").write_text("do not upload\n", encoding="utf-8") + (tmp_path / ".strixignore").write_text("private/\n", encoding="utf-8") + + manifest = source_upload.select_source(tmp_path, exclude=["cache/"]) + + assert [item.archive_name for item in manifest.files] == ["keep.py"] + assert manifest.excluded["user_pattern"] >= 2 + + +def test_source_limits_expanded_bytes_before_compression( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(source_upload, "MAX_TOTAL_BYTES", 5) + (tmp_path / "large.py").write_bytes(b"a" * 6) + with pytest.raises(http.CloudError, match="expanded-size limit"): + source_upload.select_source(tmp_path) + + +def test_source_rejects_archives_by_suffix_and_actual_bytes(tmp_path: Path) -> None: + (tmp_path / "app.py").write_text("print('safe')\n", encoding="utf-8") + (tmp_path / "dependency.jar").write_bytes(b"not-even-a-valid-archive") + (tmp_path / "renamed-source.txt").write_bytes(b"PK\x03\x04" + b"x" * 32) + tar_header = bytearray(512) + tar_header[257:262] = b"ustar" + (tmp_path / "renamed-tar.bin").write_bytes(tar_header) + + manifest = source_upload.select_source(tmp_path) + + assert [item.archive_name for item in manifest.files] == ["app.py"] + assert manifest.excluded["nested_archive"] == 3 + + +def test_source_enumeration_is_bounded_before_filtering( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(source_upload, "MAX_CANDIDATE_PATHS", 2) + for index in range(3): + (tmp_path / f"file-{index}.py").write_text("safe\n", encoding="utf-8") + + with pytest.raises(http.CloudError, match="enumeration exceeded 2 paths"): + source_upload.select_source(tmp_path) + + +def test_strixignore_size_and_pattern_counts_are_bounded( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + ignore = tmp_path / ".strixignore" + monkeypatch.setattr(source_upload, "MAX_IGNORE_BYTES", 4) + ignore.write_text("12345", encoding="utf-8") + with pytest.raises(http.CloudError, match="larger than the 4-byte limit"): + source_upload.select_source(tmp_path) + + monkeypatch.setattr(source_upload, "MAX_IGNORE_BYTES", 1_000) + monkeypatch.setattr(source_upload, "MAX_IGNORE_PATTERNS", 1) + ignore.write_text("one\ntwo\n", encoding="utf-8") + with pytest.raises(http.CloudError, match="more than 1 exclusion patterns"): + source_upload.select_source(tmp_path) + + +@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="named pipes are not supported") +def test_strixignore_must_be_a_nonblocking_regular_file(tmp_path: Path) -> None: + (tmp_path / "app.py").write_text("print('ok')\n", encoding="utf-8") + os.mkfifo(tmp_path / ".strixignore") + + with pytest.raises(http.CloudError, match="must be a regular file"): + source_upload.select_source(tmp_path) + + +def test_source_archive_rejects_a_path_swapped_after_manifest_review(tmp_path: Path) -> None: + source_path = tmp_path / "app.py" + source_path.write_bytes(b"safe") + manifest = source_upload.select_source(tmp_path) + + replacement = tmp_path / "replacement" + replacement.write_bytes(b"oops") + replacement.replace(source_path) + + with pytest.raises(http.CloudError, match="changed while the source archive was being built"): + source_upload._write_archive(tmp_path / "source.zip", manifest.files) + + +def test_source_archive_rejects_same_inode_same_size_change_after_review(tmp_path: Path) -> None: + source_path = tmp_path / "app.py" + source_path.write_bytes(b"safe") + manifest = source_upload.select_source(tmp_path) + + source_path.write_bytes(b"evil") + selected = manifest.files[0] + os.utime( + source_path, + ns=(selected.mtime_ns + 1_000_000, selected.mtime_ns + 1_000_000), + ) + + with pytest.raises(http.CloudError, match="changed while the source archive was being built"): + source_upload._write_archive(tmp_path / "source.zip", manifest.files) + + +def test_source_dry_run_never_calls_the_api( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + (tmp_path / "app.py").write_text("print('safe')\n", encoding="utf-8") + + def fail_request(*_args: Any, **_kwargs: Any) -> Any: + raise AssertionError("dry-run must not make an API request") + + monkeypatch.setattr(http, "request", fail_request) + assert ( + cloud.run_cloud( + ["scans", "start", "--source", str(tmp_path), "--dry-run", "--show-files", "--json"] + ) + == 0 + ) + payload = json.loads(capsys.readouterr().out) + assert payload["source"]["files"] == ["app.py"] + assert payload["source"]["archive_sha256"] + + +def test_noninteractive_source_upload_requires_yes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + (tmp_path / "app.py").write_text("print('safe')\n", encoding="utf-8") + monkeypatch.setattr( + http, + "request", + lambda *_args, **_kwargs: pytest.fail("approval must happen before any API request"), + ) + assert cloud.run_cloud(["scans", "start", "--source", str(tmp_path), "--json"]) == 1 + output = capsys.readouterr().out + assert "requires explicit approval" in output + assert "--approve-sha256 " in output + assert "--yes" in output + assert "one-shot approval" in output + + +def test_source_digest_approval_rejects_a_changed_snapshot( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + source = tmp_path / "app.py" + source.write_text("print('reviewed')\n", encoding="utf-8") + assert ( + cloud.run_cloud(["scans", "start", "--source", str(tmp_path), "--dry-run", "--json"]) == 0 + ) + approved = json.loads(capsys.readouterr().out)["source"]["archive_sha256"] + source.write_text("print('changed')\n", encoding="utf-8") + monkeypatch.setattr( + http, + "request", + lambda *_args, **_kwargs: pytest.fail("a changed snapshot must not reach the API"), + ) + + assert ( + cloud.run_cloud( + [ + "scans", + "start", + "--source", + str(tmp_path), + "--approve-sha256", + approved, + "--json", + ] + ) + == http.EXIT_ERROR + ) + assert "does not match" in json.loads(capsys.readouterr().out)["error"] + + +def test_source_upload_is_completed_and_attached_to_scan( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + (tmp_path / "app.py").write_text("print('safe')\n", encoding="utf-8") + calls: list[tuple[str, str, dict[str, Any]]] = [] + uploaded_path: Path | None = None + + def fake_request(method: str, path: str, **kwargs: Any) -> FakeResponse: + calls.append((method, path, kwargs)) + if path == "/uploads/request": + return FakeResponse( + { + "upload_id": "upload-1", + "signed_url": "https://storage.test/object", + "token": "signed", + } + ) + if path == "/uploads/complete": + return FakeResponse({"id": "upload-1"}) + if path == "/scans": + return FakeResponse({"scan_id": "scan-1", "status": "pending"}) + raise AssertionError(path) + + def fake_upload(_url: str, _token: str, path: Path) -> None: + nonlocal uploaded_path + uploaded_path = path + assert path.exists() + + monkeypatch.setattr(http, "request", fake_request) + monkeypatch.setattr(http, "upload_file", fake_upload) + + assert ( + cloud.run_cloud( + ["scans", "start", "--source", str(tmp_path), "--yes", "--show-files", "--json"] + ) + == 0 + ) + payload = json.loads(capsys.readouterr().out) + assert payload["upload_id"] == "upload-1" + assert payload["scan"]["scan_id"] == "scan-1" + assert payload["source"]["files"] == ["app.py"] + scan_call = next(call for call in calls if call[1] == "/scans") + assert scan_call[2]["body"] == { + "engagement_type": "code_review", + "upload_ids": ["upload-1"], + } + assert uploaded_path is not None and not uploaded_path.exists() + + +def test_source_upload_with_domain_is_a_live_test( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + (tmp_path / "app.py").write_text("print('safe')\n", encoding="utf-8") + calls: list[tuple[str, str, dict[str, Any]]] = [] + + def fake_request(method: str, path: str, **kwargs: Any) -> FakeResponse: + calls.append((method, path, kwargs)) + if path == "/uploads/request": + return FakeResponse( + { + "upload_id": "upload-1", + "signed_url": "https://storage.test/object", + "token": "signed", + } + ) + if path == "/uploads/complete": + return FakeResponse({"id": "upload-1"}) + if path == "/scans": + return FakeResponse({"scan_id": "scan-1", "status": "pending"}) + raise AssertionError(path) + + monkeypatch.setattr(http, "request", fake_request) + monkeypatch.setattr(http, "upload_file", lambda *_args, **_kwargs: None) + + assert ( + cloud.run_cloud( + [ + "scans", + "start", + "--source", + str(tmp_path), + "--domain-ids", + "domain-1", + "--yes", + "--json", + ] + ) + == 0 + ) + scan_call = next(call for call in calls if call[1] == "/scans") + assert scan_call[2]["body"] == { + "engagement_type": "live_test", + "domain_ids": ["domain-1"], + "upload_ids": ["upload-1"], + } + assert json.loads(capsys.readouterr().out)["scan"]["scan_id"] == "scan-1" + + +def test_failed_scan_deletes_completed_source_upload( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + (tmp_path / "app.py").write_text("print('safe')\n", encoding="utf-8") + paths: list[tuple[str, str]] = [] + + def fake_request(method: str, path: str, **_kwargs: Any) -> FakeResponse: + paths.append((method, path)) + if path == "/uploads/request": + return FakeResponse( + { + "upload_id": "upload-1", + "signed_url": "https://storage.test/object", + "token": "signed", + } + ) + if path == "/uploads/complete": + return FakeResponse({"id": "upload-1"}) + if path == "/scans": + return FakeResponse({"detail": "not enough credits"}, status_code=402) + if path == "/uploads/upload-1": + return FakeResponse({"ok": True}) + raise AssertionError(path) + + monkeypatch.setattr(http, "request", fake_request) + monkeypatch.setattr(http, "upload_file", lambda *_args, **_kwargs: None) + assert cloud.run_cloud(["scans", "start", "--source", str(tmp_path), "--yes", "--json"]) == 5 + assert ("DELETE", "/uploads/upload-1") in paths + + +@pytest.mark.parametrize( + "failure", + ["network", "server", "malformed_success", "malformed_json_success", "wrong_shape_success"], +) +def test_ambiguous_scan_launch_retains_completed_source_upload( + failure: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: Any, +) -> None: + (tmp_path / "app.py").write_text("print('safe')\n", encoding="utf-8") + paths: list[tuple[str, str]] = [] + + def fake_request(method: str, path: str, **_kwargs: Any) -> FakeResponse: + paths.append((method, path)) + if path == "/uploads/request": + return FakeResponse( + { + "upload_id": "upload-ambiguous", + "signed_url": "https://storage.test/object", + "token": "signed", + } + ) + if path == "/uploads/complete": + return FakeResponse({"id": "upload-ambiguous"}) + if path == "/scans": + if failure == "network": + raise http.CloudError("connection closed before a response") + if failure == "server": + return FakeResponse({"detail": "temporary failure"}, status_code=500) + if failure == "malformed_json_success": + return MalformedJsonResponse("accepted") + if failure == "wrong_shape_success": + return FakeResponse({}) + response = FakeResponse("accepted") + response.headers = {"content-type": "text/html"} + return response + if path == "/uploads/upload-ambiguous": + pytest.fail("an upload with an ambiguous launch must not be deleted") + raise AssertionError(path) + + monkeypatch.setattr(http, "request", fake_request) + monkeypatch.setattr(http, "upload_file", lambda *_args, **_kwargs: None) + + assert ( + cloud.run_cloud(["scans", "start", "--source", str(tmp_path), "--yes", "--json"]) + == http.EXIT_ERROR + ) + payload = json.loads(capsys.readouterr().out) + assert payload["upload_id"] == "upload-ambiguous" + assert payload["upload_retained"] is True + assert payload["launch_outcome_unknown"] is True + assert "outcome is unknown" in payload["error"] + assert ("DELETE", "/uploads/upload-ambiguous") not in paths + + +def test_mismatched_upload_completion_response_is_cleaned_before_launch( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + (tmp_path / "app.py").write_text("print('safe')\n", encoding="utf-8") + paths: list[tuple[str, str]] = [] + + def fake_request(method: str, path: str, **_kwargs: Any) -> FakeResponse: + paths.append((method, path)) + if path == "/uploads/request": + return FakeResponse( + { + "upload_id": "upload-expected", + "signed_url": "https://storage.test/object", + "token": "signed", + } + ) + if path == "/uploads/complete": + return FakeResponse({"id": "upload-different"}) + if path == "/uploads/upload-expected": + return FakeResponse({"ok": True}) + if path == "/scans": + pytest.fail("a scan must not launch before upload completion is confirmed") + raise AssertionError(path) + + monkeypatch.setattr(http, "request", fake_request) + monkeypatch.setattr(http, "upload_file", lambda *_args, **_kwargs: None) + + assert cloud.run_cloud(["scans", "start", "--source", str(tmp_path), "--yes"]) == 1 + assert ("DELETE", "/uploads/upload-expected") in paths + + +def test_interrupted_scan_launch_retains_completed_source_upload( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + (tmp_path / "app.py").write_text("print('safe')\n", encoding="utf-8") + paths: list[tuple[str, str]] = [] + + def fake_request(method: str, path: str, **_kwargs: Any) -> FakeResponse: + paths.append((method, path)) + if path == "/uploads/request": + return FakeResponse( + { + "upload_id": "upload-interrupted", + "signed_url": "https://storage.test/object", + "token": "signed", + } + ) + if path == "/uploads/complete": + return FakeResponse({"id": "upload-interrupted"}) + if path == "/scans": + raise KeyboardInterrupt + if path == "/uploads/upload-interrupted": + pytest.fail("an upload with an interrupted launch must not be deleted") + raise AssertionError(path) + + monkeypatch.setattr(http, "request", fake_request) + monkeypatch.setattr(http, "upload_file", lambda *_args, **_kwargs: None) + + assert cloud.run_cloud(["scans", "start", "--source", str(tmp_path), "--yes", "--json"]) == 130 + payload = json.loads(capsys.readouterr().out) + assert payload["interrupted"] is True + assert payload["upload_id"] == "upload-interrupted" + assert payload["upload_retained"] is True + assert payload["launch_outcome_unknown"] is True + assert "scans list" in payload["error"] + assert ("DELETE", "/uploads/upload-interrupted") not in paths + + +@pytest.mark.parametrize("cleanup_failure", ["timeout", "server"]) +def test_failed_automatic_upload_cleanup_reports_retained_id( + cleanup_failure: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: Any, +) -> None: + (tmp_path / "app.py").write_text("print('safe')\n", encoding="utf-8") + + def fake_request(method: str, path: str, **_kwargs: Any) -> FakeResponse: + if path == "/uploads/request": + return FakeResponse( + { + "upload_id": "upload-orphaned", + "signed_url": "https://storage.test/object", + "token": "signed", + } + ) + if path == "/uploads/upload-orphaned" and method == "DELETE": + if cleanup_failure == "timeout": + raise http.CloudError("cleanup timed out") + return FakeResponse({"detail": "cleanup unavailable"}, status_code=500) + raise AssertionError((method, path)) + + monkeypatch.setattr(http, "request", fake_request) + monkeypatch.setattr( + http, + "upload_file", + lambda *_args, **_kwargs: (_ for _ in ()).throw(http.CloudError("upload failed")), + ) + + assert ( + cloud.run_cloud(["scans", "start", "--source", str(tmp_path), "--yes", "--json"]) + == http.EXIT_ERROR + ) + payload = json.loads(capsys.readouterr().out) + assert payload["upload_id"] == "upload-orphaned" + assert payload["upload_retained"] is True + assert payload["cleanup_unknown"] is True + assert "uploads delete upload-orphaned" in payload["error"] + + +def test_incomplete_upload_credentials_delete_the_reserved_upload( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + (tmp_path / "app.py").write_text("print('safe')\n", encoding="utf-8") + paths: list[tuple[str, str]] = [] + + def fake_request(method: str, path: str, **_kwargs: Any) -> FakeResponse: + paths.append((method, path)) + if path == "/uploads/request": + return FakeResponse({"upload_id": "upload-incomplete"}) + if path == "/uploads/upload-incomplete": + return FakeResponse({"ok": True}) + raise AssertionError(path) + + monkeypatch.setattr(http, "request", fake_request) + assert cloud.run_cloud(["scans", "start", "--source", str(tmp_path), "--yes", "--json"]) == 1 + assert ("DELETE", "/uploads/upload-incomplete") in paths diff --git a/tests/test_completions.py b/tests/test_completions.py new file mode 100644 index 00000000..f9f9157e --- /dev/null +++ b/tests/test_completions.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +from typing import Any + +from strix.interface.completions import completion_candidates, run_completions + + +def test_root_completion_candidates() -> None: + assert completion_candidates(["cl"]) == ["cloud"] + assert "completions" in completion_candidates([""]) + + +def test_cloud_group_and_alias_candidates() -> None: + candidates = completion_candidates(["cloud", "work"]) + assert candidates == ["workspace", "workspaces"] + + +def test_cloud_verb_candidates_include_multiword_prefixes() -> None: + assert "test-users" in completion_candidates(["cloud", "domains", ""]) + assert completion_candidates(["cloud", "domains", "test-users", "in"]) == [ + "inbox", + "inbox-message", + ] + + +def test_cloud_leaf_flag_candidates_come_from_command_spec() -> None: + candidates = completion_candidates(["cloud", "scans", "start", "--"]) + assert "--domain-ids" in candidates + assert "--json" in candidates + assert "--wait" in candidates + assert "--source" in candidates + assert "--approve-sha256" in candidates + assert "--dry-run" in candidates + assert "--include-hidden" in candidates + + +def test_boolean_completion_includes_positive_and_negative_flags() -> None: + candidates = completion_candidates(["cloud", "billing", "auto-topup", "update", "--"]) + assert "--enabled" in candidates + assert "--no-enabled" in candidates + assert "--no-monthly-cap" in candidates + + +def test_session_and_workspace_use_completions_include_their_real_flags() -> None: + credit_flags = completion_candidates(["cloud", "credits", "--"]) + assert {"--json", "--token", "--app-url", "--timeout", "--help"} <= set(credit_flags) + assert "--json" in completion_candidates(["cloud", "logout", "--"]) + + workspace_use = completion_candidates(["cloud", "workspace", "use", "--"]) + assert {"--scopes", "--json", "--token", "--app-url", "--timeout"} <= set(workspace_use) + + +def test_leaf_flags_remain_available_after_options_and_positionals() -> None: + after_option = completion_candidates(["cloud", "scans", "list", "--status", "running", "--"]) + assert {"--page", "--limit", "--json"} <= set(after_option) + + after_positional = completion_candidates(["cloud", "scans", "get", "scan-1", "--"]) + assert {"--json", "--token", "--app-url", "--timeout"} <= set(after_positional) + + +def test_default_verbs_complete_flags_without_an_explicit_verb() -> None: + audit = completion_candidates(["cloud", "audit", "--"]) + assert {"--page", "--limit", "--json"} <= set(audit) + + after_option = completion_candidates(["cloud", "audit", "--page", "2", "--"]) + assert {"--limit", "--format", "--json"} <= set(after_option) + + workspaces = completion_candidates(["cloud", "workspace", "--"]) + assert {"--json", "--token", "--app-url", "--timeout"} <= set(workspaces) + + +def test_completion_does_not_offer_flags_while_an_option_value_is_empty() -> None: + assert completion_candidates(["cloud", "scans", "list", "--page", ""]) == [] + assert completion_candidates(["cloud", "scans", "start", "--approve-sha256", ""]) == [] + + +def test_exact_verbs_that_are_also_prefixes_keep_their_subverbs() -> None: + candidates = completion_candidates(["cloud", "billing", "auto-topup", ""]) + assert "update" in candidates + assert "--json" in candidates + + +def test_contract_fix_flags_are_completed() -> None: + integration_connect = completion_candidates( + ["cloud", "integrations", "connect", "gitlab", "--"] + ) + assert { + "--provider-token", + "--instance-url", + "--account-email", + "--installation-id", + } <= set(integration_connect) + + disconnect = completion_candidates(["cloud", "integrations", "disconnect", "--"]) + assert "--installation-id" in disconnect + + connector = completion_candidates(["cloud", "connectors", "get", "connector-1", "--"]) + assert "--include-command" in connector + assert "--no-include-command" in connector + + scan_wait = completion_candidates(["cloud", "scans", "start", "--"]) + assert "--wait-timeout" in scan_wait + + audit_export = completion_candidates(["cloud", "audit", "--"]) + assert {"--output", "--force"} <= set(audit_export) + + token_create = completion_candidates(["cloud", "tokens", "create", "--"]) + assert {"--expires-at", "--rbac-scopes"} <= set(token_create) + + +def test_filesystem_completion_for_source_output_and_data(tmp_path: Any, monkeypatch: Any) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "source tree").mkdir() + (tmp_path / "source.txt").write_text("source", encoding="utf-8") + (tmp_path / "request.json").write_text("{}", encoding="utf-8") + + source = completion_candidates(["cloud", "scans", "start", "--source", "sou"]) + assert source == ["source tree/"] + + output = completion_candidates(["cloud", "scans", "report", "scan-1", "--output", "req"]) + assert output == ["request.json"] + + audit_output = completion_candidates(["cloud", "audit", "--output", "req"]) + assert audit_output == ["request.json"] + + data = completion_candidates(["cloud", "scans", "start", "--data", "@req"]) + assert data == ["@request.json"] + + +def test_filesystem_completion_omits_terminal_control_names( + tmp_path: Any, monkeypatch: Any, capsys: Any +) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "safe.json").write_text("{}", encoding="utf-8") + (tmp_path / "unsafe\nname.json").write_text("{}", encoding="utf-8") + (tmp_path / "unsafe\x1b]52;c;payload\x07.json").write_text("{}", encoding="utf-8") + + words = ["cloud", "scans", "start", "--data", "@"] + assert completion_candidates(words) == ["@safe.json"] + assert run_completions(["--candidates", *words]) == 0 + assert capsys.readouterr().out == "@safe.json\n" + + +def test_completion_scripts_cover_supported_shells(capsys: Any) -> None: + for shell in ("zsh", "bash", "fish"): + assert run_completions([shell]) == 0 + output = capsys.readouterr().out + assert "completions --candidates" in output + + +def test_bash_completion_preserves_candidates_with_spaces(capsys: Any) -> None: + assert run_completions(["bash"]) == 0 + output = capsys.readouterr().out + assert 'COMPREPLY=("${candidates[@]}")' in output + assert "while IFS= read -r candidate" in output + assert "mapfile" not in output + + +def test_completion_rejects_unknown_shell(capsys: Any) -> None: + assert run_completions(["powershell\x1b]52;c;payload\x07"]) == 2 + error = capsys.readouterr().err + assert "Choose zsh, bash, or fish" in error + assert "\x1b" not in error + assert "\\x1b" in error diff --git a/tests/test_pricing.py b/tests/test_pricing.py index abff873d..c9e9e5df 100644 --- a/tests/test_pricing.py +++ b/tests/test_pricing.py @@ -14,7 +14,10 @@ def test_resolves_common_bare_model_names() -> None: assert resolve_litellm_model("deepseek-v4-flash") == "deepseek/deepseek-v4-flash" assert resolve_litellm_model("openai/deepseek-v4-flash") == "deepseek/deepseek-v4-flash" assert resolve_litellm_model("grok-4.5") == "xai/grok-4.5" - assert resolve_litellm_model("MiniMax-M3") == "minimax/MiniMax-M3" + # MiniMax-M3 is sold by several LiteLLM providers at different prices, so + # the resolver must not guess from its bare name. A provider-qualified + # model remains deterministic. + assert resolve_litellm_model("minimax/MiniMax-M3") == "minimax/MiniMax-M3" def test_resolver_returns_none_for_unresolvable_model() -> None: