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.
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/PKG-INFO +2 -2
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/pyproject.toml +2 -2
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/__init__.py +1 -1
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/commands/checker.py +2 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/commands/interactive_init.py +3 -7
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/main.py +136 -35
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/publish.py +2 -2
- opencomplai_cli-0.7.1/tests/test_check_signing.py +129 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_checker_cmd.py +92 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_ci_integration_guide.py +3 -1
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_docs_generate_wiring.py +23 -1
- opencomplai_cli-0.7.1/tests/test_gap_report_input.py +96 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_gaps_nist_rmf_target.py +34 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_publish.py +144 -47
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_service_check.py +0 -3
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/.gitignore +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/README.md +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/_encoding.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/commands/__init__.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/commands/controls.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/commands/dashboard.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/commands/halt.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/commands/push.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/commands/qms.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/commands/serve.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/connectors/__init__.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/connectors/github_actions.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/connectors/gitlab_ci.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/data/checker-local.html +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/exit_codes.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/oidc_client.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/__init__.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/fixtures/trap/trap-manifest.json +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_ai_preload.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_cli.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_connectors.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_controls_cmd.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_controls_sync.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_dashboard_cmd.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_docs_generate_push.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_encoding.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_eu_ai_scan_golden.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_evidence_provenance.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_exit_codes.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_fria_generate.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_gaps_mapped_column.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_halt_wire.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_oidc_client.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_push_cmd.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_push_cmd_dossier.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_qms_generate_cmd.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_readme_command_table.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_readme_quickstart_pin.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_recommend_qms_clauses.py +0 -0
- {opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/tests/test_scan_cmd.py +0 -0
- {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.
|
|
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
|
|
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.
|
|
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
|
|
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",
|
|
@@ -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
|
}
|
{opencomplai_cli-0.7.0 → opencomplai_cli-0.7.1}/src/opencomplai_cli/commands/interactive_init.py
RENAMED
|
@@ -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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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":
|
|
1088
|
-
"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 =
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
|
3088
|
-
|
|
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
|
|
3178
|
-
|
|
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
|
-
|
|
3198
|
-
|
|
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
|
-
|
|
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"]
|