opencomplai-cli 0.1.2__tar.gz → 0.3.0__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.
Files changed (30) hide show
  1. {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/.gitignore +15 -0
  2. {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/PKG-INFO +6 -2
  3. {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/pyproject.toml +12 -1
  4. {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/src/opencomplai_cli/__init__.py +1 -1
  5. {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/src/opencomplai_cli/commands/checker.py +29 -5
  6. {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/src/opencomplai_cli/commands/dashboard.py +65 -5
  7. opencomplai_cli-0.3.0/src/opencomplai_cli/commands/push.py +153 -0
  8. opencomplai_cli-0.3.0/src/opencomplai_cli/commands/serve.py +37 -0
  9. {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/src/opencomplai_cli/connectors/github_actions.py +126 -25
  10. {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/src/opencomplai_cli/connectors/gitlab_ci.py +124 -24
  11. {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/src/opencomplai_cli/data/checker-local.html +49 -5
  12. {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/src/opencomplai_cli/main.py +723 -8
  13. opencomplai_cli-0.3.0/src/opencomplai_cli/oidc_client.py +77 -0
  14. opencomplai_cli-0.3.0/src/opencomplai_cli/publish.py +310 -0
  15. {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/tests/test_checker_cmd.py +40 -0
  16. opencomplai_cli-0.3.0/tests/test_connectors.py +686 -0
  17. {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/tests/test_dashboard_cmd.py +125 -0
  18. {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/tests/test_eu_ai_scan_golden.py +6 -1
  19. opencomplai_cli-0.3.0/tests/test_oidc_client.py +91 -0
  20. opencomplai_cli-0.3.0/tests/test_publish.py +434 -0
  21. opencomplai_cli-0.3.0/tests/test_push_cmd.py +413 -0
  22. {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/tests/test_scan_cmd.py +150 -1
  23. opencomplai_cli-0.3.0/tests/test_scan_quick.py +96 -0
  24. opencomplai_cli-0.1.2/tests/test_connectors.py +0 -198
  25. {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/README.md +0 -0
  26. {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/src/opencomplai_cli/commands/__init__.py +0 -0
  27. {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/src/opencomplai_cli/commands/interactive_init.py +0 -0
  28. {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/src/opencomplai_cli/connectors/__init__.py +0 -0
  29. {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/tests/__init__.py +0 -0
  30. {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/tests/test_cli.py +0 -0
@@ -57,6 +57,7 @@ docs/checker-widget/node_modules/
57
57
  packages/cli/src/opencomplai_cli/data/checker-local.html
58
58
 
59
59
  # --- Public-repo hardening (keep AI-tooling and internal artifacts out) ---
60
+ .oss-sync-state
60
61
  .claude/
61
62
  .cursor/
62
63
  .autopilot.json
@@ -67,3 +68,17 @@ x.key
67
68
  .tmp-*.db
68
69
  ci-logs/
69
70
  tmp/
71
+
72
+ .vercel
73
+ .env*.local
74
+
75
+ # `vercel env pull <environment>` writes to a file named after the environment
76
+ # when no `-o` path is given, so a mistyped pull drops a real token into the
77
+ # repo root under a name none of the patterns above match. Ignore the three
78
+ # environment names outright rather than relying on catching it in review.
79
+ /development
80
+ /preview
81
+ /production
82
+
83
+ # Scratch worktrees created by local agent tooling.
84
+ .harness-worktrees/
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: opencomplai-cli
3
- Version: 0.1.2
3
+ Version: 0.3.0
4
4
  Summary: Opencomplai command-line interface for EU AI Act compliance assessment
5
5
  Project-URL: Homepage, https://opencomplai.com
6
6
  Project-URL: Documentation, https://docs.opencomplai.com
@@ -12,8 +12,12 @@ Requires-Dist: questionary>=2.0.0
12
12
  Requires-Dist: rich>=13.0.0
13
13
  Requires-Dist: typer>=0.9.0
14
14
  Provides-Extra: dev
15
+ Requires-Dist: jsonschema>=4.21; extra == 'dev'
15
16
  Requires-Dist: pytest>=7.0; extra == 'dev'
16
17
  Requires-Dist: ruff>=0.1.0; extra == 'dev'
18
+ Provides-Extra: serve
19
+ Requires-Dist: fastapi>=0.110; extra == 'serve'
20
+ Requires-Dist: uvicorn>=0.27; extra == 'serve'
17
21
  Description-Content-Type: text/markdown
18
22
 
19
23
  # opencomplai-cli
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "opencomplai-cli"
3
- version = "0.1.2"
3
+ version = "0.3.0"
4
4
  description = "Opencomplai command-line interface for EU AI Act compliance assessment"
5
5
  readme = "README.md"
6
6
  license = { text = "AGPL-3.0-only" }
@@ -24,6 +24,17 @@ opencomplai = "opencomplai_cli.main:app"
24
24
  dev = [
25
25
  "pytest>=7.0",
26
26
  "ruff>=0.1.0",
27
+ # Only used by test_publish.py's structural schema-validation test (an
28
+ # end-to-end proof that the real repo-root compliance-artifact.json,
29
+ # once mapped, validates against dashboard-saas's vendored
30
+ # first_scan_status.schema.json) -- never imported by runtime code.
31
+ # Same package/version floor dashboard-saas/services/ingest-api's
32
+ # pyproject.toml already declares.
33
+ "jsonschema>=4.21",
34
+ ]
35
+ serve = [
36
+ "fastapi>=0.110",
37
+ "uvicorn>=0.27",
27
38
  ]
28
39
 
29
40
  [tool.hatch.build.targets.wheel]
@@ -1,6 +1,6 @@
1
1
  """Opencomplai CLI."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
4
4
 
5
5
  # Canonical project metadata — single source of truth for the `--version`
6
6
  # flag and the `opencomplai info` command. Mirrors the [project] table in
@@ -155,25 +155,48 @@ def run_interactive_wizard(*, skip_allowed: bool = True) -> CheckerSession | Non
155
155
  "Is the system a product with AI as safety component under Annex I harmonisation law?",
156
156
  default=False,
157
157
  )
158
+ if answers["hr1_annex_i"]:
159
+ answers["hr8_conformity_assessment"] = _confirm(
160
+ "Is that Annex I product required to undergo a third-party "
161
+ "conformity assessment by a notified body (Art 6(1)(b))?",
162
+ default=False,
163
+ )
158
164
  answers["hr2_annex_iii"] = _confirm(
159
165
  "Does the system fall within an Annex III high-risk use case?",
160
166
  default=False,
161
167
  )
162
- if answers["hr1_annex_i"] or answers["hr2_annex_iii"]:
168
+ if answers["hr2_annex_iii"]:
169
+ answers["hr7_profiling"] = _confirm(
170
+ "Does the system perform profiling of natural persons (automated "
171
+ "processing of personal data to evaluate, analyse, or predict "
172
+ "aspects such as behaviour, preferences, location, or performance)?",
173
+ default=False,
174
+ )
175
+ # The Art. 6(3)(a)-(d) exceptions are an Annex III derogation only; an
176
+ # Annex I trigger (safety component + conformity assessment) has none.
177
+ hr1_effective = answers["hr1_annex_i"] and answers.get(
178
+ "hr8_conformity_assessment", False
179
+ )
180
+ if answers["hr2_annex_iii"] and not hr1_effective:
163
181
  answers["hr3_art_6_3"] = _confirm(
164
- "Does Article 6(3) apply (safety component required for product conformity)?",
182
+ "Is the AI system intended only to improve the result of a "
183
+ "previously completed human activity (Art 6(3)(b))?",
165
184
  default=False,
166
185
  )
167
186
  answers["hr4_narrow_task"] = _confirm(
168
- "Is the AI intended only for a narrow procedural task (Art 6(3) exception)?",
187
+ "Is the AI intended only for a narrow procedural task (Art 6(3)(a))?",
169
188
  default=False,
170
189
  )
171
190
  answers["hr5_no_significant_risk"] = _confirm(
172
- "Does the system NOT pose significant risk to health, safety, or fundamental rights?",
191
+ "Is the AI system intended only to detect decision-making patterns "
192
+ "or deviations from prior decisions, without replacing or "
193
+ "influencing the prior human assessment and without proper human "
194
+ "review (Art 6(3)(c))?",
173
195
  default=False,
174
196
  )
175
197
  answers["hr6_accessory"] = _confirm(
176
- "Is the system purely accessory to the relevant human decision (Art 6(3))?",
198
+ "Is the AI system intended only to perform a preparatory task for "
199
+ "an assessment relevant to the Annex III use case (Art 6(3)(d))?",
177
200
  default=False,
178
201
  )
179
202
 
@@ -199,6 +222,7 @@ def run_interactive_wizard(*, skip_allowed: bool = True) -> CheckerSession | Non
199
222
  default=False,
200
223
  )
201
224
  if answers["s1_gpai"]:
225
+ _show_help("gpai_systemic_risk_threshold")
202
226
  answers["s1_gpai_systemic_risk"] = _confirm(
203
227
  "Does the GPAI model have systemic risk (high impact capabilities)?",
204
228
  default=False,
@@ -62,6 +62,30 @@ _CONFIG_FILE = _OPENCOMPLAI_DIR / "config.yaml"
62
62
  _SIGNING_PUB = _OPENCOMPLAI_DIR / "signing.pub"
63
63
  _EGRESS_ALLOWLIST = _OPENCOMPLAI_DIR / "egress_allowlist.json"
64
64
  _LEDGER_FILE = _OPENCOMPLAI_DIR / "ledger.jsonl"
65
+ _CLIENT_SECRET_FILE = _OPENCOMPLAI_DIR / "client.secret"
66
+
67
+
68
+ def _write_client_secret(raw_secret: str) -> None:
69
+ """
70
+ Persist the plaintext client_secret received once from /enroll. Stored
71
+ as a dedicated file (mirroring signing.key/signing.pub's separateness
72
+ from config.yaml's non-secret flat scalars), chmod 0600 where the
73
+ platform supports it — a no-op on Windows, where this permission model
74
+ doesn't apply the same way.
75
+ """
76
+ _OPENCOMPLAI_DIR.mkdir(parents=True, exist_ok=True)
77
+ _CLIENT_SECRET_FILE.write_text(raw_secret)
78
+ try:
79
+ os.chmod(_CLIENT_SECRET_FILE, 0o600)
80
+ except (OSError, NotImplementedError):
81
+ pass
82
+
83
+
84
+ def _read_client_secret() -> str | None:
85
+ if not _CLIENT_SECRET_FILE.exists():
86
+ return None
87
+ secret = _CLIENT_SECRET_FILE.read_text().strip()
88
+ return secret or None
65
89
 
66
90
 
67
91
  # ---------------------------------------------------------------------------
@@ -242,6 +266,17 @@ def enroll(
242
266
  audit_event_hash = data.get("audit_event_hash", "")
243
267
  dashboard_url_from_api = data.get("dashboard_url", base_url)
244
268
 
269
+ # --- Persist OIDC client-credentials pair (AUTH-SAAS) ---
270
+ # /enroll is the only place these are ever returned; client_id is not
271
+ # secret (like install_id) and lives in config.yaml, client_secret gets
272
+ # its own file since it must never be handed back a second time.
273
+ client_id = data.get("client_id", "")
274
+ client_secret = data.get("client_secret", "")
275
+ if client_id and client_secret:
276
+ cfg["client_id"] = client_id
277
+ _write_config(cfg)
278
+ _write_client_secret(client_secret)
279
+
245
280
  # --- Write EgressConsent ledger event (OSS-side immutable ledger) ---
246
281
  from datetime import UTC, datetime
247
282
 
@@ -269,6 +304,9 @@ def enroll(
269
304
  _console.print(f" tenant_id: {tenant}")
270
305
  _console.print(f" install_id: {install_id}")
271
306
  _console.print(f" audit_event_hash: {audit_event_hash}")
307
+ if client_id and client_secret:
308
+ _console.print(f" client_id: {client_id}")
309
+ _console.print(f" client_secret: saved to {_CLIENT_SECRET_FILE}")
272
310
  _console.print(f"\nDashboard: {dashboard_url_from_api}")
273
311
  _console.print(
274
312
  "\nNext step: run [bold]opencomplai check --sign[/bold] to emit your first signed artifact."
@@ -307,6 +345,18 @@ def withdraw(
307
345
  ).rstrip("/")
308
346
 
309
347
  # --- Notify dashboard if URL is available ---
348
+ # Best-effort and non-fatal by design (an offline install can still clear
349
+ # its own local state) — but a >=400 response is not the same as being
350
+ # unreachable: the dashboard *processed* the request and refused it, so
351
+ # egress consent almost certainly stays active server-side. That case
352
+ # gets its own louder message below rather than folding into the local
353
+ # "Withdrawal complete" line, which would otherwise claim a success that
354
+ # did not happen remotely (admin-api's /v1/admin/withdraw requires an
355
+ # authenticated, tenant-scoped service JWT as of SEC-2 — this CLI does
356
+ # not yet mint one, so every call currently gets 401; see HANDOFF.md).
357
+ # Vacuously true when no dashboard URL is configured at all — a purely
358
+ # local install has no remote consent state to fall out of sync with.
359
+ dashboard_notified = not base_url
310
360
  if base_url:
311
361
  try:
312
362
  status, data = _post(
@@ -315,9 +365,12 @@ def withdraw(
315
365
  )
316
366
  if status >= 400:
317
367
  _err_console.print(
318
- f"[yellow]Warning:[/yellow] dashboard withdraw call returned {status}: "
319
- f"{data.get('message', data)}"
368
+ f"[red]Error:[/red] dashboard rejected the withdraw request "
369
+ f"({status}: {data.get('message', data)}) — egress consent was "
370
+ "NOT revoked server-side. Clearing local state only."
320
371
  )
372
+ else:
373
+ dashboard_notified = True
321
374
  except ConnectionError as exc:
322
375
  _err_console.print(
323
376
  f"[yellow]Warning:[/yellow] could not reach dashboard: {exc}"
@@ -342,9 +395,16 @@ def withdraw(
342
395
  _write_egress_allowlist(allowlist)
343
396
 
344
397
  if removed:
345
- _console.print(
346
- f"[green]Withdrawal complete.[/green] Egress allowlist entry removed for tenant {tenant}."
347
- )
398
+ if dashboard_notified:
399
+ _console.print(
400
+ f"[green]Withdrawal complete.[/green] Egress allowlist entry removed for tenant {tenant}."
401
+ )
402
+ else:
403
+ _console.print(
404
+ f"[yellow]Local withdrawal complete[/yellow] — egress allowlist entry removed "
405
+ f"for tenant {tenant}, but the dashboard was not notified (see error above). "
406
+ "Server-side egress consent likely remains active."
407
+ )
348
408
  else:
349
409
  _console.print(
350
410
  f"[dim]No egress allowlist entry found for tenant {tenant} — nothing to remove.[/dim]"
@@ -0,0 +1,153 @@
1
+ """
2
+ CLI: ``opencomplai push`` — publish a signed ``compliance-artifact.json`` to
3
+ the Opencomplai Premium Dashboard (GO-LIVE CORE-4).
4
+
5
+ Deliberately a standalone command, not a ``--push`` flag on ``check``/
6
+ ``scan``: those commands' exit-code semantics are a CI-gating contract
7
+ (0/1/2/3/4, see ``main.py``'s module docstring) that must never depend on
8
+ network reachability. ``push`` is a separate, opt-in step a pipeline runs
9
+ *after* ``check`` has already gated the build locally.
10
+
11
+ Auth model: API-key, not the CI connectors' JWT/OIDC path. ``push`` sends
12
+ no ``install_id`` in its envelope at all -- ``dashboard_ingest.routes``
13
+ derives it server-side from the authenticated API key's own project (see
14
+ ingest-api's ``test_key_auth.py::test_envelope_install_id_unknown_is
15
+ _overridden_by_principal``), and a key-authed request MAY omit
16
+ ``signature`` entirely since the key itself attests authenticity
17
+ (``dashboard_ingest.contract.validate_envelope_shape``'s
18
+ ``require_install_id=False`` default for this path).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ from pathlib import Path
26
+ from typing import Any
27
+ from urllib.parse import urlparse
28
+
29
+ from rich.console import Console
30
+ from rich.markup import escape
31
+
32
+ from opencomplai_cli.publish import (
33
+ envelope_signature,
34
+ prepare_scan_status_artifact,
35
+ publish_scan_status,
36
+ )
37
+
38
+ console = Console()
39
+ err_console = Console(stderr=True)
40
+
41
+ EXIT_OK = 0
42
+ EXIT_PUBLISH_FAILED = 3
43
+
44
+ _LOCAL_HOSTNAMES = {"localhost", "127.0.0.1"}
45
+
46
+
47
+ def run_push(artifact_file: Path, *, env: dict[str, str] | None = None) -> int:
48
+ """
49
+ Read ``artifact_file``, shape it, and POST it to the dashboard.
50
+
51
+ Returns the process exit code (0 on HTTP 200/201, 3 otherwise) instead
52
+ of calling ``sys.exit`` directly -- same convention as
53
+ ``connectors.github_actions.run_connector`` -- so tests can assert on
54
+ the return value without catching ``SystemExit``. The ``env`` mapping
55
+ defaults to ``os.environ`` and exists for the same reason: tests pass a
56
+ synthetic environment instead of monkeypatching process-global state.
57
+ """
58
+ _env = env if env is not None else os.environ
59
+
60
+ if not artifact_file.exists():
61
+ err_console.print(f"[red]Error:[/red] artifact file not found: {artifact_file}")
62
+ err_console.print(
63
+ "Run [bold]opencomplai check --sign[/bold] first to produce one."
64
+ )
65
+ return EXIT_PUBLISH_FAILED
66
+
67
+ # A directory path, an unreadable file, or non-UTF-8 bytes must all
68
+ # produce a clean one-line error rather than an uncaught traceback --
69
+ # this is a CLI a pipeline script parses stderr from, not a Python API.
70
+ try:
71
+ raw_text = artifact_file.read_text(encoding="utf-8")
72
+ except (OSError, UnicodeDecodeError) as exc:
73
+ err_console.print(f"[red]Error:[/red] could not read {artifact_file}: {exc}")
74
+ return EXIT_PUBLISH_FAILED
75
+
76
+ try:
77
+ artifact: Any = json.loads(raw_text)
78
+ except json.JSONDecodeError as exc:
79
+ err_console.print(f"[red]Error:[/red] {artifact_file} is not valid JSON: {exc}")
80
+ return EXIT_PUBLISH_FAILED
81
+
82
+ if not isinstance(artifact, dict):
83
+ err_console.print(
84
+ f"[red]Error:[/red] {artifact_file} must contain a JSON object "
85
+ "(a ScanStatusArtifact), not a "
86
+ f"{type(artifact).__name__}"
87
+ )
88
+ return EXIT_PUBLISH_FAILED
89
+
90
+ api_key = _env.get("OPENCOMPLAI_API_KEY", "")
91
+ dashboard_url = _env.get("OPENCOMPLAI_DASHBOARD_URL", "").rstrip("/")
92
+ if not api_key or not dashboard_url:
93
+ err_console.print(
94
+ "[red]Error:[/red] OPENCOMPLAI_API_KEY and OPENCOMPLAI_DASHBOARD_URL "
95
+ "must both be set."
96
+ )
97
+ return EXIT_PUBLISH_FAILED
98
+
99
+ parsed_url = urlparse(dashboard_url)
100
+ if parsed_url.scheme == "http" and parsed_url.hostname not in _LOCAL_HOSTNAMES:
101
+ err_console.print(
102
+ f"[yellow]Warning:[/yellow] OPENCOMPLAI_DASHBOARD_URL ({dashboard_url}) "
103
+ "uses plain http -- the API key will be sent unencrypted over the network. "
104
+ "Use https unless this is local development."
105
+ )
106
+
107
+ prepared = prepare_scan_status_artifact(
108
+ artifact, commit_env=_env, repo_dir=artifact_file.resolve().parent
109
+ )
110
+
111
+ # No install_id (see module docstring) -- the server derives it from
112
+ # the API key's own project. signature is only forwarded when the
113
+ # mapper changed nothing at all (G-5, see publish.envelope_signature);
114
+ # otherwise it falls back to "" (never null) so the envelope always
115
+ # matches the JSON Schema's string-or-absent shape.
116
+ signature = envelope_signature(artifact, prepared)
117
+ original_signature = artifact.get("signature")
118
+ if (
119
+ isinstance(original_signature, str)
120
+ and original_signature != ""
121
+ and not signature
122
+ ):
123
+ err_console.print(
124
+ "[dim]note: artifact signature not forwarded (mapped payload differs "
125
+ "from signed bytes; the API key attests this push)[/dim]"
126
+ )
127
+
128
+ envelope = {
129
+ "system_id": prepared.get("system_id", "unknown"),
130
+ "artifact": prepared,
131
+ "signature": signature,
132
+ }
133
+
134
+ status, data = publish_scan_status(dashboard_url, api_key, envelope)
135
+
136
+ if status in (200, 201):
137
+ outcome = escape(
138
+ str(data.get("outcome", "accepted" if status == 201 else "replayed"))
139
+ )
140
+ content_hash = escape(str(data.get("content_hash", "")))
141
+ console.print(f"[green]Push {outcome}.[/green]")
142
+ console.print(f" system_id: {envelope['system_id']}")
143
+ console.print(f" content_hash: {content_hash}")
144
+ console.print(f"\nDashboard: {dashboard_url}")
145
+ return EXIT_OK
146
+
147
+ # `data` is server-controlled (a JSON error body echoed back to the
148
+ # terminal) -- escaped so it can never inject rich markup.
149
+ err_console.print(f"[red]Push failed ({status}):[/red] {escape(str(data))}")
150
+ return EXIT_PUBLISH_FAILED
151
+
152
+
153
+ __all__ = ["EXIT_OK", "EXIT_PUBLISH_FAILED", "run_push"]
@@ -0,0 +1,37 @@
1
+ """CLI: opencomplai serve — local loopback dashboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from rich.console import Console
10
+
11
+ err_console = Console(stderr=True)
12
+
13
+
14
+ def run_serve(project_root: Path, host: str, port: int) -> None:
15
+ if host not in {"127.0.0.1", "localhost"}:
16
+ err_console.print(
17
+ "[red]Error:[/red] serve binds to 127.0.0.1/localhost only "
18
+ "(this is not the Pro SaaS dashboard)."
19
+ )
20
+ raise typer.Exit(2)
21
+ try:
22
+ import uvicorn
23
+ except ImportError as exc:
24
+ err_console.print(
25
+ "[red]Error:[/red] Install the serve extra: "
26
+ "pip install 'opencomplai-cli[serve]'"
27
+ )
28
+ raise typer.Exit(2) from exc
29
+
30
+ from opencomplai_core.local_dashboard import create_app
31
+
32
+ app = create_app(project_root.resolve())
33
+ err_console.print(
34
+ f"[green]OpenComplAI local dashboard[/green] on http://{host}:{port}/ "
35
+ "(loopback only — not Pro/SaaS)"
36
+ )
37
+ uvicorn.run(app, host=host, port=port, log_level="info")
@@ -19,7 +19,19 @@ Environment variables consumed
19
19
  ``GITHUB_STEP_SUMMARY`` — path to the job summary markdown file.
20
20
  ``OPENCOMPLAI_DASHBOARD_URL`` — dashboard ingest base URL.
21
21
  ``OPENCOMPLAI_TENANT_ID`` — tenant ID for the current org.
22
- ``OPENCOMPLAI_AUTH_TOKEN`` — bearer token for dashboard ingest.
22
+ ``OPENCOMPLAI_API_KEY`` — an ``ock_...`` API key (GO-LIVE CORE-3/-4).
23
+ Takes precedence over everything below: when
24
+ set, the connector sends the key-authed
25
+ envelope (no ``install_id``) and never
26
+ touches ``OPENCOMPLAI_AUTH_TOKEN`` or OIDC.
27
+ ``OPENCOMPLAI_AUTH_TOKEN`` — bearer token for dashboard ingest (legacy
28
+ JWT path, only consulted when
29
+ OPENCOMPLAI_API_KEY is unset). Takes
30
+ precedence over the three vars below when set.
31
+ ``OPENCOMPLAI_CLIENT_ID`` — OIDC client_id from /enroll, used only when
32
+ OPENCOMPLAI_AUTH_TOKEN is unset.
33
+ ``OPENCOMPLAI_CLIENT_SECRET`` — OIDC client_secret from /enroll (same gate).
34
+ ``OPENCOMPLAI_TOKEN_ENDPOINT`` — the IdP's OAuth2 token endpoint (same gate).
23
35
 
24
36
  Exit codes
25
37
  ----------
@@ -49,8 +61,14 @@ import json
49
61
  import os
50
62
  import subprocess
51
63
  import sys
64
+ from pathlib import Path
52
65
  from typing import Any
53
66
 
67
+ # `check --sign` (main.py's check_cmd) always writes its artifact here,
68
+ # relative to whatever cwd the subprocess inherited -- this connector never
69
+ # passes cwd= to subprocess.run, so that is this process's own cwd too.
70
+ _ARTIFACT_FILENAME = "compliance-artifact.json"
71
+
54
72
  # ---------------------------------------------------------------------------
55
73
  # Platform detection helpers
56
74
  # ---------------------------------------------------------------------------
@@ -119,8 +137,18 @@ def run_connector(
119
137
  stdout = result.stdout
120
138
  stderr = result.stderr
121
139
 
122
- # Parse the signed artifact result from stdout (JSON line on stdout).
123
- artifact_result = _parse_artifact_result(stdout)
140
+ # Prefer the artifact `check --sign` wrote to disk. The `cmd` list
141
+ # above never passes `-o json`, so real stdout is Rich's indent=2
142
+ # human/JSON output (`console.print_json`) -- never the single-line
143
+ # JSON-object-per-line shape `_parse_artifact_result` scans for, so
144
+ # that parse essentially never matches a real CI run and this
145
+ # connector silently never published anything. The stdout parse is
146
+ # kept as a fallback for a caller who passes `-o json` explicitly via
147
+ # `check_args`, or if the artifact file is missing for some other
148
+ # reason (e.g. `check` crashed before writing it).
149
+ artifact_result = _read_disk_artifact(
150
+ Path(_ARTIFACT_FILENAME)
151
+ ) or _parse_artifact_result(stdout)
124
152
 
125
153
  # Set step output.
126
154
  if artifact_result:
@@ -153,6 +181,26 @@ def run_connector(
153
181
  return 0
154
182
 
155
183
 
184
+ def _read_disk_artifact(path: Path) -> dict[str, Any] | None:
185
+ """Read a ``ScanStatusArtifact`` dict written to disk by ``check --sign``.
186
+
187
+ Returns ``None`` (never raises) on anything short of a real artifact --
188
+ missing file, unreadable/non-UTF-8, invalid JSON, JSON that isn't an
189
+ object, or an object missing the ``result`` field -- so callers can
190
+ fall back to the stdout parse in every one of those cases exactly as
191
+ if the disk read had never been attempted.
192
+ """
193
+ if not path.exists():
194
+ return None
195
+ try:
196
+ data = json.loads(path.read_text(encoding="utf-8"))
197
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
198
+ return None
199
+ if not isinstance(data, dict) or "result" not in data:
200
+ return None
201
+ return data
202
+
203
+
156
204
  def _parse_artifact_result(stdout: str) -> dict[str, Any] | None:
157
205
  for line in stdout.splitlines():
158
206
  line = line.strip()
@@ -204,35 +252,88 @@ def _publish_to_dashboard(artifact: dict | None, env: dict[str, str]) -> None:
204
252
  if not artifact:
205
253
  return
206
254
  base_url = env.get("OPENCOMPLAI_DASHBOARD_URL", "").rstrip("/")
255
+ if not base_url:
256
+ return
257
+
258
+ from opencomplai_cli.publish import (
259
+ envelope_signature,
260
+ prepare_scan_status_artifact,
261
+ publish_scan_status,
262
+ )
263
+
264
+ # G-4: the raw artifact never validated against ingest's schema
265
+ # (additionalProperties: false rejected evidence_hashes/rationale_hash/
266
+ # scan_summary/gap_report/eval_summary/install_id outright, and
267
+ # commit_ref="HEAD" failed minLength: 7) — every payload this connector
268
+ # ever sent was rejected with a 422. prepare_scan_status_artifact fills
269
+ # in only what the OSS artifact genuinely lacks (policy_bundle_version,
270
+ # timestamp, a real commit_ref); everything else passes through
271
+ # untouched onto the now-widened schema.
272
+
273
+ api_key = env.get("OPENCOMPLAI_API_KEY", "")
274
+ if api_key:
275
+ # ock_ key-authed path (GO-LIVE CORE-3/CORE-4) — the same auth
276
+ # `opencomplai push` uses. Takes priority over OPENCOMPLAI_AUTH_TOKEN
277
+ # and the OIDC client-credentials grant below: a caller who sets
278
+ # OPENCOMPLAI_API_KEY has opted into the key-authed contract, which
279
+ # is a different envelope shape (no install_id at all — the server
280
+ # derives it from the key's own project), not just a different
281
+ # bearer value on the same envelope.
282
+ prepared = prepare_scan_status_artifact(artifact, commit_env=env)
283
+ envelope = {
284
+ "system_id": prepared.get("system_id", "unknown"),
285
+ "artifact": prepared,
286
+ "signature": envelope_signature(artifact, prepared),
287
+ }
288
+ status, data = publish_scan_status(base_url, api_key, envelope)
289
+ if status not in (200, 201):
290
+ _annotate(
291
+ "warning",
292
+ f"Opencomplai dashboard publish failed: HTTP {status} {data}",
293
+ )
294
+ return
295
+
296
+ # Legacy JWT/OIDC path (pre-CORE-3 envelope contract). An operator-set
297
+ # OPENCOMPLAI_AUTH_TOKEN always wins. If unset, acquire one via the
298
+ # OIDC client-credentials grant using the pair /enroll issued
299
+ # (AUTH-SAAS) — same trio of env vars, no manual token pasting.
207
300
  token = env.get("OPENCOMPLAI_AUTH_TOKEN", "")
208
- if not base_url or not token:
301
+ if not token:
302
+ client_id = env.get("OPENCOMPLAI_CLIENT_ID", "")
303
+ client_secret = env.get("OPENCOMPLAI_CLIENT_SECRET", "")
304
+ token_endpoint = env.get("OPENCOMPLAI_TOKEN_ENDPOINT", "")
305
+ if client_id and client_secret and token_endpoint:
306
+ from opencomplai_cli.oidc_client import OidcTokenError, acquire_token
307
+
308
+ try:
309
+ token, _ = acquire_token(token_endpoint, client_id, client_secret)
310
+ except OidcTokenError as exc:
311
+ _annotate(
312
+ "warning", f"Opencomplai OIDC token acquisition failed: {exc}"
313
+ )
314
+
315
+ if not token:
209
316
  return
210
317
 
211
- import urllib.error
212
- import urllib.request
318
+ prepared = prepare_scan_status_artifact(artifact, commit_env=env)
213
319
 
214
320
  install_id = env.get("OPENCOMPLAI_INSTALL_ID", "unknown")
215
- payload = {
321
+ envelope = {
216
322
  "install_id": install_id,
217
- "system_id": artifact.get("system_id", "unknown"),
218
- "artifact": artifact,
219
- "signature": artifact.get("signature", ""),
323
+ "system_id": prepared.get("system_id", "unknown"),
324
+ "artifact": prepared,
325
+ # envelope_signature (not prepared.get("signature", "")) — otherwise
326
+ # an unsigned OSS artifact dict carrying an explicit
327
+ # `"signature": None` key would send JSON `null`: .get()'s default
328
+ # only applies when the key is absent, not when it's None.
329
+ "signature": envelope_signature(artifact, prepared),
220
330
  }
221
- data = json.dumps(payload).encode()
222
- req = urllib.request.Request(
223
- f"{base_url}/v1/ingest/scan-status",
224
- data=data,
225
- headers={
226
- "Content-Type": "application/json",
227
- "Authorization": f"Bearer {token}",
228
- },
229
- method="POST",
230
- )
231
- try:
232
- with urllib.request.urlopen(req, timeout=30):
233
- pass
234
- except Exception as exc:
235
- _annotate("warning", f"Opencomplai dashboard publish failed: {exc}")
331
+ status, data = publish_scan_status(base_url, token, envelope)
332
+ if status not in (200, 201):
333
+ _annotate(
334
+ "warning",
335
+ f"Opencomplai dashboard publish failed: HTTP {status} {data}",
336
+ )
236
337
 
237
338
 
238
339
  # ---------------------------------------------------------------------------