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/cec.pyi ADDED
@@ -0,0 +1,37 @@
1
+ """Type stubs for spanforge.sdk.cec (DX-001)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from spanforge.sdk._base import SFClientConfig, SFServiceClient
6
+ from spanforge.sdk._types import (
7
+ BundleResult,
8
+ BundleVerificationResult,
9
+ CECStatusInfo,
10
+ DPADocument,
11
+ )
12
+
13
+ class SFCECClient(SFServiceClient):
14
+ def __init__(self, config: SFClientConfig) -> None: ...
15
+ def build_bundle(
16
+ self,
17
+ project_id: str,
18
+ date_range: tuple[str, str],
19
+ frameworks: list[str] | None = None,
20
+ ) -> BundleResult: ...
21
+ def verify_bundle(self, zip_path: str) -> BundleVerificationResult: ...
22
+ def generate_dpa(
23
+ self,
24
+ project_id: str,
25
+ controller_details: dict[str, str],
26
+ processor_details: dict[str, str],
27
+ *,
28
+ processing_purposes: list[str] | None = None,
29
+ data_categories: list[str] | None = None,
30
+ data_subjects: list[str] | None = None,
31
+ sub_processors: list[str] | None = None,
32
+ transfer_mechanism: str = "SCCs",
33
+ scc_clauses: str = "Module 2 (controller-to-processor)",
34
+ retention_period: str = ...,
35
+ security_measures: list[str] | None = None,
36
+ ) -> DPADocument: ...
37
+ def get_status(self) -> CECStatusInfo: ...
@@ -0,0 +1,641 @@
1
+ """spanforge.sdk.config — .halluccheck.toml config block parser (Phase 9).
2
+
3
+ Implements:
4
+
5
+ * CFG-001: ``[spanforge]`` block parser — ``enabled``, ``project_id``,
6
+ ``api_key`` (env-only, never stored), ``endpoint``,
7
+ ``[spanforge.services]`` toggle dict, ``[spanforge.local_fallback]``.
8
+ Unknown keys → ``WARNING``, not error.
9
+ * CFG-002: ``[spanforge.services]`` service toggles (8 booleans).
10
+ Disabled service → always uses local fallback regardless of endpoint.
11
+ * CFG-003: ``[spanforge.local_fallback]`` sub-block — ``enabled``,
12
+ ``max_retries``, ``timeout_ms``. Enterprise mode: ``enabled=false``
13
+ causes any unreachable service to raise :exc:`SFServiceUnavailableError`.
14
+ * CFG-004: ``[pii]`` block — ``enabled``, ``action``, ``threshold``,
15
+ ``entity_types``, ``dpdp_scope``.
16
+ * CFG-005: ``[secrets]`` block — ``enabled``, ``auto_block``,
17
+ ``confidence``, ``allowlist``, ``store_redacted``.
18
+ * CFG-006: Env var precedence — env vars always override file values.
19
+ Startup DEBUG log prints resolved config with all secrets redacted.
20
+ * CFG-007: :func:`validate_config` — validates full schema.
21
+
22
+ Security requirements
23
+ ---------------------
24
+ * ``api_key`` is **never** stored to disk. It is only read from
25
+ ``SPANFORGE_API_KEY`` at runtime and is never passed to
26
+ :func:`load_config_file`.
27
+ * Resolved config is logged at DEBUG level with all secret values masked
28
+ (``"***"``).
29
+ * Unknown top-level keys (outside known blocks) emit a ``WARNING`` but
30
+ do not abort startup.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import logging
36
+ import os
37
+ import re
38
+ import sys
39
+ from dataclasses import dataclass, field
40
+ from pathlib import Path
41
+ from typing import Any
42
+
43
+ from spanforge.sdk._exceptions import SFConfigError, SFConfigValidationError
44
+
45
+ __all__ = [
46
+ "SFConfigBlock",
47
+ "SFLocalFallbackConfig",
48
+ "SFPIIConfig",
49
+ "SFSecretsConfig",
50
+ "SFServiceToggles",
51
+ "load_config_file",
52
+ "validate_config",
53
+ ]
54
+
55
+ _log = logging.getLogger(__name__)
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Known keys — used to warn on unknown entries (CFG-001)
59
+ # ---------------------------------------------------------------------------
60
+
61
+ _KNOWN_SPANFORGE_KEYS: frozenset[str] = frozenset(
62
+ {"enabled", "project_id", "endpoint", "sandbox", "services", "local_fallback"}
63
+ )
64
+ _KNOWN_SERVICES_KEYS: frozenset[str] = frozenset(
65
+ {
66
+ "sf_alert",
67
+ "sf_audit",
68
+ "sf_cec",
69
+ "sf_gate",
70
+ "sf_identity",
71
+ "sf_observe",
72
+ "sf_pii",
73
+ "sf_secrets",
74
+ }
75
+ )
76
+ _KNOWN_FALLBACK_KEYS: frozenset[str] = frozenset({"enabled", "max_retries", "timeout_ms"})
77
+ _KNOWN_PII_KEYS: frozenset[str] = frozenset(
78
+ {"action", "dpdp_scope", "enabled", "entity_types", "threshold"}
79
+ )
80
+ _KNOWN_SECRETS_KEYS: frozenset[str] = frozenset(
81
+ {"enabled", "auto_block", "confidence", "allowlist", "store_redacted"}
82
+ )
83
+ _KNOWN_PII_ENTITY_TYPES: frozenset[str] = frozenset(
84
+ {
85
+ "EMAIL",
86
+ "PHONE",
87
+ "SSN",
88
+ "CREDIT_CARD",
89
+ "IBAN",
90
+ "IP_ADDRESS",
91
+ "URL",
92
+ "PERSON",
93
+ "LOCATION",
94
+ "DATE_TIME",
95
+ "NRP",
96
+ "MEDICAL_LICENSE",
97
+ "US_PASSPORT",
98
+ "UK_NHS",
99
+ "AU_ABN",
100
+ "AU_ACN",
101
+ "AU_TFN",
102
+ "AU_MEDICARE",
103
+ "IN_PAN",
104
+ "IN_AADHAAR",
105
+ "CRYPTO",
106
+ "DRIVER_LICENSE",
107
+ }
108
+ )
109
+ _KNOWN_PII_ACTIONS: frozenset[str] = frozenset({"flag", "redact", "block"})
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Dataclasses (CFG-002, CFG-003, CFG-004, CFG-005)
114
+ # ---------------------------------------------------------------------------
115
+
116
+
117
+ @dataclass
118
+ class SFServiceToggles:
119
+ """Per-service enable/disable toggles (CFG-002).
120
+
121
+ A disabled service always uses local fallback regardless of whether the
122
+ remote endpoint is reachable.
123
+ """
124
+
125
+ sf_observe: bool = True
126
+ sf_pii: bool = True
127
+ sf_secrets: bool = True
128
+ sf_audit: bool = True
129
+ sf_gate: bool = True
130
+ sf_cec: bool = True
131
+ sf_identity: bool = True
132
+ sf_alert: bool = True
133
+
134
+ def is_enabled(self, name: str) -> bool:
135
+ """Return ``True`` if the named service is enabled.
136
+
137
+ Args:
138
+ name: Service name, e.g. ``"sf_pii"``.
139
+
140
+ Returns:
141
+ ``True`` when the service toggle is on (default).
142
+ """
143
+ return bool(getattr(self, name, True))
144
+
145
+ def as_dict(self) -> dict[str, bool]:
146
+ """Return a dict of ``{service_name: enabled}``."""
147
+ return {
148
+ "sf_observe": self.sf_observe,
149
+ "sf_pii": self.sf_pii,
150
+ "sf_secrets": self.sf_secrets,
151
+ "sf_audit": self.sf_audit,
152
+ "sf_gate": self.sf_gate,
153
+ "sf_cec": self.sf_cec,
154
+ "sf_identity": self.sf_identity,
155
+ "sf_alert": self.sf_alert,
156
+ }
157
+
158
+
159
+ @dataclass
160
+ class SFLocalFallbackConfig:
161
+ """``[spanforge.local_fallback]`` configuration (CFG-003).
162
+
163
+ Enterprise mode: ``enabled=False`` causes any unreachable service to
164
+ raise :exc:`~spanforge.sdk._exceptions.SFServiceUnavailableError`
165
+ immediately rather than falling back to local logic.
166
+ """
167
+
168
+ enabled: bool = True
169
+ max_retries: int = 3
170
+ timeout_ms: int = 2000
171
+
172
+
173
+ @dataclass
174
+ class SFPIIConfig:
175
+ """``[pii]`` block configuration (CFG-004).
176
+
177
+ Attributes:
178
+ enabled: Whether PII scanning is active (default: ``True``).
179
+ action: What to do on a hit — ``"flag"``, ``"redact"``, or
180
+ ``"block"`` (default: ``"redact"``).
181
+ threshold: Confidence score [0.0-1.0] for PII detection
182
+ (default: ``0.75``).
183
+ entity_types: List of entity-type codes to scan for. An empty
184
+ list means *all* supported entity types.
185
+ dpdp_scope: List of DPDP purposes that require consent checks.
186
+ """
187
+
188
+ enabled: bool = True
189
+ action: str = "redact"
190
+ threshold: float = 0.75
191
+ entity_types: list[str] = field(default_factory=list)
192
+ dpdp_scope: list[str] = field(default_factory=list)
193
+
194
+
195
+ @dataclass
196
+ class SFSecretsConfig:
197
+ """``[secrets]`` block configuration (CFG-005).
198
+
199
+ Attributes:
200
+ enabled: Whether secrets scanning is active (default: ``True``).
201
+ auto_block: Automatically block requests that contain secrets
202
+ (default: ``True``).
203
+ confidence: Minimum detection confidence [0.0-1.0]
204
+ (default: ``0.75``).
205
+ allowlist: Known-safe patterns (e.g. ``["AKIA_EXAMPLE"]``).
206
+ store_redacted: Persist redacted versions in the audit log
207
+ (default: ``False``).
208
+ """
209
+
210
+ enabled: bool = True
211
+ auto_block: bool = True
212
+ confidence: float = 0.75
213
+ allowlist: list[str] = field(default_factory=list)
214
+ store_redacted: bool = False
215
+
216
+
217
+ @dataclass
218
+ class SFConfigBlock:
219
+ """Resolved configuration for the ``[spanforge]`` block.
220
+
221
+ This is the single source of truth for all Phase 9 config. Produced
222
+ by :func:`load_config_file` after merging the TOML file with env-var
223
+ overrides.
224
+
225
+ Attributes:
226
+ enabled: Whether the SpanForge integration is active.
227
+ project_id: Default project scope.
228
+ endpoint: Remote service endpoint URL. Empty string → local mode.
229
+ services: Per-service enable/disable toggles.
230
+ local_fallback: Fallback policy configuration.
231
+ pii: PII scanning configuration.
232
+ secrets: Secrets scanning configuration.
233
+ """
234
+
235
+ enabled: bool = True
236
+ project_id: str = ""
237
+ endpoint: str = ""
238
+ sandbox: bool = False
239
+ services: SFServiceToggles = field(default_factory=SFServiceToggles)
240
+ local_fallback: SFLocalFallbackConfig = field(default_factory=SFLocalFallbackConfig)
241
+ pii: SFPIIConfig = field(default_factory=SFPIIConfig)
242
+ secrets: SFSecretsConfig = field(default_factory=SFSecretsConfig)
243
+
244
+
245
+ # ---------------------------------------------------------------------------
246
+ # Minimal TOML parser (stdlib-only, Python 3.9+ compatible)
247
+ # ---------------------------------------------------------------------------
248
+
249
+ _ARRAY_STR_RE = re.compile(r'"([^"]*)"')
250
+ _ARRAY_SINGLE_STR_RE = re.compile(r"'([^']*)'")
251
+
252
+
253
+ def _parse_inline_string_array(raw: str) -> list[str]: # pragma: no cover
254
+ """Parse a TOML inline array of strings — ``["a", "b"]`` or ``['a', 'b']``."""
255
+ inner = raw.strip()
256
+ if not (inner.startswith("[") and inner.endswith("]")):
257
+ return []
258
+ content = inner[1:-1]
259
+ # Try double-quoted first, then single-quoted
260
+ results = _ARRAY_STR_RE.findall(content)
261
+ if not results:
262
+ results = _ARRAY_SINGLE_STR_RE.findall(content)
263
+ return results
264
+
265
+
266
+ def _parse_toml_value(raw: str) -> Any: # pragma: no cover
267
+ """Parse a single TOML scalar or inline array value."""
268
+ s = raw.strip()
269
+ # Strip trailing inline comment (only if outside a string)
270
+ if s and s[0] not in ('"', "'", "["):
271
+ s = s.split("#")[0].strip()
272
+
273
+ if s == "true":
274
+ return True
275
+ if s == "false":
276
+ return False
277
+ if s.startswith("[") and s.endswith("]"):
278
+ return _parse_inline_string_array(s)
279
+ if (s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'")):
280
+ return s[1:-1]
281
+ # Try numeric coercion
282
+ try:
283
+ return float(s) if "." in s else int(s)
284
+ except ValueError:
285
+ return s
286
+
287
+
288
+ def _parse_toml(text: str) -> dict[str, Any]:
289
+ """Parse a ``.halluccheck.toml`` file into a nested dict.
290
+
291
+ Handles the subset of TOML used by SpanForge config:
292
+ * ``[section]`` and ``[section.sub]`` headers.
293
+ * ``key = value`` (bool, int, float, quoted string, inline string array).
294
+ * ``# comments`` (line and inline).
295
+ * Blank lines.
296
+
297
+ This intentionally does NOT handle multi-line strings, inline tables,
298
+ or other advanced TOML features which are not used in ``.halluccheck.toml``.
299
+ """
300
+ # Prefer stdlib tomllib on Python 3.11+
301
+ if sys.version_info >= (3, 11):
302
+ import tomllib # type: ignore[import-not-found]
303
+
304
+ return tomllib.loads(text) # type: ignore[return-value]
305
+
306
+ result: dict[str, Any] = {} # pragma: no cover
307
+ current_path: list[str] = [] # pragma: no cover
308
+
309
+ for raw_line in text.splitlines(): # pragma: no cover
310
+ line = raw_line.strip()
311
+
312
+ # Skip blank lines and comments
313
+ if not line or line.startswith("#"):
314
+ continue
315
+
316
+ # Section header: [section] or [section.sub]
317
+ if line.startswith("[") and not line.startswith("[["):
318
+ # Strip inline comment after closing bracket
319
+ closing = line.find("]")
320
+ if closing == -1:
321
+ continue
322
+ header = line[1:closing].strip()
323
+ current_path = header.split(".")
324
+ # Ensure the nested path exists
325
+ node: dict[str, Any] = result
326
+ for part in current_path:
327
+ node = node.setdefault(part, {})
328
+ continue
329
+
330
+ # Key = value
331
+ if "=" in line:
332
+ eq_pos = line.index("=")
333
+ key = line[:eq_pos].strip()
334
+ raw_val = line[eq_pos + 1 :].strip()
335
+ value = _parse_toml_value(raw_val)
336
+ # Navigate to current section
337
+ node = result
338
+ for part in current_path:
339
+ node = node.setdefault(part, {})
340
+ node[key] = value
341
+
342
+ return result # pragma: no cover
343
+
344
+
345
+ # ---------------------------------------------------------------------------
346
+ # Config loading (CFG-001 through CFG-006)
347
+ # ---------------------------------------------------------------------------
348
+
349
+
350
+ def _warn_unknown(section_label: str, keys: set[str], known: frozenset[str]) -> None:
351
+ for k in keys - known:
352
+ _log.warning(
353
+ "Unknown key %r in [%s] of .halluccheck.toml — ignored.",
354
+ k,
355
+ section_label,
356
+ )
357
+
358
+
359
+ def load_config_file(
360
+ path: str | Path | None = None,
361
+ ) -> SFConfigBlock:
362
+ """Parse a ``.halluccheck.toml`` file and return an :class:`SFConfigBlock`.
363
+
364
+ Env vars are applied **after** file parsing and always take precedence
365
+ (CFG-006). The resolved config is logged at ``DEBUG`` level with all
366
+ secret values redacted.
367
+
368
+ If ``path`` is ``None`` the function searches for ``.halluccheck.toml``
369
+ first in the current working directory, then in the user's home directory
370
+ (``~/.halluccheck.toml``). If no file is found a default
371
+ :class:`SFConfigBlock` is returned with all values populated from env
372
+ vars and defaults.
373
+
374
+ Args:
375
+ path: Explicit path to the config file. Pass ``None`` for automatic
376
+ discovery.
377
+
378
+ Returns:
379
+ A fully resolved :class:`SFConfigBlock`.
380
+
381
+ Raises:
382
+ :exc:`~spanforge.sdk._exceptions.SFConfigError`: If the file exists
383
+ but cannot be parsed.
384
+ """
385
+ raw: dict[str, Any] = {}
386
+
387
+ resolved_path = _find_config(path)
388
+ if resolved_path is not None:
389
+ try:
390
+ text = resolved_path.read_text(encoding="utf-8")
391
+ raw = _parse_toml(text)
392
+ except Exception as exc:
393
+ raise SFConfigError(f"Failed to parse {resolved_path}: {exc}") from exc
394
+ _log.debug("Loaded SpanForge config from %s", resolved_path)
395
+ else:
396
+ _log.debug("No .halluccheck.toml found; using defaults + env vars.")
397
+
398
+ block = _build_config_block(raw)
399
+ _apply_env_overrides(block)
400
+ _log_resolved_config(block)
401
+ return block
402
+
403
+
404
+ def _find_config(path: str | Path | None) -> Path | None:
405
+ """Resolve config file location."""
406
+ if path is not None:
407
+ p = Path(path)
408
+ return p if p.exists() else None
409
+ for candidate in (
410
+ Path.cwd() / ".halluccheck.toml",
411
+ Path.home() / ".halluccheck.toml",
412
+ ):
413
+ if candidate.exists():
414
+ return candidate
415
+ return None
416
+
417
+
418
+ def _build_config_block(raw: dict[str, Any]) -> SFConfigBlock:
419
+ """Construct an :class:`SFConfigBlock` from the raw parsed dict."""
420
+ sf_raw = raw.get("spanforge", {})
421
+ if not isinstance(sf_raw, dict):
422
+ sf_raw = {}
423
+
424
+ _warn_unknown("spanforge", set(sf_raw.keys()), _KNOWN_SPANFORGE_KEYS)
425
+
426
+ # [spanforge.services]
427
+ svc_raw = sf_raw.get("services", {})
428
+ if not isinstance(svc_raw, dict):
429
+ svc_raw = {}
430
+ _warn_unknown("spanforge.services", set(svc_raw.keys()), _KNOWN_SERVICES_KEYS)
431
+
432
+ toggles = SFServiceToggles(
433
+ sf_observe=bool(svc_raw.get("sf_observe", True)),
434
+ sf_pii=bool(svc_raw.get("sf_pii", True)),
435
+ sf_secrets=bool(svc_raw.get("sf_secrets", True)),
436
+ sf_audit=bool(svc_raw.get("sf_audit", True)),
437
+ sf_gate=bool(svc_raw.get("sf_gate", True)),
438
+ sf_cec=bool(svc_raw.get("sf_cec", True)),
439
+ sf_identity=bool(svc_raw.get("sf_identity", True)),
440
+ sf_alert=bool(svc_raw.get("sf_alert", True)),
441
+ )
442
+
443
+ # [spanforge.local_fallback]
444
+ fb_raw = sf_raw.get("local_fallback", {})
445
+ if not isinstance(fb_raw, dict):
446
+ fb_raw = {}
447
+ _warn_unknown("spanforge.local_fallback", set(fb_raw.keys()), _KNOWN_FALLBACK_KEYS)
448
+
449
+ fallback = SFLocalFallbackConfig(
450
+ enabled=bool(fb_raw.get("enabled", True)),
451
+ max_retries=int(fb_raw.get("max_retries", 3)),
452
+ timeout_ms=int(fb_raw.get("timeout_ms", 2000)),
453
+ )
454
+
455
+ # [pii]
456
+ pii_raw = raw.get("pii", {})
457
+ if not isinstance(pii_raw, dict):
458
+ pii_raw = {}
459
+ _warn_unknown("pii", set(pii_raw.keys()), _KNOWN_PII_KEYS)
460
+
461
+ entity_types = pii_raw.get("entity_types", [])
462
+ if not isinstance(entity_types, list):
463
+ entity_types = []
464
+ dpdp_scope = pii_raw.get("dpdp_scope", [])
465
+ if not isinstance(dpdp_scope, list):
466
+ dpdp_scope = []
467
+
468
+ pii_cfg = SFPIIConfig(
469
+ enabled=bool(pii_raw.get("enabled", True)),
470
+ action=str(pii_raw.get("action", "redact")),
471
+ threshold=float(pii_raw.get("threshold", 0.75)),
472
+ entity_types=list(entity_types),
473
+ dpdp_scope=list(dpdp_scope),
474
+ )
475
+
476
+ # [secrets]
477
+ sec_raw = raw.get("secrets", {})
478
+ if not isinstance(sec_raw, dict):
479
+ sec_raw = {}
480
+ _warn_unknown("secrets", set(sec_raw.keys()), _KNOWN_SECRETS_KEYS)
481
+
482
+ allowlist = sec_raw.get("allowlist", [])
483
+ if not isinstance(allowlist, list):
484
+ allowlist = []
485
+
486
+ secrets_cfg = SFSecretsConfig(
487
+ enabled=bool(sec_raw.get("enabled", True)),
488
+ auto_block=bool(sec_raw.get("auto_block", True)),
489
+ confidence=float(sec_raw.get("confidence", 0.75)),
490
+ allowlist=list(allowlist),
491
+ store_redacted=bool(sec_raw.get("store_redacted", False)),
492
+ )
493
+
494
+ return SFConfigBlock(
495
+ enabled=bool(sf_raw.get("enabled", True)),
496
+ project_id=str(sf_raw.get("project_id", "")),
497
+ endpoint=str(sf_raw.get("endpoint", "")),
498
+ sandbox=bool(sf_raw.get("sandbox", False)),
499
+ services=toggles,
500
+ local_fallback=fallback,
501
+ pii=pii_cfg,
502
+ secrets=secrets_cfg,
503
+ )
504
+
505
+
506
+ def _apply_env_overrides(block: SFConfigBlock) -> None:
507
+ """Apply SPANFORGE_* env var overrides to ``block`` in-place (CFG-006)."""
508
+ if val := os.environ.get("SPANFORGE_ENDPOINT"):
509
+ block.endpoint = val
510
+ if val := os.environ.get("SPANFORGE_PROJECT_ID"):
511
+ block.project_id = val
512
+ if val := os.environ.get("SPANFORGE_SANDBOX"):
513
+ block.sandbox = val.lower() in ("1", "true", "yes")
514
+
515
+ # PII threshold
516
+ if val := os.environ.get("SPANFORGE_PII_THRESHOLD"):
517
+ try:
518
+ block.pii.threshold = float(val)
519
+ except ValueError:
520
+ _log.warning("SPANFORGE_PII_THRESHOLD=%r is not a valid float — ignored.", val)
521
+
522
+ # Secrets auto-block
523
+ if val := os.environ.get("SPANFORGE_SECRETS_AUTO_BLOCK"):
524
+ block.secrets.auto_block = val.lower() not in ("false", "0", "no")
525
+
526
+ # Local fallback enable/disable
527
+ if val := os.environ.get("SPANFORGE_LOCAL_FALLBACK"):
528
+ block.local_fallback.enabled = val.lower() not in ("false", "0", "no")
529
+
530
+ # Fallback max_retries
531
+ if val := os.environ.get("SPANFORGE_FALLBACK_MAX_RETRIES"):
532
+ try:
533
+ block.local_fallback.max_retries = int(val)
534
+ except ValueError:
535
+ _log.warning("SPANFORGE_FALLBACK_MAX_RETRIES=%r is not a valid int — ignored.", val)
536
+
537
+ # Fallback timeout_ms
538
+ if val := os.environ.get("SPANFORGE_FALLBACK_TIMEOUT_MS"):
539
+ try:
540
+ block.local_fallback.timeout_ms = int(val)
541
+ except ValueError:
542
+ _log.warning("SPANFORGE_FALLBACK_TIMEOUT_MS=%r is not a valid int — ignored.", val)
543
+
544
+
545
+ def _log_resolved_config(block: SFConfigBlock) -> None:
546
+ """Log the resolved config at DEBUG level. Secrets are always redacted."""
547
+ if not _log.isEnabledFor(logging.DEBUG):
548
+ return
549
+ _log.debug(
550
+ "Resolved SpanForge config: enabled=%s project_id=%r endpoint=%r "
551
+ "api_key=*** local_fallback.enabled=%s max_retries=%d timeout_ms=%d "
552
+ "pii.action=%r pii.threshold=%.2f secrets.auto_block=%s "
553
+ "services=%s",
554
+ block.enabled,
555
+ block.project_id,
556
+ block.endpoint or "(local-mode)",
557
+ block.local_fallback.enabled,
558
+ block.local_fallback.max_retries,
559
+ block.local_fallback.timeout_ms,
560
+ block.pii.action,
561
+ block.pii.threshold,
562
+ block.secrets.auto_block,
563
+ block.services.as_dict(),
564
+ )
565
+
566
+
567
+ # ---------------------------------------------------------------------------
568
+ # Config validation (CFG-007)
569
+ # ---------------------------------------------------------------------------
570
+
571
+
572
+ def validate_config(block: SFConfigBlock) -> list[str]:
573
+ """Validate an :class:`SFConfigBlock` against the v6.0 schema.
574
+
575
+ Returns a list of human-readable error strings. An empty list means
576
+ the config is valid.
577
+
578
+ Args:
579
+ block: The config block to validate.
580
+
581
+ Returns:
582
+ A (possibly empty) list of validation error strings.
583
+
584
+ Example::
585
+
586
+ errors = validate_config(my_block)
587
+ if errors:
588
+ for err in errors:
589
+ print(f" - {err}")
590
+ """
591
+ errors: list[str] = []
592
+
593
+ # PII action
594
+ if block.pii.action not in _KNOWN_PII_ACTIONS:
595
+ errors.append(
596
+ f"[pii] action={block.pii.action!r} is invalid; "
597
+ f"must be one of {sorted(_KNOWN_PII_ACTIONS)}"
598
+ )
599
+
600
+ # PII threshold
601
+ if not 0.0 <= block.pii.threshold <= 1.0:
602
+ errors.append(f"[pii] threshold={block.pii.threshold} is out of range; must be 0.0-1.0")
603
+
604
+ # PII entity types
605
+ errors.extend(
606
+ f"[pii] entity_types contains unknown type {et!r}; "
607
+ f"supported types: {sorted(_KNOWN_PII_ENTITY_TYPES)}"
608
+ for et in block.pii.entity_types
609
+ if et not in _KNOWN_PII_ENTITY_TYPES
610
+ )
611
+
612
+ # Secrets confidence
613
+ if not 0.0 <= block.secrets.confidence <= 1.0:
614
+ errors.append(
615
+ f"[secrets] confidence={block.secrets.confidence} is out of range; must be 0.0-1.0"
616
+ )
617
+
618
+ # Fallback retries / timeout
619
+ if block.local_fallback.max_retries < 0:
620
+ errors.append(
621
+ f"[spanforge.local_fallback] max_retries={block.local_fallback.max_retries} "
622
+ "must be >= 0"
623
+ )
624
+ if block.local_fallback.timeout_ms < 0:
625
+ errors.append(
626
+ f"[spanforge.local_fallback] timeout_ms={block.local_fallback.timeout_ms} must be >= 0"
627
+ )
628
+
629
+ return errors
630
+
631
+
632
+ def validate_config_strict(block: SFConfigBlock) -> None:
633
+ """Like :func:`validate_config` but raises on any errors.
634
+
635
+ Raises:
636
+ :exc:`~spanforge.sdk._exceptions.SFConfigValidationError`: If any
637
+ validation errors are found.
638
+ """
639
+ errors = validate_config(block)
640
+ if errors:
641
+ raise SFConfigValidationError(errors)