diff --git a/README.md b/README.md index f5ba6541..7a3500c9 100644 --- a/README.md +++ b/README.md @@ -364,7 +364,7 @@ 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, states, and numbered selectors, while redirected output (or `--json`) preserves complete machine-readable records and IDs. 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 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. Write commands take request fields as flags, and every write command also accepts one JSON object with `--data`: diff --git a/skills/managed-pentesting-with-strix/SKILL.md b/skills/managed-pentesting-with-strix/SKILL.md index 9a6e7d76..d5e54427 100644 --- a/skills/managed-pentesting-with-strix/SKILL.md +++ b/skills/managed-pentesting-with-strix/SKILL.md @@ -16,7 +16,7 @@ There are two equivalent interfaces. Prefer the CLI: - **`strix cloud` CLI** — every REST operation has a command in the form `strix cloud `. Install with `curl -sSL https://strix.ai/install | bash`. Run `strix cloud` to list all resources and `strix cloud ` to list its verbs. - **REST API** — base URL `https://app.strix.ai/api/v1`, `Authorization: Bearer ` on every request. Full reference: **[docs.app.strix.ai](https://docs.app.strix.ai)** · 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, states, and numbered selectors. 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 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. 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: diff --git a/strix/interface/cloud/render.py b/strix/interface/cloud/render.py index f96aee42..36fa0887 100644 --- a/strix/interface/cloud/render.py +++ b/strix/interface/cloud/render.py @@ -5,6 +5,7 @@ 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 @@ -31,8 +32,11 @@ _INTERNAL_COLUMNS = frozenset( "user_id", "userId", "installation_id", + "added_by", "created_by", "connected_by", + "invited_by", + "uploaded_by", "avatarUrl", } ) @@ -119,6 +123,7 @@ _LIST_ENVELOPE_KEYS = frozenset( "deliveries", "entries", "documents", + "docs", "policies", "tokens", "uploads", @@ -146,6 +151,7 @@ _ENVELOPE_METADATA_KEYS = frozenset( "summary", "stats", "scansThisMonth", + "organization_id", } ) @@ -190,6 +196,75 @@ _VIEW_COLUMNS: dict[str, tuple[str, ...]] = { "default_collection_name", "connected_at", ), + "GET /domains": ( + "domain", + "asset_type", + "verified", + "last_scan_at", + "context", + "tags", + "business_unit", + "id", + ), + "GET /repositories": ( + "full_name", + "provider", + "default_branch", + "pr_review_enabled", + "last_scan_at", + "tags", + "business_unit", + "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/policies": ( + "policy_key", + "policy_type", + "is_active", + "policy_value", + "updated_at", + "created_at", + "id", + ), + "GET /domains/{domainId}/test-users": ( + "label", + "username", + "mfa_method", + "mfa_email", + "has_password", + "login_url", + "updated_at", + "id", + ), + "GET /tokens": ( + "name", + "type", + "status", + "scopes", + "secret_prefix", + "expires_at", + "last_used_at", + "id", + ), "GET /webhooks": ("url", "events", "is_active", "last_delivery_at", "created_at", "id"), "GET /webhooks/{webhookId}/deliveries": ( "event", @@ -253,6 +328,18 @@ def emit( # noqa: PLR0911, PLR0912 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: @@ -364,6 +451,36 @@ def _integration_rows(data: Any) -> list[dict[str, Any]] | None: 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) + 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) @@ -470,6 +587,7 @@ def _print_table( columns = columns[:_MAX_TABLE_COLUMNS] if console.width < _NARROW_TABLE_WIDTH: _print_cards(console, rows, columns, row_numbers=row_numbers) + _print_long_identifiers(console, rows) console.print(f"[dim]{len(rows)} item(s). Use --json for the full records.[/]") if hint: console.print(f"[dim]{escape(sanitize_terminal_text(hint))}[/]") @@ -485,6 +603,7 @@ def _print_table( cells.insert(0, str(index)) table.add_row(*cells) console.print(table) + _print_long_identifiers(console, rows) console.print(f"[dim]{len(rows)} item(s). Use --json for the full records.[/]") if hint: console.print(f"[dim]{escape(sanitize_terminal_text(hint))}[/]") @@ -514,6 +633,32 @@ def _print_cards( console.print(continuation + part, soft_wrap=True) +def _print_long_identifiers(console: Console, rows: list[dict[str, Any]]) -> None: + """Print opaque IDs losslessly when the compact cell view shortens them.""" + identifiers = [ + (index, row, str(row["id"])) + for index, row in enumerate(rows, start=1) + if row.get("id") is not None and len(str(row["id"])) > _MAX_CELL_LENGTH + ] + if not identifiers: + return + console.print("[dim]Copyable IDs:[/]") + for index, row, identifier in identifiers: + 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)}: {sanitize_terminal_text(identifier)}", + 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 _PREFERRED_KEYS if key in data and key not in _INTERNAL_COLUMNS] diff --git a/tests/test_cloud_cli.py b/tests/test_cloud_cli.py index 3f74ff77..47f1a51e 100644 --- a/tests/test_cloud_cli.py +++ b/tests/test_cloud_cli.py @@ -1019,6 +1019,9 @@ 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 @@ -1299,17 +1302,13 @@ def test_corrected_help_distinguishes_inboxes_reports_and_self_hosted_commands() 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 - } + 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 - } + 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"] @@ -1923,6 +1922,190 @@ def test_pr_review_human_list_prioritizes_actionable_fields( assert value not in output +@pytest.mark.parametrize( + ("command", "payload", "visible", "hidden"), + [ + ( + ["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", + "default_branch": "main", + "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", "main", "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 IDs", + ), + ("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"], + "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"], + "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"], + "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", "Old CI", "revoked", "Expired CI", "expired"): + assert value in output + assert "org-secret" not in output + + def test_human_get_prioritizes_details_and_hides_internal_identity_fields( monkeypatch: pytest.MonkeyPatch, capsys: Any ) -> None: