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.
- spanforge/__init__.py +815 -0
- spanforge/_ansi.py +93 -0
- spanforge/_batch_exporter.py +409 -0
- spanforge/_cli.py +2094 -0
- spanforge/_cli_audit.py +639 -0
- spanforge/_cli_compliance.py +711 -0
- spanforge/_cli_cost.py +243 -0
- spanforge/_cli_ops.py +791 -0
- spanforge/_cli_phase11.py +356 -0
- spanforge/_hooks.py +337 -0
- spanforge/_server.py +1708 -0
- spanforge/_span.py +1036 -0
- spanforge/_store.py +288 -0
- spanforge/_stream.py +664 -0
- spanforge/_trace.py +335 -0
- spanforge/_tracer.py +254 -0
- spanforge/actor.py +141 -0
- spanforge/alerts.py +469 -0
- spanforge/auto.py +464 -0
- spanforge/baseline.py +335 -0
- spanforge/cache.py +635 -0
- spanforge/compliance.py +325 -0
- spanforge/config.py +532 -0
- spanforge/consent.py +228 -0
- spanforge/consumer.py +377 -0
- spanforge/core/__init__.py +5 -0
- spanforge/core/compliance_mapping.py +1254 -0
- spanforge/cost.py +600 -0
- spanforge/debug.py +548 -0
- spanforge/deprecations.py +205 -0
- spanforge/drift.py +482 -0
- spanforge/egress.py +58 -0
- spanforge/eval.py +648 -0
- spanforge/event.py +1064 -0
- spanforge/exceptions.py +240 -0
- spanforge/explain.py +178 -0
- spanforge/export/__init__.py +69 -0
- spanforge/export/append_only.py +337 -0
- spanforge/export/cloud.py +357 -0
- spanforge/export/datadog.py +497 -0
- spanforge/export/grafana.py +320 -0
- spanforge/export/jsonl.py +195 -0
- spanforge/export/openinference.py +158 -0
- spanforge/export/otel_bridge.py +294 -0
- spanforge/export/otlp.py +811 -0
- spanforge/export/otlp_bridge.py +233 -0
- spanforge/export/redis_backend.py +282 -0
- spanforge/export/siem_schema.py +98 -0
- spanforge/export/siem_splunk.py +264 -0
- spanforge/export/siem_syslog.py +212 -0
- spanforge/export/webhook.py +299 -0
- spanforge/exporters/__init__.py +30 -0
- spanforge/exporters/console.py +271 -0
- spanforge/exporters/jsonl.py +144 -0
- spanforge/exporters/sqlite.py +142 -0
- spanforge/gate.py +1150 -0
- spanforge/governance.py +181 -0
- spanforge/hitl.py +295 -0
- spanforge/http.py +187 -0
- spanforge/inspect.py +427 -0
- spanforge/integrations/__init__.py +45 -0
- spanforge/integrations/_pricing.py +280 -0
- spanforge/integrations/anthropic.py +388 -0
- spanforge/integrations/azure_openai.py +133 -0
- spanforge/integrations/bedrock.py +292 -0
- spanforge/integrations/crewai.py +251 -0
- spanforge/integrations/gemini.py +351 -0
- spanforge/integrations/groq.py +442 -0
- spanforge/integrations/langchain.py +349 -0
- spanforge/integrations/langgraph.py +306 -0
- spanforge/integrations/llamaindex.py +373 -0
- spanforge/integrations/ollama.py +287 -0
- spanforge/integrations/openai.py +368 -0
- spanforge/integrations/together.py +483 -0
- spanforge/io.py +214 -0
- spanforge/lint.py +322 -0
- spanforge/metrics.py +417 -0
- spanforge/metrics_export.py +343 -0
- spanforge/migrate.py +402 -0
- spanforge/model_registry.py +278 -0
- spanforge/models.py +389 -0
- spanforge/namespaces/__init__.py +254 -0
- spanforge/namespaces/audit.py +256 -0
- spanforge/namespaces/cache.py +237 -0
- spanforge/namespaces/chain.py +77 -0
- spanforge/namespaces/confidence.py +72 -0
- spanforge/namespaces/consent.py +92 -0
- spanforge/namespaces/cost.py +179 -0
- spanforge/namespaces/decision.py +143 -0
- spanforge/namespaces/diff.py +157 -0
- spanforge/namespaces/drift.py +80 -0
- spanforge/namespaces/eval_.py +251 -0
- spanforge/namespaces/feedback.py +241 -0
- spanforge/namespaces/fence.py +193 -0
- spanforge/namespaces/guard.py +105 -0
- spanforge/namespaces/hitl.py +91 -0
- spanforge/namespaces/latency.py +72 -0
- spanforge/namespaces/prompt.py +190 -0
- spanforge/namespaces/redact.py +173 -0
- spanforge/namespaces/retrieval.py +379 -0
- spanforge/namespaces/runtime_governance.py +494 -0
- spanforge/namespaces/template.py +208 -0
- spanforge/namespaces/tool_call.py +77 -0
- spanforge/namespaces/trace.py +1029 -0
- spanforge/normalizer.py +171 -0
- spanforge/plugins.py +82 -0
- spanforge/presidio_backend.py +349 -0
- spanforge/processor.py +258 -0
- spanforge/prompt_registry.py +418 -0
- spanforge/py.typed +0 -0
- spanforge/redact.py +914 -0
- spanforge/regression.py +192 -0
- spanforge/runtime_policy.py +159 -0
- spanforge/sampling.py +511 -0
- spanforge/schema.py +183 -0
- spanforge/schemas/v1.0/schema.json +170 -0
- spanforge/schemas/v2.0/schema.json +536 -0
- spanforge/sdk/__init__.py +625 -0
- spanforge/sdk/_base.py +584 -0
- spanforge/sdk/_base.pyi +71 -0
- spanforge/sdk/_exceptions.py +1096 -0
- spanforge/sdk/_types.py +2184 -0
- spanforge/sdk/alert.py +1514 -0
- spanforge/sdk/alert.pyi +56 -0
- spanforge/sdk/audit.py +1196 -0
- spanforge/sdk/audit.pyi +67 -0
- spanforge/sdk/cec.py +1215 -0
- spanforge/sdk/cec.pyi +37 -0
- spanforge/sdk/config.py +641 -0
- spanforge/sdk/config.pyi +55 -0
- spanforge/sdk/enterprise.py +714 -0
- spanforge/sdk/enterprise.pyi +79 -0
- spanforge/sdk/explain.py +170 -0
- spanforge/sdk/fallback.py +432 -0
- spanforge/sdk/feedback.py +351 -0
- spanforge/sdk/gate.py +874 -0
- spanforge/sdk/gate.pyi +51 -0
- spanforge/sdk/identity.py +2114 -0
- spanforge/sdk/identity.pyi +47 -0
- spanforge/sdk/lineage.py +175 -0
- spanforge/sdk/observe.py +1065 -0
- spanforge/sdk/observe.pyi +50 -0
- spanforge/sdk/operator.py +338 -0
- spanforge/sdk/pii.py +1473 -0
- spanforge/sdk/pii.pyi +119 -0
- spanforge/sdk/pipelines.py +458 -0
- spanforge/sdk/pipelines.pyi +39 -0
- spanforge/sdk/policy.py +930 -0
- spanforge/sdk/rag.py +594 -0
- spanforge/sdk/rbac.py +280 -0
- spanforge/sdk/registry.py +430 -0
- spanforge/sdk/registry.pyi +46 -0
- spanforge/sdk/scope.py +279 -0
- spanforge/sdk/secrets.py +293 -0
- spanforge/sdk/secrets.pyi +25 -0
- spanforge/sdk/security.py +560 -0
- spanforge/sdk/security.pyi +57 -0
- spanforge/sdk/trust.py +472 -0
- spanforge/sdk/trust.pyi +41 -0
- spanforge/secrets.py +799 -0
- spanforge/signing.py +1179 -0
- spanforge/stats.py +100 -0
- spanforge/stream.py +560 -0
- spanforge/testing.py +378 -0
- spanforge/testing_mocks.py +1052 -0
- spanforge/trace.py +199 -0
- spanforge/types.py +696 -0
- spanforge/ulid.py +300 -0
- spanforge/validate.py +379 -0
- spanforge-1.0.0.dist-info/METADATA +1509 -0
- spanforge-1.0.0.dist-info/RECORD +174 -0
- spanforge-1.0.0.dist-info/WHEEL +4 -0
- spanforge-1.0.0.dist-info/entry_points.txt +5 -0
- 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: ...
|
spanforge/sdk/config.py
ADDED
|
@@ -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)
|