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
|
@@ -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: ...
|
spanforge/sdk/explain.py
ADDED
|
@@ -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}
|