opencomplai-cli 0.5.0__tar.gz → 0.7.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 (55) hide show
  1. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/.gitignore +2 -0
  2. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/PKG-INFO +40 -4
  3. opencomplai_cli-0.7.0/README.md +117 -0
  4. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/pyproject.toml +1 -1
  5. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/__init__.py +1 -1
  6. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/commands/dashboard.py +129 -60
  7. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/commands/push.py +104 -1
  8. opencomplai_cli-0.7.0/src/opencomplai_cli/commands/qms.py +177 -0
  9. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/commands/serve.py +0 -1
  10. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/connectors/github_actions.py +91 -21
  11. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/connectors/gitlab_ci.py +95 -21
  12. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/main.py +385 -10
  13. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/oidc_client.py +4 -2
  14. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/publish.py +136 -0
  15. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/tests/test_checker_cmd.py +8 -2
  16. opencomplai_cli-0.7.0/tests/test_ci_integration_guide.py +108 -0
  17. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/tests/test_connectors.py +5 -4
  18. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/tests/test_dashboard_cmd.py +116 -5
  19. opencomplai_cli-0.7.0/tests/test_docs_generate_push.py +170 -0
  20. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/tests/test_docs_generate_wiring.py +68 -1
  21. opencomplai_cli-0.7.0/tests/test_fria_generate.py +153 -0
  22. opencomplai_cli-0.7.0/tests/test_gaps_mapped_column.py +54 -0
  23. opencomplai_cli-0.7.0/tests/test_gaps_nist_rmf_target.py +211 -0
  24. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/tests/test_oidc_client.py +8 -2
  25. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/tests/test_publish.py +265 -0
  26. opencomplai_cli-0.7.0/tests/test_push_cmd_dossier.py +263 -0
  27. opencomplai_cli-0.7.0/tests/test_qms_generate_cmd.py +96 -0
  28. opencomplai_cli-0.7.0/tests/test_readme_command_table.py +80 -0
  29. opencomplai_cli-0.7.0/tests/test_readme_quickstart_pin.py +94 -0
  30. opencomplai_cli-0.7.0/tests/test_recommend_qms_clauses.py +77 -0
  31. opencomplai_cli-0.5.0/README.md +0 -81
  32. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/_encoding.py +0 -0
  33. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/commands/__init__.py +0 -0
  34. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/commands/checker.py +0 -0
  35. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/commands/controls.py +0 -0
  36. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/commands/halt.py +0 -0
  37. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/commands/interactive_init.py +0 -0
  38. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/connectors/__init__.py +0 -0
  39. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/data/checker-local.html +0 -0
  40. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/exit_codes.py +0 -0
  41. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/tests/__init__.py +0 -0
  42. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/tests/fixtures/trap/trap-manifest.json +0 -0
  43. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/tests/test_ai_preload.py +0 -0
  44. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/tests/test_cli.py +0 -0
  45. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/tests/test_controls_cmd.py +0 -0
  46. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/tests/test_controls_sync.py +0 -0
  47. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/tests/test_encoding.py +0 -0
  48. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/tests/test_eu_ai_scan_golden.py +0 -0
  49. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/tests/test_evidence_provenance.py +0 -0
  50. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/tests/test_exit_codes.py +0 -0
  51. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/tests/test_halt_wire.py +0 -0
  52. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/tests/test_push_cmd.py +0 -0
  53. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/tests/test_scan_cmd.py +0 -0
  54. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/tests/test_scan_quick.py +0 -0
  55. {opencomplai_cli-0.5.0 → opencomplai_cli-0.7.0}/tests/test_service_check.py +0 -0
@@ -86,3 +86,5 @@ tmp/
86
86
 
87
87
  # Scratch worktrees created by local agent tooling.
88
88
  .harness-worktrees/
89
+
90
+ .vercel
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: opencomplai-cli
3
- Version: 0.5.0
3
+ Version: 0.7.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
@@ -50,13 +50,32 @@ meta-package instead.
50
50
  | `opencomplai init` | Scaffold a `system-manifest.json` for your project |
51
51
  | `opencomplai scan` | Corroborate the manifest against your code and report discrepancies |
52
52
  | `opencomplai check` | Run the compliance gate and write `compliance-artifact.json` |
53
- | `opencomplai dashboard` | Manage premium dashboard enrollment and sync |
53
+ | `opencomplai push` | Publish a signed artifact (scan status or Annex IV dossier) to the Premium Dashboard |
54
+ | `opencomplai checker` | Run the interactive EU AI Act applicability checker |
55
+ | `opencomplai gaps` | Print a per-article EU AI Act gap report (informational — never gates CI) |
56
+ | `opencomplai recommend` | Write copy-paste remediation templates for Missing/Partial gap-report rows |
57
+ | `opencomplai report` | Render a single shareable HTML/PDF compliance report |
58
+ | `opencomplai eval` | Run safety, bias, and data-leakage pipeline evaluators |
59
+ | `opencomplai validate-manifest` | Validate a `system-manifest.json` against the required schema |
60
+ | `opencomplai serve` | Start a localhost-only scan dashboard (not Pro/SaaS) |
61
+ | `opencomplai approve` | Mint a signed HITL approval token for a `HALTED_PENDING_REVIEW` system |
62
+ | `opencomplai resume` | Resume a `HALTED_PENDING_REVIEW` system with a signed approval token |
63
+ | `opencomplai verify-output` | Verify an AI output claim against ground-truth sources |
64
+ | `opencomplai version` | Show the installed Opencomplai version |
65
+ | `opencomplai info` | Show full package metadata (`pip show`-style, across the whole suite) |
66
+
67
+ Also available as command groups (`opencomplai <group> --help` for their own subcommands):
68
+ `docs` (Annex IV dossier generation), `risk` (risk classification), `sync` (metadata sync),
69
+ `keys` (signing-key rotation), `ai` (optional AI-intent plugin configuration), `controls`
70
+ (control-register status).
54
71
 
55
72
  Run `opencomplai --help` for the full command list, or `opencomplai <command> --help` for
56
73
  options.
57
74
 
58
75
  ## Quick start
59
76
 
77
+ The self-serve path: mint a key on the dashboard, export two env vars, sign a scan, push it.
78
+
60
79
  ```bash
61
80
  # 1. Scaffold a manifest for your project
62
81
  opencomplai init
@@ -64,10 +83,23 @@ opencomplai init
64
83
  # 2. Cross-check the manifest against your source tree
65
84
  opencomplai scan --manifest system-manifest.json --repo-root .
66
85
 
67
- # 3. Run the compliance gate (writes compliance-artifact.json)
68
- opencomplai check
86
+ # 3. Get an API key from the dashboard's /connect page (Projects -> your
87
+ # project -> Connect), then export it alongside the dashboard's ingest URL
88
+ export OPENCOMPLAI_API_KEY=ock_...
89
+ export OPENCOMPLAI_DASHBOARD_URL=https://your-dashboard-host/api/ingest
90
+
91
+ # 4. Run the compliance gate and sign the artifact (writes compliance-artifact.json)
92
+ opencomplai check --sign
93
+
94
+ # 5. Push the signed artifact to the dashboard
95
+ opencomplai push
69
96
  ```
70
97
 
98
+ Following this start-to-finish lands the scan on the dashboard's `/systems` page for that
99
+ project. `/connect` also generates ready-to-paste GitHub Actions / GitLab CI snippets that
100
+ wire the same two env vars into a pipeline — see
101
+ [CI integration](https://docs.opencomplai.com/guides/ci-integration/).
102
+
71
103
  `opencomplai check` is the canonical CI gate. Its exit code is contractual:
72
104
 
73
105
  | Exit code | Meaning |
@@ -84,6 +116,10 @@ So you can wire it straight into CI:
84
116
  opencomplai check || exit $?
85
117
  ```
86
118
 
119
+ To publish an Annex IV dossier instead of (or in addition to) a scan-status artifact, run
120
+ `opencomplai docs generate --system-id ... --push` — same `OPENCOMPLAI_API_KEY` /
121
+ `OPENCOMPLAI_DASHBOARD_URL` as `opencomplai push` above.
122
+
87
123
  ## Optional: AI intent analysis
88
124
 
89
125
  Install the [`opencomplai-ai`](https://pypi.org/project/opencomplai-ai/) plugin to unlock
@@ -0,0 +1,117 @@
1
+ # opencomplai-cli
2
+
3
+ [![License: AGPL-3.0](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
4
+ [![PyPI](https://img.shields.io/pypi/v/opencomplai-cli.svg)](https://pypi.org/project/opencomplai-cli/)
5
+ [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/)
6
+
7
+ The `opencomplai` command-line tool for EU AI Act compliance assessment. It scans your
8
+ repository, classifies your AI system against the EU AI Act, and produces an auditable,
9
+ CI-gateable compliance artifact.
10
+
11
+ Built on [`opencomplai-core`](https://pypi.org/project/opencomplai-core/) — the same
12
+ deterministic, rule-based risk engine, with a rich terminal UX.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pip install opencomplai-cli
18
+ ```
19
+
20
+ This pulls in `opencomplai-core[reports]` automatically. To install the full suite
21
+ (engine + CLI) in one step, use the [`opencomplai`](https://pypi.org/project/opencomplai/)
22
+ meta-package instead.
23
+
24
+ ## Core commands
25
+
26
+ | Command | What it does |
27
+ |---|---|
28
+ | `opencomplai init` | Scaffold a `system-manifest.json` for your project |
29
+ | `opencomplai scan` | Corroborate the manifest against your code and report discrepancies |
30
+ | `opencomplai check` | Run the compliance gate and write `compliance-artifact.json` |
31
+ | `opencomplai push` | Publish a signed artifact (scan status or Annex IV dossier) to the Premium Dashboard |
32
+ | `opencomplai checker` | Run the interactive EU AI Act applicability checker |
33
+ | `opencomplai gaps` | Print a per-article EU AI Act gap report (informational — never gates CI) |
34
+ | `opencomplai recommend` | Write copy-paste remediation templates for Missing/Partial gap-report rows |
35
+ | `opencomplai report` | Render a single shareable HTML/PDF compliance report |
36
+ | `opencomplai eval` | Run safety, bias, and data-leakage pipeline evaluators |
37
+ | `opencomplai validate-manifest` | Validate a `system-manifest.json` against the required schema |
38
+ | `opencomplai serve` | Start a localhost-only scan dashboard (not Pro/SaaS) |
39
+ | `opencomplai approve` | Mint a signed HITL approval token for a `HALTED_PENDING_REVIEW` system |
40
+ | `opencomplai resume` | Resume a `HALTED_PENDING_REVIEW` system with a signed approval token |
41
+ | `opencomplai verify-output` | Verify an AI output claim against ground-truth sources |
42
+ | `opencomplai version` | Show the installed Opencomplai version |
43
+ | `opencomplai info` | Show full package metadata (`pip show`-style, across the whole suite) |
44
+
45
+ Also available as command groups (`opencomplai <group> --help` for their own subcommands):
46
+ `docs` (Annex IV dossier generation), `risk` (risk classification), `sync` (metadata sync),
47
+ `keys` (signing-key rotation), `ai` (optional AI-intent plugin configuration), `controls`
48
+ (control-register status).
49
+
50
+ Run `opencomplai --help` for the full command list, or `opencomplai <command> --help` for
51
+ options.
52
+
53
+ ## Quick start
54
+
55
+ The self-serve path: mint a key on the dashboard, export two env vars, sign a scan, push it.
56
+
57
+ ```bash
58
+ # 1. Scaffold a manifest for your project
59
+ opencomplai init
60
+
61
+ # 2. Cross-check the manifest against your source tree
62
+ opencomplai scan --manifest system-manifest.json --repo-root .
63
+
64
+ # 3. Get an API key from the dashboard's /connect page (Projects -> your
65
+ # project -> Connect), then export it alongside the dashboard's ingest URL
66
+ export OPENCOMPLAI_API_KEY=ock_...
67
+ export OPENCOMPLAI_DASHBOARD_URL=https://your-dashboard-host/api/ingest
68
+
69
+ # 4. Run the compliance gate and sign the artifact (writes compliance-artifact.json)
70
+ opencomplai check --sign
71
+
72
+ # 5. Push the signed artifact to the dashboard
73
+ opencomplai push
74
+ ```
75
+
76
+ Following this start-to-finish lands the scan on the dashboard's `/systems` page for that
77
+ project. `/connect` also generates ready-to-paste GitHub Actions / GitLab CI snippets that
78
+ wire the same two env vars into a pipeline — see
79
+ [CI integration](https://docs.opencomplai.com/guides/ci-integration/).
80
+
81
+ `opencomplai check` is the canonical CI gate. Its exit code is contractual:
82
+
83
+ | Exit code | Meaning |
84
+ |---|---|
85
+ | `0` | PASS |
86
+ | `1` | CONTROL_FAIL |
87
+ | `2` | VALIDATION_FAIL |
88
+ | `3` | POLICY_BLOCK |
89
+ | `4` | TRAP_DETECTED |
90
+
91
+ So you can wire it straight into CI:
92
+
93
+ ```bash
94
+ opencomplai check || exit $?
95
+ ```
96
+
97
+ To publish an Annex IV dossier instead of (or in addition to) a scan-status artifact, run
98
+ `opencomplai docs generate --system-id ... --push` — same `OPENCOMPLAI_API_KEY` /
99
+ `OPENCOMPLAI_DASHBOARD_URL` as `opencomplai push` above.
100
+
101
+ ## Optional: AI intent analysis
102
+
103
+ Install the [`opencomplai-ai`](https://pypi.org/project/opencomplai-ai/) plugin to unlock
104
+ the `--ai-intent` flag, which classifies how each AI callsite is actually used:
105
+
106
+ ```bash
107
+ pip install opencomplai-ai
108
+ opencomplai scan --ai-intent
109
+ ```
110
+
111
+ ## Documentation
112
+
113
+ Full CLI reference and guides at **[docs.opencomplai.com](https://docs.opencomplai.com)**.
114
+
115
+ ## License
116
+
117
+ AGPL-3.0-only. See [LICENSE](https://www.gnu.org/licenses/agpl-3.0).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "opencomplai-cli"
3
- version = "0.5.0"
3
+ version = "0.7.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" }
@@ -1,6 +1,6 @@
1
1
  """Opencomplai CLI."""
2
2
 
3
- __version__ = "0.5.0"
3
+ __version__ = "0.7.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
@@ -1,26 +1,25 @@
1
1
  """
2
2
  CLI dashboard sub-commands (C0.8).
3
3
 
4
- Subcommand: ``opencomplai dashboard enroll``
5
- Opt an OSS install into the Premium Dashboard.
6
-
7
- 1. Validates the bootstrap token against the dashboard
8
- ``POST /v1/admin/enroll`` (rate-limited, one-time-use).
9
- 2. Registers the install's signing key(s) with the tenant.
10
- 3. Writes an ``EgressConsent`` (``consent_scope=dashboard_metadata``) to
11
- the OSS-side ledger.
12
- 4. Adds the dashboard endpoint to the local egress-proxy allowlist with a
13
- per-endpoint policy reference.
14
- 5. Prints the dashboard URL and an audit-event hash the operator can verify.
15
-
16
- Idempotent: re-running with the same tenant returns the existing enrollment
17
- and emits no new ledger event.
4
+ Subcommand: ``opencomplai dashboard enroll`` (hidden — see its own docstring)
5
+ Opt an OSS install into the Premium Dashboard via a one-time bootstrap
6
+ token. This is an operator path: the token has to come from somewhere,
7
+ and as of DG-11 nothing in this repo issues one outside a test (admin-api's
8
+ ``issue_bootstrap_token`` has zero non-test callers — no route, no web UI).
9
+ The self-serve onboarding path is the ``/connect`` page + an
10
+ ``OPENCOMPLAI_API_KEY`` (see ``opencomplai push`` / ``opencomplai docs
11
+ generate --push``), which needs none of this.
18
12
 
19
13
  Subcommand: ``opencomplai dashboard withdraw``
20
- Remove dashboard enrollment for this install.
21
-
22
- Emits a ``consent_revoked`` ledger event and removes the egress-proxy
23
- allowlist entry within the same command run.
14
+ Clear this install's local enrollment state (egress-allowlist entry +
15
+ a ``consent_revoked`` ledger event) unconditionally. Also best-effort
16
+ notifies the dashboard's ``POST /v1/admin/withdraw`` when this install
17
+ can actually authenticate to it — that endpoint requires a tenant-scoped
18
+ service JWT (SEC-2) that a plain OSS install typically never has (it is
19
+ minted from ``enroll``'s OIDC client-credentials pair, and ``enroll`` is
20
+ the hidden/effectively-dead path described above). When no such token
21
+ can be produced, the remote call is skipped rather than sent bare and
22
+ guaranteed a 401 — see ``withdraw()``'s own docstring.
24
23
  """
25
24
 
26
25
  from __future__ import annotations
@@ -53,9 +52,7 @@ except ImportError:
53
52
  _console = _FallbackConsole() # type: ignore[assignment]
54
53
  _err_console = _FallbackConsole() # type: ignore[assignment]
55
54
 
56
- app = typer.Typer(
57
- help="Premium Dashboard enrollment and withdrawal.", add_completion=False
58
- )
55
+ app = typer.Typer(help="Premium Dashboard connection management.", add_completion=False)
59
56
 
60
57
  _OPENCOMPLAI_DIR = Path.home() / ".opencomplai"
61
58
  _CONFIG_FILE = _OPENCOMPLAI_DIR / "config.yaml"
@@ -177,7 +174,7 @@ def _post(url: str, payload: dict, token: str | None = None) -> tuple[int, dict]
177
174
  # ---------------------------------------------------------------------------
178
175
 
179
176
 
180
- @app.command("enroll")
177
+ @app.command("enroll", hidden=True)
181
178
  def enroll(
182
179
  tenant: str = typer.Option(
183
180
  ..., "--tenant", help="Tenant ID from the dashboard signup"
@@ -192,10 +189,19 @@ def enroll(
192
189
  ),
193
190
  ) -> None:
194
191
  """
195
- Enroll this install in the Opencomplai Premium Dashboard.
196
-
197
- Validates the bootstrap token, registers signing keys, writes EgressConsent
198
- to the local ledger, and adds the dashboard endpoint to the egress allowlist.
192
+ Operator path pending a UI to mint bootstrap tokens.
193
+
194
+ Enrolls this install in the Opencomplai Premium Dashboard by validating a
195
+ one-time bootstrap token, registering signing keys, writing EgressConsent
196
+ to the local ledger, and adding the dashboard endpoint to the egress
197
+ allowlist. Hidden from --help as of DG-11: nothing in this product issues
198
+ a bootstrap token outside a test today (admin-api's
199
+ ``issue_bootstrap_token`` has zero non-test callers), so this command has
200
+ no working entry point for a real customer to reach. It is kept, not
201
+ deleted, for the operator who already has -- or will eventually get a
202
+ console to mint -- a token. Everyone else wants the self-serve path:
203
+ an API key from ``/connect`` plus ``opencomplai push`` /
204
+ ``opencomplai docs generate --push``.
199
205
  """
200
206
  cfg = _load_config()
201
207
  install_id = cfg.get("install_id", "")
@@ -318,6 +324,41 @@ def enroll(
318
324
  # ---------------------------------------------------------------------------
319
325
 
320
326
 
327
+ def _resolve_withdraw_token() -> str | None:
328
+ """
329
+ Best-effort bearer token for admin-api's ``POST /v1/admin/withdraw``
330
+ (SEC-2's ``PrincipalDep`` requires a tenant-scoped service JWT), mirroring
331
+ the CI connectors' own auth precedence
332
+ (``connectors/github_actions.py``/``gitlab_ci.py``): an operator-set
333
+ legacy bearer-token env var wins outright; otherwise this install's own
334
+ OIDC client-credentials pair (persisted locally by ``enroll`` — see
335
+ module docstring) is exchanged for a short-lived JWT.
336
+
337
+ Returns ``None`` — never raises — when neither path produces a token.
338
+ That is the *ordinary* outcome for the vast majority of installs, which
339
+ never went through ``enroll`` (it is hidden and has no working entry
340
+ point today): callers must treat it as "there is nothing to
341
+ authenticate a remote withdraw with," not as an error.
342
+ """
343
+ token = os.environ.get("OPENCOMPLAI_AUTH_TOKEN", "")
344
+ if token:
345
+ return token
346
+
347
+ client_id = _load_config().get("client_id", "")
348
+ client_secret = _read_client_secret() or ""
349
+ token_endpoint = os.environ.get("OPENCOMPLAI_TOKEN_ENDPOINT", "")
350
+ if not (client_id and client_secret and token_endpoint):
351
+ return None
352
+
353
+ from opencomplai_cli.oidc_client import OidcTokenError, acquire_token
354
+
355
+ try:
356
+ token, _ = acquire_token(token_endpoint, client_id, client_secret)
357
+ except OidcTokenError:
358
+ return None
359
+ return token
360
+
361
+
321
362
  @app.command("withdraw")
322
363
  def withdraw(
323
364
  tenant: str = typer.Option(..., "--tenant", help="Tenant ID to withdraw from"),
@@ -326,11 +367,28 @@ def withdraw(
326
367
  "--dashboard-url",
327
368
  help="Dashboard base URL (defaults to OPENCOMPLAI_DASHBOARD_URL env var)",
328
369
  ),
370
+ local_only: bool = typer.Option(
371
+ False,
372
+ "--local-only",
373
+ help=(
374
+ "Clear local state only — never attempt the remote "
375
+ "/v1/admin/withdraw call, even if a token is available."
376
+ ),
377
+ ),
329
378
  ) -> None:
330
379
  """
331
380
  Withdraw dashboard enrollment for this install.
332
381
 
333
- Emits a consent_revoked ledger event and removes the egress allowlist entry.
382
+ Always clears local state: emits a consent_revoked ledger event and
383
+ removes the egress allowlist entry, in this same run. Also best-effort
384
+ notifies the dashboard, but only when a bearer token can actually be
385
+ produced for it (see _resolve_withdraw_token) — admin-api's
386
+ /v1/admin/withdraw requires an authenticated, tenant-scoped service JWT
387
+ (SEC-2), which most OSS installs have no way to mint. Sending the
388
+ request anyway would just guarantee a 401 that looks like a real
389
+ failure; skipping it prints a direct next step instead. A genuine
390
+ >=400 from the dashboard on a request that *did* carry a token is a
391
+ real rejection and still gets the loud error message.
334
392
  """
335
393
  cfg = _load_config()
336
394
  install_id = cfg.get("install_id", "")
@@ -344,37 +402,39 @@ def withdraw(
344
402
  "OPENCOMPLAI_DASHBOARD_URL", ""
345
403
  ).rstrip("/")
346
404
 
347
- # --- Notify dashboard if URL is available ---
405
+ # --- Notify dashboard, only if we can actually authenticate to it ---
348
406
  # 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
360
- if base_url:
361
- try:
362
- status, data = _post(
363
- f"{base_url}/v1/admin/withdraw",
364
- {"tenant_id": tenant, "install_id": install_id},
365
- )
366
- if status >= 400:
407
+ # its own local state). Three distinct outcomes below, each with its own
408
+ # message: (1) skipped because no token could be minted, or --local-only
409
+ # was passed, or no dashboard URL is configured at all -- the common
410
+ # case, not an error; (2) skipped because of an actual bug/note aside;
411
+ # (3) attempted with a real token and the dashboard rejected it (>=400)
412
+ # or was unreachable -- a genuine failure, kept loud so it isn't
413
+ # confused with (1)'s "there was never anything to try" case.
414
+ dashboard_notified = False
415
+ skipped_no_token = True
416
+ if base_url and not local_only:
417
+ token = _resolve_withdraw_token()
418
+ if token is not None:
419
+ skipped_no_token = False
420
+ try:
421
+ status, data = _post(
422
+ f"{base_url}/v1/admin/withdraw",
423
+ {"tenant_id": tenant, "install_id": install_id},
424
+ token=token,
425
+ )
426
+ if status >= 400:
427
+ _err_console.print(
428
+ f"[red]Error:[/red] dashboard rejected the withdraw request "
429
+ f"({status}: {data.get('message', data)}) — egress consent was "
430
+ "NOT revoked server-side. Clearing local state only."
431
+ )
432
+ else:
433
+ dashboard_notified = True
434
+ except ConnectionError as exc:
367
435
  _err_console.print(
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."
436
+ f"[yellow]Warning:[/yellow] could not reach dashboard: {exc}"
371
437
  )
372
- else:
373
- dashboard_notified = True
374
- except ConnectionError as exc:
375
- _err_console.print(
376
- f"[yellow]Warning:[/yellow] could not reach dashboard: {exc}"
377
- )
378
438
 
379
439
  # --- Emit consent_revoked ledger event ---
380
440
  from datetime import UTC, datetime
@@ -394,17 +454,26 @@ def withdraw(
394
454
  removed = allowlist.pop(key, None)
395
455
  _write_egress_allowlist(allowlist)
396
456
 
397
- if removed:
398
- if dashboard_notified:
457
+ if dashboard_notified:
458
+ if removed:
399
459
  _console.print(
400
460
  f"[green]Withdrawal complete.[/green] Egress allowlist entry removed for tenant {tenant}."
401
461
  )
402
462
  else:
403
463
  _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."
464
+ f"[green]Withdrawal complete.[/green] No local egress allowlist entry was present for tenant {tenant}."
407
465
  )
466
+ elif skipped_no_token:
467
+ _console.print(
468
+ "[green]Local state cleared.[/green] Remote egress consent must be "
469
+ "revoked from the dashboard (Projects → Archive project)."
470
+ )
471
+ elif removed:
472
+ _console.print(
473
+ f"[yellow]Local withdrawal complete[/yellow] — egress allowlist entry removed "
474
+ f"for tenant {tenant}, but the dashboard was not notified (see error above). "
475
+ "Server-side egress consent likely remains active."
476
+ )
408
477
  else:
409
478
  _console.print(
410
479
  f"[dim]No egress allowlist entry found for tenant {tenant} — nothing to remove.[/dim]"
@@ -31,7 +31,9 @@ from rich.markup import escape
31
31
 
32
32
  from opencomplai_cli.publish import (
33
33
  envelope_signature,
34
+ prepare_dossier_envelope,
34
35
  prepare_scan_status_artifact,
36
+ publish_dossier_envelope,
35
37
  publish_scan_status,
36
38
  )
37
39
 
@@ -150,4 +152,105 @@ def run_push(artifact_file: Path, *, env: dict[str, str] | None = None) -> int:
150
152
  return EXIT_PUBLISH_FAILED
151
153
 
152
154
 
153
- __all__ = ["EXIT_OK", "EXIT_PUBLISH_FAILED", "run_push"]
155
+ def run_push_dossier(dossier_file: Path, *, env: dict[str, str] | None = None) -> int:
156
+ """
157
+ Read ``dossier_file`` (an ``AnnexIVDossier`` JSON -- e.g. the
158
+ ``dossier_<id>.json`` ``opencomplai docs generate`` writes locally),
159
+ shape it into a ``dossier_envelope``, and POST it to the dashboard
160
+ (DG-10).
161
+
162
+ Transliterated from ``run_push`` above: same file/env validation, same
163
+ ``http``-non-local warning, same auth model (``ock_`` API key, no
164
+ ``install_id``), same ``(0 success / 3 otherwise)`` return contract.
165
+ Two differences beyond the obvious (path, mapper, transport function):
166
+
167
+ * The envelope's own ``signature`` is always ``""``. Unlike a
168
+ ``ScanStatusArtifact``, an ``AnnexIVDossier``'s own ``signature``
169
+ field (when present) signs the *dossier bundle* under a scheme
170
+ unrelated to the ingest envelope's G-5 byte-identity check
171
+ (``envelope_signature``'s docstring) -- forwarding it would not
172
+ "sort of" verify, it would deterministically fail closed. A
173
+ key-authed push already attests via the API key itself, same as
174
+ scan status's own key-authed path (``dashboard_ingest.routes``).
175
+ * The error message on a missing file points at ``docs generate``
176
+ instead of ``check --sign``.
177
+ """
178
+ _env = env if env is not None else os.environ
179
+
180
+ if not dossier_file.exists():
181
+ err_console.print(f"[red]Error:[/red] dossier file not found: {dossier_file}")
182
+ err_console.print(
183
+ "Run [bold]opencomplai docs generate[/bold] first to produce one."
184
+ )
185
+ return EXIT_PUBLISH_FAILED
186
+
187
+ try:
188
+ raw_text = dossier_file.read_text(encoding="utf-8")
189
+ except (OSError, UnicodeDecodeError) as exc:
190
+ err_console.print(f"[red]Error:[/red] could not read {dossier_file}: {exc}")
191
+ return EXIT_PUBLISH_FAILED
192
+
193
+ try:
194
+ dossier: Any = json.loads(raw_text)
195
+ except json.JSONDecodeError as exc:
196
+ err_console.print(f"[red]Error:[/red] {dossier_file} is not valid JSON: {exc}")
197
+ return EXIT_PUBLISH_FAILED
198
+
199
+ if not isinstance(dossier, dict):
200
+ err_console.print(
201
+ f"[red]Error:[/red] {dossier_file} must contain a JSON object "
202
+ "(an AnnexIVDossier), not a "
203
+ f"{type(dossier).__name__}"
204
+ )
205
+ return EXIT_PUBLISH_FAILED
206
+
207
+ api_key = _env.get("OPENCOMPLAI_API_KEY", "")
208
+ dashboard_url = _env.get("OPENCOMPLAI_DASHBOARD_URL", "").rstrip("/")
209
+ if not api_key or not dashboard_url:
210
+ err_console.print(
211
+ "[red]Error:[/red] OPENCOMPLAI_API_KEY and OPENCOMPLAI_DASHBOARD_URL "
212
+ "must both be set."
213
+ )
214
+ return EXIT_PUBLISH_FAILED
215
+
216
+ parsed_url = urlparse(dashboard_url)
217
+ if parsed_url.scheme == "http" and parsed_url.hostname not in _LOCAL_HOSTNAMES:
218
+ err_console.print(
219
+ f"[yellow]Warning:[/yellow] OPENCOMPLAI_DASHBOARD_URL ({dashboard_url}) "
220
+ "uses plain http -- the API key will be sent unencrypted over the network. "
221
+ "Use https unless this is local development."
222
+ )
223
+
224
+ prepared = prepare_dossier_envelope(
225
+ dossier, commit_env=_env, repo_dir=dossier_file.resolve().parent
226
+ )
227
+
228
+ envelope = {
229
+ "system_id": prepared.get("system_id", "unknown"),
230
+ "artifact": prepared,
231
+ "signature": "",
232
+ }
233
+
234
+ status, data = publish_dossier_envelope(dashboard_url, api_key, envelope)
235
+
236
+ if status in (200, 201):
237
+ outcome = escape(
238
+ str(data.get("outcome", "accepted" if status == 201 else "replayed"))
239
+ )
240
+ content_hash = escape(str(data.get("content_hash", "")))
241
+ console.print(f"[green]Push {outcome}.[/green]")
242
+ console.print(f" system_id: {envelope['system_id']}")
243
+ console.print(f" content_hash: {content_hash}")
244
+ console.print(f"\nDashboard: {dashboard_url}")
245
+ return EXIT_OK
246
+
247
+ err_console.print(f"[red]Push failed ({status}):[/red] {escape(str(data))}")
248
+ return EXIT_PUBLISH_FAILED
249
+
250
+
251
+ __all__ = [
252
+ "EXIT_OK",
253
+ "EXIT_PUBLISH_FAILED",
254
+ "run_push",
255
+ "run_push_dossier",
256
+ ]