opencomplai-cli 0.6.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.
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/.gitignore +2 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/PKG-INFO +1 -1
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/pyproject.toml +1 -1
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/__init__.py +1 -1
- opencomplai_cli-0.7.0/src/opencomplai_cli/commands/qms.py +177 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/main.py +246 -3
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_ci_integration_guide.py +44 -6
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_docs_generate_wiring.py +68 -1
- opencomplai_cli-0.7.0/tests/test_fria_generate.py +153 -0
- opencomplai_cli-0.7.0/tests/test_gaps_mapped_column.py +54 -0
- opencomplai_cli-0.7.0/tests/test_gaps_nist_rmf_target.py +211 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_publish.py +10 -0
- opencomplai_cli-0.7.0/tests/test_qms_generate_cmd.py +96 -0
- opencomplai_cli-0.7.0/tests/test_readme_quickstart_pin.py +94 -0
- opencomplai_cli-0.7.0/tests/test_recommend_qms_clauses.py +77 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/README.md +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/_encoding.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/commands/__init__.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/commands/checker.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/commands/controls.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/commands/dashboard.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/commands/halt.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/commands/interactive_init.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/commands/push.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/commands/serve.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/connectors/__init__.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/connectors/github_actions.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/connectors/gitlab_ci.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/data/checker-local.html +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/exit_codes.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/oidc_client.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/publish.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/__init__.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/fixtures/trap/trap-manifest.json +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_ai_preload.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_checker_cmd.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_cli.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_connectors.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_controls_cmd.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_controls_sync.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_dashboard_cmd.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_docs_generate_push.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_encoding.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_eu_ai_scan_golden.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_evidence_provenance.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_exit_codes.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_halt_wire.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_oidc_client.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_push_cmd.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_push_cmd_dossier.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_readme_command_table.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_scan_cmd.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_scan_quick.py +0 -0
- {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_service_check.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: opencomplai-cli
|
|
3
|
-
Version: 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
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`opencomplai qms generate` -- CP-15.
|
|
3
|
+
|
|
4
|
+
Renders the Art. 17(1)(a)-(m) quality-management-system document by directly
|
|
5
|
+
reusing CP-7's per-clause probes (`qms_article_17_clause_statuses`, via
|
|
6
|
+
`opencomplai_core.qms_document`) -- never re-derives clause status, so this
|
|
7
|
+
command can't disagree with what `opencomplai recommend`/`opencomplai gaps`
|
|
8
|
+
already report for the same repo.
|
|
9
|
+
|
|
10
|
+
Templated on `commands/controls.py` for the Typer/console/output-format
|
|
11
|
+
conventions; `console`/`err_console` are obtained LAZILY inside the command
|
|
12
|
+
via `from opencomplai_cli import main as _main` (not at module load time)
|
|
13
|
+
because `main.py` imports this module's `app` to register the `qms`
|
|
14
|
+
sub-typer, so a module-level import back into `main` would be circular.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
from datetime import UTC, datetime
|
|
21
|
+
from enum import StrEnum
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
import typer
|
|
25
|
+
from opencomplai_core.control_catalog import get_catalog
|
|
26
|
+
from opencomplai_core.models import CorroborationReport, EvalReport
|
|
27
|
+
from opencomplai_core.qms_document import (
|
|
28
|
+
build_qms_document,
|
|
29
|
+
render_qms_document_markdown,
|
|
30
|
+
)
|
|
31
|
+
from rich.table import Table
|
|
32
|
+
|
|
33
|
+
app = typer.Typer(help="Art. 17 quality-management-system document commands.")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class OutputFormat(StrEnum):
|
|
37
|
+
"""CLI output format -- mirrors `opencomplai_cli.main.OutputFormat`'s values."""
|
|
38
|
+
|
|
39
|
+
human = "human"
|
|
40
|
+
json = "json"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _evidence_hashes(
|
|
44
|
+
scan_report: CorroborationReport | None, eval_report: EvalReport | None
|
|
45
|
+
) -> list[str]:
|
|
46
|
+
"""SHA-256 evidence-object hashes, sourced exactly like `generate_dossier`
|
|
47
|
+
(doc-generator) does: the scan report's own hash plus each evaluator
|
|
48
|
+
result's evidence hash. No new evidence-vault client -- same inputs
|
|
49
|
+
`opencomplai check --scan`/`--sample-set` already write to disk."""
|
|
50
|
+
hashes: list[str] = []
|
|
51
|
+
if scan_report is not None:
|
|
52
|
+
hashes.append(scan_report.report_hash)
|
|
53
|
+
if eval_report is not None:
|
|
54
|
+
hashes.extend(r.evidence_hash for r in eval_report.results)
|
|
55
|
+
return hashes
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command("generate")
|
|
59
|
+
def generate_cmd(
|
|
60
|
+
system_id: str = typer.Option(
|
|
61
|
+
"", "--system-id", help="System identifier (document header only)"
|
|
62
|
+
),
|
|
63
|
+
commit_ref: str = typer.Option("HEAD", "--commit-ref", help="Git commit reference"),
|
|
64
|
+
repo_root: Path = typer.Option(
|
|
65
|
+
Path("."),
|
|
66
|
+
"--repo-root",
|
|
67
|
+
help="Repository root for the Art. 17(1)(a)-(m) per-clause artifact "
|
|
68
|
+
"probes -- the same probes `opencomplai recommend`/`opencomplai "
|
|
69
|
+
"gaps` use for Art. 17",
|
|
70
|
+
),
|
|
71
|
+
scan_report_file: Path = typer.Option(
|
|
72
|
+
Path("scan-report.json"),
|
|
73
|
+
"--scan-report",
|
|
74
|
+
help="Optional CorroborationReport JSON (written by `opencomplai "
|
|
75
|
+
"check --scan`); its report_hash is cited as evidence when present. "
|
|
76
|
+
"Absence stays honest -- never fabricated.",
|
|
77
|
+
),
|
|
78
|
+
eval_report_file: Path = typer.Option(
|
|
79
|
+
Path("eval-report.json"),
|
|
80
|
+
"--eval-report",
|
|
81
|
+
help="Optional EvalReport JSON (written by `opencomplai check "
|
|
82
|
+
"--sample-set ...`); its per-evaluator evidence hashes are cited "
|
|
83
|
+
"when present. Absence stays honest -- never fabricated.",
|
|
84
|
+
),
|
|
85
|
+
output_file: Path = typer.Option(
|
|
86
|
+
Path("qms-document.md"),
|
|
87
|
+
"--output",
|
|
88
|
+
"-o",
|
|
89
|
+
help="Path to write the rendered Markdown document",
|
|
90
|
+
),
|
|
91
|
+
output_format: OutputFormat = typer.Option(OutputFormat.human, "--output-format"),
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Generate a filled Art. 17(1)(a)-(m) QMS document: per-clause evidence
|
|
94
|
+
status (present/missing/partial) for each of the 13 sub-points, not a
|
|
95
|
+
single article-level verdict."""
|
|
96
|
+
from opencomplai_cli import main as _main
|
|
97
|
+
|
|
98
|
+
scan_report: CorroborationReport | None = None
|
|
99
|
+
if scan_report_file.exists():
|
|
100
|
+
try:
|
|
101
|
+
scan_report = CorroborationReport.model_validate(
|
|
102
|
+
json.loads(scan_report_file.read_text())
|
|
103
|
+
)
|
|
104
|
+
except Exception as exc:
|
|
105
|
+
_main.err_console.print(
|
|
106
|
+
f"[yellow]Warning:[/yellow] failed to parse {scan_report_file}: {exc}"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
eval_report: EvalReport | None = None
|
|
110
|
+
if eval_report_file.exists():
|
|
111
|
+
try:
|
|
112
|
+
eval_report = EvalReport.model_validate(
|
|
113
|
+
json.loads(eval_report_file.read_text())
|
|
114
|
+
)
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
_main.err_console.print(
|
|
117
|
+
f"[yellow]Warning:[/yellow] failed to parse {eval_report_file}: {exc}"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
resolved_repo_root = repo_root.resolve()
|
|
121
|
+
doc = build_qms_document(
|
|
122
|
+
resolved_repo_root,
|
|
123
|
+
system_id=system_id,
|
|
124
|
+
commit_ref=commit_ref,
|
|
125
|
+
generated_at=datetime.now(UTC).isoformat(),
|
|
126
|
+
evidence_hashes=_evidence_hashes(scan_report, eval_report),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
markdown = render_qms_document_markdown(doc)
|
|
130
|
+
output_file.write_text(markdown, encoding="utf-8")
|
|
131
|
+
|
|
132
|
+
if output_format == OutputFormat.json:
|
|
133
|
+
payload = {
|
|
134
|
+
"system_id": doc.system_id,
|
|
135
|
+
"commit_ref": doc.commit_ref,
|
|
136
|
+
"generated_at": doc.generated_at,
|
|
137
|
+
"clauses": [
|
|
138
|
+
{
|
|
139
|
+
"clause": f"Art. 17(1)({c.letter})",
|
|
140
|
+
"title": c.title,
|
|
141
|
+
"status": c.status.value,
|
|
142
|
+
"status_label": c.status_label,
|
|
143
|
+
"evidence_ref": c.evidence_ref,
|
|
144
|
+
"rationale": c.rationale,
|
|
145
|
+
}
|
|
146
|
+
for c in doc.clauses
|
|
147
|
+
],
|
|
148
|
+
"present_count": doc.present_count,
|
|
149
|
+
"missing_count": doc.missing_count,
|
|
150
|
+
"unverified_count": doc.unverified_count,
|
|
151
|
+
"evidence_hashes": doc.evidence_hashes,
|
|
152
|
+
"output_file": str(output_file),
|
|
153
|
+
}
|
|
154
|
+
_main.console.print_json(json.dumps(payload))
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
catalog_entry = get_catalog().get("Art. 17")
|
|
158
|
+
title = catalog_entry.title if catalog_entry else "Quality management system"
|
|
159
|
+
_main.console.print(f"\n[bold]Art. 17 -- {title}[/bold]\n")
|
|
160
|
+
|
|
161
|
+
table = Table(show_header=True, header_style="bold")
|
|
162
|
+
table.add_column("#", style="dim")
|
|
163
|
+
table.add_column("QMS element")
|
|
164
|
+
table.add_column("Status", min_width=10)
|
|
165
|
+
table.add_column("Evidence", style="dim")
|
|
166
|
+
for clause in doc.clauses:
|
|
167
|
+
table.add_row(
|
|
168
|
+
f"({clause.letter})", clause.title, clause.status_label, clause.evidence_ref
|
|
169
|
+
)
|
|
170
|
+
_main.console.print(table)
|
|
171
|
+
|
|
172
|
+
summary = f"{doc.present_count} present / {doc.missing_count} missing"
|
|
173
|
+
if doc.unverified_count:
|
|
174
|
+
summary += f" / {doc.unverified_count} unverified"
|
|
175
|
+
_main.console.print(
|
|
176
|
+
f"\n[bold]{summary}[/bold] (of 13 clauses) -- wrote {output_file}\n"
|
|
177
|
+
)
|
|
@@ -27,14 +27,18 @@ from typing import TYPE_CHECKING
|
|
|
27
27
|
from urllib.parse import urlparse
|
|
28
28
|
|
|
29
29
|
import typer
|
|
30
|
+
from opencomplai_core.compliance_checker.models import ComplianceCheckerResult
|
|
30
31
|
from opencomplai_core.control_assessment import build_controls_block, derive_controls
|
|
31
32
|
from opencomplai_core.control_catalog import get_catalog
|
|
32
33
|
from opencomplai_core.control_identity import fingerprint_manifest
|
|
33
34
|
from opencomplai_core.engine import assess
|
|
34
35
|
from opencomplai_core.eval_engine import eval_summary_from_report, run_evals
|
|
36
|
+
from opencomplai_core.fria import generate_fria, render_fria_markdown
|
|
37
|
+
from opencomplai_core.gap_probes import qms_article_17_clause_statuses
|
|
35
38
|
from opencomplai_core.gap_report import build_gap_report
|
|
36
39
|
from opencomplai_core.models import (
|
|
37
40
|
AssessmentInput,
|
|
41
|
+
ComplianceTarget,
|
|
38
42
|
ControlInstance,
|
|
39
43
|
ControlState,
|
|
40
44
|
CorroborationReport,
|
|
@@ -53,6 +57,7 @@ from opencomplai_core.models import (
|
|
|
53
57
|
SystemManifest,
|
|
54
58
|
SystemState,
|
|
55
59
|
)
|
|
60
|
+
from opencomplai_core.nist_rmf_report import build_nist_rmf_report
|
|
56
61
|
from opencomplai_core.output_envelope import wrap_scan_output
|
|
57
62
|
from opencomplai_core.principle_report import build_principle_summary
|
|
58
63
|
from opencomplai_core.recommend_engine import render_recommendations
|
|
@@ -102,6 +107,7 @@ app = typer.Typer(
|
|
|
102
107
|
)
|
|
103
108
|
risk_app = typer.Typer(help="Risk classification commands.")
|
|
104
109
|
docs_app = typer.Typer(help="Documentation generation commands.")
|
|
110
|
+
fria_app = typer.Typer(help="Fundamental rights impact assessment (Art. 27) commands.")
|
|
105
111
|
sync_app = typer.Typer(help="Metadata sync commands.")
|
|
106
112
|
keys_app = typer.Typer(
|
|
107
113
|
help="Signing key management (ISO 27001 A.8.24 / FedRAMP SC-12)."
|
|
@@ -121,6 +127,7 @@ from opencomplai_cli.commands.dashboard import app as dashboard_app # noqa: E40
|
|
|
121
127
|
from opencomplai_cli.commands.halt import approve_cmd, resume_cmd # noqa: E402
|
|
122
128
|
from opencomplai_cli.commands.interactive_init import run_interactive_init # noqa: E402
|
|
123
129
|
from opencomplai_cli.commands.push import run_push, run_push_dossier # noqa: E402
|
|
130
|
+
from opencomplai_cli.commands.qms import app as qms_app # noqa: E402
|
|
124
131
|
from opencomplai_cli.commands.serve import run_serve # noqa: E402
|
|
125
132
|
|
|
126
133
|
ai_app = typer.Typer(help="AI intent analysis commands.")
|
|
@@ -132,11 +139,13 @@ install_ascii_fallback(sys.stdout, sys.stderr)
|
|
|
132
139
|
|
|
133
140
|
app.add_typer(risk_app, name="risk")
|
|
134
141
|
app.add_typer(docs_app, name="docs")
|
|
142
|
+
app.add_typer(fria_app, name="fria")
|
|
135
143
|
app.add_typer(sync_app, name="sync")
|
|
136
144
|
app.add_typer(dashboard_app, name="dashboard")
|
|
137
145
|
app.add_typer(keys_app, name="keys")
|
|
138
146
|
app.add_typer(ai_app, name="ai")
|
|
139
147
|
app.add_typer(controls_app, name="controls")
|
|
148
|
+
app.add_typer(qms_app, name="qms")
|
|
140
149
|
# HALT-WIRE: `approve`/`resume` are top-level commands (not a sub-typer),
|
|
141
150
|
# implemented in commands/halt.py and registered here to avoid a circular
|
|
142
151
|
# import (halt.py needs `console`/`_emit_event` from this module, lazily).
|
|
@@ -1184,6 +1193,15 @@ def gaps_cmd(
|
|
|
1184
1193
|
"--repo-root",
|
|
1185
1194
|
help="Repository root for artifact path probes (Arts. 9/13/14/16/24/43)",
|
|
1186
1195
|
),
|
|
1196
|
+
target: ComplianceTarget = typer.Option(
|
|
1197
|
+
ComplianceTarget.EU_AI_ACT,
|
|
1198
|
+
"--target",
|
|
1199
|
+
help=(
|
|
1200
|
+
"EU_AI_ACT (default, evaluated natively) or NIST_AI_RMF "
|
|
1201
|
+
"(evaluated by re-projecting the EU_AI_ACT evidence below through "
|
|
1202
|
+
"data/framework_crosswalk.json — see docs/src/concepts/nist-ai-rmf.md)"
|
|
1203
|
+
),
|
|
1204
|
+
),
|
|
1187
1205
|
output: OutputFormat = typer.Option(OutputFormat.human, "--output", "-o"),
|
|
1188
1206
|
) -> None:
|
|
1189
1207
|
"""
|
|
@@ -1191,6 +1209,8 @@ def gaps_cmd(
|
|
|
1191
1209
|
|
|
1192
1210
|
Purely a projection of already-computed rule/obligation/scan/eval results —
|
|
1193
1211
|
informational only, never gates CI (see `opencomplai check` for the CI gate).
|
|
1212
|
+
`--target NIST_AI_RMF` re-projects the same evidence into a per-subcategory
|
|
1213
|
+
NIST AI RMF 1.0 profile instead — no new scanner or evaluator involved.
|
|
1194
1214
|
"""
|
|
1195
1215
|
if not manifest_file.exists():
|
|
1196
1216
|
err_console.print(f"[red]Error:[/red] manifest file not found: {manifest_file}")
|
|
@@ -1250,34 +1270,87 @@ def gaps_cmd(
|
|
|
1250
1270
|
|
|
1251
1271
|
_sync_controls_to_vault(report, manifest, quiet=(output == OutputFormat.json))
|
|
1252
1272
|
|
|
1273
|
+
nist_report = (
|
|
1274
|
+
build_nist_rmf_report(report)
|
|
1275
|
+
if target == ComplianceTarget.NIST_AI_RMF
|
|
1276
|
+
else None
|
|
1277
|
+
)
|
|
1278
|
+
|
|
1253
1279
|
if output == OutputFormat.json:
|
|
1280
|
+
envelope_payload = json.loads(report.model_dump_json())
|
|
1281
|
+
if nist_report is not None:
|
|
1282
|
+
envelope_payload["nist_rmf_report"] = json.loads(
|
|
1283
|
+
nist_report.model_dump_json()
|
|
1284
|
+
)
|
|
1254
1285
|
envelope = wrap_scan_output(
|
|
1255
|
-
|
|
1286
|
+
envelope_payload,
|
|
1256
1287
|
scan_errors=[],
|
|
1257
1288
|
tool_version=__version__,
|
|
1258
1289
|
)
|
|
1259
1290
|
console.print_json(envelope.model_dump_json(indent=2))
|
|
1260
1291
|
return
|
|
1261
1292
|
|
|
1293
|
+
if nist_report is not None:
|
|
1294
|
+
console.print(
|
|
1295
|
+
f"\n[bold]Opencomplai NIST AI RMF Gap Report[/bold] — {manifest.system_id}\n"
|
|
1296
|
+
)
|
|
1297
|
+
console.print(
|
|
1298
|
+
"[dim]Re-projected from EU AI Act evidence via the framework crosswalk "
|
|
1299
|
+
"(data/framework_crosswalk.json) — heuristic estimates, not a legal "
|
|
1300
|
+
"determination. See docs/src/concepts/nist-ai-rmf.md for the "
|
|
1301
|
+
"methodology; no new scanner or evaluator backs this.[/dim]\n"
|
|
1302
|
+
)
|
|
1303
|
+
rmf_table = Table(show_header=True, header_style="bold")
|
|
1304
|
+
rmf_table.add_column("Subcategory", style="dim")
|
|
1305
|
+
rmf_table.add_column("Status", min_width=10)
|
|
1306
|
+
rmf_table.add_column("Mapping conf.", style="dim")
|
|
1307
|
+
rmf_table.add_column("From (EU AI Act)", style="dim")
|
|
1308
|
+
rmf_table.add_column("Rationale")
|
|
1309
|
+
for row in nist_report.subcategories:
|
|
1310
|
+
rmf_table.add_row(
|
|
1311
|
+
row.subcategory,
|
|
1312
|
+
_GAP_STATUS_STYLE[row.status],
|
|
1313
|
+
row.mapping_confidence or "—",
|
|
1314
|
+
", ".join(row.source_eu_ai_act_articles) or "—",
|
|
1315
|
+
row.rationale,
|
|
1316
|
+
)
|
|
1317
|
+
console.print(rmf_table)
|
|
1318
|
+
console.print(
|
|
1319
|
+
"\n[dim]NIST AI RMF report is informational only — it does not gate "
|
|
1320
|
+
"CI. Every row cites the EU AI Act evidence it was derived from; "
|
|
1321
|
+
"UNVERIFIED rows have no framework_crosswalk.json coverage yet.[/dim]\n"
|
|
1322
|
+
)
|
|
1323
|
+
return
|
|
1324
|
+
|
|
1262
1325
|
console.print(f"\n[bold]Opencomplai Gap Report[/bold] — {manifest.system_id}\n")
|
|
1263
1326
|
console.print(
|
|
1264
1327
|
"[dim]Statuses are heuristic estimates — not a legal determination.[/dim]\n"
|
|
1265
1328
|
)
|
|
1266
1329
|
table = Table(show_header=True, header_style="bold")
|
|
1267
1330
|
table.add_column("Article", style="dim")
|
|
1331
|
+
table.add_column("Mapped", style="dim")
|
|
1268
1332
|
table.add_column("Status", min_width=10)
|
|
1269
1333
|
table.add_column("Source", style="dim")
|
|
1270
1334
|
table.add_column("Evidence", style="dim")
|
|
1271
1335
|
table.add_column("Rationale")
|
|
1336
|
+
catalog = get_catalog()
|
|
1272
1337
|
for row in report.articles:
|
|
1338
|
+
catalog_entry = catalog.get(row.article)
|
|
1339
|
+
mapped = catalog_entry.iso_42001_clause if catalog_entry else None
|
|
1273
1340
|
table.add_row(
|
|
1274
1341
|
row.article,
|
|
1342
|
+
mapped or "—",
|
|
1275
1343
|
_GAP_STATUS_STYLE[row.status],
|
|
1276
1344
|
row.source.value,
|
|
1277
1345
|
row.evidence_ref,
|
|
1278
1346
|
row.rationale,
|
|
1279
1347
|
)
|
|
1280
1348
|
console.print(table)
|
|
1349
|
+
console.print(
|
|
1350
|
+
"[dim]Mapped: ISO/IEC 42001:2023 clause from the framework crosswalk "
|
|
1351
|
+
"(data/framework_crosswalk.json) — a citation, not a computed "
|
|
1352
|
+
"conformity verdict.[/dim]\n"
|
|
1353
|
+
)
|
|
1281
1354
|
|
|
1282
1355
|
console.print("\n[bold]Principle Summary[/bold]\n")
|
|
1283
1356
|
principle_table = Table(show_header=True, header_style="bold")
|
|
@@ -1326,6 +1399,12 @@ def recommend_cmd(
|
|
|
1326
1399
|
"-o",
|
|
1327
1400
|
help="Directory to write remediation templates to",
|
|
1328
1401
|
),
|
|
1402
|
+
repo_root: Path = typer.Option(
|
|
1403
|
+
Path("."),
|
|
1404
|
+
"--repo-root",
|
|
1405
|
+
help="Repository root for artifact path probes (Arts. 9/13/14/16/17/24/43), "
|
|
1406
|
+
"including the Art. 17 QMS per-clause breakdown",
|
|
1407
|
+
),
|
|
1329
1408
|
) -> None:
|
|
1330
1409
|
"""
|
|
1331
1410
|
Write copy-paste remediation templates for every Missing/Partial gap-report row.
|
|
@@ -1391,9 +1470,11 @@ def recommend_cmd(
|
|
|
1391
1470
|
risk_result=risk_result,
|
|
1392
1471
|
corroboration_report=corroboration_report,
|
|
1393
1472
|
eval_report=eval_report,
|
|
1473
|
+
repo_root=repo_root.resolve(),
|
|
1394
1474
|
)
|
|
1395
1475
|
|
|
1396
|
-
|
|
1476
|
+
resolved_repo_root = repo_root.resolve()
|
|
1477
|
+
written = render_recommendations(report, output_dir, repo_root=resolved_repo_root)
|
|
1397
1478
|
|
|
1398
1479
|
if not written:
|
|
1399
1480
|
console.print(
|
|
@@ -1407,6 +1488,140 @@ def recommend_cmd(
|
|
|
1407
1488
|
for path in written:
|
|
1408
1489
|
console.print(f" {path}")
|
|
1409
1490
|
|
|
1491
|
+
if any(row.article == "Art. 17" for row in report.articles):
|
|
1492
|
+
qms_rows = qms_article_17_clause_statuses(resolved_repo_root)
|
|
1493
|
+
present = sum(1 for r in qms_rows if r.status == GapStatus.PARTIAL)
|
|
1494
|
+
missing = sum(1 for r in qms_rows if r.status == GapStatus.MISSING)
|
|
1495
|
+
console.print(
|
|
1496
|
+
f"\n[bold]Art. 17 QMS per-clause:[/bold] {present}/13 present, "
|
|
1497
|
+
f"{missing}/13 missing (see the qms_outline.md written above for detail)",
|
|
1498
|
+
soft_wrap=True,
|
|
1499
|
+
)
|
|
1500
|
+
|
|
1501
|
+
|
|
1502
|
+
@fria_app.command("generate")
|
|
1503
|
+
def fria_generate_cmd(
|
|
1504
|
+
manifest_file: Path = typer.Option(
|
|
1505
|
+
Path("system-manifest.json"),
|
|
1506
|
+
"--manifest",
|
|
1507
|
+
"-m",
|
|
1508
|
+
help="Path to system manifest JSON file",
|
|
1509
|
+
),
|
|
1510
|
+
commit_ref: str = typer.Option("HEAD", "--commit-ref", help="Git commit reference"),
|
|
1511
|
+
checker_report_file: Path | None = typer.Option(
|
|
1512
|
+
None,
|
|
1513
|
+
"--checker-report",
|
|
1514
|
+
help=(
|
|
1515
|
+
"Path to a checker JSON report (written by `opencomplai checker "
|
|
1516
|
+
"--export-json ...`). Defaults to the manifest's own "
|
|
1517
|
+
"checker_session.report_json_path when set. When neither is "
|
|
1518
|
+
"available, Art. 27(1)(c) stays honestly unpopulated."
|
|
1519
|
+
),
|
|
1520
|
+
),
|
|
1521
|
+
repo_root: Path = typer.Option(
|
|
1522
|
+
Path("."),
|
|
1523
|
+
"--repo-root",
|
|
1524
|
+
help="Repository root for the Art. 27 control-register probe (provider_fria)",
|
|
1525
|
+
),
|
|
1526
|
+
output_dir: Path = typer.Option(Path("./fria"), "--output-dir"),
|
|
1527
|
+
output: OutputFormat = typer.Option(OutputFormat.human, "--output", "-o"),
|
|
1528
|
+
) -> None:
|
|
1529
|
+
"""Draft an Art. 27 fundamental rights impact assessment (D-1, CP-14).
|
|
1530
|
+
|
|
1531
|
+
Populates Art. 27(1)(a)-(f) from the system manifest, the checker's own
|
|
1532
|
+
persisted session/obligations when one was run, and the Art. 27 row
|
|
1533
|
+
`opencomplai gaps` already computes (CP-6's control-register wiring) --
|
|
1534
|
+
not just CP-6's fill-in-only template. A point with no real data behind
|
|
1535
|
+
it is labelled "not captured", never fabricated.
|
|
1536
|
+
"""
|
|
1537
|
+
if not manifest_file.exists():
|
|
1538
|
+
err_console.print(f"[red]Error:[/red] manifest file not found: {manifest_file}")
|
|
1539
|
+
err_console.print("Run [bold]opencomplai init[/bold] first.")
|
|
1540
|
+
sys.exit(2)
|
|
1541
|
+
try:
|
|
1542
|
+
manifest = SystemManifest.model_validate(json.loads(manifest_file.read_text()))
|
|
1543
|
+
except Exception as e:
|
|
1544
|
+
err_console.print(f"[red]Validation error:[/red] {e}")
|
|
1545
|
+
sys.exit(2)
|
|
1546
|
+
|
|
1547
|
+
# The checker persists its own session/obligations as a plain
|
|
1548
|
+
# ComplianceCheckerResult JSON export (opencomplai checker --export-json,
|
|
1549
|
+
# or the file `opencomplai init --interactive` writes and references via
|
|
1550
|
+
# manifest.checker_session.report_json_path) -- read it the same way,
|
|
1551
|
+
# no new storage mechanism.
|
|
1552
|
+
resolved_checker_path = checker_report_file
|
|
1553
|
+
if resolved_checker_path is None and manifest.checker_session is not None:
|
|
1554
|
+
candidate = manifest.checker_session.report_json_path
|
|
1555
|
+
if candidate:
|
|
1556
|
+
resolved_checker_path = Path(candidate)
|
|
1557
|
+
|
|
1558
|
+
checker_result: ComplianceCheckerResult | None = None
|
|
1559
|
+
if resolved_checker_path is not None:
|
|
1560
|
+
if resolved_checker_path.exists():
|
|
1561
|
+
try:
|
|
1562
|
+
checker_result = ComplianceCheckerResult.model_validate_json(
|
|
1563
|
+
resolved_checker_path.read_text(encoding="utf-8")
|
|
1564
|
+
)
|
|
1565
|
+
except Exception as exc:
|
|
1566
|
+
err_console.print(
|
|
1567
|
+
f"[yellow]Warning:[/yellow] failed to parse checker report "
|
|
1568
|
+
f"{resolved_checker_path}: {exc}"
|
|
1569
|
+
)
|
|
1570
|
+
else:
|
|
1571
|
+
console.print(
|
|
1572
|
+
f"[dim]No checker report found at {resolved_checker_path} -- "
|
|
1573
|
+
"Art. 27(1)(c) stays unpopulated (run 'opencomplai checker' "
|
|
1574
|
+
"first, or pass --checker-report).[/dim]"
|
|
1575
|
+
)
|
|
1576
|
+
|
|
1577
|
+
# Control register: the Art. 27 row CP-6 already wires into gaps.json
|
|
1578
|
+
# (obligation 'fria' + artifact probe 'provider_fria', worst-status-wins).
|
|
1579
|
+
resolved_repo_root = repo_root.resolve()
|
|
1580
|
+
assessment_input = AssessmentInput(
|
|
1581
|
+
model=ModelMetadata(
|
|
1582
|
+
name=manifest.system_id,
|
|
1583
|
+
version=commit_ref,
|
|
1584
|
+
modality="text",
|
|
1585
|
+
use_case=manifest.intended_purpose,
|
|
1586
|
+
deployment_context="local",
|
|
1587
|
+
)
|
|
1588
|
+
)
|
|
1589
|
+
risk_result = assess(assessment_input)
|
|
1590
|
+
report = build_gap_report(
|
|
1591
|
+
system_id=manifest.system_id,
|
|
1592
|
+
commit_ref=commit_ref,
|
|
1593
|
+
risk_result=risk_result,
|
|
1594
|
+
repo_root=resolved_repo_root,
|
|
1595
|
+
)
|
|
1596
|
+
gap_row = next((r for r in report.articles if r.article == "Art. 27"), None)
|
|
1597
|
+
|
|
1598
|
+
document = generate_fria(manifest, gap_row=gap_row, checker_result=checker_result)
|
|
1599
|
+
|
|
1600
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
1601
|
+
json_path = output_dir / f"fria_{document.fria_id}.json"
|
|
1602
|
+
md_path = output_dir / f"fria_{document.fria_id}.md"
|
|
1603
|
+
json_path.write_text(document.model_dump_json(indent=2), encoding="utf-8")
|
|
1604
|
+
md_path.write_text(render_fria_markdown(document), encoding="utf-8")
|
|
1605
|
+
|
|
1606
|
+
if output == OutputFormat.json:
|
|
1607
|
+
console.print_json(document.model_dump_json(indent=2))
|
|
1608
|
+
return
|
|
1609
|
+
|
|
1610
|
+
console.print("\n[bold]Fundamental Rights Impact Assessment Draft (Art. 27)[/bold]")
|
|
1611
|
+
console.print(f" fria_id: {document.fria_id}")
|
|
1612
|
+
console.print(
|
|
1613
|
+
f" populated: {document.populated_point_count}/{document.total_points} points"
|
|
1614
|
+
)
|
|
1615
|
+
console.print(f" json: {json_path}")
|
|
1616
|
+
console.print(f" markdown: {md_path}")
|
|
1617
|
+
for point in document.points:
|
|
1618
|
+
marker = (
|
|
1619
|
+
"[green]populated[/green]"
|
|
1620
|
+
if point.populated
|
|
1621
|
+
else "[yellow]not captured[/yellow]"
|
|
1622
|
+
)
|
|
1623
|
+
console.print(f" ({point.point}) {marker} -- source: {point.source}")
|
|
1624
|
+
|
|
1410
1625
|
|
|
1411
1626
|
@app.command("report")
|
|
1412
1627
|
def report_cmd(
|
|
@@ -2591,8 +2806,17 @@ def check_cmd(
|
|
|
2591
2806
|
if derived_controls is not None
|
|
2592
2807
|
else None
|
|
2593
2808
|
)
|
|
2809
|
+
nist_rmf_report = (
|
|
2810
|
+
build_nist_rmf_report(gap_report)
|
|
2811
|
+
if manifest.compliance_target == ComplianceTarget.NIST_AI_RMF
|
|
2812
|
+
else None
|
|
2813
|
+
)
|
|
2594
2814
|
artifact = artifact.model_copy(
|
|
2595
|
-
update={
|
|
2815
|
+
update={
|
|
2816
|
+
"gap_report": gap_report,
|
|
2817
|
+
"controls": controls_block,
|
|
2818
|
+
"nist_rmf_report": nist_rmf_report,
|
|
2819
|
+
}
|
|
2596
2820
|
)
|
|
2597
2821
|
|
|
2598
2822
|
# Write artifact to disk for CI consumption
|
|
@@ -3405,6 +3629,18 @@ def docs_generate_cmd(
|
|
|
3405
3629
|
"see module notes below."
|
|
3406
3630
|
),
|
|
3407
3631
|
),
|
|
3632
|
+
allow_incomplete: bool = typer.Option(
|
|
3633
|
+
False,
|
|
3634
|
+
"--allow-incomplete",
|
|
3635
|
+
help=(
|
|
3636
|
+
"Exit 0 even when the generated dossier fails Annex IV schema "
|
|
3637
|
+
"validation. Off by default: an invalid dossier is still "
|
|
3638
|
+
"written to disk (or persisted server-side in service-backed "
|
|
3639
|
+
"mode) so an auditor can see what failed, but the process exits "
|
|
3640
|
+
"2 (VALIDATION_FAIL) to fail CI closed. Pass this flag to "
|
|
3641
|
+
"restore the old always-exit-0 behaviour."
|
|
3642
|
+
),
|
|
3643
|
+
),
|
|
3408
3644
|
) -> None:
|
|
3409
3645
|
"""Generate an Annex IV technical documentation dossier (REQ-DOC-001)."""
|
|
3410
3646
|
# HALT-WIRE / E-12(d): a HALTED_PENDING_REVIEW system refuses dossier
|
|
@@ -3477,6 +3713,7 @@ def docs_generate_cmd(
|
|
|
3477
3713
|
"commit_ref": commit_ref,
|
|
3478
3714
|
"intended_purpose": intended_purpose,
|
|
3479
3715
|
"provider_name": provider_name,
|
|
3716
|
+
"allow_incomplete": allow_incomplete,
|
|
3480
3717
|
}
|
|
3481
3718
|
if loaded_manifest is not None:
|
|
3482
3719
|
payload.update(
|
|
@@ -3622,6 +3859,12 @@ def docs_generate_cmd(
|
|
|
3622
3859
|
dossier, repo_dir=out_file.resolve().parent
|
|
3623
3860
|
):
|
|
3624
3861
|
sys.exit(3)
|
|
3862
|
+
# Fail-closed dossier gate (D-2): the file above is always written
|
|
3863
|
+
# first so an auditor can see what failed, but an invalid dossier
|
|
3864
|
+
# must not report success unless the caller explicitly opted into
|
|
3865
|
+
# the old always-exit-0 behaviour via --allow-incomplete.
|
|
3866
|
+
if not schema_valid and not allow_incomplete:
|
|
3867
|
+
sys.exit(HARD_FAIL_EXIT_CODES[ScanResult.VALIDATION_FAIL])
|
|
3625
3868
|
sys.exit(0)
|
|
3626
3869
|
except Exception as exc:
|
|
3627
3870
|
err_console.print(f"[red]Dossier generation failed:[/red] {exc}")
|