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
spanforge/sdk/gate.py ADDED
@@ -0,0 +1,874 @@
1
+ """spanforge.sdk.gate — SpanForge sf-gate CI/CD Gate Pipeline Client (Phase 8).
2
+
3
+ Implements the full sf-gate SDK surface: gate evaluation, trust gate checks,
4
+ PRRI governance evaluation, artifact management, and cross-service integration
5
+ with sf-audit, sf-observe, and sf-alert.
6
+
7
+ Architecture
8
+ ------------
9
+ * :meth:`evaluate` is the **primary evaluation entry point**. It runs a
10
+ named gate check against a payload dict, writes the result to the artifact
11
+ store, emits an ``hc.gate.evaluated`` span via sf-observe, and appends an
12
+ audit record under schema ``halluccheck.gate.v1``.
13
+ * :meth:`run_trust_gate` queries the local audit store for HRI, PII, and
14
+ Secrets records to determine whether the trust gate passes. On failure it
15
+ publishes a ``halluccheck.trust_gate.failed`` alert via sf-alert at
16
+ CRITICAL severity.
17
+ * :meth:`evaluate_prri` scores a PRRI payload and returns a
18
+ :class:`~spanforge.sdk._types.PRRIResult` with a GREEN / AMBER / RED verdict.
19
+ * All methods operate in local-fallback mode when ``config.endpoint`` is empty
20
+ or the remote service is unreachable and ``config.local_fallback_enabled``
21
+ is ``True``.
22
+
23
+ Cross-service integration
24
+ --------------------------
25
+ All integrations use **lazy imports** inside methods to prevent circular
26
+ import cycles:
27
+
28
+ * ``sf_audit`` ← queries for HRI / PII / Secrets records (run_trust_gate)
29
+ * ``sf_observe`` ← emits ``hc.gate.evaluated`` span (evaluate)
30
+ * ``sf_alert`` ← publishes trust-gate failure alert (run_trust_gate)
31
+
32
+ Gate topics (GAT-025)
33
+ ----------------------
34
+ Eight built-in gate-related alert topics:
35
+
36
+ * ``halluccheck.trust_gate.failed`` — CRITICAL
37
+ * ``halluccheck.gate.blocked`` — HIGH
38
+ * ``halluccheck.gate.warn`` — MEDIUM
39
+ * ``halluccheck.prri.red`` — HIGH
40
+ * ``halluccheck.prri.amber`` — MEDIUM
41
+ * ``halluccheck.schema.violation`` — HIGH
42
+ * ``halluccheck.dependency.critical`` — CRITICAL
43
+ * ``halluccheck.secrets.leak`` — CRITICAL
44
+
45
+ Security requirements
46
+ ---------------------
47
+ * API keys are never logged or included in exception messages.
48
+ * Artifact paths are restricted to the ``.sf-gate/`` directory; no path
49
+ traversal is possible (paths are validated against the base dir).
50
+ * Trust gate failure alerts are only sent once per ``(project_id, pipeline_id)``
51
+ within the deduplication window.
52
+ * Thread-safety: in-memory counters and artifact caches use locks.
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ import json
58
+ import logging
59
+ import os
60
+ import threading
61
+ import time
62
+ import uuid
63
+ from datetime import datetime, timezone
64
+ from pathlib import Path
65
+ from typing import Any
66
+
67
+ from spanforge.sdk._base import (
68
+ SFClientConfig,
69
+ SFServiceClient,
70
+ _CircuitBreaker,
71
+ )
72
+ from spanforge.sdk._exceptions import (
73
+ SFGateError,
74
+ SFGateEvaluationError,
75
+ )
76
+ from spanforge.sdk._types import (
77
+ GateArtifact,
78
+ GateEvaluationResult,
79
+ GateStatusInfo,
80
+ GateVerdict,
81
+ PRRIResult,
82
+ PRRIVerdict,
83
+ TrustGateResult,
84
+ )
85
+
86
+ __all__ = [
87
+ "GATE_KNOWN_TOPICS",
88
+ "SFGateClient",
89
+ ]
90
+
91
+ _log = logging.getLogger(__name__)
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Constants
95
+ # ---------------------------------------------------------------------------
96
+
97
+ #: Built-in gate-related alert topics (GAT-025).
98
+ GATE_KNOWN_TOPICS: frozenset[str] = frozenset(
99
+ {
100
+ "halluccheck.trust_gate.failed",
101
+ "halluccheck.gate.blocked",
102
+ "halluccheck.gate.warn",
103
+ "halluccheck.prri.red",
104
+ "halluccheck.prri.amber",
105
+ "halluccheck.schema.violation",
106
+ "halluccheck.dependency.critical",
107
+ "halluccheck.secrets.leak",
108
+ }
109
+ )
110
+
111
+ #: PRRI thresholds
112
+ _PRRI_RED_THRESHOLD: int = 70
113
+ _PRRI_AMBER_THRESHOLD: int = 40
114
+
115
+ #: HRI critical rate threshold for trust gate
116
+ _HRI_CRITICAL_THRESHOLD: float = 0.05
117
+
118
+ #: Artifact base directory (relative to CWD)
119
+ _ARTIFACT_BASE: str = ".sf-gate/artifacts"
120
+
121
+ #: Per-project/pipeline trust-gate alert dedup window
122
+ _ALERT_DEDUP_WINDOW_SECONDS: float = 300.0
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # SFGateClient
127
+ # ---------------------------------------------------------------------------
128
+
129
+
130
+ class SFGateClient(SFServiceClient):
131
+ """Client for the SpanForge CI/CD Gate Pipeline service (sf-gate).
132
+
133
+ Provides gate evaluation, trust gate checks, PRRI governance scoring,
134
+ and artifact management.
135
+
136
+ Args:
137
+ config: SDK configuration. Loads from environment variables if
138
+ not supplied explicitly.
139
+
140
+ Environment variables
141
+ ----------------------
142
+ ``SPANFORGE_API_KEY`` — service API key
143
+ ``SPANFORGE_ENDPOINT`` — remote API endpoint
144
+ ``SPANFORGE_LOCAL_FALLBACK`` — ``"true"`` to enable local mode
145
+ ``SPANFORGE_GATE_ARTIFACT_DIR`` — override for artifact directory
146
+ ``SPANFORGE_GATE_HRI_WINDOW`` — number of HRI records to sample
147
+ ``SPANFORGE_GATE_PII_WINDOW_HOURS`` — hours window for PII check
148
+ ``SPANFORGE_GATE_SECRETS_WINDOW_HOURS``— hours window for secrets check
149
+
150
+ Example::
151
+
152
+ from spanforge.sdk import sf_gate
153
+
154
+ result = sf_gate.evaluate(
155
+ "gate5_governance",
156
+ {"prri_score": 42, "framework": "eu-ai-act"},
157
+ project_id="my-project",
158
+ pipeline_id="ci-12",
159
+ )
160
+ print(result.verdict) # "PASS"
161
+ """
162
+
163
+ def __init__(self, config: SFClientConfig) -> None:
164
+ super().__init__(config, "gate")
165
+ self._lock = threading.Lock()
166
+ # Per-gate-sink circuit breakers (GAT-040)
167
+ self._gate_circuit_breakers: dict[str, _CircuitBreaker] = {}
168
+ # Artifact base directory
169
+ artifact_dir_env = os.environ.get("SPANFORGE_GATE_ARTIFACT_DIR", "")
170
+ self._artifact_dir = Path(artifact_dir_env) if artifact_dir_env else Path(_ARTIFACT_BASE)
171
+ self._artifact_dir.mkdir(parents=True, exist_ok=True)
172
+ # Dedup store for trust-gate alerts: set of (project_id, pipeline_id)
173
+ self._alerted_trust_gates: dict[str, float] = {}
174
+ # Stats
175
+ self._evaluate_count: int = 0
176
+ self._trust_gate_count: int = 0
177
+ self._last_evaluate_at: str | None = None
178
+
179
+ # ------------------------------------------------------------------
180
+ # Circuit breaker helpers
181
+ # ------------------------------------------------------------------
182
+
183
+ def _get_cb(self, sink_id: str) -> _CircuitBreaker:
184
+ """Return (or create) a per-sink circuit breaker."""
185
+ with self._lock:
186
+ if sink_id not in self._gate_circuit_breakers:
187
+ self._gate_circuit_breakers[sink_id] = _CircuitBreaker()
188
+ return self._gate_circuit_breakers[sink_id]
189
+
190
+ # ------------------------------------------------------------------
191
+ # Artifact helpers
192
+ # ------------------------------------------------------------------
193
+
194
+ def _artifact_path(self, gate_id: str) -> Path:
195
+ """Return the canonical artifact path for *gate_id*.
196
+
197
+ Path traversal is prevented by resolving against ``_artifact_dir``
198
+ and asserting the result is still inside that directory.
199
+ """
200
+ safe_id = gate_id.replace("/", "_").replace("..", "_")
201
+ candidate = (self._artifact_dir / f"{safe_id}_result.json").resolve()
202
+ base_resolved = self._artifact_dir.resolve()
203
+ if not str(candidate).startswith(str(base_resolved)):
204
+ raise SFGateError(f"Unsafe artifact path detected for gate_id={gate_id!r}.")
205
+ return candidate
206
+
207
+ def _write_artifact(self, gate_id: str, data: dict[str, Any]) -> Path:
208
+ """Serialise *data* as JSON and write to the artifact store."""
209
+ path = self._artifact_path(gate_id)
210
+ try:
211
+ path.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8")
212
+ except OSError as exc:
213
+ _log.warning("Could not write gate artifact for %r: %s", gate_id, exc)
214
+ return path
215
+
216
+ def _read_artifact(self, gate_id: str) -> dict[str, Any] | None:
217
+ """Read and parse the artifact JSON for *gate_id*."""
218
+ path = self._artifact_path(gate_id)
219
+ if not path.exists():
220
+ return None
221
+ try:
222
+ return json.loads(path.read_text(encoding="utf-8")) # type: ignore[no-any-return]
223
+ except (json.JSONDecodeError, OSError):
224
+ return None
225
+
226
+ # ------------------------------------------------------------------
227
+ # Public API — evaluate
228
+ # ------------------------------------------------------------------
229
+
230
+ def evaluate(
231
+ self,
232
+ gate_id: str,
233
+ payload: dict[str, Any],
234
+ *,
235
+ project_id: str = "",
236
+ pipeline_id: str = "",
237
+ ) -> GateEvaluationResult:
238
+ """Evaluate a gate condition and record the result (GAT-004).
239
+
240
+ Writes the result to ``.sf-gate/artifacts/<gate_id>_result.json``,
241
+ emits an ``hc.gate.evaluated`` span to sf-observe, and appends an
242
+ audit record under schema ``halluccheck.gate.v1``.
243
+
244
+ Args:
245
+ gate_id: Unique gate identifier (e.g. ``"gate5_governance"``).
246
+ payload: Metrics dict to evaluate. Content depends on the
247
+ gate type.
248
+ project_id: Optional project scoping.
249
+ pipeline_id: Optional CI pipeline identifier.
250
+
251
+ Returns:
252
+ :class:`~spanforge.sdk._types.GateEvaluationResult`
253
+
254
+ Raises:
255
+ :exc:`~spanforge.sdk._exceptions.SFGateEvaluationError`: If
256
+ gate evaluation encounters a fatal error.
257
+ """
258
+ if not gate_id or not gate_id.strip():
259
+ raise SFGateEvaluationError("gate_id must be a non-empty string.")
260
+
261
+ started = time.monotonic()
262
+ timestamp = datetime.now(timezone.utc).isoformat()
263
+ pipeline_id = pipeline_id or str(uuid.uuid4())
264
+
265
+ try:
266
+ # Determine verdict from payload
267
+ verdict = self._infer_verdict(gate_id, payload)
268
+ duration_ms = int((time.monotonic() - started) * 1000)
269
+
270
+ # Build artifact
271
+ artifact_data: dict[str, Any] = {
272
+ "gate_id": gate_id,
273
+ "verdict": verdict,
274
+ "metrics": payload,
275
+ "timestamp": timestamp,
276
+ "duration_ms": duration_ms,
277
+ "project_id": project_id,
278
+ "pipeline_id": pipeline_id,
279
+ }
280
+ artifact_path = self._write_artifact(gate_id, artifact_data)
281
+ artifact_url = f"file://{artifact_path}"
282
+
283
+ result = GateEvaluationResult(
284
+ gate_id=gate_id,
285
+ verdict=verdict,
286
+ metrics=payload,
287
+ artifact_url=artifact_url,
288
+ duration_ms=duration_ms,
289
+ )
290
+
291
+ # Async audit + observe (best-effort)
292
+ self._post_evaluate_hooks(
293
+ gate_id=gate_id,
294
+ result=result,
295
+ project_id=project_id,
296
+ pipeline_id=pipeline_id,
297
+ timestamp=timestamp,
298
+ )
299
+
300
+ with self._lock:
301
+ self._evaluate_count += 1
302
+ self._last_evaluate_at = timestamp
303
+
304
+ return result # noqa: TRY300
305
+
306
+ except SFGateEvaluationError:
307
+ raise
308
+ except Exception as exc:
309
+ raise SFGateEvaluationError(
310
+ f"Gate evaluation failed for gate_id={gate_id!r}: {exc}"
311
+ ) from exc
312
+
313
+ def _infer_verdict(self, gate_id: str, payload: dict[str, Any]) -> str: # noqa: PLR0911
314
+ """Derive a verdict string from *payload*.
315
+
316
+ Looks for a top-level ``"verdict"`` key first, then falls back to
317
+ checking ``"pass"``, ``"failed"``, ``"status"``.
318
+
319
+ Returns one of :class:`~spanforge.sdk._types.GateVerdict` constants.
320
+ """
321
+ if "verdict" in payload:
322
+ v = str(payload["verdict"]).upper()
323
+ if v in {GateVerdict.PASS, GateVerdict.FAIL, GateVerdict.WARN, GateVerdict.SKIPPED}:
324
+ return v
325
+ if payload.get("pass") is True or payload.get("passed") is True:
326
+ return GateVerdict.PASS
327
+ if payload.get("failed") is True or payload.get("pass") is False:
328
+ return GateVerdict.FAIL
329
+ status = str(payload.get("status", "")).lower()
330
+ if status in {"pass", "passed", "green", "ok"}:
331
+ return GateVerdict.PASS
332
+ if status in {"fail", "failed", "red", "error"}:
333
+ return GateVerdict.FAIL
334
+ if status in {"warn", "warning", "amber"}:
335
+ return GateVerdict.WARN
336
+ # Default to PASS when payload contains no explicit failure indicators
337
+ return GateVerdict.PASS
338
+
339
+ def _post_evaluate_hooks(
340
+ self,
341
+ *,
342
+ gate_id: str,
343
+ result: GateEvaluationResult,
344
+ project_id: str,
345
+ pipeline_id: str,
346
+ timestamp: str,
347
+ ) -> None:
348
+ """Best-effort sf-observe span + sf-audit append after evaluate()."""
349
+ # sf-observe: emit hc.gate.evaluated span (GAT-004)
350
+ try:
351
+ from spanforge.sdk import sf_observe
352
+
353
+ sf_observe.emit_span(
354
+ "hc.gate.evaluated",
355
+ attributes={
356
+ "gate_id": gate_id,
357
+ "verdict": result.verdict,
358
+ "project_id": project_id,
359
+ "pipeline_id": pipeline_id,
360
+ "duration_ms": result.duration_ms,
361
+ },
362
+ )
363
+ except Exception:
364
+ _log.debug("sf_observe.emit_span failed for gate %r", gate_id)
365
+
366
+ # sf-audit: append halluccheck.gate.v1 (GAT-004)
367
+ try:
368
+ from spanforge.sdk import sf_audit
369
+
370
+ sf_audit.append(
371
+ {
372
+ "gate_id": gate_id,
373
+ "verdict": result.verdict,
374
+ "metrics": result.metrics,
375
+ "project_id": project_id,
376
+ "pipeline_id": pipeline_id,
377
+ "timestamp": timestamp,
378
+ },
379
+ "halluccheck.gate.v1",
380
+ )
381
+ except Exception:
382
+ _log.debug("sf_audit.append failed for gate %r", gate_id)
383
+
384
+ # ------------------------------------------------------------------
385
+ # Public API — run_trust_gate
386
+ # ------------------------------------------------------------------
387
+
388
+ def run_trust_gate(
389
+ self,
390
+ project_id: str,
391
+ *,
392
+ pipeline_id: str = "",
393
+ hri_window: int | None = None,
394
+ pii_window_hours: int = 24,
395
+ secrets_window_hours: int = 24,
396
+ ) -> TrustGateResult:
397
+ """Run the HallucCheck Trust Gate (GAT-020/021).
398
+
399
+ Queries sf-audit for:
400
+ * Last N ``halluccheck.score.v1`` records → compute ``hri_critical_rate``
401
+ * Last 24 h ``halluccheck.pii.v1`` records → ``pii_detected``
402
+ * Last 24 h ``halluccheck.secrets.v1`` records → ``secrets_detected``
403
+
404
+ On failure, publishes ``halluccheck.trust_gate.failed`` via sf-alert
405
+ at CRITICAL severity.
406
+
407
+ Args:
408
+ project_id: Project to evaluate.
409
+ pipeline_id: Optional CI pipeline identifier.
410
+ hri_window: Number of score records to sample.
411
+ Defaults to env var
412
+ ``SPANFORGE_GATE_HRI_WINDOW`` or 100.
413
+ pii_window_hours: Hours window for PII detections.
414
+ secrets_window_hours: Hours window for secrets detections.
415
+
416
+ Returns:
417
+ :class:`~spanforge.sdk._types.TrustGateResult`
418
+
419
+ Raises:
420
+ :exc:`~spanforge.sdk._exceptions.SFGateTrustFailedError`: When the
421
+ trust gate fails AND ``raise_on_fail=True`` (default False in
422
+ this method; the caller may inspect ``result.pass_`` instead).
423
+ """
424
+ if hri_window is None:
425
+ hri_window = int(os.environ.get("SPANFORGE_GATE_HRI_WINDOW", "100"))
426
+
427
+ pipeline_id = pipeline_id or str(uuid.uuid4())
428
+ timestamp = datetime.now(timezone.utc).isoformat()
429
+
430
+ hri_critical_rate, _ = self._compute_hri_critical_rate(project_id, hri_window)
431
+ pii_detected, pii_count = self._check_pii_window(project_id, pii_window_hours)
432
+ secrets_detected, secrets_count = self._check_secrets_window(
433
+ project_id, secrets_window_hours
434
+ )
435
+
436
+ failures: list[str] = []
437
+ if hri_critical_rate >= _HRI_CRITICAL_THRESHOLD:
438
+ failures.append(
439
+ f"hri_critical_rate={hri_critical_rate:.4f} >= threshold={_HRI_CRITICAL_THRESHOLD}"
440
+ )
441
+ if pii_detected:
442
+ failures.append(
443
+ f"pii_detected=true ({pii_count} detection(s) in last {pii_window_hours}h)"
444
+ )
445
+ if secrets_detected:
446
+ failures.append(
447
+ "secrets_detected=true "
448
+ f"({secrets_count} detection(s) in last {secrets_window_hours}h)"
449
+ )
450
+
451
+ pass_ = len(failures) == 0
452
+ verdict = GateVerdict.PASS if pass_ else GateVerdict.FAIL
453
+
454
+ result = TrustGateResult(
455
+ gate_id="gate6_trust",
456
+ verdict=verdict,
457
+ hri_critical_rate=hri_critical_rate,
458
+ hri_critical_threshold=_HRI_CRITICAL_THRESHOLD,
459
+ pii_detected=pii_detected,
460
+ pii_detections_24h=pii_count,
461
+ secrets_detected=secrets_detected,
462
+ secrets_detections_24h=secrets_count,
463
+ failures=failures,
464
+ timestamp=timestamp,
465
+ pipeline_id=pipeline_id,
466
+ project_id=project_id,
467
+ pass_=pass_,
468
+ )
469
+
470
+ # Write artifact
471
+ self._write_artifact(
472
+ "gate6_trust",
473
+ {
474
+ "gate_id": "gate6_trust",
475
+ "verdict": verdict,
476
+ "hri_critical_rate": hri_critical_rate,
477
+ "hri_critical_threshold": _HRI_CRITICAL_THRESHOLD,
478
+ "pii_detected": pii_detected,
479
+ "pii_detections_24h": pii_count,
480
+ "secrets_detected": secrets_detected,
481
+ "secrets_detections_24h": secrets_count,
482
+ "failures": failures,
483
+ "timestamp": timestamp,
484
+ "pipeline_id": pipeline_id,
485
+ "project_id": project_id,
486
+ },
487
+ )
488
+
489
+ with self._lock:
490
+ self._trust_gate_count += 1
491
+
492
+ if not pass_:
493
+ self._send_trust_gate_alert(
494
+ project_id=project_id,
495
+ pipeline_id=pipeline_id,
496
+ failures=failures,
497
+ timestamp=timestamp,
498
+ )
499
+
500
+ return result
501
+
502
+ def _compute_hri_critical_rate(
503
+ self,
504
+ project_id: str,
505
+ window: int,
506
+ ) -> tuple[float, int]:
507
+ """Return (hri_critical_rate, total_records) from sf-audit.
508
+
509
+ Queries last *window* ``halluccheck.score.v1`` records and computes the
510
+ fraction that have ``is_critical=true``.
511
+ """
512
+ try:
513
+ from datetime import datetime, timedelta
514
+ from datetime import timezone as _tz
515
+
516
+ from spanforge.sdk import sf_audit
517
+
518
+ since = datetime.now(_tz.utc) - timedelta(hours=24 * 30)
519
+ records = sf_audit.export(
520
+ date_range=(since.isoformat(), datetime.now(_tz.utc).isoformat()),
521
+ limit=window,
522
+ )
523
+ total = len(records)
524
+ if total == 0:
525
+ return 0.0, 0
526
+ critical = sum(
527
+ 1
528
+ for r in records
529
+ if r.get("is_critical") is True or str(r.get("category", "")).lower() == "critical"
530
+ )
531
+ return critical / total, total
532
+ except Exception:
533
+ _log.debug("Could not query HRI records from sf_audit")
534
+ return 0.0, 0
535
+
536
+ def _check_pii_window(
537
+ self,
538
+ project_id: str,
539
+ window_hours: int,
540
+ ) -> tuple[bool, int]:
541
+ """Return (pii_detected, count) from sf-audit for last *window_hours*."""
542
+ try:
543
+ from datetime import datetime, timedelta
544
+ from datetime import timezone as _tz
545
+
546
+ from spanforge.sdk import sf_audit
547
+
548
+ since = datetime.now(_tz.utc) - timedelta(hours=window_hours)
549
+ records = sf_audit.export(
550
+ schema_key="halluccheck.pii.v1",
551
+ date_range=(since.isoformat(), datetime.now(_tz.utc).isoformat()),
552
+ limit=1000,
553
+ )
554
+ # Filter by project_id if non-empty
555
+ if project_id:
556
+ records = [
557
+ r
558
+ for r in records
559
+ if r.get("project_id") == project_id or not r.get("project_id")
560
+ ]
561
+ count = sum(
562
+ 1 for r in records if r.get("detected") is True or r.get("pii_detected") is True
563
+ )
564
+ return count > 0, count # noqa: TRY300
565
+ except Exception:
566
+ _log.debug("Could not query PII records from sf_audit")
567
+ return False, 0
568
+
569
+ def _check_secrets_window(
570
+ self,
571
+ project_id: str,
572
+ window_hours: int,
573
+ ) -> tuple[bool, int]:
574
+ """Return (secrets_detected, count) from sf-audit for last *window_hours*."""
575
+ try:
576
+ from datetime import datetime, timedelta
577
+ from datetime import timezone as _tz
578
+
579
+ from spanforge.sdk import sf_audit
580
+
581
+ since = datetime.now(_tz.utc) - timedelta(hours=window_hours)
582
+ records = sf_audit.export(
583
+ schema_key="halluccheck.secrets.v1",
584
+ date_range=(since.isoformat(), datetime.now(_tz.utc).isoformat()),
585
+ limit=1000,
586
+ )
587
+ if project_id:
588
+ records = [
589
+ r
590
+ for r in records
591
+ if r.get("project_id") == project_id or not r.get("project_id")
592
+ ]
593
+ count = sum(
594
+ 1
595
+ for r in records
596
+ if r.get("has_secrets") is True or r.get("secrets_detected") is True
597
+ )
598
+ return count > 0, count # noqa: TRY300
599
+ except Exception:
600
+ _log.debug("Could not query Secrets records from sf_audit")
601
+ return False, 0
602
+
603
+ def _send_trust_gate_alert(
604
+ self,
605
+ *,
606
+ project_id: str,
607
+ pipeline_id: str,
608
+ failures: list[str],
609
+ timestamp: str,
610
+ ) -> None:
611
+ """Publish halluccheck.trust_gate.failed alert via sf-alert (GAT-022).
612
+
613
+ Deduplicates by (project_id, pipeline_id) within 5 minutes.
614
+ """
615
+ dedup_key = f"{project_id}:{pipeline_id}"
616
+ now = time.monotonic()
617
+ with self._lock:
618
+ last_sent = self._alerted_trust_gates.get(dedup_key)
619
+ if last_sent is not None and (now - last_sent) < _ALERT_DEDUP_WINDOW_SECONDS:
620
+ _log.debug("Trust gate alert suppressed (dedup): %s", dedup_key)
621
+ return
622
+ self._alerted_trust_gates[dedup_key] = now
623
+
624
+ try:
625
+ from spanforge.sdk import sf_alert
626
+ from spanforge.sdk._types import AlertSeverity
627
+
628
+ sf_alert.publish(
629
+ "halluccheck.trust_gate.failed",
630
+ {
631
+ "project_id": project_id,
632
+ "pipeline_id": pipeline_id,
633
+ "failures": failures,
634
+ "timestamp": timestamp,
635
+ "gate_id": "gate6_trust",
636
+ },
637
+ severity=AlertSeverity.CRITICAL.value,
638
+ project_id=project_id,
639
+ )
640
+ except Exception as exc:
641
+ _log.debug("sf_alert.publish failed for trust gate: %s", exc)
642
+
643
+ # ------------------------------------------------------------------
644
+ # Public API — evaluate_prri
645
+ # ------------------------------------------------------------------
646
+
647
+ def evaluate_prri( # noqa: PLR0913
648
+ self,
649
+ project_id: str,
650
+ *,
651
+ prri_score: int,
652
+ threshold: int = _PRRI_RED_THRESHOLD,
653
+ framework: str = "",
654
+ policy_file: str = "",
655
+ dimension_breakdown: dict[str, Any] | None = None,
656
+ ) -> PRRIResult:
657
+ """Score a PRRI payload and return a GREEN / AMBER / RED verdict (GAT-010).
658
+
659
+ Args:
660
+ project_id: Project being evaluated.
661
+ prri_score: Raw PRRI score (0-100, higher = more risk).
662
+ threshold: RED threshold. Scores >= threshold → RED.
663
+ Default: 70.
664
+ framework: Regulatory framework (e.g. ``"eu-ai-act"``).
665
+ policy_file: Path to the policy file used for scoring.
666
+ dimension_breakdown: Optional per-dimension breakdown dict.
667
+
668
+ Returns:
669
+ :class:`~spanforge.sdk._types.PRRIResult`
670
+
671
+ Raises:
672
+ :exc:`~spanforge.sdk._exceptions.SFGateEvaluationError`:
673
+ If *prri_score* is out of range.
674
+ """
675
+ if not (0 <= prri_score <= 100): # noqa: PLR2004
676
+ raise SFGateEvaluationError(f"prri_score must be in [0, 100], got {prri_score}.")
677
+
678
+ timestamp = datetime.now(timezone.utc).isoformat()
679
+ amber_threshold = _PRRI_AMBER_THRESHOLD
680
+
681
+ if prri_score >= threshold:
682
+ verdict = PRRIVerdict.RED
683
+ allow = False
684
+ elif prri_score >= amber_threshold:
685
+ verdict = PRRIVerdict.AMBER
686
+ allow = True
687
+ else:
688
+ verdict = PRRIVerdict.GREEN
689
+ allow = True
690
+
691
+ result = PRRIResult(
692
+ gate_id="gate5_governance",
693
+ prri_score=prri_score,
694
+ verdict=verdict,
695
+ dimension_breakdown=dimension_breakdown or {},
696
+ framework=framework,
697
+ policy_file=policy_file,
698
+ timestamp=timestamp,
699
+ allow=allow,
700
+ project_id=project_id,
701
+ )
702
+
703
+ # Write artifact
704
+ self._write_artifact(
705
+ "gate5_governance",
706
+ {
707
+ "gate_id": "gate5_governance",
708
+ "prri_score": prri_score,
709
+ "verdict": verdict,
710
+ "dimension_breakdown": dimension_breakdown or {},
711
+ "framework": framework,
712
+ "policy_file": policy_file,
713
+ "timestamp": timestamp,
714
+ "allow": allow,
715
+ "project_id": project_id,
716
+ },
717
+ )
718
+
719
+ # Publish alert if RED or AMBER (GAT-011)
720
+ if verdict == PRRIVerdict.RED:
721
+ self._publish_prri_alert(
722
+ "halluccheck.prri.red", project_id, prri_score, verdict, timestamp
723
+ )
724
+ elif verdict == PRRIVerdict.AMBER:
725
+ self._publish_prri_alert(
726
+ "halluccheck.prri.amber", project_id, prri_score, verdict, timestamp
727
+ )
728
+
729
+ return result
730
+
731
+ def _publish_prri_alert(
732
+ self,
733
+ topic: str,
734
+ project_id: str,
735
+ prri_score: int,
736
+ verdict: str,
737
+ timestamp: str,
738
+ ) -> None:
739
+ """Publish a PRRI alert via sf-alert (best-effort)."""
740
+ try:
741
+ from spanforge.sdk import sf_alert
742
+ from spanforge.sdk._types import AlertSeverity
743
+
744
+ severity = AlertSeverity.HIGH if verdict == PRRIVerdict.RED else AlertSeverity.WARNING
745
+ sf_alert.publish(
746
+ topic,
747
+ {
748
+ "project_id": project_id,
749
+ "prri_score": prri_score,
750
+ "verdict": verdict,
751
+ "timestamp": timestamp,
752
+ },
753
+ severity=severity.value,
754
+ project_id=project_id,
755
+ )
756
+ except Exception:
757
+ _log.debug("sf_alert.publish failed for PRRI alert: %s", topic)
758
+
759
+ # ------------------------------------------------------------------
760
+ # Public API — list_artifacts
761
+ # ------------------------------------------------------------------
762
+
763
+ def list_artifacts(
764
+ self,
765
+ gate_id: str | None = None,
766
+ *,
767
+ limit: int = 50,
768
+ ) -> list[GateArtifact]:
769
+ """List gate artifacts in the artifact store (GAT-003).
770
+
771
+ Args:
772
+ gate_id: Filter to artifacts for a specific gate. ``None`` means
773
+ all gates.
774
+ limit: Maximum number of results to return (most-recent first).
775
+
776
+ Returns:
777
+ List of :class:`~spanforge.sdk._types.GateArtifact` objects.
778
+ """
779
+ pattern = f"{gate_id}_result.json" if gate_id else "*_result.json"
780
+ paths = sorted(
781
+ self._artifact_dir.glob(pattern),
782
+ key=lambda p: p.stat().st_mtime,
783
+ reverse=True,
784
+ )[:limit]
785
+
786
+ artifacts: list[GateArtifact] = []
787
+ for path in paths:
788
+ try:
789
+ data = json.loads(path.read_text(encoding="utf-8"))
790
+ artifacts.append(
791
+ GateArtifact(
792
+ gate_id=data.get("gate_id", path.stem.replace("_result", "")),
793
+ name=data.get("name", data.get("gate_id", "")),
794
+ verdict=data.get("verdict", GateVerdict.PASS),
795
+ metrics=data.get("metrics", {}),
796
+ timestamp=data.get("timestamp", ""),
797
+ duration_ms=int(data.get("duration_ms", 0)),
798
+ artifact_path=str(path),
799
+ )
800
+ )
801
+ except (json.JSONDecodeError, OSError): # noqa: PERF203
802
+ continue
803
+ return artifacts
804
+
805
+ # ------------------------------------------------------------------
806
+ # evaluate_async (F-10)
807
+ # ------------------------------------------------------------------
808
+
809
+ async def evaluate_async(
810
+ self,
811
+ gate_id: str,
812
+ payload: dict,
813
+ *,
814
+ project_id: str = "",
815
+ pipeline_id: str = "",
816
+ ):
817
+ """Async variant of :meth:`evaluate` (F-10).
818
+
819
+ Runs :meth:`evaluate` in a thread-pool executor via
820
+ :func:`asyncio.run_in_executor`, making it safe to ``await``
821
+ from async code without blocking the event loop.
822
+
823
+ Args:
824
+ gate_id: Gate identifier.
825
+ payload: Evaluation payload dict.
826
+ project_id: Optional project scope.
827
+ pipeline_id: Optional pipeline scope.
828
+
829
+ Returns:
830
+ :class:`~spanforge.sdk._types.GateEvaluationResult` — same as
831
+ :meth:`evaluate`.
832
+ """
833
+ import asyncio
834
+ import functools
835
+
836
+ loop = asyncio.get_event_loop()
837
+ return await loop.run_in_executor(
838
+ None,
839
+ functools.partial(
840
+ self.evaluate,
841
+ gate_id,
842
+ payload,
843
+ project_id=project_id,
844
+ pipeline_id=pipeline_id,
845
+ ),
846
+ )
847
+
848
+ # ------------------------------------------------------------------
849
+ # Public API — get_status
850
+ # ------------------------------------------------------------------
851
+
852
+ def get_status(self) -> GateStatusInfo:
853
+ """Return health and statistics for sf-gate.
854
+
855
+ Returns:
856
+ :class:`~spanforge.sdk._types.GateStatusInfo`
857
+ """
858
+ with self._lock:
859
+ evaluate_count = self._evaluate_count
860
+ trust_gate_count = self._trust_gate_count
861
+ last_evaluate_at = self._last_evaluate_at
862
+ cb_open = [k for k, v in self._gate_circuit_breakers.items() if v.is_open()]
863
+
864
+ artifact_count = len(list(self._artifact_dir.glob("*_result.json")))
865
+
866
+ return GateStatusInfo(
867
+ status="degraded" if cb_open else "ok",
868
+ evaluate_count=evaluate_count,
869
+ trust_gate_count=trust_gate_count,
870
+ last_evaluate_at=last_evaluate_at,
871
+ artifact_count=artifact_count,
872
+ artifact_dir=str(self._artifact_dir),
873
+ open_circuit_breakers=cb_open,
874
+ )