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,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
|
spanforge/sdk/secrets.py
ADDED
|
@@ -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]: ...
|