spanforge 1.0.0__py3-none-any.whl

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 (174) hide show
  1. spanforge/__init__.py +815 -0
  2. spanforge/_ansi.py +93 -0
  3. spanforge/_batch_exporter.py +409 -0
  4. spanforge/_cli.py +2094 -0
  5. spanforge/_cli_audit.py +639 -0
  6. spanforge/_cli_compliance.py +711 -0
  7. spanforge/_cli_cost.py +243 -0
  8. spanforge/_cli_ops.py +791 -0
  9. spanforge/_cli_phase11.py +356 -0
  10. spanforge/_hooks.py +337 -0
  11. spanforge/_server.py +1708 -0
  12. spanforge/_span.py +1036 -0
  13. spanforge/_store.py +288 -0
  14. spanforge/_stream.py +664 -0
  15. spanforge/_trace.py +335 -0
  16. spanforge/_tracer.py +254 -0
  17. spanforge/actor.py +141 -0
  18. spanforge/alerts.py +469 -0
  19. spanforge/auto.py +464 -0
  20. spanforge/baseline.py +335 -0
  21. spanforge/cache.py +635 -0
  22. spanforge/compliance.py +325 -0
  23. spanforge/config.py +532 -0
  24. spanforge/consent.py +228 -0
  25. spanforge/consumer.py +377 -0
  26. spanforge/core/__init__.py +5 -0
  27. spanforge/core/compliance_mapping.py +1254 -0
  28. spanforge/cost.py +600 -0
  29. spanforge/debug.py +548 -0
  30. spanforge/deprecations.py +205 -0
  31. spanforge/drift.py +482 -0
  32. spanforge/egress.py +58 -0
  33. spanforge/eval.py +648 -0
  34. spanforge/event.py +1064 -0
  35. spanforge/exceptions.py +240 -0
  36. spanforge/explain.py +178 -0
  37. spanforge/export/__init__.py +69 -0
  38. spanforge/export/append_only.py +337 -0
  39. spanforge/export/cloud.py +357 -0
  40. spanforge/export/datadog.py +497 -0
  41. spanforge/export/grafana.py +320 -0
  42. spanforge/export/jsonl.py +195 -0
  43. spanforge/export/openinference.py +158 -0
  44. spanforge/export/otel_bridge.py +294 -0
  45. spanforge/export/otlp.py +811 -0
  46. spanforge/export/otlp_bridge.py +233 -0
  47. spanforge/export/redis_backend.py +282 -0
  48. spanforge/export/siem_schema.py +98 -0
  49. spanforge/export/siem_splunk.py +264 -0
  50. spanforge/export/siem_syslog.py +212 -0
  51. spanforge/export/webhook.py +299 -0
  52. spanforge/exporters/__init__.py +30 -0
  53. spanforge/exporters/console.py +271 -0
  54. spanforge/exporters/jsonl.py +144 -0
  55. spanforge/exporters/sqlite.py +142 -0
  56. spanforge/gate.py +1150 -0
  57. spanforge/governance.py +181 -0
  58. spanforge/hitl.py +295 -0
  59. spanforge/http.py +187 -0
  60. spanforge/inspect.py +427 -0
  61. spanforge/integrations/__init__.py +45 -0
  62. spanforge/integrations/_pricing.py +280 -0
  63. spanforge/integrations/anthropic.py +388 -0
  64. spanforge/integrations/azure_openai.py +133 -0
  65. spanforge/integrations/bedrock.py +292 -0
  66. spanforge/integrations/crewai.py +251 -0
  67. spanforge/integrations/gemini.py +351 -0
  68. spanforge/integrations/groq.py +442 -0
  69. spanforge/integrations/langchain.py +349 -0
  70. spanforge/integrations/langgraph.py +306 -0
  71. spanforge/integrations/llamaindex.py +373 -0
  72. spanforge/integrations/ollama.py +287 -0
  73. spanforge/integrations/openai.py +368 -0
  74. spanforge/integrations/together.py +483 -0
  75. spanforge/io.py +214 -0
  76. spanforge/lint.py +322 -0
  77. spanforge/metrics.py +417 -0
  78. spanforge/metrics_export.py +343 -0
  79. spanforge/migrate.py +402 -0
  80. spanforge/model_registry.py +278 -0
  81. spanforge/models.py +389 -0
  82. spanforge/namespaces/__init__.py +254 -0
  83. spanforge/namespaces/audit.py +256 -0
  84. spanforge/namespaces/cache.py +237 -0
  85. spanforge/namespaces/chain.py +77 -0
  86. spanforge/namespaces/confidence.py +72 -0
  87. spanforge/namespaces/consent.py +92 -0
  88. spanforge/namespaces/cost.py +179 -0
  89. spanforge/namespaces/decision.py +143 -0
  90. spanforge/namespaces/diff.py +157 -0
  91. spanforge/namespaces/drift.py +80 -0
  92. spanforge/namespaces/eval_.py +251 -0
  93. spanforge/namespaces/feedback.py +241 -0
  94. spanforge/namespaces/fence.py +193 -0
  95. spanforge/namespaces/guard.py +105 -0
  96. spanforge/namespaces/hitl.py +91 -0
  97. spanforge/namespaces/latency.py +72 -0
  98. spanforge/namespaces/prompt.py +190 -0
  99. spanforge/namespaces/redact.py +173 -0
  100. spanforge/namespaces/retrieval.py +379 -0
  101. spanforge/namespaces/runtime_governance.py +494 -0
  102. spanforge/namespaces/template.py +208 -0
  103. spanforge/namespaces/tool_call.py +77 -0
  104. spanforge/namespaces/trace.py +1029 -0
  105. spanforge/normalizer.py +171 -0
  106. spanforge/plugins.py +82 -0
  107. spanforge/presidio_backend.py +349 -0
  108. spanforge/processor.py +258 -0
  109. spanforge/prompt_registry.py +418 -0
  110. spanforge/py.typed +0 -0
  111. spanforge/redact.py +914 -0
  112. spanforge/regression.py +192 -0
  113. spanforge/runtime_policy.py +159 -0
  114. spanforge/sampling.py +511 -0
  115. spanforge/schema.py +183 -0
  116. spanforge/schemas/v1.0/schema.json +170 -0
  117. spanforge/schemas/v2.0/schema.json +536 -0
  118. spanforge/sdk/__init__.py +625 -0
  119. spanforge/sdk/_base.py +584 -0
  120. spanforge/sdk/_base.pyi +71 -0
  121. spanforge/sdk/_exceptions.py +1096 -0
  122. spanforge/sdk/_types.py +2184 -0
  123. spanforge/sdk/alert.py +1514 -0
  124. spanforge/sdk/alert.pyi +56 -0
  125. spanforge/sdk/audit.py +1196 -0
  126. spanforge/sdk/audit.pyi +67 -0
  127. spanforge/sdk/cec.py +1215 -0
  128. spanforge/sdk/cec.pyi +37 -0
  129. spanforge/sdk/config.py +641 -0
  130. spanforge/sdk/config.pyi +55 -0
  131. spanforge/sdk/enterprise.py +714 -0
  132. spanforge/sdk/enterprise.pyi +79 -0
  133. spanforge/sdk/explain.py +170 -0
  134. spanforge/sdk/fallback.py +432 -0
  135. spanforge/sdk/feedback.py +351 -0
  136. spanforge/sdk/gate.py +874 -0
  137. spanforge/sdk/gate.pyi +51 -0
  138. spanforge/sdk/identity.py +2114 -0
  139. spanforge/sdk/identity.pyi +47 -0
  140. spanforge/sdk/lineage.py +175 -0
  141. spanforge/sdk/observe.py +1065 -0
  142. spanforge/sdk/observe.pyi +50 -0
  143. spanforge/sdk/operator.py +338 -0
  144. spanforge/sdk/pii.py +1473 -0
  145. spanforge/sdk/pii.pyi +119 -0
  146. spanforge/sdk/pipelines.py +458 -0
  147. spanforge/sdk/pipelines.pyi +39 -0
  148. spanforge/sdk/policy.py +930 -0
  149. spanforge/sdk/rag.py +594 -0
  150. spanforge/sdk/rbac.py +280 -0
  151. spanforge/sdk/registry.py +430 -0
  152. spanforge/sdk/registry.pyi +46 -0
  153. spanforge/sdk/scope.py +279 -0
  154. spanforge/sdk/secrets.py +293 -0
  155. spanforge/sdk/secrets.pyi +25 -0
  156. spanforge/sdk/security.py +560 -0
  157. spanforge/sdk/security.pyi +57 -0
  158. spanforge/sdk/trust.py +472 -0
  159. spanforge/sdk/trust.pyi +41 -0
  160. spanforge/secrets.py +799 -0
  161. spanforge/signing.py +1179 -0
  162. spanforge/stats.py +100 -0
  163. spanforge/stream.py +560 -0
  164. spanforge/testing.py +378 -0
  165. spanforge/testing_mocks.py +1052 -0
  166. spanforge/trace.py +199 -0
  167. spanforge/types.py +696 -0
  168. spanforge/ulid.py +300 -0
  169. spanforge/validate.py +379 -0
  170. spanforge-1.0.0.dist-info/METADATA +1509 -0
  171. spanforge-1.0.0.dist-info/RECORD +174 -0
  172. spanforge-1.0.0.dist-info/WHEEL +4 -0
  173. spanforge-1.0.0.dist-info/entry_points.txt +5 -0
  174. spanforge-1.0.0.dist-info/licenses/LICENSE +128 -0
@@ -0,0 +1,711 @@
1
+ """Compliance command group for the SpanForge CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any, Callable
11
+
12
+ _FRAMEWORK_SLUG_MAP: dict[str, str] = {
13
+ "eu_ai_act": "EU AI Act",
14
+ "iso_42001": "ISO/IEC 42001",
15
+ "nist_ai_rmf": "NIST AI RMF",
16
+ "gdpr": "GDPR",
17
+ "soc2": "SOC 2 Type II",
18
+ "hipaa": "HIPAA",
19
+ }
20
+
21
+
22
+ def _resolve_framework(framework: str) -> tuple[str, Any]:
23
+ """Resolve a CLI framework value to a ComplianceFramework enum."""
24
+ from spanforge.core.compliance_mapping import ComplianceFramework
25
+
26
+ fw_map = {member.value: member for member in ComplianceFramework}
27
+ for slug, value in _FRAMEWORK_SLUG_MAP.items():
28
+ if value in fw_map:
29
+ fw_map[slug] = fw_map[value]
30
+
31
+ framework_key = framework.lower()
32
+ for key, fw_member in fw_map.items():
33
+ if key.lower() == framework_key:
34
+ return framework_key, fw_member
35
+
36
+ valid = ", ".join(sorted(_FRAMEWORK_SLUG_MAP))
37
+ print(f"error: unknown framework {framework!r}. Valid slugs: {valid}", file=sys.stderr)
38
+ return framework_key, None
39
+
40
+
41
+ def _load_audit_events(events_file: str | None) -> tuple[int, list[dict[str, Any]] | None]:
42
+ """Load audit events from an optional JSONL file."""
43
+ if not events_file:
44
+ return 0, None
45
+
46
+ events_path = Path(events_file)
47
+ if not events_path.exists():
48
+ print(f"error: events file not found: {events_path}", file=sys.stderr)
49
+ return 2, None
50
+
51
+ audit_events: list[dict[str, Any]] = []
52
+ for line in events_path.read_text(encoding="utf-8").splitlines():
53
+ line = line.strip()
54
+ if not line:
55
+ continue
56
+ try:
57
+ audit_events.append(json.loads(line))
58
+ except json.JSONDecodeError as exc:
59
+ print(f"warning: skipping invalid JSON line in events file: {exc}", file=sys.stderr)
60
+ return 0, audit_events
61
+
62
+
63
+ def _attestation_from_dict(data: dict[str, Any]) -> object:
64
+ """Reconstruct a ComplianceAttestation from its JSON form."""
65
+ from spanforge.core.compliance_mapping import (
66
+ ClauseStatus,
67
+ ComplianceAttestation,
68
+ EvidenceRecord,
69
+ )
70
+
71
+ period = data.get("period", {})
72
+ clauses = [
73
+ EvidenceRecord(
74
+ clause_id=clause["clause_id"],
75
+ status=ClauseStatus(clause["status"]),
76
+ evidence_count=clause.get("evidence_count", 0),
77
+ audit_ids=clause.get("audit_ids", []),
78
+ summary=clause.get("summary", ""),
79
+ )
80
+ for clause in data.get("clauses", [])
81
+ ]
82
+ return ComplianceAttestation(
83
+ model_id=data["model_id"],
84
+ framework=data["framework"],
85
+ period_from=period.get("from", data.get("period_from", "")),
86
+ period_to=period.get("to", data.get("period_to", "")),
87
+ generated_at=data.get("generated_at", ""),
88
+ generated_by=data.get("generated_by", ""),
89
+ clauses=clauses,
90
+ overall_status=ClauseStatus(data["overall_status"]),
91
+ hmac_sig=data.get("hmac_sig", ""),
92
+ )
93
+
94
+
95
+ def cmd_generate(args: argparse.Namespace) -> int:
96
+ """Implement ``spanforge compliance generate``."""
97
+ from spanforge.core.compliance_mapping import ComplianceMappingEngine
98
+
99
+ framework_key, framework = _resolve_framework(args.framework)
100
+ if framework is None:
101
+ return 2
102
+
103
+ status, audit_events = _load_audit_events(getattr(args, "events_file", None))
104
+ if status != 0:
105
+ return status
106
+
107
+ engine = ComplianceMappingEngine()
108
+ try:
109
+ package = engine.generate_evidence_package(
110
+ model_id=args.model_id,
111
+ framework=framework.value,
112
+ from_date=args.from_date,
113
+ to_date=args.to_date,
114
+ audit_events=audit_events or None,
115
+ )
116
+ except Exception as exc:
117
+ print(f"error: evidence package generation failed: {exc}", file=sys.stderr)
118
+ return 1
119
+
120
+ out_dir = Path(args.output)
121
+ out_dir.mkdir(parents=True, exist_ok=True)
122
+
123
+ safe_id = args.model_id.replace("/", "_")[:40]
124
+ prefix = f"{framework_key}_{safe_id}_{args.from_date}_{args.to_date}"
125
+
126
+ attestation_path = out_dir / f"{prefix}_attestation.json"
127
+ attestation_path.write_text(package.attestation.to_json(), encoding="utf-8")
128
+ print(f"[✓] Attestation → {attestation_path}")
129
+
130
+ report_path = out_dir / f"{prefix}_report.txt"
131
+ report_path.write_text(package.report_text, encoding="utf-8")
132
+ print(f"[✓] Report → {report_path}")
133
+
134
+ if package.gap_report.has_gaps:
135
+ from spanforge.core.compliance_mapping import _FRAMEWORK_CLAUSES, _FRAMEWORK_KEY_MAP
136
+
137
+ _fw_key = _FRAMEWORK_KEY_MAP.get(framework_key, framework_key)
138
+ _clauses_def = _FRAMEWORK_CLAUSES.get(_fw_key, {})
139
+ gap_data = {
140
+ "model_id": package.gap_report.model_id,
141
+ "framework": package.gap_report.framework,
142
+ "period_from": package.gap_report.period_from,
143
+ "period_to": package.gap_report.period_to,
144
+ "generated_at": package.gap_report.generated_at,
145
+ "gap_clause_ids": package.gap_report.gap_clause_ids,
146
+ "partial_clause_ids": package.gap_report.partial_clause_ids,
147
+ "remediation": {
148
+ cid: _clauses_def.get(cid, {}).get("remediation_steps", "")
149
+ for cid in package.gap_report.gap_clause_ids
150
+ },
151
+ }
152
+ gap_path = out_dir / f"{prefix}_gap_report.json"
153
+ gap_path.write_text(json.dumps(gap_data, indent=2), encoding="utf-8")
154
+ print(f"[✓] Gap report → {gap_path}")
155
+ else:
156
+ print("[✓] No compliance gaps found")
157
+
158
+ if package.audit_exports:
159
+ exports_dir = out_dir / "exports"
160
+ exports_dir.mkdir(exist_ok=True)
161
+ for clause_id, events in package.audit_exports.items():
162
+ safe_clause = clause_id.replace("/", "_").replace(".", "_")
163
+ clause_path = exports_dir / f"{safe_clause}.jsonl"
164
+ clause_path.write_text("\n".join(json.dumps(event) for event in events), encoding="utf-8")
165
+ print(f"[✓] Clause exports → {exports_dir}/ ({len(package.audit_exports)} clause(s))")
166
+
167
+ print(f"\nOverall status: {package.attestation.overall_status.value}")
168
+ return 0
169
+
170
+
171
+ def cmd_validate_attestation(args: argparse.Namespace) -> int:
172
+ """Implement ``spanforge compliance validate-attestation``."""
173
+ from spanforge.compliance import verify_attestation_signature
174
+ from spanforge.core.compliance_mapping import ComplianceAttestation
175
+
176
+ attestation_path = Path(args.attestation_file)
177
+ if not attestation_path.exists():
178
+ print(f"error: file not found: {attestation_path}", file=sys.stderr)
179
+ return 2
180
+
181
+ try:
182
+ data = json.loads(attestation_path.read_text(encoding="utf-8"))
183
+ except json.JSONDecodeError as exc:
184
+ print(f"error: invalid JSON in {attestation_path}: {exc}", file=sys.stderr)
185
+ return 2
186
+
187
+ try:
188
+ attestation = _attestation_from_dict(data)
189
+ except (KeyError, ValueError) as exc:
190
+ print(f"error: could not parse attestation: {exc}", file=sys.stderr)
191
+ return 2
192
+
193
+ assert isinstance(attestation, ComplianceAttestation) # nosec B101
194
+ if verify_attestation_signature(attestation):
195
+ print(f"[✓] Attestation signature is valid model_id={data.get('model_id')!r}")
196
+ return 0
197
+ print(
198
+ f"[✗] Attestation signature is INVALID model_id={data.get('model_id')!r}",
199
+ file=sys.stderr,
200
+ )
201
+ return 1
202
+
203
+
204
+ def cmd_report(args: argparse.Namespace) -> int:
205
+ """Implement ``spanforge compliance report``."""
206
+ from spanforge.core.compliance_mapping import ComplianceMappingEngine
207
+
208
+ framework_key, framework = _resolve_framework(args.framework)
209
+ if framework is None:
210
+ return 2
211
+
212
+ status, audit_events = _load_audit_events(getattr(args, "events_file", None))
213
+ if status != 0:
214
+ return status
215
+
216
+ engine = ComplianceMappingEngine()
217
+ try:
218
+ package = engine.generate_evidence_package(
219
+ model_id=args.model_id,
220
+ framework=framework.value,
221
+ from_date=args.from_date,
222
+ to_date=args.to_date,
223
+ audit_events=audit_events or None,
224
+ )
225
+ except Exception as exc:
226
+ print(f"error: report generation failed: {exc}", file=sys.stderr)
227
+ return 1
228
+
229
+ out_dir = Path(args.output)
230
+ out_dir.mkdir(parents=True, exist_ok=True)
231
+ safe_id = args.model_id.replace("/", "_")[:40]
232
+ prefix = f"{framework_key}_{safe_id}_{args.from_date}_{args.to_date}"
233
+ fmt = getattr(args, "report_format", "json")
234
+
235
+ if fmt in ("json", "both"):
236
+ json_path = out_dir / f"{prefix}_report.json"
237
+ json_path.write_text(package.to_json(), encoding="utf-8")
238
+ print(f"[✓] JSON report → {json_path}")
239
+
240
+ if fmt in ("markdown", "both"):
241
+ md_path = out_dir / f"{prefix}_report.md"
242
+ md_path.write_text(package.to_markdown(), encoding="utf-8")
243
+ print(f"[✓] Markdown report → {md_path}")
244
+
245
+ if fmt in ("pdf", "both"):
246
+ pdf_path = out_dir / f"{prefix}_report.pdf"
247
+ try:
248
+ package.to_pdf(str(pdf_path))
249
+ print(f"[✓] PDF report → {pdf_path}")
250
+ except ImportError:
251
+ print(
252
+ "error: PDF generation requires reportlab. Install: pip install spanforge[compliance]",
253
+ file=sys.stderr,
254
+ )
255
+ return 1
256
+
257
+ overall = package.attestation.overall_status.value
258
+ print(f"\nOverall status: {overall.upper()}")
259
+ return 0 if overall == "pass" else 1
260
+
261
+
262
+ def cmd_check(args: argparse.Namespace) -> int:
263
+ """Implement ``spanforge compliance check``."""
264
+ from spanforge.core.compliance_mapping import ComplianceMappingEngine
265
+
266
+ status, audit_events = _load_audit_events(getattr(args, "events_file", None))
267
+ if status != 0:
268
+ return status
269
+
270
+ engine = ComplianceMappingEngine()
271
+ try:
272
+ package = engine.generate_evidence_package(
273
+ model_id=args.model_id,
274
+ framework=args.framework,
275
+ from_date=args.from_date,
276
+ to_date=args.to_date,
277
+ audit_events=audit_events or None,
278
+ )
279
+ except ValueError as exc:
280
+ print(f"error: {exc}", file=sys.stderr)
281
+ return 2
282
+ except Exception as exc:
283
+ print(f"error: compliance check failed: {exc}", file=sys.stderr)
284
+ return 1
285
+
286
+ allow_partial = getattr(args, "allow_partial", False)
287
+ gap = package.gap_report
288
+ overall = package.attestation.overall_status.value
289
+
290
+ for record in package.attestation.clauses:
291
+ icon = {"pass": "[✓]", "fail": "[✗]", "partial": "[~]"}.get(record.status.value, "[?]") # nosec B105
292
+ print(f" {icon} {record.clause_id:<20} {record.status.value:<8} {record.evidence_count} events")
293
+
294
+ print(f"\nOverall: {overall.upper()}")
295
+
296
+ if gap.gap_clause_ids:
297
+ print(f"Gaps : {', '.join(gap.gap_clause_ids)}")
298
+ if gap.partial_clause_ids:
299
+ print(f"Partial : {', '.join(gap.partial_clause_ids)}")
300
+
301
+ if gap.has_gaps and (not allow_partial or gap.gap_clause_ids):
302
+ print("\n[FAIL] Compliance check failed — fix gaps before deploying.", file=sys.stderr)
303
+ return 1
304
+ print("\n[PASS] Compliance check passed.")
305
+ return 0
306
+
307
+
308
+ def _build_readiness_checks(fw_slug: str) -> list[tuple[bool, str, str]]: # noqa: PLR0915
309
+ """Build the list of (passed, label, fix) tuples for *cmd_readiness*."""
310
+ import os
311
+
312
+ checks: list[tuple[bool, str, str]] = []
313
+
314
+ # Check 1: signing key
315
+ signing_key = os.environ.get("SPANFORGE_SIGNING_KEY", "")
316
+ signing_ok = bool(signing_key) and signing_key not in {
317
+ "spanforge-default",
318
+ "spanforge-insecure-default-do-not-use-in-production",
319
+ }
320
+ checks.append((
321
+ signing_ok,
322
+ "SPANFORGE_SIGNING_KEY is set to a non-default value",
323
+ "export SPANFORGE_SIGNING_KEY=$(openssl rand -hex 32)",
324
+ ))
325
+
326
+ # Check 2: durable exporter
327
+ exporter_name = "unknown"
328
+ durable = False
329
+ try:
330
+ from spanforge.config import get_config
331
+ cfg = get_config()
332
+ exporter_name = getattr(cfg, "exporter", "console") or "console"
333
+ durable = exporter_name not in ("console", "")
334
+ except Exception: # nosec B110
335
+ pass
336
+ checks.append((
337
+ durable,
338
+ f"Durable exporter configured (current: {exporter_name!r})",
339
+ "spanforge.configure(exporter='sqlite', endpoint='./spanforge.db')",
340
+ ))
341
+
342
+ # Check 3: PII redaction
343
+ pii_on = False
344
+ try:
345
+ from spanforge.config import get_config
346
+ cfg = get_config()
347
+ pii_on = bool(getattr(cfg, "redact_pii", False))
348
+ except Exception: # nosec B110
349
+ pass
350
+ checks.append((
351
+ pii_on,
352
+ "PII redaction enabled (redact_pii=True)",
353
+ "spanforge.configure(redact_pii=True)",
354
+ ))
355
+
356
+ # Framework-specific
357
+ if fw_slug in ("eu_ai_act", "gdpr"):
358
+ explain_available = False
359
+ try:
360
+ from spanforge.sdk import sf_explain # noqa: F401
361
+ explain_available = True
362
+ except Exception: # nosec B110
363
+ pass
364
+ checks.append((
365
+ explain_available,
366
+ "sf_explain module available",
367
+ "pip install spanforge # sf_explain is bundled",
368
+ ))
369
+
370
+ if fw_slug in ("eu_ai_act", "gdpr", "nist_ai_rmf"):
371
+ drift_on = False
372
+ try:
373
+ from spanforge.config import get_config
374
+ cfg = get_config()
375
+ drift_on = bool(getattr(cfg, "drift_detection", False))
376
+ except Exception: # nosec B110
377
+ pass
378
+ checks.append((
379
+ drift_on,
380
+ "Drift detection enabled (drift_detection=True)",
381
+ "spanforge.configure(drift_detection=True)",
382
+ ))
383
+
384
+ if fw_slug in ("eu_ai_act", "gdpr"):
385
+ hitl_available = False
386
+ try:
387
+ from spanforge.sdk import sf_hitl # type: ignore[attr-defined] # noqa: F401
388
+ hitl_available = True
389
+ except Exception: # nosec B110
390
+ pass
391
+ checks.append((
392
+ hitl_available,
393
+ "sf_hitl (human-in-the-loop) module available",
394
+ "pip install spanforge # sf_hitl is bundled",
395
+ ))
396
+
397
+ if fw_slug in ("soc2", "hipaa", "nist_ai_rmf", "iso_42001"):
398
+ eval_on = False
399
+ try:
400
+ from spanforge.config import get_config
401
+ cfg = get_config()
402
+ eval_on = bool(getattr(cfg, "track_eval", False))
403
+ except Exception: # nosec B110
404
+ pass
405
+ checks.append((
406
+ eval_on,
407
+ "Eval tracking enabled (track_eval=True)",
408
+ "spanforge.configure(track_eval=True)",
409
+ ))
410
+
411
+ if fw_slug in ("soc2", "hipaa", "iso_42001"):
412
+ cost_on = False
413
+ try:
414
+ from spanforge.config import get_config
415
+ cfg = get_config()
416
+ cost_on = bool(getattr(cfg, "track_cost", False))
417
+ except Exception: # nosec B110
418
+ pass
419
+ checks.append((
420
+ cost_on,
421
+ "Cost tracking enabled (track_cost=True)",
422
+ "spanforge.configure(track_cost=True)",
423
+ ))
424
+
425
+ return checks
426
+
427
+
428
+ def cmd_readiness(args: argparse.Namespace) -> int:
429
+ """Implement ``spanforge compliance readiness``.
430
+
431
+ Checks the current SpanForge configuration against the requirements
432
+ for a target framework — *without* needing any events. Answers:
433
+ "What do I need to turn on before I hire an auditor?"
434
+ """
435
+ framework_key, framework = _resolve_framework(getattr(args, "framework", "eu_ai_act"))
436
+ if framework is None:
437
+ return 2
438
+
439
+ pass_marker = "[✓]" # noqa: S105 # nosec B105
440
+ fail_marker = "[✗]"
441
+ warn_marker = "[!]"
442
+
443
+ checks = _build_readiness_checks(framework_key.lower())
444
+
445
+ # --- Render ---
446
+ print(f"SpanForge Compliance Readiness — {framework.value}")
447
+ print("=" * 56)
448
+ failures = 0
449
+ for passed, label, fix in checks:
450
+ if passed:
451
+ print(f" {pass_marker} {label}")
452
+ else:
453
+ print(f" {fail_marker} {label}")
454
+ print(f" Fix: {fix}")
455
+ failures += 1
456
+
457
+ score = len(checks) - failures
458
+ pct = int(score / len(checks) * 100) if checks else 0
459
+ print("")
460
+ print(f"Readiness: {score}/{len(checks)} checks passing ({pct}%)")
461
+
462
+ if failures == 0:
463
+ print(f"\n{pass_marker} Ready to begin a {framework.value} audit engagement.")
464
+ return 0
465
+ print(f"\n{warn_marker} Fix {failures} item(s) above before starting your audit engagement.")
466
+ return 1
467
+
468
+
469
+ def cmd_status(args: argparse.Namespace) -> int:
470
+ """Implement ``spanforge compliance status``."""
471
+ from spanforge.redact import scan_payload
472
+ from spanforge.signing import verify_chain
473
+
474
+ events_path = Path(args.events_file)
475
+ if not events_path.exists():
476
+ print(f"error: events file not found: {events_path}", file=sys.stderr)
477
+ return 2
478
+
479
+ raw_events: list[dict[str, Any]] = []
480
+ for line in events_path.read_text(encoding="utf-8").splitlines():
481
+ line = line.strip()
482
+ if not line:
483
+ continue
484
+ try:
485
+ raw_events.append(json.loads(line))
486
+ except json.JSONDecodeError:
487
+ continue
488
+
489
+ if not raw_events:
490
+ print("error: no events found in file", file=sys.stderr)
491
+ return 1
492
+
493
+ signing_key = os.environ.get("SPANFORGE_SIGNING_KEY", "")
494
+ chain_ok = False
495
+ chain_msg = "no signing key"
496
+ if signing_key:
497
+ try:
498
+ chain_result = verify_chain(raw_events, signing_key) # type: ignore[arg-type]
499
+ chain_ok = chain_result.valid
500
+ chain_msg = (
501
+ "valid" if chain_result.valid else f"broken at event {chain_result.first_tampered}"
502
+ )
503
+ except Exception as exc:
504
+ chain_msg = f"error: {exc}"
505
+
506
+ pii_clean = True
507
+ pii_hits = 0
508
+ for event in raw_events:
509
+ payload = event.get("payload", {})
510
+ if isinstance(payload, dict):
511
+ scan_result = scan_payload(payload)
512
+ if not scan_result.clean:
513
+ pii_clean = False
514
+ pii_hits += len(scan_result.hits)
515
+
516
+ clause_summary: dict[str, Any] = {}
517
+ try:
518
+ from spanforge.core.compliance_mapping import ComplianceMappingEngine
519
+
520
+ engine = ComplianceMappingEngine()
521
+ package = engine.generate_evidence_package(
522
+ model_id="*",
523
+ framework=args.framework,
524
+ from_date="2000-01-01",
525
+ to_date="2099-12-31",
526
+ audit_events=raw_events,
527
+ )
528
+ for record in package.attestation.clauses:
529
+ clause_summary[record.clause_id] = {
530
+ "status": record.status.value,
531
+ "evidence_count": record.evidence_count,
532
+ }
533
+ except Exception:
534
+ clause_summary = {"error": "could not evaluate clause coverage"}
535
+
536
+ last_attestation: str | None = None
537
+ for event in reversed(raw_events):
538
+ event_type = event.get("event_type", "")
539
+ if "attestation" in event_type.lower() or "compliance" in event_type.lower():
540
+ last_attestation = event.get("timestamp")
541
+ break
542
+
543
+ summary = {
544
+ "chain_integrity": {"valid": chain_ok, "message": chain_msg},
545
+ "pii_scan": {"clean": pii_clean, "hit_count": pii_hits},
546
+ "clause_coverage": clause_summary,
547
+ "last_attestation_timestamp": last_attestation,
548
+ "events_analysed": len(raw_events),
549
+ }
550
+ print(json.dumps(summary, indent=2, default=str))
551
+ return 0
552
+
553
+
554
+ def add_compliance_subcommands(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> argparse.ArgumentParser:
555
+ """Register the compliance subcommands on the top-level parser."""
556
+ compliance_parser = subparsers.add_parser(
557
+ "compliance",
558
+ help="Compliance evidence generation and attestation validation",
559
+ )
560
+ comp_sub = compliance_parser.add_subparsers(dest="compliance_command", metavar="<action>")
561
+
562
+ gen_parser = comp_sub.add_parser(
563
+ "generate",
564
+ help="Generate a compliance evidence package for a model/framework/period",
565
+ )
566
+ gen_parser.add_argument("--model-id", dest="model_id", required=True, help="Model UUID")
567
+ gen_parser.add_argument(
568
+ "--framework",
569
+ required=True,
570
+ help="Compliance framework (eu_ai_act, gdpr, iso_42001, nist_ai_rmf, soc2)",
571
+ )
572
+ gen_parser.add_argument("--from", dest="from_date", required=True, metavar="DATE", help="Period start date (YYYY-MM-DD)")
573
+ gen_parser.add_argument("--to", dest="to_date", required=True, metavar="DATE", help="Period end date (YYYY-MM-DD)")
574
+ gen_parser.add_argument(
575
+ "--output",
576
+ default=".",
577
+ metavar="DIR",
578
+ help="Output directory for evidence files (default: .)",
579
+ )
580
+ gen_parser.add_argument(
581
+ "--events-file",
582
+ dest="events_file",
583
+ metavar="JSONL",
584
+ help="Optional JSONL file of audit events to include",
585
+ )
586
+
587
+ val_att_parser = comp_sub.add_parser(
588
+ "validate-attestation",
589
+ help="Verify the HMAC signature of a compliance attestation JSON file",
590
+ )
591
+ val_att_parser.add_argument(
592
+ "attestation_file",
593
+ metavar="ATTESTATION_JSON",
594
+ help="Path to a compliance attestation JSON file",
595
+ )
596
+
597
+ report_parser = comp_sub.add_parser(
598
+ "report",
599
+ help="Generate a compliance report (JSON, PDF, or both) with HMAC attestation",
600
+ )
601
+ report_parser.add_argument("--model-id", dest="model_id", required=True, help="Model UUID")
602
+ report_parser.add_argument(
603
+ "--framework",
604
+ required=True,
605
+ help="Compliance framework (eu_ai_act, gdpr, hipaa, iso_42001, nist_ai_rmf, soc2)",
606
+ )
607
+ report_parser.add_argument("--from", dest="from_date", required=True, metavar="DATE", help="Period start date (YYYY-MM-DD)")
608
+ report_parser.add_argument("--to", dest="to_date", required=True, metavar="DATE", help="Period end date (YYYY-MM-DD)")
609
+ report_parser.add_argument(
610
+ "--format",
611
+ dest="report_format",
612
+ default="json",
613
+ choices=["json", "pdf", "markdown", "both"],
614
+ help="Output format: json, pdf, markdown, or both (default: json)",
615
+ )
616
+ report_parser.add_argument(
617
+ "--output",
618
+ default=".",
619
+ metavar="DIR",
620
+ help="Output directory (default: .)",
621
+ )
622
+ report_parser.add_argument(
623
+ "--events-file",
624
+ dest="events_file",
625
+ metavar="JSONL",
626
+ help="Optional JSONL file of audit events to include",
627
+ )
628
+ report_parser.add_argument(
629
+ "--sign",
630
+ action="store_true",
631
+ default=False,
632
+ help="Embed HMAC attestation signature in the output",
633
+ )
634
+
635
+ check_parser = comp_sub.add_parser(
636
+ "check",
637
+ help="CI-friendly compliance gate: exits 0 if all clauses pass, 1 if gaps exist",
638
+ )
639
+ check_parser.add_argument(
640
+ "--model-id",
641
+ dest="model_id",
642
+ default="*",
643
+ help="Model ID to check (default: * = all models)",
644
+ )
645
+ check_parser.add_argument(
646
+ "--framework",
647
+ required=True,
648
+ help="Compliance framework (eu_ai_act, gdpr, hipaa, iso_42001, nist_ai_rmf, soc2)",
649
+ )
650
+ check_parser.add_argument("--from", dest="from_date", required=True, metavar="DATE", help="Period start date (YYYY-MM-DD)")
651
+ check_parser.add_argument("--to", dest="to_date", required=True, metavar="DATE", help="Period end date (YYYY-MM-DD)")
652
+ check_parser.add_argument(
653
+ "--events-file",
654
+ dest="events_file",
655
+ metavar="JSONL",
656
+ help="Optional JSONL file of audit events",
657
+ )
658
+ check_parser.add_argument(
659
+ "--allow-partial",
660
+ dest="allow_partial",
661
+ action="store_true",
662
+ help="Exit 0 on partial coverage (only fail on zero-evidence clauses)",
663
+ )
664
+
665
+ readiness_parser = comp_sub.add_parser(
666
+ "readiness",
667
+ help="Pre-audit config check: are you ready to start a compliance engagement?",
668
+ )
669
+ readiness_parser.add_argument(
670
+ "--framework",
671
+ default="eu_ai_act",
672
+ help="Target framework to check readiness for (default: eu_ai_act)",
673
+ )
674
+
675
+ status_parser = comp_sub.add_parser(
676
+ "status",
677
+ help="Output a single JSON summary of compliance posture",
678
+ )
679
+ status_parser.add_argument(
680
+ "--events-file",
681
+ dest="events_file",
682
+ required=True,
683
+ metavar="JSONL",
684
+ help="JSONL file of audit events to analyse",
685
+ )
686
+ status_parser.add_argument(
687
+ "--framework",
688
+ default="eu_ai_act",
689
+ help="Compliance framework (default: eu_ai_act)",
690
+ )
691
+
692
+ return compliance_parser
693
+
694
+
695
+ def dispatch_compliance_command(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
696
+ """Dispatch the compliance command group."""
697
+ action = getattr(args, "compliance_command", None)
698
+ import argparse as _ap
699
+ _dispatch: dict[str, Callable[[_ap.Namespace], int]] = {
700
+ "generate": cmd_generate,
701
+ "validate-attestation": cmd_validate_attestation,
702
+ "check": cmd_check,
703
+ "report": cmd_report,
704
+ "status": cmd_status,
705
+ "readiness": cmd_readiness,
706
+ }
707
+ handler = _dispatch.get(action or "")
708
+ if handler is not None:
709
+ return handler(args)
710
+ parser.print_help()
711
+ return 2