Make cloud CLI workflows actionable and safe

This commit is contained in:
bearsyankees 2026-08-28 12:53:08 -04:00
parent b074e0f632
commit d265724bfa
11 changed files with 2481 additions and 120 deletions

View file

@ -51,7 +51,7 @@ Target-specific workflows built on the same engine:
- Every REST operation has a `strix cloud <resource> <verb>` command. Run `strix cloud` to list them. Output is JSON when stdout is not a terminal (or with `--json`), and there are no prompts without a TTY. Binary downloads are the exception: redirect raw bytes intentionally, or combine `--output FILE --json` for structured download metadata. Exit codes: `0` success, `1` error, `2` usage, `4` auth or plan limit, `5` payment required. `--token` or `STRIX_API_TOKEN` overrides the stored sign-in. `--data` adds extra request fields as JSON, and accepts `@file` or `-` for standard input.
- Local source uploads require `uploads:write`. 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`, 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`.
- 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).

View file

@ -339,7 +339,8 @@ Every operation of the [REST API](https://docs.app.strix.ai) has a matching comm
```bash
strix cloud # list all resources
strix cloud scans # list the verbs of a resource
strix cloud scans # run the safe default (`scans list`)
strix cloud scans help # list the verbs of a resource
strix cloud domains add --domain example.com --asset-type web_app
strix cloud scans start --engagement-type live_test --domain-ids <uuid> --wait
strix cloud scans start --source . --dry-run --show-files --json # review + capture source.archive_sha256
@ -354,7 +355,7 @@ 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"
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 billing subscribe --plan strix_cloud # opens the hosted checkout page
strix cloud billing portal # opens the billing portal
@ -364,7 +365,7 @@ strix cloud domains verify <domain-id> # prints the DNS record to add
The last four commands end at a person. Strix creates the link, opens the browser for an interactive terminal, and always prints the URL. The user enters the card, approves the installation, or adds the DNS record. Pass `--no-browser` to print the URL only.
The commands work for humans and agents: terminal output favors names, branches, lifecycle states, and numbered selectors, while redirected output (or `--json`) preserves complete machine-readable records and IDs. Human asset, knowledge, test-user, and token lists retain the record IDs needed by follow-up commands but omit internal organization/user IDs; token lists label credentials as active, expired, or revoked. 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. Set the token with `--token` or `STRIX_API_TOKEN` to skip the stored sign-in.
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 label credentials as active, expired, or revoked. 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. Set the token with `--token` or `STRIX_API_TOKEN` to skip the stored sign-in.
Write commands take request fields as flags, and every write command also accepts one JSON object with `--data`:
@ -389,8 +390,8 @@ the service independently validates the archive. Source alone infers a code revi
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, or interruption
makes the launch outcome ambiguous, it retains the upload and reports its `upload_id` with
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`.

View file

@ -13,12 +13,12 @@ Use this when you want Strix's autonomous pentesting **without running Docker or
There are two equivalent interfaces. Prefer the CLI:
- **`strix cloud` CLI** — every REST operation has a command in the form `strix cloud <resource> <verb>`. Install with `curl -sSL https://strix.ai/install | bash`. Run `strix cloud` to list all resources and `strix cloud <resource>` to list its verbs.
- **`strix cloud` CLI** — every REST operation has a command in the form `strix cloud <resource> <verb>`. Install with `curl -sSL https://strix.ai/install | bash`. Run `strix cloud` to list all resources and `strix cloud <resource> help` (or `-h`) to list a resource's verbs; a bare resource with a safe read operation runs its documented default.
- **REST API** — base URL `https://app.strix.ai/api/v1`, `Authorization: Bearer <token>` on every request. Full reference: **[docs.app.strix.ai](https://docs.app.strix.ai)** · agent index: `https://docs.app.strix.ai/llms.txt` · OpenAPI: `https://docs.app.strix.ai/openapi.json`.
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 asset, knowledge, test-user, and token lists retain the record IDs needed by follow-up commands but omit internal organization/user IDs; 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.
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 has a useful default list/read action, and `-h` or `help` always shows its verbs. Native tab completion includes resources, verbs, flags, workspace commands, and local paths:
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
@ -62,8 +62,9 @@ The user approves the sign-in in the browser. With `--scopes` (and optionally `-
| `pr_reviews:write` | trigger PR security reviews |
| `webhooks:read` / `:write` | manage webhook subscriptions |
| `uploads:write` | upload local source or documents for a scan |
| `organizations:read` | list and switch workspaces |
| `tokens:write` | create/revoke API tokens |
| `organizations:read` | list workspaces |
| `organizations:write` | create/update workspaces (admin) |
| `tokens:write` | switch workspaces · create/revoke API tokens |
| `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) |
@ -121,13 +122,13 @@ 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"
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 org members invite --email dev@example.com --role analyst
```
`workspaces use` rotates the current personal token to a workspace the user already belongs to and stores the updated workspace metadata; the bearer secret stays unchanged. The role in the target workspace limits the scopes. Add `--scopes` to request a smaller set.
`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. The role in the target workspace limits the scopes. Add `--scopes` to request a smaller set.
### Handoffs a person must finish
@ -199,7 +200,10 @@ Useful flags (each maps to a `CreateScanRequest` field):
| `--upload-ids` | attach uploaded source/docs archives for white-box context |
| `--notify-on-completion` / `--notification-emails` | email when done |
The 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.
### Scan a local workspace in the cloud

View file

@ -300,7 +300,13 @@ def check(response: requests.Response) -> Any:
"the server returned a non-JSON response. Check STRIX_APP_URL and preview "
"access, then retry."
)
return data
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):

File diff suppressed because it is too large Load diff

View file

@ -377,7 +377,10 @@ def _execute( # noqa: PLR0912, PLR0915
return http.EXIT_OK
source_workflow.mark_launch_started()
scan_request_started = cmd.idempotent
# 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,
@ -414,7 +417,7 @@ def _execute( # noqa: PLR0912, PLR0915
json_metadata=binary_json_metadata,
)
try:
result = http.check(response)
result = _validated_operation_result(http.check(response), cmd)
except BaseException as exc:
source_workflow.handle_response_failure(
exc,
@ -1044,11 +1047,20 @@ def _created_id(created: Any) -> str | None:
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):
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,
@ -1059,8 +1071,12 @@ def _wait(
wait_timeout: float,
) -> Any:
item_id = _created_id(created)
if not item_id or not cmd.wait_path:
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(

View file

@ -251,7 +251,7 @@ def _upload_scan_source(bundle: SourceBundle, *, token: str | None) -> str:
raise error
try:
http.upload_file(cast("str", signed_url), cast("str", upload_token), bundle.archive_path)
http.check(
completed = http.check(
http.request(
"POST",
"/uploads/complete",
@ -259,6 +259,7 @@ def _upload_scan_source(bundle: SourceBundle, *, token: str | None) -> str:
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)
@ -273,6 +274,12 @@ def _upload_scan_source(bundle: SourceBundle, *, token: str | None) -> str:
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:

View file

@ -300,8 +300,7 @@ SPEC: dict[str, dict[str, Cmd]] = {
P(
"status",
help=(
"New status: open, in_progress, snoozed, fixed, ignored, or "
"not_affected."
"New status: open, in_progress, snoozed, fixed, ignored, or not_affected."
),
),
P("note", help="Note that explains the change."),
@ -1096,10 +1095,27 @@ SPEC: dict[str, dict[str, Cmd]] = {
# 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",
}

File diff suppressed because it is too large Load diff

View file

@ -940,10 +940,10 @@ def test_device_flow_accepts_external_authkit_url_and_binds_token_origin(
def test_missing_verb_json_is_structured(capsys: Any) -> None:
assert cloud.run_cloud(["scans", "--json"]) == 0
assert cloud.run_cloud(["uploads", "--json"]) == 0
payload = json.loads(capsys.readouterr().out)
assert payload["command"] == "strix cloud scans"
assert any(item["name"] == "start" for item in payload["verbs"])
assert payload["command"] == "strix cloud uploads"
assert any(item["name"] == "request" for item in payload["verbs"])
@pytest.mark.parametrize(
@ -981,8 +981,8 @@ def test_non_tty_dispatcher_always_emits_structured_json(
assert cloud.run_cloud([]) == 0
assert json.loads(capsys.readouterr().out)["command"] == "strix cloud"
assert cloud.run_cloud(["scans"]) == 0
assert json.loads(capsys.readouterr().out)["command"] == "strix cloud scans"
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"}

View file

@ -32,6 +32,11 @@ class FakeResponse:
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")
@ -467,7 +472,10 @@ def test_failed_scan_deletes_completed_source_upload(
assert ("DELETE", "/uploads/upload-1") in paths
@pytest.mark.parametrize("failure", ["network", "server", "malformed_success"])
@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,
@ -494,6 +502,10 @@ def test_ambiguous_scan_launch_retains_completed_source_upload(
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
@ -516,6 +528,37 @@ def test_ambiguous_scan_launch_retains_completed_source_upload(
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: