opencomplai-cli 0.7.0__tar.gz → 0.7.1__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 (56) hide show
  1. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/PKG-INFO +2 -2
  2. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/pyproject.toml +2 -2
  3. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/__init__.py +1 -1
  4. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/commands/checker.py +2 -0
  5. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/commands/interactive_init.py +3 -7
  6. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/main.py +136 -35
  7. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/publish.py +2 -2
  8. opencomplai_cli-0.7.1/tests/test_check_signing.py +129 -0
  9. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_checker_cmd.py +92 -0
  10. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_ci_integration_guide.py +3 -1
  11. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_docs_generate_wiring.py +23 -1
  12. opencomplai_cli-0.7.1/tests/test_gap_report_input.py +96 -0
  13. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_gaps_nist_rmf_target.py +34 -0
  14. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_publish.py +144 -47
  15. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_service_check.py +0 -3
  16. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/.gitignore +0 -0
  17. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/README.md +0 -0
  18. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/_encoding.py +0 -0
  19. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/commands/__init__.py +0 -0
  20. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/commands/controls.py +0 -0
  21. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/commands/dashboard.py +0 -0
  22. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/commands/halt.py +0 -0
  23. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/commands/push.py +0 -0
  24. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/commands/qms.py +0 -0
  25. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/commands/serve.py +0 -0
  26. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/connectors/__init__.py +0 -0
  27. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/connectors/github_actions.py +0 -0
  28. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/connectors/gitlab_ci.py +0 -0
  29. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/data/checker-local.html +0 -0
  30. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/exit_codes.py +0 -0
  31. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/oidc_client.py +0 -0
  32. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/__init__.py +0 -0
  33. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/fixtures/trap/trap-manifest.json +0 -0
  34. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_ai_preload.py +0 -0
  35. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_cli.py +0 -0
  36. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_connectors.py +0 -0
  37. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_controls_cmd.py +0 -0
  38. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_controls_sync.py +0 -0
  39. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_dashboard_cmd.py +0 -0
  40. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_docs_generate_push.py +0 -0
  41. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_encoding.py +0 -0
  42. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_eu_ai_scan_golden.py +0 -0
  43. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_evidence_provenance.py +0 -0
  44. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_exit_codes.py +0 -0
  45. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_fria_generate.py +0 -0
  46. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_gaps_mapped_column.py +0 -0
  47. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_halt_wire.py +0 -0
  48. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_oidc_client.py +0 -0
  49. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_push_cmd.py +0 -0
  50. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_push_cmd_dossier.py +0 -0
  51. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_qms_generate_cmd.py +0 -0
  52. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_readme_command_table.py +0 -0
  53. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_readme_quickstart_pin.py +0 -0
  54. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_recommend_qms_clauses.py +0 -0
  55. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_scan_cmd.py +0 -0
  56. {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_scan_quick.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: opencomplai-cli
3
- Version: 0.7.0
3
+ Version: 0.7.1
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
7
7
  Author-email: Opencomplai <hello@opencomplai.com>
8
8
  License: AGPL-3.0-only
9
9
  Requires-Python: >=3.11
10
- Requires-Dist: opencomplai-core[reports]>=0.1.0
10
+ Requires-Dist: opencomplai-core[reports]>=0.7.1
11
11
  Requires-Dist: questionary>=2.0.0
12
12
  Requires-Dist: rich>=13.0.0
13
13
  Requires-Dist: typer>=0.9.0
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "opencomplai-cli"
3
- version = "0.7.0"
3
+ version = "0.7.1"
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" }
7
7
  authors = [{ name = "Opencomplai", email = "hello@opencomplai.com" }]
8
8
  requires-python = ">=3.11"
9
9
  dependencies = [
10
- "opencomplai-core[reports]>=0.1.0",
10
+ "opencomplai-core[reports]>=0.7.1",
11
11
  "typer>=0.9.0",
12
12
  "rich>=13.0.0",
13
13
  "questionary>=2.0.0",
@@ -1,6 +1,6 @@
1
1
  """Opencomplai CLI."""
2
2
 
3
- __version__ = "0.7.0"
3
+ __version__ = "0.7.1"
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
@@ -14,6 +14,7 @@ from opencomplai_core.compliance_checker import (
14
14
  CheckerSession,
15
15
  ComplianceCheckerResult,
16
16
  EntityType,
17
+ bridge_to_manifest_fields,
17
18
  evaluate,
18
19
  export_all,
19
20
  render_json,
@@ -363,4 +364,5 @@ def build_checker_session_ref(
363
364
  "session_id": result.session_id or str(uuid.uuid4()),
364
365
  "completed_at": datetime.now(UTC).isoformat(),
365
366
  "report_json_path": str(report_json_path) if report_json_path else "",
367
+ "verdict": str(bridge_to_manifest_fields(result)["checker_verdict"]),
366
368
  }
@@ -41,23 +41,19 @@ def run_interactive_init(
41
41
  report_path = output_file.parent / "eu-ai-act-result.json"
42
42
  write_exports(checker_result, export_json=report_path)
43
43
  bridged = bridge_to_manifest_fields(checker_result)
44
- default_purpose = bridged.get("intended_purpose", "")
45
44
  default_role = bridged.get("operator_role", "")
46
45
  default_hr = bridged.get("high_risk_presumption", False)
47
46
  else:
48
- default_purpose = ""
49
47
  default_role = ""
50
48
  default_hr = False
51
49
  else:
52
- default_purpose = ""
53
50
  default_role = ""
54
51
  default_hr = False
55
52
 
56
53
  system_id = typer.prompt("System ID", default="my-ai-system")
57
- intended_purpose = typer.prompt(
58
- "Intended purpose (Annex III mapping)",
59
- default=str(default_purpose) if default_purpose else "",
60
- )
54
+ # The checker verdict travels in checker_session.verdict; the purpose is
55
+ # the text the EU rules keyword-match, so it has no checker default.
56
+ intended_purpose = typer.prompt("Intended purpose (Annex III mapping)", default="")
61
57
  high_risk = typer.confirm(
62
58
  "Presume high-risk classification?",
63
59
  default=bool(default_hr),
@@ -11,8 +11,10 @@ Exit codes (contractual — never deviate):
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
+ import codecs
14
15
  import importlib.metadata
15
16
  import json
17
+ import locale
16
18
  import os
17
19
  import re
18
20
  import sys
@@ -27,6 +29,7 @@ from typing import TYPE_CHECKING
27
29
  from urllib.parse import urlparse
28
30
 
29
31
  import typer
32
+ from opencomplai_core.compliance_checker import CHECKER_VERDICTS
30
33
  from opencomplai_core.compliance_checker.models import ComplianceCheckerResult
31
34
  from opencomplai_core.control_assessment import build_controls_block, derive_controls
32
35
  from opencomplai_core.control_catalog import get_catalog
@@ -586,6 +589,58 @@ def _result_from_local(risk_result) -> tuple[ScanResult, list[str]]:
586
589
  return ScanResult.PASS, failed_ids
587
590
 
588
591
 
592
+ # Checker verdicts `check` enforces, and the result + control each one fails.
593
+ _VERDICT_FAILURES: dict[str, tuple[ScanResult, str]] = {
594
+ "prohibited_practice": (ScanResult.POLICY_BLOCK, "EU_AIA_ART5_UNACCEPTABLE"),
595
+ "high_risk_ai_system": (ScanResult.CONTROL_FAIL, "EU_AIA_ART6_HIGH_RISK"),
596
+ }
597
+ # Results a verdict may raise, by severity. TRAP_DETECTED and VALIDATION_FAIL
598
+ # are absent on purpose: a verdict never replaces them.
599
+ _VERDICT_RAISABLE_RANK = {
600
+ ScanResult.PASS: 0,
601
+ ScanResult.DEGRADED_COMPLETE: 0,
602
+ ScanResult.CONTROL_FAIL: 1,
603
+ ScanResult.POLICY_BLOCK: 2,
604
+ }
605
+
606
+
607
+ def _apply_checker_verdict(
608
+ artifact: ScanStatusArtifact, manifest: SystemManifest
609
+ ) -> tuple[ScanStatusArtifact, bool]:
610
+ """Fail the artifact on a prohibited or high-risk applicability verdict.
611
+
612
+ The EU rules only keyword-match `intended_purpose`, so a system the
613
+ checker classified prohibited or high-risk would otherwise pass. The
614
+ verdict comes from `checker_session.verdict`; a 0.7.0 manifest wrote it
615
+ into `intended_purpose` instead, which is still honoured with a warning.
616
+ Only ever escalates the result. Returns `(artifact, escalated)`, with
617
+ `escalated` True when the verdict is prohibited or high-risk.
618
+ """
619
+ cs = manifest.checker_session
620
+ verdict = cs.verdict if cs is not None else None
621
+ if manifest.intended_purpose in CHECKER_VERDICTS:
622
+ err_console.print(
623
+ "[yellow]WARN:[/yellow] intended_purpose is the checker verdict "
624
+ f"'{manifest.intended_purpose}', not a purpose. Replace it with what "
625
+ "the system does so the EU rules can classify it."
626
+ )
627
+ verdict = verdict or manifest.intended_purpose
628
+ if verdict not in _VERDICT_FAILURES:
629
+ return artifact, False
630
+ result, control = _VERDICT_FAILURES[verdict]
631
+ if artifact.result not in _VERDICT_RAISABLE_RANK:
632
+ return artifact, True
633
+ if _VERDICT_RAISABLE_RANK[artifact.result] >= _VERDICT_RAISABLE_RANK[result]:
634
+ result = artifact.result
635
+ failed_controls = list(dict.fromkeys([*artifact.failed_controls, control]))
636
+ return (
637
+ artifact.model_copy(
638
+ update={"result": result, "failed_controls": failed_controls}
639
+ ),
640
+ True,
641
+ )
642
+
643
+
589
644
  def _maybe_halt_system(system_id: str, commit_ref: str, reason: str) -> None:
590
645
  """HALT-WIRE / E-12(a): persist HALTED_PENDING_REVIEW when this `check`
591
646
  run hit a trap or an unresolved HIGH-risk corroboration gap, via the
@@ -1030,6 +1085,11 @@ def checker_cmd(
1030
1085
  write_manifest: Path | None = typer.Option(
1031
1086
  None, "--write-manifest", help="Write bridged fields into manifest at this path"
1032
1087
  ),
1088
+ intended_purpose: str | None = typer.Option(
1089
+ None,
1090
+ "--intended-purpose",
1091
+ help="With --write-manifest: what the system does (prompted when omitted)",
1092
+ ),
1033
1093
  web: bool = typer.Option(
1034
1094
  False, "--web", help="Open the browser-based checker on the docs site"
1035
1095
  ),
@@ -1083,9 +1143,12 @@ def checker_cmd(
1083
1143
  report_path = export_json or (
1084
1144
  export_all_base.with_suffix(".json") if export_all_base else None
1085
1145
  )
1146
+ system_id = typer.prompt("System ID", default="my-ai-system")
1147
+ if intended_purpose is None:
1148
+ intended_purpose = typer.prompt("Intended purpose (what the system does)")
1086
1149
  payload: dict = {
1087
- "system_id": typer.prompt("System ID", default="my-ai-system"),
1088
- "intended_purpose": bridged["intended_purpose"],
1150
+ "system_id": system_id,
1151
+ "intended_purpose": intended_purpose,
1089
1152
  "compliance_target": "EU_AI_ACT",
1090
1153
  "high_risk_presumption": bridged["high_risk_presumption"],
1091
1154
  "commit_ref": "HEAD",
@@ -1161,6 +1224,29 @@ def _load_sample_set(
1161
1224
  return sample
1162
1225
 
1163
1226
 
1227
+ def _read_gap_report(path: Path) -> GapReport:
1228
+ """Load a `--gap-report` file: a bare GapReport or the envelope that
1229
+ `opencomplai gaps --output json` prints (report nested under `payload`).
1230
+
1231
+ Windows PowerShell 5.1 `>` redirection writes UTF-16LE with a BOM, so a
1232
+ UTF-16 BOM selects UTF-16; anything else is read as UTF-8 (BOM optional),
1233
+ falling back to the locale encoding: a cmd.exe or Git Bash `>` redirect on
1234
+ Windows writes the ANSI code page (cp1252), e.g. an em dash as 0x97.
1235
+ """
1236
+ data = path.read_bytes()
1237
+ if data.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
1238
+ text = data.decode("utf-16")
1239
+ else:
1240
+ try:
1241
+ text = data.decode("utf-8-sig")
1242
+ except UnicodeDecodeError:
1243
+ text = data.decode(locale.getpreferredencoding(False))
1244
+ raw = json.loads(text)
1245
+ if isinstance(raw, dict) and "payload" in raw and "tool_version" in raw:
1246
+ raw = raw["payload"]
1247
+ return GapReport.model_validate(raw)
1248
+
1249
+
1164
1250
  _GAP_STATUS_STYLE = {
1165
1251
  GapStatus.MET: "[green]MET[/green]",
1166
1252
  GapStatus.PARTIAL: "[yellow]PARTIAL[/yellow]",
@@ -1418,7 +1504,7 @@ def recommend_cmd(
1418
1504
  f"[red]Error:[/red] gap report not found: {gap_report_file}"
1419
1505
  )
1420
1506
  sys.exit(2)
1421
- report = GapReport.model_validate(json.loads(gap_report_file.read_text()))
1507
+ report = _read_gap_report(gap_report_file)
1422
1508
  else:
1423
1509
  if not manifest_file.exists():
1424
1510
  err_console.print(
@@ -1639,7 +1725,8 @@ def report_cmd(
1639
1725
  gap_report_file: Path | None = typer.Option(
1640
1726
  None,
1641
1727
  "--gap-report",
1642
- help="Path to a GapReport JSON file (overrides any gap_report embedded in --artifact)",
1728
+ help="Path to a GapReport JSON file, e.g. from `opencomplai gaps --output json` "
1729
+ "(overrides any gap_report embedded in --artifact)",
1643
1730
  ),
1644
1731
  output_file: Path = typer.Option(
1645
1732
  Path("report.html"), "--output", "-o", help="Output path (.html or .pdf)"
@@ -1676,7 +1763,7 @@ def report_cmd(
1676
1763
  f"[red]Error:[/red] gap report not found: {gap_report_file}"
1677
1764
  )
1678
1765
  sys.exit(2)
1679
- gap_report = GapReport.model_validate(json.loads(gap_report_file.read_text()))
1766
+ gap_report = _read_gap_report(gap_report_file)
1680
1767
 
1681
1768
  assessment_input = AssessmentInput(
1682
1769
  model=ModelMetadata(
@@ -2632,7 +2719,14 @@ def check_cmd(
2632
2719
  run_code_scan: bool = typer.Option(
2633
2720
  False, "--scan", help="Run code corroboration scan (opt-in)"
2634
2721
  ),
2635
- repo_root: Path = typer.Option(Path("."), "--repo-root"),
2722
+ repo_root: Path = typer.Option(
2723
+ Path("."),
2724
+ "--repo-root",
2725
+ help=(
2726
+ "Repository root for the --scan code scan and the --with-gaps "
2727
+ "artifact path probes (Arts. 9/13/14/16/24/43)"
2728
+ ),
2729
+ ),
2636
2730
  emit_scan_evidence: bool = typer.Option(True, "--emit-evidence/--no-emit-evidence"),
2637
2731
  scan_fail_on: FailOnLevel = typer.Option(
2638
2732
  FailOnLevel.none,
@@ -2734,7 +2828,6 @@ def check_cmd(
2734
2828
  commit_ref,
2735
2829
  scan_mode,
2736
2830
  install_id,
2737
- sign,
2738
2831
  eval_summary,
2739
2832
  eval_failed,
2740
2833
  eval_hashes,
@@ -2747,7 +2840,6 @@ def check_cmd(
2747
2840
  commit_ref,
2748
2841
  scan_mode,
2749
2842
  install_id,
2750
- sign,
2751
2843
  eval_summary,
2752
2844
  eval_failed,
2753
2845
  eval_hashes,
@@ -2761,6 +2853,9 @@ def check_cmd(
2761
2853
  dict.fromkeys([*artifact.failed_controls, "CODE_CORROBORATION_GAP"])
2762
2854
  )
2763
2855
 
2856
+ artifact, escalated = _apply_checker_verdict(artifact, manifest)
2857
+ risk_high |= escalated
2858
+
2764
2859
  # HALT-WIRE / E-12(a): halt trigger — trap always halts; an unresolved
2765
2860
  # HIGH-risk corroboration gap (--scan --fail-on ... failed) halts too,
2766
2861
  # even when the artifact result itself stays CONTROL_FAIL rather than
@@ -2797,6 +2892,7 @@ def check_cmd(
2797
2892
  risk_result=gap_risk_result,
2798
2893
  corroboration_report=scan_report,
2799
2894
  eval_report=gap_eval_report,
2895
+ repo_root=repo_root.resolve(),
2800
2896
  )
2801
2897
  derived_controls = _sync_controls_to_vault(
2802
2898
  gap_report, manifest, quiet=(output == OutputFormat.json)
@@ -2819,6 +2915,17 @@ def check_cmd(
2819
2915
  }
2820
2916
  )
2821
2917
 
2918
+ # Sign only now: the scan override, checker verdict and --with-gaps
2919
+ # blocks above all change the bytes the signature has to cover.
2920
+ artifact = _sign_artifact(artifact, sign)
2921
+
2922
+ if api_available:
2923
+ # Step 8 — append the final artifact to the ledger
2924
+ _emit_event(
2925
+ "scan_status_artifact",
2926
+ json.loads(artifact.model_dump_json()),
2927
+ )
2928
+
2822
2929
  # Write artifact to disk for CI consumption
2823
2930
  artifact_path = Path("compliance-artifact.json")
2824
2931
  artifact_path.write_text(artifact.model_dump_json(indent=2))
@@ -2842,7 +2949,6 @@ def _run_service_check(
2842
2949
  commit_ref: str,
2843
2950
  scan_mode: str,
2844
2951
  install_id: str,
2845
- sign: bool,
2846
2952
  eval_summary: EvalSummary | None = None,
2847
2953
  eval_failed: list[str] | None = None,
2848
2954
  eval_hashes: list[str] | None = None,
@@ -2898,7 +3004,6 @@ def _run_service_check(
2898
3004
  "sha256:validation_failed",
2899
3005
  0,
2900
3006
  int((time.monotonic() - start_ms) * 1000),
2901
- sign,
2902
3007
  ),
2903
3008
  False,
2904
3009
  )
@@ -2929,7 +3034,6 @@ def _run_service_check(
2929
3034
  "sha256:classify_failed",
2930
3035
  0,
2931
3036
  int((time.monotonic() - start_ms) * 1000),
2932
- sign,
2933
3037
  ),
2934
3038
  False,
2935
3039
  )
@@ -2975,7 +3079,6 @@ def _run_service_check(
2975
3079
  rationale_hash,
2976
3080
  0,
2977
3081
  int((time.monotonic() - start_ms) * 1000),
2978
- sign,
2979
3082
  ),
2980
3083
  risk_high,
2981
3084
  )
@@ -3057,7 +3160,6 @@ def _run_service_check(
3057
3160
  rationale_hash,
3058
3161
  pending_verifications_count,
3059
3162
  duration_ms,
3060
- sign,
3061
3163
  eval_summary=eval_summary,
3062
3164
  scan_summary=scan_summary,
3063
3165
  )
@@ -3084,12 +3186,8 @@ def _run_service_check(
3084
3186
  },
3085
3187
  )
3086
3188
 
3087
- # Step 8 — append artifact to ledger
3088
- _emit_event(
3089
- "scan_status_artifact",
3090
- json.loads(artifact.model_dump_json()),
3091
- )
3092
-
3189
+ # Step 8 (appending the artifact to the ledger) happens in `check_cmd`,
3190
+ # once the artifact is final and signed.
3093
3191
  return artifact, risk_high
3094
3192
 
3095
3193
 
@@ -3098,7 +3196,6 @@ def _run_local_check(
3098
3196
  commit_ref: str,
3099
3197
  scan_mode: str,
3100
3198
  install_id: str,
3101
- sign: bool,
3102
3199
  eval_summary: EvalSummary | None = None,
3103
3200
  eval_failed: list[str] | None = None,
3104
3201
  eval_hashes: list[str] | None = None,
@@ -3151,7 +3248,6 @@ def _run_local_check(
3151
3248
  rationale_hash,
3152
3249
  0,
3153
3250
  duration_ms,
3154
- sign,
3155
3251
  eval_summary=eval_summary,
3156
3252
  scan_summary=scan_summary,
3157
3253
  ),
@@ -3170,12 +3266,11 @@ def _finalize_artifact(
3170
3266
  rationale_hash: str,
3171
3267
  pending_verifications_count: int,
3172
3268
  duration_ms: int,
3173
- sign: bool,
3174
3269
  eval_summary: EvalSummary | None = None,
3175
3270
  scan_summary: ScanSummary | None = None,
3176
3271
  ) -> ScanStatusArtifact:
3177
- """Build and optionally sign the ScanStatusArtifact."""
3178
- artifact = ScanStatusArtifact(
3272
+ """Build the unsigned ScanStatusArtifact (`check_cmd` signs it last)."""
3273
+ return ScanStatusArtifact(
3179
3274
  install_id=install_id,
3180
3275
  system_id=system_id,
3181
3276
  commit_ref=commit_ref,
@@ -3190,15 +3285,23 @@ def _finalize_artifact(
3190
3285
  scan_summary=scan_summary,
3191
3286
  )
3192
3287
 
3193
- if sign and _SIGNING_KEY.exists():
3194
- try:
3195
- from opencomplai_core.signing import sign_artifact
3196
3288
 
3197
- artifact.signature = sign_artifact(artifact, _SIGNING_KEY)
3198
- except Exception as exc:
3199
- err_console.print(f"[yellow]Warning: signing failed — {exc}[/yellow]")
3289
+ def _sign_artifact(artifact: ScanStatusArtifact, sign: bool) -> ScanStatusArtifact:
3290
+ """Return a signed copy of `artifact` when `sign` is set and a key exists.
3200
3291
 
3201
- return artifact
3292
+ The signature covers every other field, so this must run only once the
3293
+ artifact is final — anything changed afterwards no longer verifies.
3294
+ """
3295
+ if not (sign and _SIGNING_KEY.exists()):
3296
+ return artifact
3297
+ try:
3298
+ from opencomplai_core.signing import sign_artifact
3299
+
3300
+ signature = sign_artifact(artifact, _SIGNING_KEY)
3301
+ except Exception as exc:
3302
+ err_console.print(f"[yellow]Warning: signing failed — {exc}[/yellow]")
3303
+ return artifact
3304
+ return artifact.model_copy(update={"signature": signature})
3202
3305
 
3203
3306
 
3204
3307
  def _print_artifact_human(artifact: ScanStatusArtifact) -> None:
@@ -3787,11 +3890,9 @@ def docs_generate_cmd(
3787
3890
 
3788
3891
  # Local fallback
3789
3892
  try:
3893
+ from opencomplai_core.dossier import validate_dossier_schema
3894
+ from opencomplai_core.dossier_generator import generate_dossier
3790
3895
  from opencomplai_core.engine import assess as _assess
3791
- from opencomplai_doc_generator.generator import (
3792
- generate_dossier,
3793
- validate_dossier_schema,
3794
- )
3795
3896
 
3796
3897
  manifest = (
3797
3898
  loaded_manifest.model_copy(update={"commit_ref": commit_ref})
@@ -160,8 +160,8 @@ def prepare_scan_status_artifact(
160
160
  Every field the OSS model produces (``install_id``, ``system_id``,
161
161
  ``commit_ref``, ``result``, ``failed_controls``, ``evidence_hashes``,
162
162
  ``rationale_hash``, ``duration_ms``, ``pending_verifications_count``,
163
- ``signature``, ``eval_summary``, ``scan_summary``, ``gap_report``)
164
- passes through unchanged -- the widened schema (G-4) now accepts all of
163
+ ``signature``, ``eval_summary``, ``scan_summary``, ``gap_report``,
164
+ ``nist_rmf_report``, ``controls``) passes through unchanged -- the widened schema (G-4) now accepts all of
165
165
  them. Only the fields the OSS model has no concept of at all are
166
166
  synthesized here, and each synthesis is documented:
167
167
 
@@ -0,0 +1,129 @@
1
+ """
2
+ `check --sign` signs the artifact it writes, not an earlier draft of it.
3
+
4
+ `check` used to sign inside `_finalize_artifact` and then change the
5
+ artifact: the `--scan --fail-on` override, the checker verdict and the
6
+ `--with-gaps` blocks all landed after the signature, so the signed
7
+ compliance-artifact.json never verified. Each test signs with a throwaway
8
+ keypair and verifies the file on disk (and, in service mode, the copy
9
+ appended to the ledger) against its public key.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from pathlib import Path
16
+
17
+ from opencomplai_cli import main
18
+ from opencomplai_cli.main import app
19
+ from opencomplai_core.models import ScanStatusArtifact, SystemManifest
20
+ from opencomplai_core.signing import generate_keypair, verify_artifact
21
+ from typer.testing import CliRunner
22
+
23
+ runner = CliRunner()
24
+
25
+
26
+ def _setup(tmp_path: Path, monkeypatch) -> tuple[Path, Path]:
27
+ """Chdir to tmp_path, force the local path, point `check` at a fresh
28
+ keypair, and write a manifest. Returns `(manifest_file, pub_key)`."""
29
+ monkeypatch.chdir(tmp_path)
30
+ monkeypatch.delenv("OPENCOMPLAI_API_URL", raising=False)
31
+ monkeypatch.delenv("OPENCOMPLAI_VAULT_URL", raising=False)
32
+ monkeypatch.delenv("SIGNING_KEY_PRIVATE", raising=False)
33
+ monkeypatch.setenv("OPENCOMPLAI_STATE_DIR", str(tmp_path / "state"))
34
+ key_dir = tmp_path / "keys"
35
+ generate_keypair(key_dir)
36
+ monkeypatch.setattr(main, "_SIGNING_KEY", key_dir / "signing.key")
37
+
38
+ manifest_file = tmp_path / "system-manifest.json"
39
+ manifest_file.write_text(
40
+ SystemManifest(
41
+ system_id="sign-sys",
42
+ intended_purpose="customer support chatbot",
43
+ compliance_target="EU_AI_ACT",
44
+ high_risk_presumption=False,
45
+ commit_ref="HEAD",
46
+ ).model_dump_json(),
47
+ encoding="utf-8",
48
+ )
49
+ return manifest_file, key_dir / "signing.pub"
50
+
51
+
52
+ def _written_artifact(tmp_path: Path) -> ScanStatusArtifact:
53
+ return ScanStatusArtifact.model_validate_json(
54
+ (tmp_path / "compliance-artifact.json").read_text(encoding="utf-8")
55
+ )
56
+
57
+
58
+ def test_signed_artifact_with_gaps_verifies(tmp_path, monkeypatch):
59
+ manifest_file, pub_key = _setup(tmp_path, monkeypatch)
60
+
61
+ result = runner.invoke(
62
+ app, ["check", "--manifest", str(manifest_file), "--sign", "--with-gaps"]
63
+ )
64
+ assert result.exit_code == 0, result.output
65
+
66
+ artifact = _written_artifact(tmp_path)
67
+ assert artifact.gap_report is not None
68
+ assert verify_artifact(artifact, pub_key) is True
69
+
70
+
71
+ def test_signed_artifact_after_failed_scan_override_verifies(tmp_path, monkeypatch):
72
+ manifest_file, pub_key = _setup(tmp_path, monkeypatch)
73
+ repo = tmp_path / "repo"
74
+ repo.mkdir()
75
+ (repo / "requirements.txt").write_text("face_recognition\n", encoding="utf-8")
76
+ (repo / "face.py").write_text("import face_recognition\n", encoding="utf-8")
77
+
78
+ result = runner.invoke(
79
+ app,
80
+ [
81
+ "check",
82
+ "--manifest",
83
+ str(manifest_file),
84
+ "--repo-root",
85
+ str(repo),
86
+ "--scan",
87
+ "--fail-on",
88
+ "major",
89
+ "--sign",
90
+ ],
91
+ )
92
+ assert result.exit_code == 1, result.output
93
+
94
+ artifact = _written_artifact(tmp_path)
95
+ assert "CODE_CORROBORATION_GAP" in artifact.failed_controls
96
+ assert verify_artifact(artifact, pub_key) is True
97
+
98
+
99
+ def test_service_mode_ledgers_the_final_signed_artifact(tmp_path, monkeypatch):
100
+ manifest_file, pub_key = _setup(tmp_path, monkeypatch)
101
+ monkeypatch.setenv("OPENCOMPLAI_API_URL", "http://gateway.invalid")
102
+
103
+ def fake_call_service(path: str, payload: dict) -> tuple[int, dict]:
104
+ if path == "/v1/risk/classify":
105
+ return 200, {
106
+ "risk_class": "minimal",
107
+ "rationale_hash": "sha256:" + "a" * 64,
108
+ }
109
+ if path == "/v1/verify/claims":
110
+ return 200, {"outcome": "verified"}
111
+ return 200, {}
112
+
113
+ events: list[tuple[str, dict]] = []
114
+ monkeypatch.setattr(main, "_call_service", fake_call_service)
115
+ monkeypatch.setattr(
116
+ main,
117
+ "_emit_event",
118
+ lambda event_type, payload, *a, **kw: events.append((event_type, payload)),
119
+ )
120
+
121
+ result = runner.invoke(
122
+ app, ["check", "--manifest", str(manifest_file), "--sign", "--with-gaps"]
123
+ )
124
+ assert result.exit_code == 0, result.output
125
+
126
+ artifact = _written_artifact(tmp_path)
127
+ assert verify_artifact(artifact, pub_key) is True
128
+ ledgered = [p for t, p in events if t == "scan_status_artifact"]
129
+ assert ledgered == [json.loads(artifact.model_dump_json())]
@@ -120,3 +120,95 @@ def test_checker_web_local_serves_and_stops_via_page_button() -> None:
120
120
 
121
121
  assert not thread.is_alive(), "CLI did not stop after the shutdown route was hit"
122
122
  assert result_holder["result"].exit_code == 0, result_holder["result"].stdout
123
+
124
+
125
+ def _isolate(tmp_path: Path, monkeypatch) -> None:
126
+ monkeypatch.chdir(tmp_path)
127
+ monkeypatch.delenv("OPENCOMPLAI_API_URL", raising=False)
128
+ monkeypatch.setenv("OPENCOMPLAI_STATE_DIR", str(tmp_path / "state"))
129
+
130
+
131
+ def _write_checker_manifest(tmp_path: Path, fixture_name: str) -> Path:
132
+ fixture = json.loads((FIXTURES / fixture_name).read_text(encoding="utf-8"))
133
+ answers_path = tmp_path / "answers.json"
134
+ answers_path.write_text(json.dumps(fixture["session"]), encoding="utf-8")
135
+ manifest_path = tmp_path / "system-manifest.json"
136
+
137
+ result = runner.invoke(
138
+ app,
139
+ [
140
+ "checker",
141
+ "--answers",
142
+ str(answers_path),
143
+ "--write-manifest",
144
+ str(manifest_path),
145
+ "--intended-purpose",
146
+ "Summarises internal documents",
147
+ ],
148
+ input="doc-summariser\n",
149
+ )
150
+ assert result.exit_code == 0, result.output
151
+ return manifest_path
152
+
153
+
154
+ def test_checker_write_manifest_keeps_purpose_and_records_verdict(
155
+ tmp_path: Path, monkeypatch
156
+ ) -> None:
157
+ """The tier label belongs in checker_session.verdict: the EU rules
158
+ keyword-match intended_purpose, so a label there classifies nothing."""
159
+ _isolate(tmp_path, monkeypatch)
160
+ manifest_path = _write_checker_manifest(tmp_path, "05_prohibited.json")
161
+
162
+ data = json.loads(manifest_path.read_text(encoding="utf-8"))
163
+ assert data["system_id"] == "doc-summariser"
164
+ assert data["intended_purpose"] == "Summarises internal documents"
165
+ assert data["checker_session"]["verdict"] == "prohibited_practice"
166
+
167
+
168
+ def test_check_blocks_prohibited_checker_verdict(tmp_path: Path, monkeypatch) -> None:
169
+ """A purpose the rules find harmless must not PASS a system the checker
170
+ classified prohibited."""
171
+ _isolate(tmp_path, monkeypatch)
172
+ manifest_path = _write_checker_manifest(tmp_path, "05_prohibited.json")
173
+
174
+ result = runner.invoke(app, ["check", "-m", str(manifest_path), "-o", "json"])
175
+ assert result.exit_code == 3, result.output
176
+ artifact = json.loads((tmp_path / "compliance-artifact.json").read_text())
177
+ assert artifact["result"] == "policy_block"
178
+ assert artifact["failed_controls"].count("EU_AIA_ART5_UNACCEPTABLE") == 1
179
+
180
+
181
+ def test_check_honours_legacy_verdict_in_intended_purpose(
182
+ tmp_path: Path, monkeypatch
183
+ ) -> None:
184
+ """0.7.0 wrote the verdict into intended_purpose; check still fails on
185
+ it and tells the user to put the real purpose there."""
186
+ _isolate(tmp_path, monkeypatch)
187
+ manifest_path = tmp_path / "system-manifest.json"
188
+ manifest_path.write_text(
189
+ json.dumps(
190
+ {
191
+ "system_id": "legacy-sys",
192
+ "intended_purpose": "high_risk_ai_system",
193
+ "compliance_target": "EU_AI_ACT",
194
+ "high_risk_presumption": True,
195
+ "commit_ref": "HEAD",
196
+ "operator_role": "provider",
197
+ "checker_session": {
198
+ "checker_version": "checker-2026-07-24",
199
+ "session_id": "legacy-session",
200
+ "completed_at": "2026-09-01T00:00:00+00:00",
201
+ "report_json_path": "",
202
+ },
203
+ }
204
+ ),
205
+ encoding="utf-8",
206
+ )
207
+
208
+ result = runner.invoke(app, ["check", "-m", str(manifest_path), "-o", "json"])
209
+ assert result.exit_code == 1, result.output
210
+ assert "WARN" in result.stderr
211
+ assert "high_risk_ai_system" in result.stderr
212
+ artifact = json.loads(result.stdout)
213
+ assert artifact["result"] == "control_fail"
214
+ assert "EU_AIA_ART6_HIGH_RISK" in artifact["failed_controls"]