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,79 @@
1
+ """Type stubs for spanforge.sdk.enterprise (DX-001)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from spanforge.sdk._base import SFClientConfig, SFServiceClient
8
+ from spanforge.sdk._types import (
9
+ AirGapConfig,
10
+ EncryptionConfig,
11
+ EnterpriseStatusInfo,
12
+ HealthEndpointResult,
13
+ IsolationScope,
14
+ TenantConfig,
15
+ )
16
+
17
+ class SFEnterpriseClient(SFServiceClient):
18
+ def __init__(self, config: SFClientConfig) -> None: ...
19
+ def register_tenant(
20
+ self,
21
+ project_id: str,
22
+ org_id: str,
23
+ *,
24
+ data_residency: str = "global",
25
+ cross_project_read: bool = False,
26
+ allowed_project_ids: list[str] | None = None,
27
+ ) -> TenantConfig: ...
28
+ def get_tenant(self, project_id: str) -> TenantConfig | None: ...
29
+ def list_tenants(self) -> list[TenantConfig]: ...
30
+ def get_isolation_scope(self, project_id: str) -> IsolationScope: ...
31
+ def check_cross_project_access(
32
+ self,
33
+ source_project_id: str,
34
+ target_project_ids: list[str],
35
+ ) -> None: ...
36
+ def get_endpoint_for_project(self, project_id: str) -> str: ...
37
+ def enforce_data_residency(
38
+ self,
39
+ project_id: str,
40
+ target_region: str,
41
+ ) -> None: ...
42
+ def configure_encryption(
43
+ self,
44
+ *,
45
+ encrypt_at_rest: bool = False,
46
+ kms_provider: str | None = None,
47
+ mtls_enabled: bool = False,
48
+ tls_cert_path: str = "",
49
+ tls_key_path: str = "",
50
+ tls_ca_path: str = "",
51
+ fips_mode: bool = False,
52
+ ) -> EncryptionConfig: ...
53
+ def get_encryption_config(self) -> EncryptionConfig: ...
54
+ def encrypt_payload(self, plaintext: bytes, key: bytes) -> dict[str, Any]: ...
55
+ def decrypt_payload(
56
+ self,
57
+ ciphertext_hex: str,
58
+ nonce_hex: str,
59
+ tag_hex: str,
60
+ key: bytes,
61
+ ) -> bytes: ...
62
+ def configure_airgap(
63
+ self,
64
+ *,
65
+ offline: bool = False,
66
+ self_hosted: bool = False,
67
+ compose_file: str = "docker-compose.yml",
68
+ helm_release_name: str = "spanforge",
69
+ health_check_interval_s: int = 30,
70
+ ) -> AirGapConfig: ...
71
+ def get_airgap_config(self) -> AirGapConfig: ...
72
+ def assert_network_allowed(self) -> None: ...
73
+ def check_health_endpoint(
74
+ self,
75
+ service: str,
76
+ endpoint: str = "/healthz",
77
+ ) -> HealthEndpointResult: ...
78
+ def check_all_services_health(self) -> list[HealthEndpointResult]: ...
79
+ def get_status(self) -> EnterpriseStatusInfo: ...
@@ -0,0 +1,170 @@
1
+ """spanforge.sdk.explain - SpanForge sf-explain client.
2
+
3
+ Phase 1 implementation for GA runtime explainability. The client is designed
4
+ to be callable from application code and to emit signed records through
5
+ sf-audit using the canonical Phase 0 explanation payload.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import threading
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ from spanforge.namespaces.runtime_governance import (
15
+ ExplanationFactor,
16
+ ExplanationPayload,
17
+ )
18
+ from spanforge.sdk._base import SFClientConfig, SFServiceClient
19
+
20
+ __all__ = ["ExplainStatusInfo", "SFExplainClient"]
21
+
22
+
23
+ @dataclass
24
+ class ExplainStatusInfo:
25
+ """sf-explain service status."""
26
+
27
+ status: str
28
+ total_generated: int
29
+ traces_tracked: int
30
+
31
+
32
+ class SFExplainClient(SFServiceClient):
33
+ """SpanForge runtime explainability service client."""
34
+
35
+ def __init__(self, config: SFClientConfig) -> None:
36
+ super().__init__(config, service_name="explain")
37
+ self._lock = threading.Lock()
38
+ self._records: dict[str, ExplanationPayload] = {}
39
+ self._by_trace: dict[str, list[str]] = {}
40
+ self._total_generated: int = 0
41
+
42
+ def generate(
43
+ self,
44
+ *,
45
+ trace_id: str,
46
+ agent_id: str,
47
+ decision_id: str,
48
+ summary: str,
49
+ policy_action: str,
50
+ generated_at: str,
51
+ factors: list[ExplanationFactor | dict[str, Any]] | None = None,
52
+ explanation_id: str | None = None,
53
+ model_id: str | None = None,
54
+ confidence: float | None = None,
55
+ policy_id: str | None = None,
56
+ metadata: dict[str, Any] | None = None,
57
+ ) -> ExplanationPayload:
58
+ """Generate and persist a canonical runtime explanation record."""
59
+ from spanforge.ulid import generate as _ulid
60
+
61
+ payload = ExplanationPayload(
62
+ explanation_id=explanation_id or _ulid(),
63
+ trace_id=trace_id,
64
+ decision_id=decision_id,
65
+ agent_id=agent_id,
66
+ summary=summary,
67
+ policy_action=policy_action,
68
+ generated_at=generated_at,
69
+ factors=[
70
+ factor
71
+ if isinstance(factor, ExplanationFactor)
72
+ else ExplanationFactor.from_dict(factor)
73
+ for factor in (factors or [])
74
+ ],
75
+ model_id=model_id,
76
+ confidence=confidence,
77
+ policy_id=policy_id,
78
+ metadata=metadata or {},
79
+ )
80
+
81
+ with self._lock:
82
+ self._records[payload.explanation_id] = payload
83
+ self._by_trace.setdefault(trace_id, []).append(payload.explanation_id)
84
+ self._total_generated += 1
85
+
86
+ self._emit_signed_record(payload)
87
+ return payload
88
+
89
+ def generate_with_policy(
90
+ self,
91
+ *,
92
+ environment: str,
93
+ trace_id: str,
94
+ agent_id: str,
95
+ decision_id: str,
96
+ summary: str,
97
+ generated_at: str,
98
+ policy_client: Any | None = None,
99
+ control: str = "explanation_generation",
100
+ coverage_score: float | None = None,
101
+ factors: list[ExplanationFactor | dict[str, Any]] | None = None,
102
+ explanation_id: str | None = None,
103
+ model_id: str | None = None,
104
+ confidence: float | None = None,
105
+ metadata: dict[str, Any] | None = None,
106
+ ) -> ExplanationPayload:
107
+ """Generate an explanation using the active runtime policy action."""
108
+ engine = policy_client or self._default_policy_client()
109
+ decision = engine.evaluate(
110
+ environment=environment,
111
+ trace_id=trace_id,
112
+ service="sf_explain",
113
+ control=control,
114
+ evaluated_at=generated_at,
115
+ observed_value=coverage_score,
116
+ metadata={"agent_id": agent_id, "decision_id": decision_id},
117
+ )
118
+ return self.generate(
119
+ trace_id=trace_id,
120
+ agent_id=agent_id,
121
+ decision_id=decision_id,
122
+ summary=summary,
123
+ policy_action=decision.action,
124
+ generated_at=generated_at,
125
+ factors=factors,
126
+ explanation_id=explanation_id,
127
+ model_id=model_id,
128
+ confidence=confidence,
129
+ policy_id=decision.policy_id,
130
+ metadata=metadata,
131
+ )
132
+
133
+ async def generate_async(self, **kwargs: Any) -> ExplanationPayload:
134
+ """Async wrapper around :meth:`generate`."""
135
+ import asyncio
136
+
137
+ loop = asyncio.get_event_loop()
138
+ return await loop.run_in_executor(None, lambda: self.generate(**kwargs))
139
+
140
+ def get(self, explanation_id: str) -> ExplanationPayload | None:
141
+ """Return a previously generated explanation payload."""
142
+ with self._lock:
143
+ return self._records.get(explanation_id)
144
+
145
+ def list_for_trace(self, trace_id: str) -> list[ExplanationPayload]:
146
+ """Return all explanation records emitted for a trace."""
147
+ with self._lock:
148
+ ids = list(self._by_trace.get(trace_id, []))
149
+ return [self._records[item] for item in ids if item in self._records]
150
+
151
+ def get_status(self) -> ExplainStatusInfo:
152
+ """Return service health and usage counters."""
153
+ with self._lock:
154
+ return ExplainStatusInfo(
155
+ status="ok",
156
+ total_generated=self._total_generated,
157
+ traces_tracked=len(self._by_trace),
158
+ )
159
+
160
+ def _emit_signed_record(self, payload: ExplanationPayload) -> None:
161
+ """Write the explanation payload into sf-audit."""
162
+ from spanforge.sdk import sf_audit
163
+
164
+ sf_audit.append(payload.to_dict(), "spanforge.explanation.v1")
165
+
166
+ @staticmethod
167
+ def _default_policy_client() -> Any:
168
+ from spanforge.sdk import sf_policy
169
+
170
+ return sf_policy
@@ -0,0 +1,432 @@
1
+ """spanforge.sdk.fallback - Local fallback implementations (Phase 9, CFG-020-027).
2
+
3
+ When a SpanForge remote service is unreachable (or disabled via service
4
+ toggle) and ``local_fallback.enabled=True``, these functions provide
5
+ best-effort local-mode equivalents for all 8 services.
6
+
7
+ ====== ==================================================================
8
+ ID Fallback
9
+ ====== ==================================================================
10
+ 020 :func:`pii_fallback` — regex scan via ``spanforge.redact``.
11
+ 021 :func:`secrets_fallback` — regex scan via ``spanforge.secrets``.
12
+ 022 :func:`audit_fallback` — HMAC-chained JSONL to local file.
13
+ 023 :func:`observe_fallback` — OTLP JSON to stdout.
14
+ 024 :func:`alert_fallback` — log to ``stderr`` at WARNING.
15
+ 025 :func:`identity_fallback` — trust ``SPANFORGE_LOCAL_TOKEN`` env var.
16
+ 026 :func:`gate_fallback` — run gate locally via ``spanforge.gate``.
17
+ 027 :func:`cec_fallback` — write CEC bundle to local JSONL file.
18
+ ====== ==================================================================
19
+
20
+ All functions emit a ``WARNING`` log entry so operators can detect when
21
+ fallback is active.
22
+
23
+ Security requirements
24
+ ---------------------
25
+ * Local identity tokens (CFG-025) are trusted **without signature
26
+ verification** — only appropriate for CLI / local dev use.
27
+ * Audit HMAC is computed using ``SPANFORGE_SIGNING_KEY`` / ``SPANFORGE_MAGIC_SECRET``
28
+ from the environment; if neither is set, an empty key is used and a
29
+ ``WARNING`` is logged.
30
+ * No secret values are ever written to stdout/stderr.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import hashlib
36
+ import hmac
37
+ import json
38
+ import logging
39
+ import os
40
+ import sys
41
+ import threading
42
+ from datetime import datetime, timezone
43
+ from pathlib import Path
44
+ from typing import Any
45
+
46
+ __all__ = [
47
+ "alert_fallback",
48
+ "audit_fallback",
49
+ "cec_fallback",
50
+ "gate_fallback",
51
+ "identity_fallback",
52
+ "observe_fallback",
53
+ "pii_fallback",
54
+ "secrets_fallback",
55
+ ]
56
+
57
+ _log = logging.getLogger(__name__)
58
+
59
+ # Default path for the local audit fallback file (CFG-022)
60
+ _DEFAULT_AUDIT_FALLBACK_PATH = Path.home() / ".spanforge" / "audit_fallback.jsonl"
61
+
62
+ # HMAC algorithm used for the local audit chain (CFG-022)
63
+ _HMAC_ALGO = "sha256"
64
+
65
+ # Lock for the local audit JSONL file to prevent interleaved writes
66
+ _audit_file_lock = threading.Lock()
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # CFG-020: sf-pii fallback
71
+ # ---------------------------------------------------------------------------
72
+
73
+
74
+ def pii_fallback(
75
+ payload: Any,
76
+ *,
77
+ threshold: float = 0.75,
78
+ entity_types: list[str] | None = None,
79
+ ) -> dict[str, Any]:
80
+ """Scan ``payload`` for PII using the local regex scanner (CFG-020).
81
+
82
+ Falls back to ``spanforge.redact.scan_payload()`` (regex-based).
83
+ If the ``presidio_backend`` optional module is importable, it is used
84
+ for richer entity detection.
85
+
86
+ Args:
87
+ payload: The data to scan — dict, list, or str.
88
+ threshold: Minimum confidence to report a hit (default: 0.75).
89
+ entity_types: Entity types to scan for. ``None`` means all.
90
+
91
+ Returns:
92
+ ``{"clean": bool, "hits": [...], "fallback": true}``
93
+ """
94
+ _log.warning("sf-pii unreachable, using local regex scan (CFG-020).")
95
+
96
+ try:
97
+ from spanforge.redact import scan_payload
98
+
99
+ result = scan_payload(payload)
100
+ hits = getattr(result, "hits", [])
101
+ return {
102
+ "clean": not hits,
103
+ "hits": [
104
+ {
105
+ "entity_type": getattr(h, "entity_type", "UNKNOWN"),
106
+ "text": getattr(h, "text", ""),
107
+ "score": getattr(h, "score", 0.0),
108
+ }
109
+ for h in hits
110
+ if getattr(h, "score", 0.0) >= threshold
111
+ and (entity_types is None or getattr(h, "entity_type", "") in entity_types)
112
+ ],
113
+ "fallback": True,
114
+ }
115
+ except Exception as exc:
116
+ _log.warning("pii_fallback: scan_payload raised %s — returning empty result.", exc)
117
+ return {"clean": True, "hits": [], "fallback": True, "error": str(exc)}
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # CFG-021: sf-secrets fallback
122
+ # ---------------------------------------------------------------------------
123
+
124
+
125
+ def secrets_fallback(text: str, *, confidence: float = 0.75) -> dict[str, Any]:
126
+ """Scan ``text`` for secrets using the regex-only scanner (CFG-021).
127
+
128
+ Entropy scoring is disabled in fallback mode.
129
+
130
+ Args:
131
+ text: Plain text to scan.
132
+ confidence: Minimum confidence threshold (default: 0.75).
133
+
134
+ Returns:
135
+ ``{"clean": bool, "hits": [...], "fallback": true}``
136
+ """
137
+ _log.warning("sf-secrets unreachable, using local regex scan (CFG-021).")
138
+
139
+ try:
140
+ import spanforge.secrets as _secrets_mod
141
+
142
+ scan_fn: Any = _secrets_mod.scan_text # type: ignore[attr-defined]
143
+ result = scan_fn(text)
144
+ hits = getattr(result, "hits", [])
145
+ return {
146
+ "clean": not hits,
147
+ "hits": [
148
+ {
149
+ "pattern_id": getattr(h, "pattern_id", "UNKNOWN"),
150
+ "redacted": getattr(h, "redacted", ""),
151
+ "confidence": getattr(h, "confidence", 0.0),
152
+ }
153
+ for h in hits
154
+ if getattr(h, "confidence", 0.0) >= confidence
155
+ ],
156
+ "fallback": True,
157
+ }
158
+ except Exception as exc:
159
+ _log.warning("secrets_fallback: scan_text raised %s — returning empty result.", exc)
160
+ return {"clean": True, "hits": [], "fallback": True, "error": str(exc)}
161
+
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # CFG-022: sf-audit fallback
165
+ # ---------------------------------------------------------------------------
166
+
167
+
168
+ def audit_fallback(
169
+ record: dict[str, Any],
170
+ *,
171
+ schema_key: str = "halluccheck.audit.fallback.v1",
172
+ fallback_path: Path | str | None = None,
173
+ ) -> dict[str, Any]:
174
+ """Append ``record`` to the local audit fallback JSONL file (CFG-022).
175
+
176
+ HMAC chain is still applied using the local org secret from
177
+ ``SPANFORGE_SIGNING_KEY`` or ``SPANFORGE_MAGIC_SECRET``. If neither is
178
+ set, an empty key is used and a ``WARNING`` is emitted.
179
+
180
+ Args:
181
+ record: The audit record payload to persist.
182
+ schema_key: Schema identifier for the record (default:
183
+ ``"halluccheck.audit.fallback.v1"``).
184
+ fallback_path: Override the default
185
+ ``~/.spanforge/audit_fallback.jsonl`` path.
186
+
187
+ Returns:
188
+ ``{"record_id": str, "fallback_path": str, "fallback": true}``
189
+ """
190
+ _log.warning("sf-audit unreachable, writing to local fallback JSONL (CFG-022).")
191
+
192
+ path = Path(fallback_path) if fallback_path else _DEFAULT_AUDIT_FALLBACK_PATH
193
+ path.parent.mkdir(parents=True, exist_ok=True)
194
+
195
+ signing_key = os.environ.get("SPANFORGE_SIGNING_KEY") or os.environ.get(
196
+ "SPANFORGE_MAGIC_SECRET", ""
197
+ )
198
+ if not signing_key:
199
+ _log.warning("audit_fallback: SPANFORGE_SIGNING_KEY not set; HMAC chain uses empty key.")
200
+
201
+ # Build the entry
202
+ now = datetime.now(timezone.utc)
203
+ record_id = _generate_record_id(record, now)
204
+ entry: dict[str, Any] = {
205
+ "record_id": record_id,
206
+ "schema_key": schema_key,
207
+ "timestamp": now.isoformat(),
208
+ "payload": record,
209
+ "fallback": True,
210
+ }
211
+
212
+ # HMAC of serialised entry (before appending the hmac field itself)
213
+ serialised = json.dumps(entry, sort_keys=True, separators=(",", ":"))
214
+ signing_key_bytes = signing_key.encode() if signing_key else b""
215
+ sig = hmac.new(
216
+ signing_key_bytes,
217
+ serialised.encode(),
218
+ _HMAC_ALGO,
219
+ ).hexdigest()
220
+ entry["hmac"] = sig
221
+
222
+ with _audit_file_lock, path.open("a", encoding="utf-8") as f:
223
+ f.write(json.dumps(entry, separators=(",", ":")) + "\n")
224
+
225
+ return {"record_id": record_id, "fallback_path": str(path), "fallback": True}
226
+
227
+
228
+ def _generate_record_id(record: Any, now: datetime) -> str:
229
+ """Generate a deterministic record ID from content + timestamp."""
230
+ raw = json.dumps(record, sort_keys=True, separators=(",", ":")) + now.isoformat()
231
+ return hashlib.sha256(raw.encode()).hexdigest()[:32]
232
+
233
+
234
+ # ---------------------------------------------------------------------------
235
+ # CFG-023: sf-observe fallback
236
+ # ---------------------------------------------------------------------------
237
+
238
+
239
+ def observe_fallback(span_data: dict[str, Any]) -> None:
240
+ """Write ``span_data`` to stdout in OTLP JSON format (CFG-023).
241
+
242
+ Uses the ``LOCAL_SPAN:`` prefix for easy grep.
243
+
244
+ Args:
245
+ span_data: Dict representation of the span to emit.
246
+ """
247
+ _log.warning("sf-observe unreachable, writing span to stdout (CFG-023).")
248
+ payload = {
249
+ "timestamp": datetime.now(timezone.utc).isoformat(),
250
+ **span_data,
251
+ }
252
+ sys.stdout.write(f"LOCAL_SPAN: {json.dumps(payload, separators=(',', ':'))}\n")
253
+ sys.stdout.flush()
254
+
255
+
256
+ # ---------------------------------------------------------------------------
257
+ # CFG-024: sf-alert fallback
258
+ # ---------------------------------------------------------------------------
259
+
260
+
261
+ def alert_fallback(
262
+ topic: str,
263
+ payload: dict[str, Any],
264
+ severity: str = "WARNING",
265
+ ) -> dict[str, Any]:
266
+ """Log the alert to stderr at WARNING level (CFG-024).
267
+
268
+ No network delivery occurs; the full alert payload is included in the
269
+ log message.
270
+
271
+ Args:
272
+ topic: Alert topic string.
273
+ payload: Alert data dict.
274
+ severity: Severity string for the log message (default: ``"WARNING"``).
275
+ """
276
+ _log.warning(
277
+ "sf-alert unreachable [%s] topic=%r payload=%s (CFG-024).",
278
+ severity,
279
+ topic,
280
+ json.dumps(payload, separators=(",", ":")),
281
+ )
282
+ sys.stderr.write(
283
+ f"SPANFORGE ALERT [{severity}] topic={topic!r}: "
284
+ f"{json.dumps(payload, separators=(',', ':'))}\n"
285
+ )
286
+ sys.stderr.flush()
287
+ return {"topic": topic, "severity": severity, "fallback": True}
288
+
289
+
290
+ # ---------------------------------------------------------------------------
291
+ # CFG-025: sf-identity fallback
292
+ # ---------------------------------------------------------------------------
293
+
294
+
295
+ def identity_fallback(
296
+ token: str | None = None,
297
+ ) -> dict[str, Any]:
298
+ """Accept a local bearer token without JWT signature validation (CFG-025).
299
+
300
+ Reads ``SPANFORGE_LOCAL_TOKEN`` from env if ``token`` is ``None``.
301
+ Only appropriate for CLI / local dev use. Logs a ``WARNING``.
302
+
303
+ Args:
304
+ token: Optional bearer token string. If ``None``, reads from
305
+ ``SPANFORGE_LOCAL_TOKEN`` env var.
306
+
307
+ Returns:
308
+ ``{"token": str, "validated": false, "fallback": true}``
309
+
310
+ Raises:
311
+ :exc:`ValueError`: If no token is available.
312
+ """
313
+ _log.warning(
314
+ "sf-identity unreachable, using local token from env (CFG-025). "
315
+ "JWT signature validation skipped."
316
+ )
317
+
318
+ resolved = token or os.environ.get("SPANFORGE_LOCAL_TOKEN", "")
319
+ if not resolved:
320
+ raise ValueError(
321
+ "sf-identity fallback requires SPANFORGE_LOCAL_TOKEN env var "
322
+ "or an explicit token argument."
323
+ )
324
+ return {"token": resolved, "validated": False, "fallback": True}
325
+
326
+
327
+ # ---------------------------------------------------------------------------
328
+ # CFG-026: sf-gate fallback
329
+ # ---------------------------------------------------------------------------
330
+
331
+
332
+ def gate_fallback(
333
+ gate_config_path: str | Path,
334
+ context: dict[str, Any] | None = None,
335
+ ) -> dict[str, Any]:
336
+ """Run gate evaluation locally using the ``spanforge.gate`` module (CFG-026).
337
+
338
+ PRRI check reads a local ``prri_result.json`` if present. Trust gate
339
+ checks the local audit fallback JSONL.
340
+
341
+ Args:
342
+ gate_config_path: Path to the gate YAML configuration file.
343
+ context: Optional template context dict.
344
+
345
+ Returns:
346
+ ``{"verdict": "PASS|FAIL|WARN|SKIPPED", "results": [...], "fallback": true}``
347
+ """
348
+ _log.warning("sf-gate unreachable, running gate evaluation locally (CFG-026).")
349
+
350
+ try:
351
+ from spanforge.gate import GateRunner
352
+
353
+ runner: Any = GateRunner()
354
+ run_result = runner.run_pipeline(str(gate_config_path), context=context or {})
355
+ return {
356
+ "verdict": getattr(run_result, "overall_verdict", "UNKNOWN"),
357
+ "results": [
358
+ {
359
+ "gate_id": getattr(r, "gate_id", ""),
360
+ "verdict": getattr(r, "verdict", "UNKNOWN"),
361
+ }
362
+ for r in getattr(run_result, "results", [])
363
+ ],
364
+ "fallback": True,
365
+ }
366
+ except Exception as exc:
367
+ _log.warning("gate_fallback: local gate run raised %s.", exc)
368
+ return {"verdict": "FAIL", "results": [], "fallback": True, "error": str(exc)}
369
+
370
+
371
+ # ---------------------------------------------------------------------------
372
+ # CFG-027: sf-cec fallback
373
+ # ---------------------------------------------------------------------------
374
+
375
+
376
+ def cec_fallback(
377
+ model_id: str,
378
+ framework: str,
379
+ events_file: str | Path | None = None,
380
+ *,
381
+ output_path: str | Path | None = None,
382
+ ) -> dict[str, Any]:
383
+ """Generate a CEC bundle from the local audit fallback JSONL (CFG-027).
384
+
385
+ No BYOS upload — bundle written to a local file.
386
+
387
+ Args:
388
+ model_id: Model UUID.
389
+ framework: Compliance framework identifier.
390
+ events_file: Path to a JSONL events file. Defaults to the local
391
+ audit fallback JSONL at ``~/.spanforge/audit_fallback.jsonl``.
392
+ output_path: Where to write the bundle. Defaults to
393
+ ``~/.spanforge/cec_bundle_{model_id}.jsonl``.
394
+
395
+ Returns:
396
+ ``{"bundle_path": str, "record_count": int, "fallback": true}``
397
+ """
398
+ _log.warning("sf-cec unreachable, generating CEC bundle locally (CFG-027).")
399
+
400
+ src = Path(events_file) if events_file else _DEFAULT_AUDIT_FALLBACK_PATH
401
+ dest = (
402
+ Path(output_path)
403
+ if output_path
404
+ else Path.home() / ".spanforge" / f"cec_bundle_{model_id}.jsonl"
405
+ )
406
+ dest.parent.mkdir(parents=True, exist_ok=True)
407
+
408
+ records: list[dict[str, Any]] = []
409
+ if src.exists():
410
+ with src.open(encoding="utf-8") as f:
411
+ for line in f:
412
+ line = line.strip()
413
+ if line:
414
+ try:
415
+ records.append(json.loads(line))
416
+ except json.JSONDecodeError:
417
+ pass
418
+
419
+ bundle: dict[str, Any] = {
420
+ "model_id": model_id,
421
+ "framework": framework,
422
+ "generated_at": datetime.now(timezone.utc).isoformat(),
423
+ "record_count": len(records),
424
+ "records": records,
425
+ "fallback": True,
426
+ }
427
+
428
+ with dest.open("w", encoding="utf-8") as f:
429
+ f.write(json.dumps(bundle, separators=(",", ":")) + "\n")
430
+
431
+ _log.info("cec_fallback: wrote %d records to %s", len(records), dest)
432
+ return {"bundle_path": str(dest), "record_count": len(records), "fallback": True}