spanforge 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. spanforge/__init__.py +815 -0
  2. spanforge/_ansi.py +93 -0
  3. spanforge/_batch_exporter.py +409 -0
  4. spanforge/_cli.py +2094 -0
  5. spanforge/_cli_audit.py +639 -0
  6. spanforge/_cli_compliance.py +711 -0
  7. spanforge/_cli_cost.py +243 -0
  8. spanforge/_cli_ops.py +791 -0
  9. spanforge/_cli_phase11.py +356 -0
  10. spanforge/_hooks.py +337 -0
  11. spanforge/_server.py +1708 -0
  12. spanforge/_span.py +1036 -0
  13. spanforge/_store.py +288 -0
  14. spanforge/_stream.py +664 -0
  15. spanforge/_trace.py +335 -0
  16. spanforge/_tracer.py +254 -0
  17. spanforge/actor.py +141 -0
  18. spanforge/alerts.py +469 -0
  19. spanforge/auto.py +464 -0
  20. spanforge/baseline.py +335 -0
  21. spanforge/cache.py +635 -0
  22. spanforge/compliance.py +325 -0
  23. spanforge/config.py +532 -0
  24. spanforge/consent.py +228 -0
  25. spanforge/consumer.py +377 -0
  26. spanforge/core/__init__.py +5 -0
  27. spanforge/core/compliance_mapping.py +1254 -0
  28. spanforge/cost.py +600 -0
  29. spanforge/debug.py +548 -0
  30. spanforge/deprecations.py +205 -0
  31. spanforge/drift.py +482 -0
  32. spanforge/egress.py +58 -0
  33. spanforge/eval.py +648 -0
  34. spanforge/event.py +1064 -0
  35. spanforge/exceptions.py +240 -0
  36. spanforge/explain.py +178 -0
  37. spanforge/export/__init__.py +69 -0
  38. spanforge/export/append_only.py +337 -0
  39. spanforge/export/cloud.py +357 -0
  40. spanforge/export/datadog.py +497 -0
  41. spanforge/export/grafana.py +320 -0
  42. spanforge/export/jsonl.py +195 -0
  43. spanforge/export/openinference.py +158 -0
  44. spanforge/export/otel_bridge.py +294 -0
  45. spanforge/export/otlp.py +811 -0
  46. spanforge/export/otlp_bridge.py +233 -0
  47. spanforge/export/redis_backend.py +282 -0
  48. spanforge/export/siem_schema.py +98 -0
  49. spanforge/export/siem_splunk.py +264 -0
  50. spanforge/export/siem_syslog.py +212 -0
  51. spanforge/export/webhook.py +299 -0
  52. spanforge/exporters/__init__.py +30 -0
  53. spanforge/exporters/console.py +271 -0
  54. spanforge/exporters/jsonl.py +144 -0
  55. spanforge/exporters/sqlite.py +142 -0
  56. spanforge/gate.py +1150 -0
  57. spanforge/governance.py +181 -0
  58. spanforge/hitl.py +295 -0
  59. spanforge/http.py +187 -0
  60. spanforge/inspect.py +427 -0
  61. spanforge/integrations/__init__.py +45 -0
  62. spanforge/integrations/_pricing.py +280 -0
  63. spanforge/integrations/anthropic.py +388 -0
  64. spanforge/integrations/azure_openai.py +133 -0
  65. spanforge/integrations/bedrock.py +292 -0
  66. spanforge/integrations/crewai.py +251 -0
  67. spanforge/integrations/gemini.py +351 -0
  68. spanforge/integrations/groq.py +442 -0
  69. spanforge/integrations/langchain.py +349 -0
  70. spanforge/integrations/langgraph.py +306 -0
  71. spanforge/integrations/llamaindex.py +373 -0
  72. spanforge/integrations/ollama.py +287 -0
  73. spanforge/integrations/openai.py +368 -0
  74. spanforge/integrations/together.py +483 -0
  75. spanforge/io.py +214 -0
  76. spanforge/lint.py +322 -0
  77. spanforge/metrics.py +417 -0
  78. spanforge/metrics_export.py +343 -0
  79. spanforge/migrate.py +402 -0
  80. spanforge/model_registry.py +278 -0
  81. spanforge/models.py +389 -0
  82. spanforge/namespaces/__init__.py +254 -0
  83. spanforge/namespaces/audit.py +256 -0
  84. spanforge/namespaces/cache.py +237 -0
  85. spanforge/namespaces/chain.py +77 -0
  86. spanforge/namespaces/confidence.py +72 -0
  87. spanforge/namespaces/consent.py +92 -0
  88. spanforge/namespaces/cost.py +179 -0
  89. spanforge/namespaces/decision.py +143 -0
  90. spanforge/namespaces/diff.py +157 -0
  91. spanforge/namespaces/drift.py +80 -0
  92. spanforge/namespaces/eval_.py +251 -0
  93. spanforge/namespaces/feedback.py +241 -0
  94. spanforge/namespaces/fence.py +193 -0
  95. spanforge/namespaces/guard.py +105 -0
  96. spanforge/namespaces/hitl.py +91 -0
  97. spanforge/namespaces/latency.py +72 -0
  98. spanforge/namespaces/prompt.py +190 -0
  99. spanforge/namespaces/redact.py +173 -0
  100. spanforge/namespaces/retrieval.py +379 -0
  101. spanforge/namespaces/runtime_governance.py +494 -0
  102. spanforge/namespaces/template.py +208 -0
  103. spanforge/namespaces/tool_call.py +77 -0
  104. spanforge/namespaces/trace.py +1029 -0
  105. spanforge/normalizer.py +171 -0
  106. spanforge/plugins.py +82 -0
  107. spanforge/presidio_backend.py +349 -0
  108. spanforge/processor.py +258 -0
  109. spanforge/prompt_registry.py +418 -0
  110. spanforge/py.typed +0 -0
  111. spanforge/redact.py +914 -0
  112. spanforge/regression.py +192 -0
  113. spanforge/runtime_policy.py +159 -0
  114. spanforge/sampling.py +511 -0
  115. spanforge/schema.py +183 -0
  116. spanforge/schemas/v1.0/schema.json +170 -0
  117. spanforge/schemas/v2.0/schema.json +536 -0
  118. spanforge/sdk/__init__.py +625 -0
  119. spanforge/sdk/_base.py +584 -0
  120. spanforge/sdk/_base.pyi +71 -0
  121. spanforge/sdk/_exceptions.py +1096 -0
  122. spanforge/sdk/_types.py +2184 -0
  123. spanforge/sdk/alert.py +1514 -0
  124. spanforge/sdk/alert.pyi +56 -0
  125. spanforge/sdk/audit.py +1196 -0
  126. spanforge/sdk/audit.pyi +67 -0
  127. spanforge/sdk/cec.py +1215 -0
  128. spanforge/sdk/cec.pyi +37 -0
  129. spanforge/sdk/config.py +641 -0
  130. spanforge/sdk/config.pyi +55 -0
  131. spanforge/sdk/enterprise.py +714 -0
  132. spanforge/sdk/enterprise.pyi +79 -0
  133. spanforge/sdk/explain.py +170 -0
  134. spanforge/sdk/fallback.py +432 -0
  135. spanforge/sdk/feedback.py +351 -0
  136. spanforge/sdk/gate.py +874 -0
  137. spanforge/sdk/gate.pyi +51 -0
  138. spanforge/sdk/identity.py +2114 -0
  139. spanforge/sdk/identity.pyi +47 -0
  140. spanforge/sdk/lineage.py +175 -0
  141. spanforge/sdk/observe.py +1065 -0
  142. spanforge/sdk/observe.pyi +50 -0
  143. spanforge/sdk/operator.py +338 -0
  144. spanforge/sdk/pii.py +1473 -0
  145. spanforge/sdk/pii.pyi +119 -0
  146. spanforge/sdk/pipelines.py +458 -0
  147. spanforge/sdk/pipelines.pyi +39 -0
  148. spanforge/sdk/policy.py +930 -0
  149. spanforge/sdk/rag.py +594 -0
  150. spanforge/sdk/rbac.py +280 -0
  151. spanforge/sdk/registry.py +430 -0
  152. spanforge/sdk/registry.pyi +46 -0
  153. spanforge/sdk/scope.py +279 -0
  154. spanforge/sdk/secrets.py +293 -0
  155. spanforge/sdk/secrets.pyi +25 -0
  156. spanforge/sdk/security.py +560 -0
  157. spanforge/sdk/security.pyi +57 -0
  158. spanforge/sdk/trust.py +472 -0
  159. spanforge/sdk/trust.pyi +41 -0
  160. spanforge/secrets.py +799 -0
  161. spanforge/signing.py +1179 -0
  162. spanforge/stats.py +100 -0
  163. spanforge/stream.py +560 -0
  164. spanforge/testing.py +378 -0
  165. spanforge/testing_mocks.py +1052 -0
  166. spanforge/trace.py +199 -0
  167. spanforge/types.py +696 -0
  168. spanforge/ulid.py +300 -0
  169. spanforge/validate.py +379 -0
  170. spanforge-1.0.0.dist-info/METADATA +1509 -0
  171. spanforge-1.0.0.dist-info/RECORD +174 -0
  172. spanforge-1.0.0.dist-info/WHEEL +4 -0
  173. spanforge-1.0.0.dist-info/entry_points.txt +5 -0
  174. spanforge-1.0.0.dist-info/licenses/LICENSE +128 -0
@@ -0,0 +1,46 @@
1
+ """Type stubs for spanforge.sdk.registry (DX-001)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from enum import Enum
8
+ from typing import Any
9
+
10
+ class ServiceStatus(str, Enum):
11
+ UP = "up"
12
+ DEGRADED = "degraded"
13
+ DOWN = "down"
14
+
15
+ @dataclass
16
+ class ServiceHealth:
17
+ status: ServiceStatus = ServiceStatus.DOWN
18
+ latency_ms: float = -1.0
19
+ last_checked_at: datetime | None = None
20
+
21
+ class ServiceRegistry:
22
+ @classmethod
23
+ def get_instance(cls) -> ServiceRegistry: ...
24
+ @classmethod
25
+ def _reset_for_testing(cls) -> None: ...
26
+ def register(self, name: str, client: Any) -> None: ...
27
+ def get(self, name: str) -> Any: ...
28
+ def register_all(self, clients: dict[str, Any]) -> None: ...
29
+ def run_startup_check(
30
+ self,
31
+ endpoint: str = "",
32
+ *,
33
+ enabled_services: set[str] | None = None,
34
+ local_fallback_enabled: bool = True,
35
+ timeout_ms: int = 2000,
36
+ ) -> dict[str, ServiceHealth]: ...
37
+ def status_response(self) -> dict[str, dict[str, Any]]: ...
38
+ def get_health(self, name: str) -> ServiceHealth: ...
39
+ def update_health(self, name: str, health: ServiceHealth) -> None: ...
40
+ def start_background_checker(
41
+ self,
42
+ endpoint: str = "",
43
+ interval: float = 60.0,
44
+ timeout_ms: int = 2000,
45
+ ) -> None: ...
46
+ def stop_background_checker(self) -> None: ...
spanforge/sdk/scope.py ADDED
@@ -0,0 +1,279 @@
1
+ """spanforge.sdk.scope - SpanForge sf-scope client.
2
+
3
+ Phase 1 implementation for GA runtime scope enforcement. The client stores
4
+ local capability manifests per agent, evaluates requested actions against
5
+ those manifests, and emits signed scope decision records via sf-audit.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import threading
11
+ from dataclasses import dataclass, field
12
+ from typing import Any
13
+
14
+ from spanforge.namespaces.runtime_governance import ScopeDecisionPayload
15
+ from spanforge.sdk._base import SFClientConfig, SFServiceClient
16
+ from spanforge.sdk._exceptions import SFScopeError
17
+
18
+ __all__ = ["SFScopeClient", "ScopeManifest", "ScopeStatusInfo"]
19
+
20
+
21
+ @dataclass
22
+ class ScopeManifest:
23
+ """Registered scope manifest for one agent."""
24
+
25
+ agent_id: str
26
+ capabilities: list[str] = field(default_factory=list)
27
+ resource_actions: dict[str, list[str]] = field(default_factory=dict)
28
+ metadata: dict[str, Any] = field(default_factory=dict)
29
+
30
+ def __post_init__(self) -> None:
31
+ if not self.agent_id:
32
+ raise ValueError("ScopeManifest.agent_id must be non-empty")
33
+
34
+ def to_dict(self) -> dict[str, Any]:
35
+ return {
36
+ "agent_id": self.agent_id,
37
+ "capabilities": list(self.capabilities),
38
+ "resource_actions": {key: list(value) for key, value in self.resource_actions.items()},
39
+ "metadata": dict(self.metadata),
40
+ }
41
+
42
+
43
+ @dataclass
44
+ class ScopeStatusInfo:
45
+ """sf-scope service status."""
46
+
47
+ status: str
48
+ registered_agents: int
49
+ total_checks: int
50
+ blocked_checks: int
51
+
52
+
53
+ class SFScopeClient(SFServiceClient):
54
+ """SpanForge runtime scope enforcement service client."""
55
+
56
+ def __init__(self, config: SFClientConfig) -> None:
57
+ super().__init__(config, service_name="scope")
58
+ self._lock = threading.Lock()
59
+ self._manifests: dict[str, ScopeManifest] = {}
60
+ self._records: dict[str, ScopeDecisionPayload] = {}
61
+ self._by_trace: dict[str, list[str]] = {}
62
+ self._total_checks = 0
63
+ self._blocked_checks = 0
64
+
65
+ def register_agent(
66
+ self,
67
+ *,
68
+ agent_id: str,
69
+ capabilities: list[str] | None = None,
70
+ resource_actions: dict[str, list[str]] | None = None,
71
+ metadata: dict[str, Any] | None = None,
72
+ ) -> ScopeManifest:
73
+ """Register or replace the allowed capability manifest for an agent."""
74
+ normalized_resource_actions = {
75
+ resource: sorted({action for action in actions if action})
76
+ for resource, actions in (resource_actions or {}).items()
77
+ if resource
78
+ }
79
+ manifest = ScopeManifest(
80
+ agent_id=agent_id,
81
+ capabilities=sorted({item for item in (capabilities or []) if item}),
82
+ resource_actions=normalized_resource_actions,
83
+ metadata=metadata or {},
84
+ )
85
+ with self._lock:
86
+ self._manifests[agent_id] = manifest
87
+ return manifest
88
+
89
+ def get_manifest(self, agent_id: str) -> ScopeManifest | None:
90
+ """Return the registered manifest for *agent_id*."""
91
+ with self._lock:
92
+ return self._manifests.get(agent_id)
93
+
94
+ def evaluate(
95
+ self,
96
+ *,
97
+ trace_id: str,
98
+ agent_id: str,
99
+ resource: str,
100
+ action_name: str,
101
+ checked_at: str,
102
+ capability: str | None = None,
103
+ scope_id: str | None = None,
104
+ policy_id: str | None = None,
105
+ policy_action: str | None = None,
106
+ ) -> ScopeDecisionPayload:
107
+ """Evaluate a runtime action against the agent scope manifest."""
108
+ from spanforge.ulid import generate as _ulid
109
+
110
+ manifest = self.get_manifest(agent_id)
111
+ allowed, reason = self._evaluate_manifest(
112
+ manifest=manifest,
113
+ agent_id=agent_id,
114
+ resource=resource,
115
+ action_name=action_name,
116
+ capability=capability,
117
+ )
118
+ payload = ScopeDecisionPayload(
119
+ scope_id=scope_id or _ulid(),
120
+ trace_id=trace_id,
121
+ agent_id=agent_id,
122
+ resource=resource,
123
+ action_name=action_name,
124
+ allowed=allowed,
125
+ outcome=self._resolve_outcome(allowed=allowed, policy_action=policy_action),
126
+ reason=reason,
127
+ checked_at=checked_at,
128
+ capability=capability,
129
+ policy_id=policy_id,
130
+ policy_action=policy_action,
131
+ )
132
+
133
+ with self._lock:
134
+ self._records[payload.scope_id] = payload
135
+ self._by_trace.setdefault(trace_id, []).append(payload.scope_id)
136
+ self._total_checks += 1
137
+ if not payload.allowed:
138
+ self._blocked_checks += 1
139
+
140
+ self._emit_signed_record(payload)
141
+ return payload
142
+
143
+ def evaluate_with_policy(
144
+ self,
145
+ *,
146
+ environment: str,
147
+ trace_id: str,
148
+ agent_id: str,
149
+ resource: str,
150
+ action_name: str,
151
+ checked_at: str,
152
+ capability: str | None = None,
153
+ policy_client: Any | None = None,
154
+ control: str = "capability_enforcement",
155
+ ) -> ScopeDecisionPayload:
156
+ """Evaluate scope and attach the active runtime policy decision."""
157
+ manifest = self.get_manifest(agent_id)
158
+ allowed, _reason = self._evaluate_manifest(
159
+ manifest=manifest,
160
+ agent_id=agent_id,
161
+ resource=resource,
162
+ action_name=action_name,
163
+ capability=capability,
164
+ )
165
+ engine = policy_client or self._default_policy_client()
166
+ decision = engine.evaluate(
167
+ environment=environment,
168
+ trace_id=trace_id,
169
+ service="sf_scope",
170
+ control=control,
171
+ evaluated_at=checked_at,
172
+ observed_value=1.0 if allowed else 0.0,
173
+ metadata={"agent_id": agent_id, "resource": resource, "action_name": action_name},
174
+ )
175
+ return self.evaluate(
176
+ trace_id=trace_id,
177
+ agent_id=agent_id,
178
+ resource=resource,
179
+ action_name=action_name,
180
+ checked_at=checked_at,
181
+ capability=capability,
182
+ policy_id=decision.policy_id,
183
+ policy_action=decision.action,
184
+ )
185
+
186
+ async def evaluate_async(self, **kwargs: Any) -> ScopeDecisionPayload:
187
+ """Async wrapper around :meth:`evaluate`."""
188
+ import asyncio
189
+
190
+ loop = asyncio.get_event_loop()
191
+ return await loop.run_in_executor(None, lambda: self.evaluate(**kwargs))
192
+
193
+ def require_capability(self, agent_id: str, capability: str) -> None:
194
+ """Raise when an agent is missing a required capability."""
195
+ manifest = self.get_manifest(agent_id)
196
+ key_scopes = manifest.capabilities if manifest is not None else []
197
+ if capability not in key_scopes:
198
+ raise SFScopeError(required_scope=capability, key_scopes=key_scopes)
199
+
200
+ def get(self, scope_id: str) -> ScopeDecisionPayload | None:
201
+ """Return a previously emitted scope decision."""
202
+ with self._lock:
203
+ return self._records.get(scope_id)
204
+
205
+ def list_for_trace(self, trace_id: str) -> list[ScopeDecisionPayload]:
206
+ """Return all scope decisions emitted for a trace."""
207
+ with self._lock:
208
+ ids = list(self._by_trace.get(trace_id, []))
209
+ return [self._records[item] for item in ids if item in self._records]
210
+
211
+ def get_status(self) -> ScopeStatusInfo:
212
+ """Return service health and scope-evaluation counters."""
213
+ with self._lock:
214
+ return ScopeStatusInfo(
215
+ status="ok",
216
+ registered_agents=len(self._manifests),
217
+ total_checks=self._total_checks,
218
+ blocked_checks=self._blocked_checks,
219
+ )
220
+
221
+ def _evaluate_manifest(
222
+ self,
223
+ *,
224
+ manifest: ScopeManifest | None,
225
+ agent_id: str,
226
+ resource: str,
227
+ action_name: str,
228
+ capability: str | None,
229
+ ) -> tuple[bool, str]:
230
+ if manifest is None:
231
+ return False, f"agent '{agent_id}' has no registered scope manifest"
232
+ if capability is not None and capability not in manifest.capabilities:
233
+ return (
234
+ False,
235
+ f"agent '{agent_id}' is missing required capability '{capability}'",
236
+ )
237
+
238
+ allowed_actions = manifest.resource_actions.get(resource)
239
+ if allowed_actions is None:
240
+ return (
241
+ False,
242
+ f"agent '{agent_id}' is not permitted to access resource '{resource}'",
243
+ )
244
+ if action_name not in allowed_actions:
245
+ return (
246
+ False,
247
+ f"agent '{agent_id}' cannot perform action '{action_name}' on resource '{resource}'",
248
+ )
249
+
250
+ if capability is not None:
251
+ return (
252
+ True,
253
+ f"agent '{agent_id}' is permitted for capability '{capability}' on {resource}:{action_name}",
254
+ )
255
+ return True, f"agent '{agent_id}' is permitted on {resource}:{action_name}"
256
+
257
+ @staticmethod
258
+ def _resolve_outcome(*, allowed: bool, policy_action: str | None) -> str:
259
+ if allowed:
260
+ return "allow"
261
+ if policy_action == "block":
262
+ return "block"
263
+ if policy_action == "human_review":
264
+ return "human_review"
265
+ if policy_action == "redact":
266
+ return "redact"
267
+ return "escalate"
268
+
269
+ def _emit_signed_record(self, payload: ScopeDecisionPayload) -> None:
270
+ """Write the scope decision payload into sf-audit."""
271
+ from spanforge.sdk import sf_audit
272
+
273
+ sf_audit.append(payload.to_dict(), "spanforge.scope.v1")
274
+
275
+ @staticmethod
276
+ def _default_policy_client() -> Any:
277
+ from spanforge.sdk import sf_policy
278
+
279
+ return sf_policy
@@ -0,0 +1,293 @@
1
+ """spanforge.sdk.secrets — SpanForge sf-secrets client.
2
+
3
+ Implements the full sf-secrets API surface for Phase 2 of the SpanForge
4
+ roadmap. All operations run locally in-process (zero external dependencies)
5
+ when ``config.endpoint`` is empty or when the remote service is unreachable
6
+ and ``config.local_fallback_enabled`` is ``True``.
7
+
8
+ Local-mode feature parity
9
+ --------------------------
10
+ * :meth:`scan` — scan raw text for secrets using the built-in engine.
11
+ * :meth:`scan_batch` — scan multiple texts concurrently.
12
+ * :meth:`get_status` — return scanner configuration and health information.
13
+
14
+ Security requirements
15
+ ---------------------
16
+ * ``SecretHit.redacted_value`` is **always** ``"[REDACTED:<SECRET_TYPE>]"`` —
17
+ the matched secret value is never stored or transmitted.
18
+ * ``SecretStr`` API keys are never written to logs.
19
+ * All ``auto_blocked=True`` results should cause the caller to refuse storage
20
+ or further processing of the original text.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import logging
27
+ from typing import Any
28
+
29
+ from spanforge.sdk._base import SFClientConfig, SFServiceClient
30
+ from spanforge.sdk._exceptions import SFSecretsError, SFSecretsScanError
31
+ from spanforge.secrets import SecretsScanner, SecretsScanResult
32
+
33
+ __all__ = ["SFSecretsClient"]
34
+
35
+ _log = logging.getLogger(__name__)
36
+
37
+
38
+ class SFSecretsClient(SFServiceClient):
39
+ """SpanForge sf-secrets service client.
40
+
41
+ Provides secrets scanning over the full :class:`~spanforge.secrets.SecretsScanner`
42
+ detection engine. When ``config.endpoint`` is empty (local mode), all
43
+ logic runs in-process. When a remote endpoint is configured, the client
44
+ will attempt to call the remote service and fall back to local mode if
45
+ ``local_fallback_enabled`` is ``True``.
46
+
47
+ Args:
48
+ config: :class:`~spanforge.sdk._base.SFClientConfig` instance.
49
+
50
+ Example::
51
+
52
+ from spanforge.sdk import sf_secrets
53
+
54
+ # Scan a string for secrets
55
+ result = sf_secrets.scan("export STRIPE_KEY=sk_live_abc123...")
56
+ if result.auto_blocked:
57
+ raise RuntimeError("Secrets detected — refusing to process")
58
+ safe_text = result.redacted_text
59
+ """
60
+
61
+ def __init__(self, config: SFClientConfig) -> None:
62
+ super().__init__(config, service_name="secrets")
63
+ self._scanner = SecretsScanner()
64
+
65
+ # ------------------------------------------------------------------
66
+ # scan
67
+ # ------------------------------------------------------------------
68
+
69
+ def scan(
70
+ self,
71
+ text: str,
72
+ *,
73
+ confidence_threshold: float = 0.75,
74
+ extra_allowlist: frozenset[str] | None = None,
75
+ ) -> SecretsScanResult:
76
+ """Scan *text* for secrets.
77
+
78
+ Args:
79
+ text: The raw string to scan. May be any length.
80
+ confidence_threshold: Minimum confidence to include a hit in the
81
+ result (default: ``0.75``). Zero-tolerance
82
+ types are always included.
83
+ extra_allowlist: Additional literal strings to suppress.
84
+
85
+ Returns:
86
+ :class:`~spanforge.secrets.SecretsScanResult`.
87
+
88
+ Raises:
89
+ SFSecretsScanError: If *text* is not a ``str`` or scanning fails.
90
+ SFSecretsBlockedError: If ``auto_blocked=True`` and the caller should
91
+ not continue processing. (**Not** raised
92
+ automatically — callers must check ``result.auto_blocked``
93
+ and raise this themselves if required by their policy.)
94
+ SFServiceUnavailableError: Circuit breaker open and fallback disabled.
95
+ """
96
+ if not isinstance(text, str):
97
+ msg = f"scan() requires a str; got {type(text).__name__}"
98
+ raise SFSecretsScanError(msg)
99
+
100
+ if self._is_local_mode() or self._config.local_fallback_enabled:
101
+ return self._scan_local(
102
+ text,
103
+ confidence_threshold=confidence_threshold,
104
+ extra_allowlist=extra_allowlist,
105
+ )
106
+ return self._scan_remote(text, confidence_threshold=confidence_threshold)
107
+
108
+ def _scan_local(
109
+ self,
110
+ text: str,
111
+ *,
112
+ confidence_threshold: float,
113
+ extra_allowlist: frozenset[str] | None,
114
+ ) -> SecretsScanResult:
115
+ """Run the in-process scanner."""
116
+ try:
117
+ if extra_allowlist:
118
+ scanner = SecretsScanner(
119
+ confidence_threshold=confidence_threshold,
120
+ extra_allowlist=extra_allowlist,
121
+ )
122
+ else:
123
+ scanner = SecretsScanner(confidence_threshold=confidence_threshold)
124
+ return scanner.scan(text, confidence_threshold=confidence_threshold)
125
+ except (TypeError, ValueError) as exc:
126
+ raise SFSecretsScanError(str(exc)) from exc
127
+ except Exception as exc:
128
+ raise SFSecretsError(f"Unexpected error during secrets scan: {exc}") from exc
129
+
130
+ def _scan_remote(self, text: str, *, confidence_threshold: float) -> SecretsScanResult:
131
+ """Call the remote sf-secrets service."""
132
+ from spanforge.secrets import SecretHit
133
+
134
+ body: dict[str, Any] = {
135
+ "text": text,
136
+ "confidence_threshold": confidence_threshold,
137
+ }
138
+ try:
139
+ raw = self._request("POST", "/secrets/scan", body=body)
140
+ except Exception:
141
+ if self._config.local_fallback_enabled:
142
+ _log.warning(
143
+ "sf-secrets remote scan failed; falling back to local mode",
144
+ exc_info=True,
145
+ )
146
+ return self._scan_local(
147
+ text,
148
+ confidence_threshold=confidence_threshold,
149
+ extra_allowlist=None,
150
+ )
151
+ raise
152
+
153
+ hits = [
154
+ SecretHit(
155
+ secret_type=str(h.get("secret_type", "unknown")),
156
+ start=int(h.get("start", 0)),
157
+ end=int(h.get("end", 0)),
158
+ confidence=float(h.get("confidence", 0.75)),
159
+ redacted_value=str(
160
+ h.get("redacted_value", f"[REDACTED:{h.get('secret_type', 'UNKNOWN').upper()}]")
161
+ ),
162
+ auto_blocked=bool(h.get("auto_blocked", False)),
163
+ vault_hint=str(h.get("vault_hint", "")),
164
+ )
165
+ for h in raw.get("hits", [])
166
+ ]
167
+ return SecretsScanResult(
168
+ detected=bool(raw.get("detected", False)),
169
+ hits=hits,
170
+ auto_blocked=bool(raw.get("auto_blocked", False)),
171
+ redacted_text=str(raw.get("redacted_text", text)),
172
+ secret_types=list(raw.get("secret_types", [])),
173
+ confidence_scores=list(raw.get("confidence_scores", [])),
174
+ )
175
+
176
+ # ------------------------------------------------------------------
177
+ # scan_batch
178
+ # ------------------------------------------------------------------
179
+
180
+ def scan_batch(
181
+ self,
182
+ texts: list[str],
183
+ *,
184
+ confidence_threshold: float = 0.75,
185
+ ) -> list[SecretsScanResult]:
186
+ """Scan a list of strings concurrently.
187
+
188
+ Uses :func:`asyncio.gather` to parallelise local scans. If the
189
+ event loop is already running, falls back to sequential scanning.
190
+
191
+ Args:
192
+ texts: Strings to scan.
193
+ confidence_threshold: Passed to each :meth:`scan` call.
194
+
195
+ Returns:
196
+ List of :class:`~spanforge.secrets.SecretsScanResult`, one per
197
+ input string, in the same order.
198
+
199
+ Raises:
200
+ SFSecretsScanError: If any element of *texts* is not a ``str``.
201
+ """
202
+ for i, t in enumerate(texts):
203
+ if not isinstance(t, str):
204
+ msg = f"scan_batch() element {i} is not a str; got {type(t).__name__}"
205
+ raise SFSecretsScanError(msg)
206
+
207
+ try:
208
+ return asyncio.run(
209
+ self._scan_batch_async(texts, confidence_threshold=confidence_threshold)
210
+ )
211
+ except RuntimeError:
212
+ # Event loop already running — fall back to sequential
213
+ return [self.scan(t, confidence_threshold=confidence_threshold) for t in texts]
214
+
215
+ async def _scan_batch_async(
216
+ self,
217
+ texts: list[str],
218
+ *,
219
+ confidence_threshold: float,
220
+ ) -> list[SecretsScanResult]:
221
+ """Async helper — scan all texts concurrently in the thread pool."""
222
+ loop = asyncio.get_running_loop()
223
+ tasks = [
224
+ loop.run_in_executor(
225
+ None,
226
+ lambda t=text: self.scan(t, confidence_threshold=confidence_threshold),
227
+ )
228
+ for text in texts
229
+ ]
230
+ return list(await asyncio.gather(*tasks))
231
+
232
+ # ------------------------------------------------------------------
233
+ # scan_async (F-10)
234
+ # ------------------------------------------------------------------
235
+
236
+ async def scan_async(
237
+ self,
238
+ text: str,
239
+ *,
240
+ confidence_threshold: float = 0.75,
241
+ extra_allowlist: frozenset[str] | None = None,
242
+ ) -> SecretsScanResult:
243
+ """Async variant of :meth:`scan`.
244
+
245
+ Runs the scan in the default executor so the event loop is not
246
+ blocked for large payloads.
247
+
248
+ Args:
249
+ text: The raw string to scan.
250
+ confidence_threshold: Minimum confidence threshold (default: 0.75).
251
+ extra_allowlist: Additional literal strings to suppress.
252
+
253
+ Returns:
254
+ :class:`~spanforge.secrets.SecretsScanResult`.
255
+ """
256
+ import asyncio
257
+ import functools
258
+
259
+ loop = asyncio.get_event_loop()
260
+ return await loop.run_in_executor(
261
+ None,
262
+ functools.partial(
263
+ self.scan,
264
+ text,
265
+ confidence_threshold=confidence_threshold,
266
+ extra_allowlist=extra_allowlist,
267
+ ),
268
+ )
269
+
270
+ # ------------------------------------------------------------------
271
+ # get_status
272
+ # ------------------------------------------------------------------
273
+
274
+ def get_status(self) -> dict[str, Any]:
275
+ """Return scanner status and configuration.
276
+
277
+ Returns a dictionary with:
278
+
279
+ * ``"mode"`` — ``"local"`` or ``"remote"``.
280
+ * ``"local_fallback"`` — ``True`` when fallback is enabled.
281
+ * ``"circuit_breaker_open"``— ``True`` when the circuit is open.
282
+ * ``"pattern_count"`` — Number of patterns in the registry.
283
+ * ``"zero_tolerance_types"``— List of zero-tolerance type labels.
284
+ """
285
+ from spanforge.secrets import _PATTERN_REGISTRY, _ZERO_TOLERANCE_TYPES
286
+
287
+ return {
288
+ "mode": "local" if self._is_local_mode() else "remote",
289
+ "local_fallback": self._config.local_fallback_enabled,
290
+ "circuit_breaker_open": self._circuit_breaker.is_open(),
291
+ "pattern_count": len(_PATTERN_REGISTRY) + 1, # +1 for generic_api_key
292
+ "zero_tolerance_types": sorted(_ZERO_TOLERANCE_TYPES),
293
+ }
@@ -0,0 +1,25 @@
1
+ """Type stubs for spanforge.sdk.secrets (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.secrets import SecretsScanResult
9
+
10
+ class SFSecretsClient(SFServiceClient):
11
+ def __init__(self, config: SFClientConfig) -> None: ...
12
+ def scan(
13
+ self,
14
+ text: str,
15
+ *,
16
+ confidence_threshold: float = 0.75,
17
+ extra_allowlist: frozenset[str] | None = None,
18
+ ) -> SecretsScanResult: ...
19
+ def scan_batch(
20
+ self,
21
+ texts: list[str],
22
+ *,
23
+ confidence_threshold: float = 0.75,
24
+ ) -> list[SecretsScanResult]: ...
25
+ def get_status(self) -> dict[str, Any]: ...