arga-cli 0.1.6__tar.gz → 0.1.7__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {arga_cli-0.1.6 → arga_cli-0.1.7}/PKG-INFO +1 -23
- {arga_cli-0.1.6 → arga_cli-0.1.7}/README.md +0 -22
- {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli/main.py +310 -182
- {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli/wizard/provision.py +40 -12
- {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli/wizard/summary.py +19 -20
- {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli.egg-info/PKG-INFO +1 -23
- {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli.egg-info/SOURCES.txt +0 -1
- {arga_cli-0.1.6 → arga_cli-0.1.7}/pyproject.toml +1 -1
- arga_cli-0.1.7/tests/test_cli_test_url.py +107 -0
- arga_cli-0.1.6/tests/test_cli_scan.py +0 -113
- arga_cli-0.1.6/tests/test_cli_test_url.py +0 -50
- {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli/__init__.py +0 -0
- {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli/mcp.py +0 -0
- {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli/wizard/__init__.py +0 -0
- {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli/wizard/constants.py +0 -0
- {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli/wizard/env.py +0 -0
- {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli/wizard/output.py +0 -0
- {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli/wizard/prompts.py +0 -0
- {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli/wizard/session.py +0 -0
- {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli.egg-info/dependency_links.txt +0 -0
- {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli.egg-info/entry_points.txt +0 -0
- {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli.egg-info/requires.txt +0 -0
- {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli.egg-info/top_level.txt +0 -0
- {arga_cli-0.1.6 → arga_cli-0.1.7}/setup.cfg +0 -0
- {arga_cli-0.1.6 → arga_cli-0.1.7}/tests/test_cli_git.py +0 -0
- {arga_cli-0.1.6 → arga_cli-0.1.7}/tests/test_cli_mcp.py +0 -0
- {arga_cli-0.1.6 → arga_cli-0.1.7}/tests/test_cli_runs.py +0 -0
- {arga_cli-0.1.6 → arga_cli-0.1.7}/tests/test_cli_validate_config.py +0 -0
- {arga_cli-0.1.6 → arga_cli-0.1.7}/tests/test_cli_validate_pr.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: arga-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.7
|
|
4
4
|
Summary: Command-line interface for Arga authentication, MCP installation, and browser validation
|
|
5
5
|
Author: Arga Labs
|
|
6
6
|
Project-URL: Homepage, https://github.com/ArgaLabs/arga-cli
|
|
@@ -33,7 +33,6 @@ Requires-Dist: rich>=13.0.0
|
|
|
33
33
|
- Starts URL validation runs from the terminal.
|
|
34
34
|
- Starts pull request validation runs from the terminal.
|
|
35
35
|
- Wraps `git commit` and `git push` with Arga skip-validation helpers.
|
|
36
|
-
- Starts and inspects Arga app scans.
|
|
37
36
|
|
|
38
37
|
## Installation
|
|
39
38
|
|
|
@@ -112,14 +111,6 @@ arga validate config arga-labs/validation-server
|
|
|
112
111
|
arga validate config set arga-labs/validation-server --trigger branch --branch main --comments on
|
|
113
112
|
```
|
|
114
113
|
|
|
115
|
-
Start an app scan and inspect it later:
|
|
116
|
-
|
|
117
|
-
```bash
|
|
118
|
-
arga scan https://demo-app.com --budget 200
|
|
119
|
-
arga scan status <run_id>
|
|
120
|
-
arga scan report <run_id>
|
|
121
|
-
```
|
|
122
|
-
|
|
123
114
|
List and inspect recent validation runs:
|
|
124
115
|
|
|
125
116
|
```bash
|
|
@@ -173,19 +164,6 @@ arga test url \
|
|
|
173
164
|
|
|
174
165
|
Both `--email` and `--password` must be supplied together.
|
|
175
166
|
|
|
176
|
-
### App Scans
|
|
177
|
-
|
|
178
|
-
```bash
|
|
179
|
-
arga scan https://demo-app.com --budget 200
|
|
180
|
-
arga scan status <run_id>
|
|
181
|
-
arga scan report <run_id>
|
|
182
|
-
```
|
|
183
|
-
|
|
184
|
-
- `arga scan <url>` starts an app scan, waits for the generated scan plan to be ready, and auto-approves it so execution can begin.
|
|
185
|
-
- `--budget` controls the red-team action budget and defaults to `200`.
|
|
186
|
-
- `arga scan status <run_id>` prints the current run status and anomaly count.
|
|
187
|
-
- `arga scan report <run_id>` prints the final JSON report once the scan has completed.
|
|
188
|
-
|
|
189
167
|
### Validation Runs
|
|
190
168
|
|
|
191
169
|
```bash
|
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
- Starts URL validation runs from the terminal.
|
|
12
12
|
- Starts pull request validation runs from the terminal.
|
|
13
13
|
- Wraps `git commit` and `git push` with Arga skip-validation helpers.
|
|
14
|
-
- Starts and inspects Arga app scans.
|
|
15
14
|
|
|
16
15
|
## Installation
|
|
17
16
|
|
|
@@ -90,14 +89,6 @@ arga validate config arga-labs/validation-server
|
|
|
90
89
|
arga validate config set arga-labs/validation-server --trigger branch --branch main --comments on
|
|
91
90
|
```
|
|
92
91
|
|
|
93
|
-
Start an app scan and inspect it later:
|
|
94
|
-
|
|
95
|
-
```bash
|
|
96
|
-
arga scan https://demo-app.com --budget 200
|
|
97
|
-
arga scan status <run_id>
|
|
98
|
-
arga scan report <run_id>
|
|
99
|
-
```
|
|
100
|
-
|
|
101
92
|
List and inspect recent validation runs:
|
|
102
93
|
|
|
103
94
|
```bash
|
|
@@ -151,19 +142,6 @@ arga test url \
|
|
|
151
142
|
|
|
152
143
|
Both `--email` and `--password` must be supplied together.
|
|
153
144
|
|
|
154
|
-
### App Scans
|
|
155
|
-
|
|
156
|
-
```bash
|
|
157
|
-
arga scan https://demo-app.com --budget 200
|
|
158
|
-
arga scan status <run_id>
|
|
159
|
-
arga scan report <run_id>
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
- `arga scan <url>` starts an app scan, waits for the generated scan plan to be ready, and auto-approves it so execution can begin.
|
|
163
|
-
- `--budget` controls the red-team action budget and defaults to `200`.
|
|
164
|
-
- `arga scan status <run_id>` prints the current run status and anomaly count.
|
|
165
|
-
- `arga scan report <run_id>` prints the final JSON report once the scan has completed.
|
|
166
|
-
|
|
167
145
|
### Validation Runs
|
|
168
146
|
|
|
169
147
|
```bash
|
|
@@ -25,6 +25,7 @@ WIZARD_SESSION_FILE = ".arga-session.json"
|
|
|
25
25
|
WIZARD_SESSION_PATH = Path(WIZARD_SESSION_FILE)
|
|
26
26
|
POLL_INTERVAL_SECONDS = 2.0
|
|
27
27
|
POLL_TIMEOUT_SECONDS = 600.0
|
|
28
|
+
URL_VALIDATION_START_TIMEOUT_SECONDS = 60.0
|
|
28
29
|
SKIP_TRAILER = "[skip arga]"
|
|
29
30
|
|
|
30
31
|
|
|
@@ -82,15 +83,17 @@ class ApiClient:
|
|
|
82
83
|
self,
|
|
83
84
|
*,
|
|
84
85
|
url: str,
|
|
85
|
-
prompt: str,
|
|
86
|
+
prompt: str | None = None,
|
|
86
87
|
email: str | None = None,
|
|
87
88
|
password: str | None = None,
|
|
88
89
|
ttl_minutes: int | None = None,
|
|
90
|
+
scenario_id: str | None = None,
|
|
91
|
+
provision_id: str | None = None,
|
|
92
|
+
twins: list[str] | None = None,
|
|
89
93
|
) -> dict[str, str]:
|
|
90
|
-
payload: dict[str, object] = {
|
|
91
|
-
|
|
92
|
-
"prompt"
|
|
93
|
-
}
|
|
94
|
+
payload: dict[str, object] = {"url": url}
|
|
95
|
+
if prompt is not None:
|
|
96
|
+
payload["prompt"] = prompt
|
|
94
97
|
if email or password:
|
|
95
98
|
payload["credentials"] = {
|
|
96
99
|
"email": email or "",
|
|
@@ -98,13 +101,67 @@ class ApiClient:
|
|
|
98
101
|
}
|
|
99
102
|
if ttl_minutes is not None:
|
|
100
103
|
payload["ttl_minutes"] = ttl_minutes
|
|
104
|
+
if scenario_id:
|
|
105
|
+
payload["scenario_id"] = scenario_id
|
|
106
|
+
if provision_id:
|
|
107
|
+
payload["provision_id"] = provision_id
|
|
108
|
+
if twins:
|
|
109
|
+
payload["twins"] = twins
|
|
101
110
|
response = self._client.post(
|
|
102
111
|
f"{self._api_url}/validate/url-run",
|
|
103
112
|
json=payload,
|
|
104
113
|
headers=self._auth_headers(),
|
|
114
|
+
timeout=URL_VALIDATION_START_TIMEOUT_SECONDS,
|
|
105
115
|
)
|
|
106
116
|
return self._parse_json(response, "Failed to start URL validation")
|
|
107
117
|
|
|
118
|
+
def list_scenarios(self, *, include_presets: bool = False) -> list[dict[str, Any]]:
|
|
119
|
+
params: dict[str, str] = {}
|
|
120
|
+
if include_presets:
|
|
121
|
+
params["include_presets"] = "true"
|
|
122
|
+
response = self._client.get(
|
|
123
|
+
f"{self._api_url}/scenarios",
|
|
124
|
+
params=params,
|
|
125
|
+
headers=self._auth_headers(),
|
|
126
|
+
)
|
|
127
|
+
data = self._parse_json(response, "Failed to list scenarios")
|
|
128
|
+
if not isinstance(data, list):
|
|
129
|
+
raise CliError("Unexpected response from /scenarios")
|
|
130
|
+
return data
|
|
131
|
+
|
|
132
|
+
def create_scenario(
|
|
133
|
+
self,
|
|
134
|
+
*,
|
|
135
|
+
name: str,
|
|
136
|
+
prompt: str | None = None,
|
|
137
|
+
description: str | None = None,
|
|
138
|
+
twins: list[str] | None = None,
|
|
139
|
+
tags: list[str] | None = None,
|
|
140
|
+
) -> dict[str, Any]:
|
|
141
|
+
payload: dict[str, Any] = {"name": name}
|
|
142
|
+
if prompt is not None:
|
|
143
|
+
payload["prompt"] = prompt
|
|
144
|
+
if description is not None:
|
|
145
|
+
payload["description"] = description
|
|
146
|
+
if twins:
|
|
147
|
+
payload["twins"] = twins
|
|
148
|
+
if tags:
|
|
149
|
+
payload["tags"] = tags
|
|
150
|
+
response = self._client.post(
|
|
151
|
+
f"{self._api_url}/scenarios",
|
|
152
|
+
json=payload,
|
|
153
|
+
headers=self._auth_headers(),
|
|
154
|
+
timeout=URL_VALIDATION_START_TIMEOUT_SECONDS,
|
|
155
|
+
)
|
|
156
|
+
return self._parse_json(response, "Failed to create scenario")
|
|
157
|
+
|
|
158
|
+
def delete_scenario(self, scenario_id: str) -> dict[str, str]:
|
|
159
|
+
response = self._client.delete(
|
|
160
|
+
f"{self._api_url}/scenarios/{scenario_id}",
|
|
161
|
+
headers=self._auth_headers(),
|
|
162
|
+
)
|
|
163
|
+
return self._parse_json(response, "Failed to delete scenario")
|
|
164
|
+
|
|
108
165
|
def start_pr_validation(
|
|
109
166
|
self,
|
|
110
167
|
*,
|
|
@@ -122,22 +179,6 @@ class ApiClient:
|
|
|
122
179
|
)
|
|
123
180
|
return self._parse_json(response, "Failed to start PR validation")
|
|
124
181
|
|
|
125
|
-
def start_redteam_scan(self, *, url: str, action_budget: int, focus: str | None = None) -> dict[str, Any]:
|
|
126
|
-
response = self._client.post(
|
|
127
|
-
f"{self._api_url}/validate/agent-run",
|
|
128
|
-
json={"url": url, "action_budget": action_budget, "focus": focus},
|
|
129
|
-
headers=self._auth_headers(),
|
|
130
|
-
)
|
|
131
|
-
return self._parse_json(response, "Failed to start agent run")
|
|
132
|
-
|
|
133
|
-
def approve_redteam_scan(self, run_id: str) -> dict[str, Any]:
|
|
134
|
-
response = self._client.post(
|
|
135
|
-
f"{self._api_url}/redteam/{run_id}/approve",
|
|
136
|
-
json={},
|
|
137
|
-
headers=self._auth_headers(),
|
|
138
|
-
)
|
|
139
|
-
return self._parse_json(response, "Failed to approve agent run")
|
|
140
|
-
|
|
141
182
|
def get_run(self, run_id: str) -> dict[str, Any]:
|
|
142
183
|
response = self._client.get(
|
|
143
184
|
f"{self._api_url}/runs/{run_id}",
|
|
@@ -152,13 +193,6 @@ class ApiClient:
|
|
|
152
193
|
)
|
|
153
194
|
return self._parse_json(response, "Failed to load run logs")
|
|
154
195
|
|
|
155
|
-
def get_redteam_report(self, run_id: str) -> dict[str, Any]:
|
|
156
|
-
response = self._client.get(
|
|
157
|
-
f"{self._api_url}/redteam/{run_id}/report",
|
|
158
|
-
headers=self._auth_headers(),
|
|
159
|
-
)
|
|
160
|
-
return self._parse_json(response, "Failed to load agent run report")
|
|
161
|
-
|
|
162
196
|
def list_pr_validation_runs(
|
|
163
197
|
self,
|
|
164
198
|
*,
|
|
@@ -443,20 +477,87 @@ def _resolve_ttl(client: ApiClient, requested_ttl: int | None) -> int | None:
|
|
|
443
477
|
return FREE_TTL
|
|
444
478
|
|
|
445
479
|
|
|
480
|
+
def _print_twin_env_vars(status: dict) -> None:
|
|
481
|
+
"""Print twin URLs and env vars so the user can configure their app."""
|
|
482
|
+
from arga_cli.wizard.provision import with_proxy_token
|
|
483
|
+
|
|
484
|
+
proxy_token = status.get("proxy_token")
|
|
485
|
+
print("\nTwin environment variables — update your app's config to point at these:\n")
|
|
486
|
+
for name, info in status.get("twins", {}).items():
|
|
487
|
+
label = info.get("label", name)
|
|
488
|
+
base_url = info.get("base_url", "")
|
|
489
|
+
if proxy_token and base_url:
|
|
490
|
+
base_url = with_proxy_token(base_url, proxy_token)
|
|
491
|
+
print(f" {label}:")
|
|
492
|
+
print(f" Base URL: {base_url}")
|
|
493
|
+
env_vars = info.get("env_vars", {})
|
|
494
|
+
for key, val in env_vars.items():
|
|
495
|
+
print(f" {key}={val}")
|
|
496
|
+
print()
|
|
497
|
+
|
|
498
|
+
|
|
446
499
|
def run_test_url(args: argparse.Namespace) -> int:
|
|
447
500
|
if bool(args.email) != bool(args.password):
|
|
448
501
|
raise CliError("Both --email and --password must be provided together.")
|
|
449
502
|
|
|
503
|
+
scenario_id = getattr(args, "scenario", None)
|
|
504
|
+
if not args.prompt and not scenario_id:
|
|
505
|
+
raise CliError("Either --prompt or --scenario must be provided.")
|
|
506
|
+
|
|
507
|
+
twins_arg: list[str] | None = None
|
|
508
|
+
raw_twins = getattr(args, "twins", None)
|
|
509
|
+
if raw_twins:
|
|
510
|
+
twins_arg = [t.strip() for t in raw_twins.split(",") if t.strip()]
|
|
511
|
+
|
|
512
|
+
if not args.url and not twins_arg:
|
|
513
|
+
raise CliError("--url is required (or use --twins to provision twins first).")
|
|
514
|
+
|
|
515
|
+
url: str = args.url or ""
|
|
516
|
+
|
|
450
517
|
api_key = load_api_key()
|
|
451
518
|
client = ApiClient(args.api_url, api_key=api_key)
|
|
452
519
|
try:
|
|
453
520
|
ttl_minutes = _resolve_ttl(client, getattr(args, "ttl", None))
|
|
521
|
+
|
|
522
|
+
provision_id: str | None = None
|
|
523
|
+
if twins_arg:
|
|
524
|
+
from arga_cli.wizard.provision import provision_twins
|
|
525
|
+
|
|
526
|
+
status = provision_twins(client, twins_arg, ttl_minutes=ttl_minutes or 30)
|
|
527
|
+
provision_id = status.get("run_id")
|
|
528
|
+
_print_twin_env_vars(status)
|
|
529
|
+
|
|
530
|
+
if not url:
|
|
531
|
+
print("Deploy your app with the environment variables above.")
|
|
532
|
+
print("Press Ctrl+C to cancel.\n")
|
|
533
|
+
try:
|
|
534
|
+
url = input("Enter your staging URL: ").strip()
|
|
535
|
+
except KeyboardInterrupt:
|
|
536
|
+
print("\nCancelled.")
|
|
537
|
+
return 1
|
|
538
|
+
if not url:
|
|
539
|
+
raise CliError("A URL is required to start the validation run.")
|
|
540
|
+
print()
|
|
541
|
+
else:
|
|
542
|
+
print(
|
|
543
|
+
"Deploy your app with the environment variables above, then press Enter to start the validation run."
|
|
544
|
+
)
|
|
545
|
+
print("Press Ctrl+C to cancel.\n")
|
|
546
|
+
try:
|
|
547
|
+
input()
|
|
548
|
+
except KeyboardInterrupt:
|
|
549
|
+
print("\nCancelled.")
|
|
550
|
+
return 1
|
|
551
|
+
|
|
454
552
|
payload = client.start_url_validation(
|
|
455
|
-
url=
|
|
553
|
+
url=url,
|
|
456
554
|
prompt=args.prompt,
|
|
457
555
|
email=args.email,
|
|
458
556
|
password=args.password,
|
|
459
557
|
ttl_minutes=ttl_minutes,
|
|
558
|
+
scenario_id=scenario_id,
|
|
559
|
+
provision_id=provision_id,
|
|
560
|
+
twins=twins_arg if not provision_id else None,
|
|
460
561
|
)
|
|
461
562
|
finally:
|
|
462
563
|
client.close()
|
|
@@ -466,8 +567,11 @@ def run_test_url(args: argparse.Namespace) -> int:
|
|
|
466
567
|
return 0
|
|
467
568
|
|
|
468
569
|
print("Starting validation...\n")
|
|
469
|
-
print(f"URL: {
|
|
470
|
-
|
|
570
|
+
print(f"URL: {url}")
|
|
571
|
+
if args.prompt:
|
|
572
|
+
print(f"Prompt: {args.prompt}")
|
|
573
|
+
if scenario_id:
|
|
574
|
+
print(f"Scenario: {scenario_id}")
|
|
471
575
|
print(f"TTL: {ttl_minutes} minutes\n")
|
|
472
576
|
print(f"Run ID: {payload.get('run_id', 'unknown')}")
|
|
473
577
|
print(f"Status: {payload.get('status', 'unknown')}")
|
|
@@ -497,6 +601,77 @@ def run_validate_pr(args: argparse.Namespace) -> int:
|
|
|
497
601
|
return 0
|
|
498
602
|
|
|
499
603
|
|
|
604
|
+
def run_scenarios_list(args: argparse.Namespace) -> int:
|
|
605
|
+
api_key = load_api_key()
|
|
606
|
+
client = ApiClient(args.api_url, api_key=api_key)
|
|
607
|
+
try:
|
|
608
|
+
scenarios = client.list_scenarios(include_presets=getattr(args, "include_presets", False))
|
|
609
|
+
finally:
|
|
610
|
+
client.close()
|
|
611
|
+
|
|
612
|
+
if getattr(args, "json", False):
|
|
613
|
+
print(json.dumps(scenarios, indent=2))
|
|
614
|
+
return 0
|
|
615
|
+
|
|
616
|
+
if not scenarios:
|
|
617
|
+
print("No scenarios found. Create one with `arga scenarios create`.")
|
|
618
|
+
return 0
|
|
619
|
+
|
|
620
|
+
for s in scenarios:
|
|
621
|
+
marker = " (preset)" if s.get("is_preset") else ""
|
|
622
|
+
twins = ", ".join(s.get("twins") or []) or "-"
|
|
623
|
+
tags = ", ".join(s.get("tags") or []) or "-"
|
|
624
|
+
print(f"{s.get('id')} {s.get('name')}{marker}")
|
|
625
|
+
if s.get("description"):
|
|
626
|
+
print(f" description: {s['description']}")
|
|
627
|
+
print(f" twins: {twins}")
|
|
628
|
+
print(f" tags: {tags}")
|
|
629
|
+
print()
|
|
630
|
+
return 0
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def run_scenarios_create(args: argparse.Namespace) -> int:
|
|
634
|
+
api_key = load_api_key()
|
|
635
|
+
client = ApiClient(args.api_url, api_key=api_key)
|
|
636
|
+
try:
|
|
637
|
+
scenario = client.create_scenario(
|
|
638
|
+
name=args.name,
|
|
639
|
+
prompt=args.prompt,
|
|
640
|
+
description=getattr(args, "description", None),
|
|
641
|
+
twins=getattr(args, "twin", None),
|
|
642
|
+
tags=getattr(args, "tag", None),
|
|
643
|
+
)
|
|
644
|
+
finally:
|
|
645
|
+
client.close()
|
|
646
|
+
|
|
647
|
+
if getattr(args, "json", False):
|
|
648
|
+
print(json.dumps(scenario, indent=2))
|
|
649
|
+
return 0
|
|
650
|
+
|
|
651
|
+
print("Scenario created.")
|
|
652
|
+
print(f" id: {scenario.get('id')}")
|
|
653
|
+
print(f" name: {scenario.get('name')}")
|
|
654
|
+
if scenario.get("twins"):
|
|
655
|
+
print(f" twins: {', '.join(scenario['twins'])}")
|
|
656
|
+
return 0
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def run_scenarios_delete(args: argparse.Namespace) -> int:
|
|
660
|
+
api_key = load_api_key()
|
|
661
|
+
client = ApiClient(args.api_url, api_key=api_key)
|
|
662
|
+
try:
|
|
663
|
+
result = client.delete_scenario(args.scenario_id)
|
|
664
|
+
finally:
|
|
665
|
+
client.close()
|
|
666
|
+
|
|
667
|
+
if getattr(args, "json", False):
|
|
668
|
+
print(json.dumps(result))
|
|
669
|
+
return 0
|
|
670
|
+
|
|
671
|
+
print(f"Deleted scenario {args.scenario_id}.")
|
|
672
|
+
return 0
|
|
673
|
+
|
|
674
|
+
|
|
500
675
|
def _validate_help_text() -> str:
|
|
501
676
|
return (
|
|
502
677
|
"usage: arga validate pr --repo <owner/repo> --pr <number>\n"
|
|
@@ -642,150 +817,6 @@ def run_mcp_install(args: argparse.Namespace) -> int:
|
|
|
642
817
|
return 1 if failures else 0
|
|
643
818
|
|
|
644
819
|
|
|
645
|
-
def _scan_help_text() -> str:
|
|
646
|
-
return (
|
|
647
|
-
"usage: arga scan <url> [--budget 200]\n"
|
|
648
|
-
" arga scan status <run_id>\n"
|
|
649
|
-
" arga scan report <run_id>\n\n"
|
|
650
|
-
"Start or inspect Arga agent runs.\n\n"
|
|
651
|
-
"Note: scan requires a Team or Paid plan. Check your plan with `arga whoami`."
|
|
652
|
-
)
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
def _build_scan_start_parser() -> argparse.ArgumentParser:
|
|
656
|
-
parser = argparse.ArgumentParser(
|
|
657
|
-
prog="arga scan",
|
|
658
|
-
description="Start an Arga agent run. Requires a Team or Paid plan.",
|
|
659
|
-
allow_abbrev=False,
|
|
660
|
-
)
|
|
661
|
-
parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
|
|
662
|
-
parser.add_argument("url", help="Public application URL to explore")
|
|
663
|
-
parser.add_argument("--budget", type=int, default=200, help="Total action budget for the scan")
|
|
664
|
-
return parser
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
def _build_scan_status_parser() -> argparse.ArgumentParser:
|
|
668
|
-
parser = argparse.ArgumentParser(
|
|
669
|
-
prog="arga scan status",
|
|
670
|
-
description="Check the status of an Arga agent run.",
|
|
671
|
-
allow_abbrev=False,
|
|
672
|
-
)
|
|
673
|
-
parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
|
|
674
|
-
parser.add_argument("run_id", help="App scan run ID")
|
|
675
|
-
return parser
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
def _build_scan_report_parser() -> argparse.ArgumentParser:
|
|
679
|
-
parser = argparse.ArgumentParser(
|
|
680
|
-
prog="arga scan report",
|
|
681
|
-
description="View the final report for an Arga agent run.",
|
|
682
|
-
allow_abbrev=False,
|
|
683
|
-
)
|
|
684
|
-
parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
|
|
685
|
-
parser.add_argument("run_id", help="App scan run ID")
|
|
686
|
-
return parser
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
def _status_from_run(run: dict[str, Any]) -> str:
|
|
690
|
-
return str(run.get("status") or "unknown")
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
def _wait_for_scan_approval(client: ApiClient, run_id: str) -> dict[str, Any]:
|
|
694
|
-
deadline = time.monotonic() + POLL_TIMEOUT_SECONDS
|
|
695
|
-
last_run: dict[str, Any] = {"id": run_id, "status": "planning"}
|
|
696
|
-
while time.monotonic() < deadline:
|
|
697
|
-
run = client.get_run(run_id)
|
|
698
|
-
last_run = run
|
|
699
|
-
status = _status_from_run(run)
|
|
700
|
-
if status in {"queued", "running", "completed", "failed", "cancelled"}:
|
|
701
|
-
return run
|
|
702
|
-
|
|
703
|
-
if status in {"planning", "awaiting_approval"}:
|
|
704
|
-
try:
|
|
705
|
-
approval = client.approve_redteam_scan(run_id)
|
|
706
|
-
run["status"] = approval.get("status", run.get("status"))
|
|
707
|
-
return run
|
|
708
|
-
except CliError as exc:
|
|
709
|
-
if str(exc) != "Scan plan is not ready yet":
|
|
710
|
-
raise
|
|
711
|
-
|
|
712
|
-
time.sleep(POLL_INTERVAL_SECONDS)
|
|
713
|
-
|
|
714
|
-
raise CliError(f"Timed out waiting for the scan plan to be ready for run {last_run.get('id', run_id)}.")
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
def _print_scan_summary(run_id: str, run: dict[str, Any]) -> None:
|
|
718
|
-
report = run.get("redteam_report_json")
|
|
719
|
-
anomaly_count = len(report.get("anomalies") or []) if isinstance(report, dict) else 0
|
|
720
|
-
print(f"Run ID: {run_id}")
|
|
721
|
-
print(f"Status: {_status_from_run(run)}")
|
|
722
|
-
print(f"URL: {run.get('frontend_url') or run.get('pr_url') or 'unknown'}")
|
|
723
|
-
print(f"Mode: {run.get('mode') or 'unknown'}")
|
|
724
|
-
print(f"Anomalies: {anomaly_count}")
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
def run_scan_start(args: argparse.Namespace) -> int:
|
|
728
|
-
if args.budget <= 0:
|
|
729
|
-
raise CliError("Budget must be a positive integer.")
|
|
730
|
-
|
|
731
|
-
api_key = load_api_key()
|
|
732
|
-
client = ApiClient(args.api_url, api_key=api_key)
|
|
733
|
-
try:
|
|
734
|
-
payload = client.start_redteam_scan(url=args.url, action_budget=args.budget)
|
|
735
|
-
run_id = str(payload.get("run_id") or "")
|
|
736
|
-
if not run_id:
|
|
737
|
-
raise CliError("Agent run started but no run ID was returned.")
|
|
738
|
-
run = _wait_for_scan_approval(client, run_id)
|
|
739
|
-
finally:
|
|
740
|
-
client.close()
|
|
741
|
-
|
|
742
|
-
print("Starting agent run...\n")
|
|
743
|
-
print(f"URL: {args.url}")
|
|
744
|
-
print(f"Budget: {args.budget}")
|
|
745
|
-
print(f"Run ID: {run_id}")
|
|
746
|
-
print(f"Status: {_status_from_run(run)}")
|
|
747
|
-
return 0
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
def run_scan_status(args: argparse.Namespace) -> int:
|
|
751
|
-
api_key = load_api_key()
|
|
752
|
-
client = ApiClient(args.api_url, api_key=api_key)
|
|
753
|
-
try:
|
|
754
|
-
run = client.get_run(args.run_id)
|
|
755
|
-
finally:
|
|
756
|
-
client.close()
|
|
757
|
-
|
|
758
|
-
_print_scan_summary(args.run_id, run)
|
|
759
|
-
return 0
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
def run_scan_report(args: argparse.Namespace) -> int:
|
|
763
|
-
api_key = load_api_key()
|
|
764
|
-
client = ApiClient(args.api_url, api_key=api_key)
|
|
765
|
-
try:
|
|
766
|
-
report = client.get_redteam_report(args.run_id)
|
|
767
|
-
finally:
|
|
768
|
-
client.close()
|
|
769
|
-
|
|
770
|
-
if not report:
|
|
771
|
-
raise CliError("Scan report is not ready yet.")
|
|
772
|
-
|
|
773
|
-
print(json.dumps(report, indent=2))
|
|
774
|
-
return 0
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
def run_scan_cli(argv: list[str]) -> int:
|
|
778
|
-
if not argv or argv[0] in {"-h", "--help"}:
|
|
779
|
-
print(_scan_help_text())
|
|
780
|
-
return 0
|
|
781
|
-
|
|
782
|
-
if argv[0] == "status":
|
|
783
|
-
return run_scan_status(_build_scan_status_parser().parse_args(argv[1:]))
|
|
784
|
-
if argv[0] == "report":
|
|
785
|
-
return run_scan_report(_build_scan_report_parser().parse_args(argv[1:]))
|
|
786
|
-
return run_scan_start(_build_scan_start_parser().parse_args(argv))
|
|
787
|
-
|
|
788
|
-
|
|
789
820
|
def _format_timestamp(value: str | None) -> str:
|
|
790
821
|
if not value:
|
|
791
822
|
return "-"
|
|
@@ -904,6 +935,40 @@ def _print_worker_logs(worker_logs: list[dict[str, Any]]) -> None:
|
|
|
904
935
|
print()
|
|
905
936
|
|
|
906
937
|
|
|
938
|
+
def _print_timeline_events(timeline_events: list[dict[str, Any]], *, limit: int = 50) -> None:
|
|
939
|
+
print("Timeline:")
|
|
940
|
+
if not timeline_events:
|
|
941
|
+
print("None")
|
|
942
|
+
return
|
|
943
|
+
|
|
944
|
+
total = len(timeline_events)
|
|
945
|
+
events = timeline_events[-limit:] if total > limit else timeline_events
|
|
946
|
+
if total > limit:
|
|
947
|
+
print(f"(showing last {len(events)} of {total} events)")
|
|
948
|
+
|
|
949
|
+
for index, event in enumerate(events):
|
|
950
|
+
if not isinstance(event, dict):
|
|
951
|
+
continue
|
|
952
|
+
timestamp = _format_timestamp(event.get("timestamp") or event.get("created_at"))
|
|
953
|
+
event_type = str(event.get("type") or event.get("event_type") or "").strip()
|
|
954
|
+
header_parts = [part for part in (timestamp, event_type) if part and part != "-"]
|
|
955
|
+
header = " | ".join(header_parts) if header_parts else "event"
|
|
956
|
+
print(header)
|
|
957
|
+
|
|
958
|
+
message = str(event.get("message") or "").strip()
|
|
959
|
+
if message:
|
|
960
|
+
print(message)
|
|
961
|
+
|
|
962
|
+
details = event.get("details")
|
|
963
|
+
if isinstance(details, dict) and details:
|
|
964
|
+
error_message = str(details.get("error_message") or details.get("error") or "").strip()
|
|
965
|
+
if error_message and error_message != message:
|
|
966
|
+
print(f"Error: {error_message}")
|
|
967
|
+
|
|
968
|
+
if index < len(events) - 1:
|
|
969
|
+
print()
|
|
970
|
+
|
|
971
|
+
|
|
907
972
|
def _print_runtime_logs(runtime_logs: list[dict[str, Any]]) -> None:
|
|
908
973
|
print("Runtime Logs:")
|
|
909
974
|
if not runtime_logs:
|
|
@@ -996,10 +1061,18 @@ def _print_run_logs(payload: dict[str, Any], fallback_run_id: str) -> None:
|
|
|
996
1061
|
if isinstance(timeline_events, list):
|
|
997
1062
|
print(f"Timeline Events: {len(timeline_events)}")
|
|
998
1063
|
|
|
1064
|
+
worker_logs_list = worker_logs if isinstance(worker_logs, list) else []
|
|
1065
|
+
runtime_logs_list = runtime_logs if isinstance(runtime_logs, list) else []
|
|
1066
|
+
timeline_events_list = timeline_events if isinstance(timeline_events, list) else []
|
|
1067
|
+
|
|
999
1068
|
print()
|
|
1000
|
-
_print_worker_logs(
|
|
1069
|
+
_print_worker_logs(worker_logs_list)
|
|
1001
1070
|
print()
|
|
1002
|
-
_print_runtime_logs(
|
|
1071
|
+
_print_runtime_logs(runtime_logs_list)
|
|
1072
|
+
|
|
1073
|
+
if not worker_logs_list and not runtime_logs_list and timeline_events_list:
|
|
1074
|
+
print()
|
|
1075
|
+
_print_timeline_events(timeline_events_list)
|
|
1003
1076
|
|
|
1004
1077
|
if isinstance(warnings, list) and warnings:
|
|
1005
1078
|
print()
|
|
@@ -1493,8 +1566,25 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1493
1566
|
|
|
1494
1567
|
test_url_parser = test_subparsers.add_parser("url", help="Run a browser validation against a deployed URL")
|
|
1495
1568
|
test_url_parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
|
|
1496
|
-
test_url_parser.add_argument(
|
|
1497
|
-
|
|
1569
|
+
test_url_parser.add_argument(
|
|
1570
|
+
"--url", default=None, help="Deployed application URL (prompted after twin provisioning when --twins is used)"
|
|
1571
|
+
)
|
|
1572
|
+
test_url_parser.add_argument(
|
|
1573
|
+
"--prompt",
|
|
1574
|
+
default=None,
|
|
1575
|
+
help="Natural language instructions for the agent (optional when --scenario is provided)",
|
|
1576
|
+
)
|
|
1577
|
+
test_url_parser.add_argument(
|
|
1578
|
+
"--scenario",
|
|
1579
|
+
default=None,
|
|
1580
|
+
help="Scenario ID to seed twins from (obtain via `arga scenarios list` or the web app)",
|
|
1581
|
+
)
|
|
1582
|
+
test_url_parser.add_argument(
|
|
1583
|
+
"--twins",
|
|
1584
|
+
default=None,
|
|
1585
|
+
help="Comma-separated list of digital twins to provision before the run (e.g. slack,stripe). "
|
|
1586
|
+
"Twins are provisioned first, then you deploy your app against them before the validation starts.",
|
|
1587
|
+
)
|
|
1498
1588
|
test_url_parser.add_argument("--email", help="Optional login email")
|
|
1499
1589
|
test_url_parser.add_argument("--password", help="Optional login password")
|
|
1500
1590
|
test_url_parser.add_argument(
|
|
@@ -1506,6 +1596,47 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1506
1596
|
test_url_parser.add_argument("--json", action="store_true", default=False, help="Output result as JSON")
|
|
1507
1597
|
test_url_parser.set_defaults(func=run_test_url)
|
|
1508
1598
|
|
|
1599
|
+
scenarios_parser = subparsers.add_parser("scenarios", help="Manage twin seed scenarios")
|
|
1600
|
+
scenarios_subparsers = scenarios_parser.add_subparsers(dest="scenarios_command", required=True)
|
|
1601
|
+
|
|
1602
|
+
scenarios_list_parser = scenarios_subparsers.add_parser("list", help="List your scenarios")
|
|
1603
|
+
scenarios_list_parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
|
|
1604
|
+
scenarios_list_parser.add_argument(
|
|
1605
|
+
"--include-presets",
|
|
1606
|
+
action="store_true",
|
|
1607
|
+
default=False,
|
|
1608
|
+
help="Also include built-in preset scenarios",
|
|
1609
|
+
)
|
|
1610
|
+
scenarios_list_parser.add_argument("--json", action="store_true", default=False, help="Output as JSON")
|
|
1611
|
+
scenarios_list_parser.set_defaults(func=run_scenarios_list)
|
|
1612
|
+
|
|
1613
|
+
scenarios_create_parser = scenarios_subparsers.add_parser(
|
|
1614
|
+
"create", help="Create a scenario from a natural-language prompt"
|
|
1615
|
+
)
|
|
1616
|
+
scenarios_create_parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
|
|
1617
|
+
scenarios_create_parser.add_argument("--name", required=True, help="Scenario name")
|
|
1618
|
+
scenarios_create_parser.add_argument(
|
|
1619
|
+
"--prompt",
|
|
1620
|
+
required=True,
|
|
1621
|
+
help="Natural-language description of the desired twin state (LLM generates seed_config)",
|
|
1622
|
+
)
|
|
1623
|
+
scenarios_create_parser.add_argument("--description", default=None, help="Optional description")
|
|
1624
|
+
scenarios_create_parser.add_argument(
|
|
1625
|
+
"--twin",
|
|
1626
|
+
action="append",
|
|
1627
|
+
default=None,
|
|
1628
|
+
help="Restrict to specific twin(s) (repeatable). Inferred from prompt if omitted.",
|
|
1629
|
+
)
|
|
1630
|
+
scenarios_create_parser.add_argument("--tag", action="append", default=None, help="Tag the scenario (repeatable)")
|
|
1631
|
+
scenarios_create_parser.add_argument("--json", action="store_true", default=False, help="Output as JSON")
|
|
1632
|
+
scenarios_create_parser.set_defaults(func=run_scenarios_create)
|
|
1633
|
+
|
|
1634
|
+
scenarios_delete_parser = scenarios_subparsers.add_parser("delete", help="Delete a scenario")
|
|
1635
|
+
scenarios_delete_parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
|
|
1636
|
+
scenarios_delete_parser.add_argument("scenario_id", help="Scenario ID to delete")
|
|
1637
|
+
scenarios_delete_parser.add_argument("--json", action="store_true", default=False, help="Output as JSON")
|
|
1638
|
+
scenarios_delete_parser.set_defaults(func=run_scenarios_delete)
|
|
1639
|
+
|
|
1509
1640
|
validate_parser = subparsers.add_parser("validate", help="Start PR or URL validation runs")
|
|
1510
1641
|
validate_subparsers = validate_parser.add_subparsers(dest="validate_command", required=True)
|
|
1511
1642
|
|
|
@@ -1574,7 +1705,6 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1574
1705
|
|
|
1575
1706
|
subparsers.add_parser("commit", help="Wrap git commit and optionally mark it to skip Arga validation")
|
|
1576
1707
|
subparsers.add_parser("push", help="Wrap git push and verify skip state when requested")
|
|
1577
|
-
subparsers.add_parser("scan", help="Start an agent run or inspect a scan run")
|
|
1578
1708
|
return parser
|
|
1579
1709
|
|
|
1580
1710
|
|
|
@@ -1586,8 +1716,6 @@ def main() -> None:
|
|
|
1586
1716
|
exit_code = run_push_cli(sys.argv[2:])
|
|
1587
1717
|
elif len(sys.argv) > 1 and sys.argv[1] == "validate":
|
|
1588
1718
|
exit_code = run_validate_cli(sys.argv[2:])
|
|
1589
|
-
elif len(sys.argv) > 1 and sys.argv[1] == "scan":
|
|
1590
|
-
exit_code = run_scan_cli(sys.argv[2:])
|
|
1591
1719
|
elif len(sys.argv) > 1 and sys.argv[1] == "wizard":
|
|
1592
1720
|
exit_code = run_wizard_cli(sys.argv[2:])
|
|
1593
1721
|
else:
|
|
@@ -47,6 +47,7 @@ def provision_twins(
|
|
|
47
47
|
)
|
|
48
48
|
data = client._parse_json(response, "Failed to provision twins")
|
|
49
49
|
run_id = data["run_id"]
|
|
50
|
+
dim(f" Run ID: {run_id}")
|
|
50
51
|
|
|
51
52
|
# Poll until ready
|
|
52
53
|
ready_set: set[str] = set()
|
|
@@ -106,6 +107,23 @@ def provision_twins(
|
|
|
106
107
|
raise RuntimeError(f"Timed out waiting for twins to provision for run {run_id}.")
|
|
107
108
|
|
|
108
109
|
|
|
110
|
+
def _format_seed_summary(seed_info: dict) -> list[str]:
|
|
111
|
+
"""Render a server-reported seed result as a list of display lines."""
|
|
112
|
+
status_value = seed_info.get("status")
|
|
113
|
+
if status_value == "seeded":
|
|
114
|
+
counts = [
|
|
115
|
+
f"{key.replace('_', ' ')}: {value}"
|
|
116
|
+
for key, value in seed_info.items()
|
|
117
|
+
if key not in {"status", "twin"} and isinstance(value, (int, str))
|
|
118
|
+
]
|
|
119
|
+
return [f"Seeded — {', '.join(counts)}"] if counts else ["Seeded."]
|
|
120
|
+
if status_value == "skipped":
|
|
121
|
+
return [f"Skipped ({seed_info.get('reason', 'unknown')})"]
|
|
122
|
+
if status_value == "error":
|
|
123
|
+
return [f"Seed error: {seed_info.get('error', 'unknown')}"]
|
|
124
|
+
return ["Default configuration loaded."]
|
|
125
|
+
|
|
126
|
+
|
|
109
127
|
def seed_and_report(client: Any, status: dict) -> None:
|
|
110
128
|
"""Seed quickstart data for UI twins, print default config for backend-only twins."""
|
|
111
129
|
header("Quickstart setup...")
|
|
@@ -120,23 +138,29 @@ def seed_and_report(client: Any, status: dict) -> None:
|
|
|
120
138
|
backend_twins.append(name)
|
|
121
139
|
|
|
122
140
|
proxy_token = status.get("proxy_token")
|
|
141
|
+
seed_results = status.get("seed_results") or {}
|
|
123
142
|
|
|
124
143
|
# Seed and report UI twins
|
|
125
144
|
for name in ui_twins:
|
|
126
145
|
info = status["twins"][name]
|
|
127
146
|
label = TWIN_CATALOG.get(name, {}).get("label", name)
|
|
128
147
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
148
|
+
seed_info = seed_results.get(name)
|
|
149
|
+
if seed_info is None:
|
|
150
|
+
# No server-side seeding happened — fall back to the legacy client-side reset.
|
|
151
|
+
try:
|
|
152
|
+
admin_url = info.get("admin_url", "")
|
|
153
|
+
reset_url = with_proxy_token(f"{admin_url}/admin/reset", proxy_token)
|
|
154
|
+
client._client.post(reset_url, json={}, headers={"Content-Type": "application/json"})
|
|
155
|
+
except Exception:
|
|
156
|
+
pass
|
|
136
157
|
|
|
137
158
|
console.print(f" [bold cyan]{label}[/bold cyan]:")
|
|
138
|
-
|
|
139
|
-
|
|
159
|
+
if seed_info is not None:
|
|
160
|
+
summary_lines = _format_seed_summary(seed_info)
|
|
161
|
+
else:
|
|
162
|
+
summary_lines = QUICKSTART_SUMMARIES.get(name, ["Default configuration loaded."])
|
|
163
|
+
for line in summary_lines:
|
|
140
164
|
console.print(f" {line}")
|
|
141
165
|
|
|
142
166
|
# Print env vars
|
|
@@ -151,12 +175,16 @@ def seed_and_report(client: Any, status: dict) -> None:
|
|
|
151
175
|
label = TWIN_CATALOG.get(name, {}).get("label", name)
|
|
152
176
|
|
|
153
177
|
console.print(f" [bold cyan]{label}[/bold cyan] [dim](backend-only)[/dim]:")
|
|
154
|
-
base_url =
|
|
155
|
-
console.print(f" Base URL: [underline]{base_url}[/underline]")
|
|
178
|
+
base_url = info.get("base_url", "")
|
|
179
|
+
console.print(f" Base URL: [underline]{base_url}[/underline]", soft_wrap=True, overflow="ignore")
|
|
180
|
+
seed_info = seed_results.get(name)
|
|
181
|
+
if seed_info is not None:
|
|
182
|
+
for line in _format_seed_summary(seed_info):
|
|
183
|
+
console.print(f" {line}")
|
|
156
184
|
env_vars = info.get("env_vars", {})
|
|
157
185
|
if env_vars:
|
|
158
186
|
for key, val in env_vars.items():
|
|
159
187
|
console.print(f" [dim]{key}[/dim]: {val}")
|
|
160
|
-
|
|
188
|
+
elif seed_info is None:
|
|
161
189
|
dim(" No UI dashboard. Use API calls directly.")
|
|
162
190
|
console.print()
|
|
@@ -5,8 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
from datetime import datetime
|
|
6
6
|
|
|
7
7
|
from arga_cli.wizard.constants import DASHBOARD_BASE_URL, SESSION_FILE, TWIN_CATALOG
|
|
8
|
-
from arga_cli.wizard.output import dim, print_summary_box
|
|
9
|
-
from arga_cli.wizard.provision import with_proxy_token
|
|
8
|
+
from arga_cli.wizard.output import console, dim, print_summary_box
|
|
10
9
|
from arga_cli.wizard.session import save_session
|
|
11
10
|
|
|
12
11
|
|
|
@@ -18,31 +17,31 @@ def print_summary(cwd: str, status: dict, api_url: str, api_key: str) -> None:
|
|
|
18
17
|
lines = [
|
|
19
18
|
"[bold green]Arga Twins \u2014 Ready![/bold green]",
|
|
20
19
|
"",
|
|
21
|
-
|
|
22
|
-
"",
|
|
20
|
+
"Commands:",
|
|
21
|
+
" [cyan]arga wizard status[/cyan] Check health",
|
|
22
|
+
" [cyan]arga wizard reset[/cyan] Reset twin state",
|
|
23
|
+
" [cyan]arga wizard extend[/cyan] Extend by 10 min",
|
|
24
|
+
" [cyan]arga wizard teardown[/cyan] Destroy session",
|
|
23
25
|
]
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
for name, info in status.get("twins", {}).items():
|
|
27
|
-
label = TWIN_CATALOG.get(name, {}).get("label", name).ljust(16)
|
|
28
|
-
url = with_proxy_token(info.get("base_url", ""), proxy_token)
|
|
29
|
-
lines.append(f"{label} [underline]{url}[/underline]")
|
|
27
|
+
print_summary_box(lines)
|
|
30
28
|
|
|
31
|
-
|
|
29
|
+
console.print(f"Dashboard: [underline]{dashboard_url}[/underline]", soft_wrap=True, overflow="ignore")
|
|
30
|
+
console.print()
|
|
32
31
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
for name, info in status.get("twins", {}).items():
|
|
33
|
+
label = TWIN_CATALOG.get(name, {}).get("label", name)
|
|
34
|
+
url = info.get("base_url", "")
|
|
35
|
+
console.print(
|
|
36
|
+
f" [bold cyan]{label}[/bold cyan]: [underline]{url}[/underline]", soft_wrap=True, overflow="ignore"
|
|
37
|
+
)
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
lines.append(" [cyan]arga wizard extend[/cyan] Extend by 10 min")
|
|
41
|
-
lines.append(" [cyan]arga wizard teardown[/cyan] Destroy session")
|
|
39
|
+
if status.get("expires_at"):
|
|
40
|
+
console.print()
|
|
41
|
+
dim(f"Session expires: {status['expires_at']}")
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
console.print()
|
|
44
44
|
|
|
45
|
-
# Write session state
|
|
46
45
|
session = {
|
|
47
46
|
"run_id": status["run_id"],
|
|
48
47
|
"created_at": datetime.now().isoformat(),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: arga-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.7
|
|
4
4
|
Summary: Command-line interface for Arga authentication, MCP installation, and browser validation
|
|
5
5
|
Author: Arga Labs
|
|
6
6
|
Project-URL: Homepage, https://github.com/ArgaLabs/arga-cli
|
|
@@ -33,7 +33,6 @@ Requires-Dist: rich>=13.0.0
|
|
|
33
33
|
- Starts URL validation runs from the terminal.
|
|
34
34
|
- Starts pull request validation runs from the terminal.
|
|
35
35
|
- Wraps `git commit` and `git push` with Arga skip-validation helpers.
|
|
36
|
-
- Starts and inspects Arga app scans.
|
|
37
36
|
|
|
38
37
|
## Installation
|
|
39
38
|
|
|
@@ -112,14 +111,6 @@ arga validate config arga-labs/validation-server
|
|
|
112
111
|
arga validate config set arga-labs/validation-server --trigger branch --branch main --comments on
|
|
113
112
|
```
|
|
114
113
|
|
|
115
|
-
Start an app scan and inspect it later:
|
|
116
|
-
|
|
117
|
-
```bash
|
|
118
|
-
arga scan https://demo-app.com --budget 200
|
|
119
|
-
arga scan status <run_id>
|
|
120
|
-
arga scan report <run_id>
|
|
121
|
-
```
|
|
122
|
-
|
|
123
114
|
List and inspect recent validation runs:
|
|
124
115
|
|
|
125
116
|
```bash
|
|
@@ -173,19 +164,6 @@ arga test url \
|
|
|
173
164
|
|
|
174
165
|
Both `--email` and `--password` must be supplied together.
|
|
175
166
|
|
|
176
|
-
### App Scans
|
|
177
|
-
|
|
178
|
-
```bash
|
|
179
|
-
arga scan https://demo-app.com --budget 200
|
|
180
|
-
arga scan status <run_id>
|
|
181
|
-
arga scan report <run_id>
|
|
182
|
-
```
|
|
183
|
-
|
|
184
|
-
- `arga scan <url>` starts an app scan, waits for the generated scan plan to be ready, and auto-approves it so execution can begin.
|
|
185
|
-
- `--budget` controls the red-team action budget and defaults to `200`.
|
|
186
|
-
- `arga scan status <run_id>` prints the current run status and anomaly count.
|
|
187
|
-
- `arga scan report <run_id>` prints the final JSON report once the scan has completed.
|
|
188
|
-
|
|
189
167
|
### Validation Runs
|
|
190
168
|
|
|
191
169
|
```bash
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "arga-cli"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.7"
|
|
8
8
|
description = "Command-line interface for Arga authentication, MCP installation, and browser validation"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from arga_cli import main
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_start_url_validation_uses_longer_timeout(monkeypatch) -> None:
|
|
9
|
+
client = main.ApiClient("https://api.argalabs.com", api_key="arga_api_key")
|
|
10
|
+
captured: dict[str, object] = {}
|
|
11
|
+
|
|
12
|
+
def fake_post(url: str, *, json: dict[str, object], headers: dict[str, str], timeout: float):
|
|
13
|
+
captured["url"] = url
|
|
14
|
+
captured["json"] = json
|
|
15
|
+
captured["headers"] = headers
|
|
16
|
+
captured["timeout"] = timeout
|
|
17
|
+
|
|
18
|
+
class FakeResponse:
|
|
19
|
+
is_success = True
|
|
20
|
+
status_code = 200
|
|
21
|
+
|
|
22
|
+
def json(self) -> dict[str, str]:
|
|
23
|
+
return {"run_id": "run_3421", "status": "queued", "session_id": "session_1"}
|
|
24
|
+
|
|
25
|
+
return FakeResponse()
|
|
26
|
+
|
|
27
|
+
monkeypatch.setattr(client._client, "post", fake_post)
|
|
28
|
+
try:
|
|
29
|
+
payload = client.start_url_validation(url="https://demo-app.com", prompt="test login flow")
|
|
30
|
+
finally:
|
|
31
|
+
client.close()
|
|
32
|
+
|
|
33
|
+
assert captured["url"] == "https://api.argalabs.com/validate/url-run"
|
|
34
|
+
assert captured["json"] == {"url": "https://demo-app.com", "prompt": "test login flow"}
|
|
35
|
+
assert captured["timeout"] == main.URL_VALIDATION_START_TIMEOUT_SECONDS
|
|
36
|
+
assert payload == {"run_id": "run_3421", "status": "queued", "session_id": "session_1"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_test_url_command_prints_run_id(monkeypatch, capsys) -> None:
|
|
40
|
+
monkeypatch.setattr(main, "load_api_key", lambda: "arga_api_key")
|
|
41
|
+
monkeypatch.setattr(main.ApiClient, "get_me", lambda self: {"billing_plan": "free"})
|
|
42
|
+
|
|
43
|
+
def fake_start(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
url: str,
|
|
47
|
+
prompt: str | None = None,
|
|
48
|
+
email: str | None = None,
|
|
49
|
+
password: str | None = None,
|
|
50
|
+
ttl_minutes: int | None = None,
|
|
51
|
+
scenario_id: str | None = None,
|
|
52
|
+
provision_id: str | None = None,
|
|
53
|
+
twins: list[str] | None = None,
|
|
54
|
+
):
|
|
55
|
+
assert url == "https://demo-app.com"
|
|
56
|
+
assert prompt == "test login flow"
|
|
57
|
+
assert email is None
|
|
58
|
+
assert password is None
|
|
59
|
+
assert ttl_minutes == 10
|
|
60
|
+
return {"run_id": "run_3421", "status": "queued", "session_id": "session_1"}
|
|
61
|
+
|
|
62
|
+
monkeypatch.setattr(main.ApiClient, "start_url_validation", fake_start)
|
|
63
|
+
monkeypatch.setattr(main.ApiClient, "close", lambda self: None)
|
|
64
|
+
|
|
65
|
+
args = main.build_parser().parse_args(
|
|
66
|
+
["test", "url", "--url", "https://demo-app.com", "--prompt", "test login flow"]
|
|
67
|
+
)
|
|
68
|
+
exit_code = args.func(args)
|
|
69
|
+
output = capsys.readouterr().out
|
|
70
|
+
|
|
71
|
+
assert exit_code == 0
|
|
72
|
+
assert "Starting validation..." in output
|
|
73
|
+
assert "Run ID: run_3421" in output
|
|
74
|
+
assert "Status: queued" in output
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_test_url_json_flag(monkeypatch, capsys) -> None:
|
|
78
|
+
monkeypatch.setattr(main, "load_api_key", lambda: "arga_api_key")
|
|
79
|
+
monkeypatch.setattr(main.ApiClient, "get_me", lambda self: {"billing_plan": "free"})
|
|
80
|
+
|
|
81
|
+
def fake_start(
|
|
82
|
+
self,
|
|
83
|
+
*,
|
|
84
|
+
url: str,
|
|
85
|
+
prompt: str | None = None,
|
|
86
|
+
email: str | None = None,
|
|
87
|
+
password: str | None = None,
|
|
88
|
+
ttl_minutes: int | None = None,
|
|
89
|
+
scenario_id: str | None = None,
|
|
90
|
+
provision_id: str | None = None,
|
|
91
|
+
twins: list[str] | None = None,
|
|
92
|
+
):
|
|
93
|
+
assert ttl_minutes == 10
|
|
94
|
+
return {"run_id": "run_3421", "status": "queued", "session_id": "session_1"}
|
|
95
|
+
|
|
96
|
+
monkeypatch.setattr(main.ApiClient, "start_url_validation", fake_start)
|
|
97
|
+
monkeypatch.setattr(main.ApiClient, "close", lambda self: None)
|
|
98
|
+
|
|
99
|
+
args = main.build_parser().parse_args(
|
|
100
|
+
["test", "url", "--url", "https://demo-app.com", "--prompt", "test login flow", "--json"]
|
|
101
|
+
)
|
|
102
|
+
exit_code = args.func(args)
|
|
103
|
+
output = capsys.readouterr().out
|
|
104
|
+
|
|
105
|
+
assert exit_code == 0
|
|
106
|
+
parsed = json.loads(output)
|
|
107
|
+
assert parsed == {"run_id": "run_3421", "status": "queued"}
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import sys
|
|
5
|
-
|
|
6
|
-
import pytest
|
|
7
|
-
|
|
8
|
-
from arga_cli import main
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def test_scan_start_polls_until_plan_ready_and_auto_approves(monkeypatch: pytest.MonkeyPatch, capsys) -> None:
|
|
12
|
-
monkeypatch.setattr(main, "load_api_key", lambda: "arga_api_key")
|
|
13
|
-
monkeypatch.setattr(main.ApiClient, "close", lambda self: None)
|
|
14
|
-
monkeypatch.setattr(main.time, "sleep", lambda _: None)
|
|
15
|
-
|
|
16
|
-
statuses = iter(
|
|
17
|
-
[
|
|
18
|
-
{"status": "planning"},
|
|
19
|
-
{"status": "awaiting_approval"},
|
|
20
|
-
]
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
def fake_start(self, *, url: str, action_budget: int):
|
|
24
|
-
assert url == "https://demo-app.com"
|
|
25
|
-
assert action_budget == 200
|
|
26
|
-
return {"run_id": "run_scan_123", "status": "planning"}
|
|
27
|
-
|
|
28
|
-
def fake_get_run(self, run_id: str):
|
|
29
|
-
assert run_id == "run_scan_123"
|
|
30
|
-
status = next(statuses)
|
|
31
|
-
return {
|
|
32
|
-
"id": run_id,
|
|
33
|
-
"status": status["status"],
|
|
34
|
-
"frontend_url": "https://demo-app.com",
|
|
35
|
-
"mode": "redteam",
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
def fake_approve(self, run_id: str):
|
|
39
|
-
assert run_id == "run_scan_123"
|
|
40
|
-
return {"run_id": run_id, "status": "queued"}
|
|
41
|
-
|
|
42
|
-
monkeypatch.setattr(main.ApiClient, "start_redteam_scan", fake_start)
|
|
43
|
-
monkeypatch.setattr(main.ApiClient, "get_run", fake_get_run)
|
|
44
|
-
monkeypatch.setattr(main.ApiClient, "approve_redteam_scan", fake_approve)
|
|
45
|
-
|
|
46
|
-
exit_code = main.run_scan_cli(["https://demo-app.com"])
|
|
47
|
-
output = capsys.readouterr().out
|
|
48
|
-
|
|
49
|
-
assert exit_code == 0
|
|
50
|
-
assert "Starting agent run..." in output
|
|
51
|
-
assert "URL: https://demo-app.com" in output
|
|
52
|
-
assert "Budget: 200" in output
|
|
53
|
-
assert "Run ID: run_scan_123" in output
|
|
54
|
-
assert "Status: queued" in output
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def test_scan_status_prints_summary(monkeypatch: pytest.MonkeyPatch, capsys) -> None:
|
|
58
|
-
monkeypatch.setattr(main, "load_api_key", lambda: "arga_api_key")
|
|
59
|
-
monkeypatch.setattr(main.ApiClient, "close", lambda self: None)
|
|
60
|
-
|
|
61
|
-
def fake_get_run(self, run_id: str):
|
|
62
|
-
assert run_id == "run_scan_123"
|
|
63
|
-
return {
|
|
64
|
-
"id": run_id,
|
|
65
|
-
"status": "running",
|
|
66
|
-
"frontend_url": "https://demo-app.com",
|
|
67
|
-
"mode": "redteam",
|
|
68
|
-
"redteam_report_json": {"anomalies": [{"title": "Issue 1"}, {"title": "Issue 2"}]},
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
monkeypatch.setattr(main.ApiClient, "get_run", fake_get_run)
|
|
72
|
-
|
|
73
|
-
exit_code = main.run_scan_cli(["status", "run_scan_123"])
|
|
74
|
-
output = capsys.readouterr().out
|
|
75
|
-
|
|
76
|
-
assert exit_code == 0
|
|
77
|
-
assert "Run ID: run_scan_123" in output
|
|
78
|
-
assert "Status: running" in output
|
|
79
|
-
assert "Anomalies: 2" in output
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def test_scan_report_prints_json(monkeypatch: pytest.MonkeyPatch, capsys) -> None:
|
|
83
|
-
monkeypatch.setattr(main, "load_api_key", lambda: "arga_api_key")
|
|
84
|
-
monkeypatch.setattr(main.ApiClient, "close", lambda self: None)
|
|
85
|
-
|
|
86
|
-
def fake_report(self, run_id: str):
|
|
87
|
-
assert run_id == "run_scan_123"
|
|
88
|
-
return {"summary": "done", "anomalies": [{"title": "Issue 1"}]}
|
|
89
|
-
|
|
90
|
-
monkeypatch.setattr(main.ApiClient, "get_redteam_report", fake_report)
|
|
91
|
-
|
|
92
|
-
exit_code = main.run_scan_cli(["report", "run_scan_123"])
|
|
93
|
-
output = capsys.readouterr().out
|
|
94
|
-
|
|
95
|
-
assert exit_code == 0
|
|
96
|
-
assert json.loads(output) == {"summary": "done", "anomalies": [{"title": "Issue 1"}]}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def test_main_dispatches_scan_wrapper_before_argparse(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
100
|
-
captured: dict[str, object] = {}
|
|
101
|
-
|
|
102
|
-
def fake_scan(argv: list[str]) -> int:
|
|
103
|
-
captured["argv"] = argv
|
|
104
|
-
return 0
|
|
105
|
-
|
|
106
|
-
monkeypatch.setattr(main, "run_scan_cli", fake_scan)
|
|
107
|
-
monkeypatch.setattr(sys, "argv", ["arga", "scan", "status", "run_scan_123"])
|
|
108
|
-
|
|
109
|
-
with pytest.raises(SystemExit) as exc_info:
|
|
110
|
-
main.main()
|
|
111
|
-
|
|
112
|
-
assert exc_info.value.code == 0
|
|
113
|
-
assert captured["argv"] == ["status", "run_scan_123"]
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
|
|
5
|
-
from arga_cli import main
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def test_test_url_command_prints_run_id(monkeypatch, capsys) -> None:
|
|
9
|
-
monkeypatch.setattr(main, "load_api_key", lambda: "arga_api_key")
|
|
10
|
-
|
|
11
|
-
def fake_start(self, *, url: str, prompt: str, email: str | None = None, password: str | None = None):
|
|
12
|
-
assert url == "https://demo-app.com"
|
|
13
|
-
assert prompt == "test login flow"
|
|
14
|
-
assert email is None
|
|
15
|
-
assert password is None
|
|
16
|
-
return {"run_id": "run_3421", "status": "queued", "session_id": "session_1"}
|
|
17
|
-
|
|
18
|
-
monkeypatch.setattr(main.ApiClient, "start_url_validation", fake_start)
|
|
19
|
-
monkeypatch.setattr(main.ApiClient, "close", lambda self: None)
|
|
20
|
-
|
|
21
|
-
args = main.build_parser().parse_args(
|
|
22
|
-
["test", "url", "--url", "https://demo-app.com", "--prompt", "test login flow"]
|
|
23
|
-
)
|
|
24
|
-
exit_code = args.func(args)
|
|
25
|
-
output = capsys.readouterr().out
|
|
26
|
-
|
|
27
|
-
assert exit_code == 0
|
|
28
|
-
assert "Starting validation..." in output
|
|
29
|
-
assert "Run ID: run_3421" in output
|
|
30
|
-
assert "Status: queued" in output
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def test_test_url_json_flag(monkeypatch, capsys) -> None:
|
|
34
|
-
monkeypatch.setattr(main, "load_api_key", lambda: "arga_api_key")
|
|
35
|
-
|
|
36
|
-
def fake_start(self, *, url: str, prompt: str, email: str | None = None, password: str | None = None):
|
|
37
|
-
return {"run_id": "run_3421", "status": "queued", "session_id": "session_1"}
|
|
38
|
-
|
|
39
|
-
monkeypatch.setattr(main.ApiClient, "start_url_validation", fake_start)
|
|
40
|
-
monkeypatch.setattr(main.ApiClient, "close", lambda self: None)
|
|
41
|
-
|
|
42
|
-
args = main.build_parser().parse_args(
|
|
43
|
-
["test", "url", "--url", "https://demo-app.com", "--prompt", "test login flow", "--json"]
|
|
44
|
-
)
|
|
45
|
-
exit_code = args.func(args)
|
|
46
|
-
output = capsys.readouterr().out
|
|
47
|
-
|
|
48
|
-
assert exit_code == 0
|
|
49
|
-
parsed = json.loads(output)
|
|
50
|
-
assert parsed == {"run_id": "run_3421", "status": "queued"}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|