strix/strix/interface/cloud/source_scan.py
alex s de730119f0
feat(cli): strix cloud — managed platform CLI (login, scans, billing, and the rest of the API) (#1177)
* 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
2026-09-01 14:39:34 -04:00

403 lines
15 KiB
Python

"""Local-source approval, upload, and scan-launch lifecycle."""
from __future__ import annotations
import re
import sys
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, cast
from urllib.parse import quote
from rich.markup import escape
import strix.interface.cloud.http as http # noqa: PLR0402
from strix.interface.cloud.render import emit
from strix.interface.cloud.source_upload import prepare_source, remove_bundle
from strix.interface.terminal_text import sanitize_terminal_text
if TYPE_CHECKING:
import argparse
from typing import NoReturn
from rich.console import Console
from strix.interface.cloud.source_upload import SourceBundle
_SHA256 = re.compile(r"^[0-9a-fA-F]{64}$")
@dataclass
class LocalSourceScan:
"""Own one local bundle and its staged upload through a scan launch."""
bundle: SourceBundle | None = None
upload_id: str | None = None
idempotency_key: str | None = None
_launch_started: bool = False
def prepare_and_attach(
self,
console: Console,
args: argparse.Namespace,
body: dict[str, Any],
*,
as_json: bool,
token: str | None,
) -> bool:
"""Prepare source, emit a dry run, or upload and attach it to ``body``.
Returns ``True`` when a dry run was emitted and request execution should stop.
"""
self.bundle = prepare_scan_source(console, args, as_json=as_json)
if self.bundle is None:
return False
if getattr(args, "dry_run", False):
emit(
console,
{"source": self.bundle.summary(show_files=getattr(args, "show_files", False))},
as_json=as_json,
view="source_manifest",
)
return True
self.upload_id = _upload_scan_source(self.bundle, token=token)
existing = body.get("upload_ids")
body["upload_ids"] = [
*(existing if isinstance(existing, list) else []),
self.upload_id,
]
return False
def mark_launch_started(self) -> None:
"""Record that the scan-creation request may have reached the platform."""
self._launch_started = self.upload_id is not None
def handle_request_failure(self, error: BaseException, *, token: str | None) -> None:
"""Clean or retain a staged upload according to request ambiguity."""
if self.upload_id is None:
return
if self._launch_started:
if isinstance(error, KeyboardInterrupt):
raise _interrupted_source_upload_error(
self.upload_id, self.idempotency_key
) from None
if isinstance(error, Exception):
raise _retained_source_upload_error(
self.upload_id, error, self.idempotency_key
) from error
return
try:
_delete_upload(self.upload_id, token=token)
except (http.CloudError, KeyboardInterrupt) as cleanup_error:
if isinstance(error, Exception):
raise _source_cleanup_error(self.upload_id, error, cleanup_error) from error
interrupted = http.CloudError("source upload interrupted.", exit_code=130)
raise _source_cleanup_error(self.upload_id, interrupted, cleanup_error) from None
def handle_response_failure(
self,
error: BaseException,
*,
definitive: bool,
token: str | None,
) -> None:
"""Clean a rejected upload or retain one whose scan result is ambiguous."""
if self.upload_id is None:
return
if definitive:
try:
_delete_upload(self.upload_id, token=token)
except (http.CloudError, KeyboardInterrupt) as cleanup_error:
if isinstance(error, Exception):
raise _source_cleanup_error(self.upload_id, error, cleanup_error) from error
interrupted = http.CloudError("source upload interrupted.", exit_code=130)
raise _source_cleanup_error(self.upload_id, interrupted, cleanup_error) from None
return
if isinstance(error, Exception):
raise _retained_source_upload_error(
self.upload_id, error, self.idempotency_key
) from error
def wrap_result(self, result: Any, args: argparse.Namespace) -> Any:
"""Attach the approved source manifest to a successful scan response."""
if self.bundle is None:
return result
return {
"source": self.bundle.summary(show_files=getattr(args, "show_files", False)),
"upload_id": self.upload_id,
"scan": result,
}
def close(self) -> None:
"""Remove the private temporary bundle, if one was built."""
if self.bundle is not None:
remove_bundle(self.bundle)
def prepare_scan_source(
console: Console, args: argparse.Namespace, *, as_json: bool
) -> SourceBundle | None:
"""Build and approve the exact local-source snapshot for one invocation."""
source = getattr(args, "source", None)
source_flags = (
"dry_run",
"show_files",
"include_hidden",
"include_sensitive",
"include_archives",
"approve_sha256",
)
if source is None:
if any(getattr(args, name, False) for name in source_flags) or getattr(args, "exclude", []):
raise http.CloudError("source upload options require --source DIRECTORY.")
return None
bundle = prepare_source(
source,
include_hidden=bool(getattr(args, "include_hidden", False)),
include_sensitive=bool(getattr(args, "include_sensitive", False)),
include_archives=bool(getattr(args, "include_archives", False)),
exclude=cast("list[str]", getattr(args, "exclude", [])),
)
keep_bundle = False
try:
approved_digest = _validate_source_digest_approval(args, bundle)
if getattr(args, "dry_run", False):
keep_bundle = True
return bundle
if getattr(args, "yes", False) or approved_digest is not None:
keep_bundle = True
return bundle
if as_json or not (sys.stdin.isatty() and sys.stdout.isatty()):
_source_approval_error(
"source upload requires explicit approval in non-interactive mode. "
"Review with --dry-run --show-files, then rerun with "
"--approve-sha256 <reviewed hash>; use --yes only for a deliberate "
"one-shot approval of the snapshot built by that invocation."
)
console.print(
"[bold]Local source upload[/]\n"
f" {len(bundle.manifest.files):,} file(s), "
f"{_format_bytes(bundle.manifest.total_bytes)} "
f"({_format_bytes(bundle.archive_bytes)} compressed)\n"
f" {sum(bundle.manifest.excluded.values()):,} path(s) excluded\n"
" Only the selected files will be sent to Strix Cloud."
)
if getattr(args, "show_files", False):
console.print(f"\n[bold]Selected files ({len(bundle.manifest.files):,})[/]")
for selected in bundle.manifest.files:
console.print(
f" {escape(sanitize_terminal_text(selected.archive_name))}", soft_wrap=True
)
answer = (
console.input("Upload this source and start the scan? [y/N]: ", markup=False)
.strip()
.lower()
)
if answer not in ("y", "yes"):
_source_approval_error("source upload cancelled.")
keep_bundle = True
return bundle
finally:
if not keep_bundle:
remove_bundle(bundle)
def _validate_source_digest_approval(args: argparse.Namespace, bundle: SourceBundle) -> str | None:
approved_digest = getattr(args, "approve_sha256", None)
if approved_digest is None:
return None
if not isinstance(approved_digest, str) or not _SHA256.fullmatch(approved_digest):
_source_approval_error("--approve-sha256 must be exactly 64 hexadecimal characters.")
if bundle.archive_sha256 != approved_digest.lower():
_source_approval_error(
"source archive SHA-256 does not match --approve-sha256; review a fresh "
"--dry-run before uploading."
)
return approved_digest
def _source_approval_error(message: str) -> NoReturn:
raise http.CloudError(message)
def _upload_scan_source(bundle: SourceBundle, *, token: str | None) -> str:
file_name = f"strix-source-{bundle.archive_sha256[:12]}.zip"
requested = http.check(
http.request(
"POST",
"/uploads/request",
token=token,
body={
"file_name": file_name,
"file_size": bundle.archive_bytes,
"category": "repository",
},
)
)
if not isinstance(requested, dict):
raise http.CloudError("the platform returned an invalid source upload response.")
fields = cast("dict[str, Any]", requested)
upload_id = fields.get("upload_id")
signed_url = fields.get("signed_url")
upload_token = fields.get("token")
if not all(isinstance(value, str) and value for value in (upload_id, signed_url, upload_token)):
error = http.CloudError("the platform did not return complete source upload credentials.")
if isinstance(upload_id, str) and upload_id:
try:
_delete_upload(upload_id, token=token)
except (http.CloudError, KeyboardInterrupt) as cleanup_error:
raise _source_cleanup_error(upload_id, error, cleanup_error) from error
raise error
try:
http.upload_file(cast("str", signed_url), cast("str", upload_token), bundle.archive_path)
completed = http.check(
http.request(
"POST",
"/uploads/complete",
token=token,
body={"upload_id": upload_id},
)
)
_validate_completed_upload(completed, expected_id=cast("str", upload_id))
except BaseException as error:
try:
_delete_upload(cast("str", upload_id), token=token)
except (http.CloudError, KeyboardInterrupt) as cleanup_error:
if isinstance(error, Exception):
raise _source_cleanup_error(cast("str", upload_id), error, cleanup_error) from error
interrupted = http.CloudError("source upload interrupted.", exit_code=130)
raise _source_cleanup_error(
cast("str", upload_id), interrupted, cleanup_error
) from None
raise
return cast("str", upload_id)
def _validate_completed_upload(completed: Any, *, expected_id: str) -> None:
fields = cast("dict[str, Any]", completed) if isinstance(completed, dict) else {}
if fields.get("id") != expected_id:
raise http.CloudError("the platform returned an invalid source upload completion response.")
def _delete_upload(upload_id: str, *, token: str | None) -> None:
response = http.request("DELETE", f"/uploads/{quote(upload_id, safe='')}", token=token)
if response.status_code == 404 or 200 <= response.status_code < 300:
return
http.check(response)
def _source_cleanup_note(upload_id: str, cleanup_error: BaseException) -> str:
return (
f"Cleanup of source upload {upload_id} could not be confirmed: {cleanup_error}. "
f"Retry with `strix cloud uploads delete {upload_id}`."
)
def _source_cleanup_error(
upload_id: str, error: Exception, cleanup_error: BaseException
) -> http.CloudError:
"""Report a staged source object whenever automatic deletion is uncertain."""
message = f"{error} {_source_cleanup_note(upload_id, cleanup_error)}"
payload: dict[str, Any] = {}
exit_code = http.EXIT_ERROR
if isinstance(error, http.CloudError):
exit_code = error.exit_code
raw_payload: Any = error.payload
if isinstance(raw_payload, dict):
payload.update(cast("dict[str, Any]", raw_payload))
elif raw_payload is not None:
payload["detail"] = raw_payload
payload.update(
{
"error": message,
"upload_id": upload_id,
"upload_retained": True,
"cleanup_unknown": True,
}
)
return http.CloudError(message, exit_code=exit_code, payload=payload)
def _interrupted_source_upload_error(
upload_id: str, idempotency_key: str | None = None
) -> http.CloudError:
retry_note = _idempotency_retry_note(idempotency_key)
message = (
"Interrupted while starting the scan. The launch outcome is unknown, so source upload "
f"{upload_id} was retained. Check `strix cloud scans list` before retrying; if no scan "
f"was created, run `strix cloud uploads delete {upload_id}`.{retry_note}"
)
payload: dict[str, Any] = {
"error": message,
"interrupted": True,
"upload_id": upload_id,
"upload_retained": True,
"launch_outcome_unknown": True,
}
_attach_idempotency_recovery(payload, idempotency_key)
return http.CloudError(message, exit_code=130, payload=payload)
def _retained_source_upload_error(
upload_id: str,
error: Exception,
idempotency_key: str | None = None,
) -> http.CloudError:
"""Preserve source when the platform may already have accepted its scan."""
retry_note = _idempotency_retry_note(idempotency_key)
message = (
f"{error} The scan launch outcome is unknown, so source upload {upload_id} was retained. "
"Check `strix cloud scans list` before retrying; if no scan was created, clean it up "
f"with `strix cloud uploads delete {upload_id}`. Linked uploads cannot be deleted."
f"{retry_note}"
)
payload: dict[str, Any] = {}
exit_code = http.EXIT_ERROR
if isinstance(error, http.CloudError):
exit_code = error.exit_code
raw_payload: Any = error.payload
error_payload = cast("dict[str, Any]", raw_payload)
if isinstance(raw_payload, dict):
payload.update(error_payload)
elif raw_payload is not None:
payload["detail"] = raw_payload
payload.update(
{
"error": message,
"upload_id": upload_id,
"upload_retained": True,
"launch_outcome_unknown": True,
}
)
_attach_idempotency_recovery(payload, idempotency_key)
return http.CloudError(message, exit_code=exit_code, payload=payload)
def _idempotency_retry_note(idempotency_key: str | None) -> str:
if not idempotency_key:
return ""
return (
" An exact retry is safe only with the same request body and "
f"`--idempotency-key {idempotency_key}`."
)
def _attach_idempotency_recovery(payload: dict[str, Any], idempotency_key: str | None) -> None:
if not idempotency_key:
return
payload.update(
{
"idempotency_key": idempotency_key,
"retry_safe": True,
"retry_same_request": True,
}
)
def _format_bytes(value: int) -> str:
if value < 1024:
return f"{value} B"
if value < 1024 * 1024:
return f"{value / 1024:.1f} KB"
return f"{value / (1024 * 1024):.1f} MB"