argus-probe 0.1.1__py3-none-any.whl

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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.1"
@@ -0,0 +1,28 @@
1
+ """argus-probe entry point."""
2
+
3
+ import click
4
+
5
+ from argus_probe.cmd_init import init_command
6
+ from argus_probe.cmd_run import cmd_run
7
+ from argus_probe.cmd_report import cmd_report
8
+ from argus_probe.cmd_list_probes import cmd_list_probes
9
+ from argus_probe.cmd_coverage import cmd_coverage
10
+ from argus_probe.cmd_validate_target import cmd_validate_target
11
+
12
+
13
+ @click.group()
14
+ @click.version_option()
15
+ def main():
16
+ """Argus red-team CLI."""
17
+
18
+
19
+ main.add_command(init_command, "init")
20
+ main.add_command(cmd_run, "run")
21
+ main.add_command(cmd_report, "report")
22
+ main.add_command(cmd_list_probes, "list-probes")
23
+ main.add_command(cmd_coverage, "coverage")
24
+ main.add_command(cmd_validate_target, "validate-target")
25
+
26
+
27
+ if __name__ == "__main__":
28
+ main()
@@ -0,0 +1,70 @@
1
+ """Minimal httpx wrapper around the Argus REST API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ import httpx
8
+
9
+
10
+ def post_run(api_url: str, token: str, body: dict, timeout: float = 30.0) -> dict:
11
+ with httpx.Client(timeout=timeout) as client:
12
+ resp = client.post(f"{api_url}/api/v1/redteam/runs", json=body, headers={"x-api-token": token})
13
+ resp.raise_for_status()
14
+ return resp.json()
15
+
16
+
17
+ def get_run(api_url: str, token: str, run_id: str, timeout: float = 30.0) -> dict:
18
+ with httpx.Client(timeout=timeout) as client:
19
+ resp = client.get(f"{api_url}/api/v1/redteam/runs/{run_id}", headers={"x-api-token": token})
20
+ resp.raise_for_status()
21
+ return resp.json()
22
+
23
+
24
+ def get_report(api_url: str, token: str, run_id: str, format: str, timeout: float = 30.0) -> str:
25
+ with httpx.Client(timeout=timeout) as client:
26
+ resp = client.get(
27
+ f"{api_url}/api/v1/redteam/runs/{run_id}/report",
28
+ params={"format": format},
29
+ headers={"x-api-token": token},
30
+ )
31
+ resp.raise_for_status()
32
+ return resp.text
33
+
34
+
35
+ def poll_until_complete(api_url: str, token: str, run_id: str, interval: float = 2.0, max_wait: float = 1800.0) -> dict:
36
+ deadline = time.monotonic() + max_wait
37
+ while time.monotonic() < deadline:
38
+ run = get_run(api_url, token, run_id)
39
+ if run["status"] in ("completed", "failed", "cancelled"):
40
+ return run
41
+ time.sleep(interval)
42
+ raise TimeoutError(f"run {run_id} did not complete within {max_wait}s")
43
+
44
+
45
+ def list_probes(api_url: str, token: str, timeout: float = 30.0) -> list[str]:
46
+ """GET /api/v1/redteam/probes (TBD endpoint — Plan 3 Task 14 adds it)."""
47
+ with httpx.Client(timeout=timeout) as client:
48
+ resp = client.get(f"{api_url}/api/v1/redteam/probes", headers={"x-api-token": token})
49
+ resp.raise_for_status()
50
+ return resp.json()["probe_ids"]
51
+
52
+
53
+ def get_coverage(api_url: str, token: str, timeout: float = 30.0) -> dict:
54
+ """GET /api/v1/redteam/coverage — what the probe library covers. Runs no probes, costs nothing."""
55
+ with httpx.Client(timeout=timeout) as client:
56
+ resp = client.get(f"{api_url}/api/v1/redteam/coverage", headers={"x-api-token": token})
57
+ resp.raise_for_status()
58
+ return resp.json()["coverage"]
59
+
60
+
61
+ def get_diff(api_url: str, token: str, run_id: str, base: str, timeout: float = 30.0) -> dict:
62
+ """GET /api/v1/redteam/runs/{run_id}/diff — failures new, fixed or unchanged versus the latest run on `base`."""
63
+ with httpx.Client(timeout=timeout) as client:
64
+ resp = client.get(
65
+ f"{api_url}/api/v1/redteam/runs/{run_id}/diff",
66
+ params={"base": base},
67
+ headers={"x-api-token": token},
68
+ )
69
+ resp.raise_for_status()
70
+ return resp.json()
@@ -0,0 +1,19 @@
1
+ """Stable id of the target a run tests, so a baseline only matches runs against the same target."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+
7
+ _SECRET_WORDS = ("key", "token", "secret", "password", "auth", "header", "cookie")
8
+
9
+
10
+ def _clean(value):
11
+ if isinstance(value, dict):
12
+ return {k: _clean(v) for k, v in value.items() if not any(w in str(k).lower() for w in _SECRET_WORDS)}
13
+ if isinstance(value, list):
14
+ return [_clean(v) for v in value]
15
+ return value
16
+
17
+
18
+ def baseline_key(target: dict) -> str:
19
+ return hashlib.sha256(json.dumps(_clean(target), sort_keys=True).encode()).hexdigest()[:32]
@@ -0,0 +1,44 @@
1
+ """argus-probe coverage — show what the probe library covers.
2
+
3
+ Runs no probes and costs nothing: a prospect must be able to ask what we
4
+ cover before paying for a scan. Prints every cell of each standard,
5
+ including the empty ones — silently omitting what we do not cover would
6
+ invert the point of this command.
7
+ """
8
+
9
+ import json
10
+
11
+ import click
12
+
13
+ from argus_probe import api_client
14
+
15
+ STANDARDS = ["owasp-llm-top10", "mitre-atlas", "nist-ai-rmf", "eu-ai-act"]
16
+
17
+
18
+ @click.command()
19
+ @click.option("--token", envvar="ARGUS_API_TOKEN", required=True)
20
+ @click.option("--api-url", envvar="ARGUS_API_URL", required=True, help="Argus API base URL (or env ARGUS_API_URL).")
21
+ @click.option("--standard", "standard", type=click.Choice(STANDARDS), default=None,
22
+ help="Show only this standard (default: all).")
23
+ @click.option("--json", "as_json", is_flag=True, help="Emit the raw coverage manifest as JSON.")
24
+ def cmd_coverage(token: str, api_url: str, standard: str, as_json: bool):
25
+ """Show what the probe library covers, offline — no probes run."""
26
+ coverage = api_client.get_coverage(api_url, token)
27
+ if as_json:
28
+ if standard:
29
+ coverage = {**coverage, "standards": {standard: coverage["standards"][standard]}}
30
+ click.echo(json.dumps(coverage))
31
+ return
32
+
33
+ keys = [standard] if standard else STANDARDS
34
+ for key in keys:
35
+ data = coverage["standards"][key]
36
+ click.echo(f"\n{key} ({data['universe']} universe)")
37
+ for cell in data["cells"]:
38
+ name = cell.get("name", "")
39
+ click.echo(
40
+ f" {cell['id']:<8} {name:<40} {cell['probes_in_library']:>4} probes {cell['library_status']}"
41
+ )
42
+
43
+ totals = coverage["totals"]
44
+ click.echo(f"\n{totals['probes_in_library']} probes in library, {totals['probes_run']} run this scan")
@@ -0,0 +1,187 @@
1
+ """argus-probe init subcommand: scaffold a target.json + optional GH Action workflow."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from argus_probe.target_loader import _KNOWN_KINDS
10
+
11
+ # Per-kind question definitions. Each tuple: (field_name, prompt_text, default_or_None)
12
+ _KIND_QUESTIONS: dict[str, list[tuple[str, str, str | None]]] = {
13
+ "openai_compat": [
14
+ ("endpoint_url", "Endpoint URL", "https://api.openai.com/v1/chat/completions"),
15
+ ("model", "Model name", None),
16
+ ("api_key_env", "API key env var", "OPENAI_API_KEY"),
17
+ ],
18
+ "anthropic_native": [
19
+ ("model", "Model name", "claude-haiku-4-5-20251001"),
20
+ ("api_key_env", "API key env var", "ANTHROPIC_API_KEY"),
21
+ ],
22
+ "custom_http": [
23
+ ("request_url", "Request URL", None),
24
+ ("request_body_template", "Request body template (Jinja2 with {{prompt}})",
25
+ '{"input": {{prompt|tojson}}}'),
26
+ ("response_jsonpath", "Response JSONPath", "$.output"),
27
+ ],
28
+ "grpc": [
29
+ ("endpoint", "Endpoint (host:port)", "localhost:50051"),
30
+ ("service_method", "Service method (pkg.Service/Method)", None),
31
+ ("prompt_field", "Prompt field name", "user_input"),
32
+ ("response_field", "Response field name", "response"),
33
+ ],
34
+ "browser_use": [
35
+ ("agent_url", "Browser-using agent URL", None),
36
+ ("scenario_kind", "Scenario kind (dom_injection/ui_phishing/visual_injection/os_cmd)", "dom_injection"),
37
+ ],
38
+ "payment_agent": [
39
+ ("testbed_url", "Payment testbed URL", "http://127.0.0.1:8090"),
40
+ (
41
+ "inner_json",
42
+ "Inner target spec (JSON) for the transport that talks to your agent",
43
+ '{"kind": "openai_compat", "endpoint_url": "http://127.0.0.1:8091/v1/chat/completions", "model": "argus-demo-payment-agent"}',
44
+ ),
45
+ ],
46
+ "mcp_agent": [
47
+ ("testbed_url", "MCP testbed URL", "http://127.0.0.1:8092"),
48
+ (
49
+ "inner_json",
50
+ "Inner target spec (JSON) for the transport that talks to your agent",
51
+ '{"kind": "openai_compat", "endpoint_url": "http://127.0.0.1:8093/v1/chat/completions", "model": "argus-demo-mcp-agent"}',
52
+ ),
53
+ ],
54
+ "testbed_agent": [
55
+ ("environment", "Environment (mcp/payment)", "mcp"),
56
+ ("testbed_url", "Testbed URL", "http://127.0.0.1:8092"),
57
+ (
58
+ "inner_json",
59
+ "Inner target spec (JSON) for the transport that talks to your agent",
60
+ '{"kind": "openai_compat", "endpoint_url": "http://127.0.0.1:8093/v1/chat/completions", "model": "argus-demo-mcp-agent"}',
61
+ ),
62
+ ],
63
+ "http_upload": [
64
+ ("upload_url", "Upload URL", None),
65
+ ("upload_field_name", "Upload form field name", "file"),
66
+ ("upload_filename", "Upload filename", "payload.svg"),
67
+ ("upload_content_type", "Upload content type", "image/svg+xml"),
68
+ ("render_url_jsonpath", "Render URL JSONPath (extracts the render URL from the upload response)", "$.url"),
69
+ ],
70
+ }
71
+
72
+ _WORKFLOW_TEMPLATE = """\
73
+ # .github/workflows/argus-probe.yml
74
+ # Generated by `argus-probe init`. Customize as needed.
75
+ name: argus-probe
76
+ on:
77
+ pull_request:
78
+ branches: [main]
79
+ push:
80
+ branches: [main]
81
+ schedule:
82
+ # Weekly Monday 06:00 UTC
83
+ - cron: '0 6 * * 1'
84
+ workflow_dispatch: {{}}
85
+
86
+ jobs:
87
+ redteam:
88
+ runs-on: ubuntu-latest
89
+ permissions:
90
+ security-events: write # upload-sarif
91
+ contents: read
92
+ steps:
93
+ - uses: actions/checkout@v4
94
+ - uses: actions/setup-python@v5
95
+ with:
96
+ python-version: '3.11'
97
+ - run: pip install "git+https://github.com/gy15901580825/Argus.git#subdirectory=cli"
98
+ # PRs gate only on failures new relative to the base branch; pushes to main record the baseline.
99
+ - run: >
100
+ argus-probe run --target {target_path}
101
+ --probes owasp_01_prompt_injection_basic,syscard_crescendo
102
+ --format sarif --out argus.sarif --fail-on critical
103
+ ${{{{ github.event_name == 'pull_request' && format('--compare-to {{0}}', github.base_ref) || '' }}}}
104
+ env:
105
+ ARGUS_API_URL: ${{{{ vars.ARGUS_API_URL }}}}
106
+ ARGUS_API_TOKEN: ${{{{ secrets.ARGUS_API_TOKEN }}}}
107
+ - uses: github/codeql-action/upload-sarif@v3
108
+ with:
109
+ sarif_file: argus.sarif
110
+ """
111
+
112
+
113
+ @click.command("init")
114
+ @click.option(
115
+ "--kind",
116
+ required=True,
117
+ type=click.Choice(sorted(_KNOWN_KINDS)),
118
+ help="Target adapter kind",
119
+ )
120
+ @click.option(
121
+ "--output",
122
+ default="argus-target.json",
123
+ show_default=True,
124
+ help="Output target.json path",
125
+ )
126
+ @click.option(
127
+ "--workflow/--no-workflow",
128
+ default=None,
129
+ help="Generate .github/workflows/argus-probe.yml (prompts if neither flag given)",
130
+ )
131
+ def init_command(kind: str, output: str, workflow: bool | None) -> None:
132
+ """Generate a target.json (and optional GitHub Action workflow) for Argus."""
133
+ click.echo(f"Argus target initializer")
134
+ click.echo(f"Target kind: {kind}")
135
+ questions = _KIND_QUESTIONS[kind]
136
+
137
+ spec: dict[str, object] = {"kind": kind}
138
+ for field, prompt_text, default in questions:
139
+ if default is not None:
140
+ value = click.prompt(prompt_text, default=default, show_default=True)
141
+ else:
142
+ value = click.prompt(prompt_text)
143
+ spec[field] = value
144
+
145
+ # api_key_env → api_key (CLI run command resolves $-vars at runtime)
146
+ if "api_key_env" in spec:
147
+ env_var = spec.pop("api_key_env")
148
+ spec["api_key"] = "${" + str(env_var) + "}"
149
+
150
+ # inner_json → inner (payment_agent / mcp_agent / testbed_agent nest another target spec
151
+ # for the transport that actually talks to the agent)
152
+ if "inner_json" in spec:
153
+ raw_inner = spec.pop("inner_json")
154
+ try:
155
+ spec["inner"] = json.loads(str(raw_inner))
156
+ except json.JSONDecodeError as exc:
157
+ raise click.UsageError(f"inner target spec is not valid JSON: {exc}") from exc
158
+
159
+ # sandbox is a safety interlock, not a question: payment_agent,
160
+ # mcp_agent and testbed_agent drive an agent that spends money / reaches a live MCP server,
161
+ # and both api_service and the CLI loader refuse a spec without
162
+ # sandbox: true — there is no way to turn it off, so it must never be
163
+ # presented as something a user could answer "no" to.
164
+ if kind in ("payment_agent", "mcp_agent", "testbed_agent"):
165
+ spec["sandbox"] = True
166
+
167
+ # Confirm-overwrite if target file exists
168
+ target_path = Path(output)
169
+ if target_path.exists():
170
+ if not click.confirm(f"{output} exists. Overwrite?", default=False):
171
+ raise click.Abort()
172
+
173
+ target_path.write_text(json.dumps(spec, indent=2) + "\n")
174
+ click.echo(f"✅ Wrote {target_path}")
175
+
176
+ # Workflow file
177
+ if workflow is None:
178
+ workflow = click.confirm("Generate GitHub Action workflow?", default=False)
179
+ if workflow:
180
+ wf_path = Path(".github/workflows/argus-probe.yml")
181
+ wf_path.parent.mkdir(parents=True, exist_ok=True)
182
+ wf_path.write_text(_WORKFLOW_TEMPLATE.format(target_path=target_path.name))
183
+ click.echo(f"✅ Wrote {wf_path}")
184
+
185
+ click.echo("")
186
+ click.echo(f"Next: argus-probe run --target {target_path.name}")
187
+ click.echo("Set ARGUS_API_URL (repository variable) and ARGUS_API_TOKEN (secret) in CI to enable scheduled runs.")
@@ -0,0 +1,19 @@
1
+ """argus-probe list-probes — show available probe ids."""
2
+
3
+ import click
4
+
5
+ from argus_probe import api_client
6
+
7
+
8
+ @click.command()
9
+ @click.option("--token", envvar="ARGUS_API_TOKEN", required=True)
10
+ @click.option("--api-url", envvar="ARGUS_API_URL", required=True, help="Argus API base URL (or env ARGUS_API_URL).")
11
+ @click.option("--filter", "filter_substr", default="", help="Show only ids containing this substring.")
12
+ def cmd_list_probes(token: str, api_url: str, filter_substr: str):
13
+ """List probe ids available on the connected Argus instance."""
14
+ ids = api_client.list_probes(api_url, token)
15
+ if filter_substr:
16
+ ids = [i for i in ids if filter_substr.lower() in i.lower()]
17
+ for i in sorted(ids):
18
+ click.echo(i)
19
+ click.echo(f"\n{len(ids)} probes")
@@ -0,0 +1,20 @@
1
+ """argus-probe report — render an existing run's report."""
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+
7
+ from argus_probe import api_client
8
+
9
+
10
+ @click.command()
11
+ @click.option("--run-id", required=True)
12
+ @click.option("--format", "report_format", type=click.Choice(["html", "sarif", "junit"]), default="html")
13
+ @click.option("--out", "out_file", type=click.Path(), required=True)
14
+ @click.option("--token", envvar="ARGUS_API_TOKEN", required=True)
15
+ @click.option("--api-url", envvar="ARGUS_API_URL", required=True, help="Argus API base URL (or env ARGUS_API_URL).")
16
+ def cmd_report(run_id: str, report_format: str, out_file: str, token: str, api_url: str):
17
+ """Render an existing run's report to disk."""
18
+ report = api_client.get_report(api_url, token, run_id, report_format)
19
+ Path(out_file).write_text(report)
20
+ click.echo(f"Wrote {report_format} report for run {run_id} to {out_file}")
argus_probe/cmd_run.py ADDED
@@ -0,0 +1,172 @@
1
+ """argus-probe run — POST a redteam run + poll + write report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+ import httpx
10
+ from click.core import ParameterSource
11
+
12
+ from argus_probe import api_client, git_context
13
+ from argus_probe.baseline_key import baseline_key
14
+ from argus_probe.local_run import LocalUnavailable, run_local_and_render
15
+ from argus_probe.severity import SEVERITY_ORDER, findings_at_or_above
16
+ from argus_probe.target_loader import load_target_spec, TargetLoadError
17
+
18
+ # Exit codes must fit 0-255; sys.exit(402) surfaced as 146 on Linux and macOS.
19
+ EXIT_COST_CAP = 4
20
+
21
+
22
+ @click.command()
23
+ @click.option("--target", required=True, help="Target spec: JSON object (inline) or path to JSON file.")
24
+ @click.option("--probes", "probe_ids", required=True, help="Comma-separated probe ids OR 'all'.")
25
+ @click.option("--format", "report_format", type=click.Choice(["html", "sarif", "junit"]), default="html")
26
+ @click.option("--out", "out_file", type=click.Path(), required=True, help="Output report file path.")
27
+ @click.option("--token", envvar="ARGUS_API_TOKEN", default=None, help="Your Argus API token (or env ARGUS_API_TOKEN). Not used with --local.")
28
+ @click.option("--api-url", envvar="ARGUS_API_URL", default=None, help="Argus API base URL (or env ARGUS_API_URL). Not used with --local.")
29
+ @click.option("--max-wait", type=float, default=1800.0, help="Max seconds to wait for run completion. Ignored with --local.")
30
+ @click.option("--per-run-cap", "per_run_cap", type=float, default=None, help="Maximum USD cost for this run; enforced before any LLM call in both server and local mode. Default: $0.50.")
31
+ @click.option("--fail-on", "fail_on", type=click.Choice(SEVERITY_ORDER), default=None,
32
+ help="Exit 3 when any finding has verdict=fail and severity at or above this level.")
33
+ @click.option("--local", "local", is_flag=True, default=False,
34
+ help="Run probes in this process instead of submitting to an Argus API server. Needs the argus-orchestrator package and a judge API key in the environment.")
35
+ @click.option("--compare-to", "compare_to", default=None,
36
+ help="Base branch: report and gate only on failures that are new relative to the latest run on it. Server mode only.")
37
+ def cmd_run(target: str, probe_ids: str, report_format: str, out_file: str, token: str | None, api_url: str | None, max_wait: float, per_run_cap: float | None, fail_on: str | None, local: bool, compare_to: str | None):
38
+ """Submit a red-team run and write the report to disk."""
39
+ try:
40
+ spec = load_target_spec(target)
41
+ except TargetLoadError as e:
42
+ click.echo(f"Error: {e}", file=sys.stderr)
43
+ sys.exit(1)
44
+
45
+ tokens = [s.strip() for s in probe_ids.split(",") if s.strip()]
46
+ if not tokens:
47
+ # An empty list is the wire convention for "the whole library", so an
48
+ # empty --probes must not fall through to it: `--probes ""` would
49
+ # quietly launch a full sweep the user never asked for.
50
+ raise click.UsageError("--probes needs at least one probe id, or 'all'")
51
+ if "all" in tokens:
52
+ # Silently dropping `all` from a mixed list would run a fraction of the
53
+ # intended probes while the run still looks successful — the worst
54
+ # outcome for a security gate. Refuse instead.
55
+ if len(tokens) > 1:
56
+ raise click.UsageError("'all' cannot be combined with specific probe ids")
57
+ # Empty probe_ids means "all" per orchestrator convention.
58
+ tokens = []
59
+
60
+ if local:
61
+ if compare_to:
62
+ raise click.UsageError("--compare-to needs run history on an Argus server; it does not work with --local")
63
+ ctx = click.get_current_context()
64
+ typed = [
65
+ name for name, param in (("--api-url", "api_url"), ("--token", "token"))
66
+ if ctx.get_parameter_source(param) == ParameterSource.COMMANDLINE
67
+ ]
68
+ if typed:
69
+ raise click.UsageError(f"--local runs in this process; do not pass {' or '.join(typed)}")
70
+ _run_locally(spec, tokens, per_run_cap, report_format, out_file, fail_on)
71
+ return
72
+ if api_url is None:
73
+ raise click.UsageError("Missing option '--api-url'")
74
+ if token is None:
75
+ raise click.UsageError("Missing option '--token'")
76
+
77
+ body = {
78
+ "target": spec,
79
+ "probe_ids": tokens,
80
+ }
81
+ if per_run_cap is not None:
82
+ body["per_run_cap_usd"] = per_run_cap
83
+ body.update(git_context.detect())
84
+ if compare_to and "repo" not in body:
85
+ raise click.UsageError("--compare-to needs the repository name; run inside a git checkout with an origin remote, or set GITHUB_REPOSITORY")
86
+ body["baseline_key"] = baseline_key(spec)
87
+
88
+ click.echo(f"Submitting run to {api_url}/api/v1/redteam/runs ...")
89
+ try:
90
+ resp = api_client.post_run(api_url, token, body=body)
91
+ except httpx.HTTPStatusError as e:
92
+ if e.response.status_code == 402:
93
+ try:
94
+ detail = e.response.json().get("detail", "cost cap exceeded")
95
+ except Exception:
96
+ detail = "cost cap exceeded"
97
+ click.echo(f"Argus rejected run for cost: {detail}", file=sys.stderr)
98
+ sys.exit(EXIT_COST_CAP)
99
+ raise
100
+ run_id = resp["run_id"]
101
+ click.echo(f" run_id: {run_id}")
102
+
103
+ click.echo("Polling until completion...")
104
+ run = api_client.poll_until_complete(api_url, token, run_id, max_wait=max_wait)
105
+ click.echo(f" status: {run['status']}, findings: {len(run.get('findings', []))}")
106
+
107
+ click.echo(f"Fetching {report_format} report...")
108
+ report = api_client.get_report(api_url, token, run_id, report_format)
109
+ Path(out_file).write_text(report)
110
+ click.echo(f"Wrote report to {out_file}")
111
+ if run["status"] != "completed":
112
+ click.echo(f"Error: run {run_id} ended with status {run['status']}", file=sys.stderr)
113
+ sys.exit(1)
114
+ if compare_to:
115
+ try:
116
+ d = api_client.get_diff(api_url, token, run_id, compare_to)
117
+ except httpx.HTTPStatusError as e:
118
+ try:
119
+ detail = e.response.json().get("detail", e.response.status_code)
120
+ except Exception:
121
+ detail = e.response.status_code
122
+ click.echo(f"Error: could not compare with {compare_to}: {detail}", file=sys.stderr)
123
+ sys.exit(1)
124
+ base = d.get("base_run_id") or "none (first run on this branch)"
125
+ click.echo(f"Compared with {compare_to} (baseline run {base}): "
126
+ f"new: {len(d['new'])}, fixed: {len(d['fixed'])}, still failing: {len(d['still_failing'])}, "
127
+ f"not run: {len(d['not_run'])}")
128
+ for item in d["new"]:
129
+ click.echo(f" new {item['severity']:<9} {item['probe_id']}")
130
+ _apply_fail_on([{"verdict": "fail", **i} for i in d["new"]], fail_on)
131
+ else:
132
+ _apply_fail_on(run.get("findings", []), fail_on)
133
+
134
+
135
+ def _apply_fail_on(findings: list[dict], fail_on: str | None) -> None:
136
+ """Exit 3 when findings cross the --fail-on threshold. Report is already on disk."""
137
+ if fail_on is None:
138
+ return
139
+ hits = findings_at_or_above(findings, fail_on)
140
+ if hits:
141
+ ids = ", ".join(sorted({h.get("probe_id", "?") for h in hits}))
142
+ click.echo(
143
+ f"{len(hits)} finding(s) at or above {fail_on}: {ids}",
144
+ file=sys.stderr,
145
+ )
146
+ sys.exit(3)
147
+
148
+
149
+ def _run_locally(spec, tokens, per_run_cap, report_format, out_file, fail_on) -> None:
150
+ if spec.get("kind") == "browser_use":
151
+ click.echo(
152
+ "Error: --local does not support browser_use targets; they need the "
153
+ "orchestrator service and a client_agent. Submit the run to an Argus API server instead.",
154
+ file=sys.stderr,
155
+ )
156
+ sys.exit(1)
157
+ click.echo(f"Running {('all probes' if not tokens else f'{len(tokens)} probe(s)')} locally ...")
158
+ try:
159
+ report, findings = run_local_and_render(spec, tokens, per_run_cap, report_format)
160
+ except LocalUnavailable as e:
161
+ click.echo(f"Error: {e}", file=sys.stderr)
162
+ sys.exit(2)
163
+ except Exception as e: # LocalRunError or a mid-run failure; no traceback for the operator
164
+ code = getattr(e, "code", None)
165
+ if code == "cost":
166
+ click.echo(f"Run rejected for cost: {e}", file=sys.stderr)
167
+ sys.exit(EXIT_COST_CAP)
168
+ click.echo(f"Error: {e}", file=sys.stderr)
169
+ sys.exit(1)
170
+ Path(out_file).write_text(report)
171
+ click.echo(f"Wrote report to {out_file}")
172
+ _apply_fail_on(findings, fail_on)
@@ -0,0 +1,31 @@
1
+ """argus-probe validate-target — quick reachability check on a target endpoint."""
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+ import httpx
7
+ import yaml
8
+
9
+
10
+ @click.command()
11
+ @click.option("--target", "target_file", type=click.Path(exists=True), required=True)
12
+ def cmd_validate_target(target_file: str):
13
+ """POST a tiny chat-completions probe to the target and report reachability + shape compliance."""
14
+ spec = yaml.safe_load(Path(target_file).read_text())
15
+ url = spec["endpoint_url"]
16
+ headers = {}
17
+ if spec.get("api_key"):
18
+ headers["Authorization"] = f"Bearer {spec['api_key']}"
19
+ body = {"model": spec["model"], "messages": [{"role": "user", "content": "ping"}], "max_tokens": 10}
20
+ try:
21
+ resp = httpx.post(url, headers=headers, json=body, timeout=15.0)
22
+ except httpx.RequestError as e:
23
+ raise click.ClickException(f"network error: {e}")
24
+ if resp.status_code != 200:
25
+ raise click.ClickException(f"target returned {resp.status_code}: {resp.text[:200]}")
26
+ try:
27
+ data = resp.json()
28
+ text = data["choices"][0]["message"]["content"]
29
+ except (KeyError, IndexError, ValueError) as e:
30
+ raise click.ClickException(f"target response shape invalid: {e}")
31
+ click.echo(f"OK — target {url} replied with {len(text)} chars")
@@ -0,0 +1,40 @@
1
+ """Which repo, branch and commit this run tests, from CI variables or git."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ import subprocess
7
+ from typing import Mapping
8
+
9
+ _REMOTE = re.compile(r"[:/]([^/:]+/[^/]+?)(?:\.git)?$")
10
+
11
+
12
+ def _git(cwd: str | None, *args: str) -> str | None:
13
+ try:
14
+ out = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, timeout=5)
15
+ except (OSError, subprocess.SubprocessError):
16
+ return None
17
+ return out.stdout.strip() or None if out.returncode == 0 else None
18
+
19
+
20
+ def detect(env: Mapping[str, str] | None = None, cwd: str | None = None) -> dict[str, str]:
21
+ env = os.environ if env is None else env
22
+ ctx: dict[str, str] = {}
23
+ if env.get("GITHUB_REPOSITORY"):
24
+ ctx["repo"] = env["GITHUB_REPOSITORY"].lower()
25
+ branch = env.get("GITHUB_HEAD_REF") or env.get("GITHUB_REF_NAME")
26
+ if branch:
27
+ ctx["branch"] = branch
28
+ if env.get("GITHUB_SHA"):
29
+ ctx["commit"] = env["GITHUB_SHA"]
30
+ return ctx
31
+ remote = _git(cwd, "remote", "get-url", "origin")
32
+ if remote and (m := _REMOTE.search(remote)):
33
+ ctx["repo"] = m.group(1).lower()
34
+ branch = _git(cwd, "rev-parse", "--abbrev-ref", "HEAD")
35
+ if branch and branch != "HEAD":
36
+ ctx["branch"] = branch
37
+ commit = _git(cwd, "rev-parse", "HEAD")
38
+ if commit:
39
+ ctx["commit"] = commit
40
+ return ctx