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,356 @@
|
|
|
1
|
+
"""Phase 11 enterprise and security command groups for the SpanForge CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def add_phase11_subcommands(
|
|
11
|
+
sub: argparse._SubParsersAction[argparse.ArgumentParser],
|
|
12
|
+
) -> tuple[argparse.ArgumentParser, argparse.ArgumentParser]:
|
|
13
|
+
"""Register enterprise and security CLI subcommands."""
|
|
14
|
+
enterprise_parser = sub.add_parser(
|
|
15
|
+
"enterprise",
|
|
16
|
+
help="Enterprise hardening & multi-tenancy operations",
|
|
17
|
+
)
|
|
18
|
+
enterprise_sub = enterprise_parser.add_subparsers(
|
|
19
|
+
dest="enterprise_command",
|
|
20
|
+
metavar="<action>",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
enterprise_sub.add_parser(
|
|
24
|
+
"status",
|
|
25
|
+
help="Show enterprise hardening status",
|
|
26
|
+
).add_argument(
|
|
27
|
+
"--format",
|
|
28
|
+
choices=["text", "json"],
|
|
29
|
+
default="text",
|
|
30
|
+
help="Output format (default: text)",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
ent_reg = enterprise_sub.add_parser(
|
|
34
|
+
"register-tenant",
|
|
35
|
+
help="Register a project tenant with isolation config",
|
|
36
|
+
)
|
|
37
|
+
ent_reg.add_argument("--project-id", required=True, metavar="ID", help="Project identifier")
|
|
38
|
+
ent_reg.add_argument("--org-id", required=True, metavar="ID", help="Organisation identifier")
|
|
39
|
+
ent_reg.add_argument(
|
|
40
|
+
"--residency",
|
|
41
|
+
default="global",
|
|
42
|
+
choices=["eu", "us", "ap", "in", "global"],
|
|
43
|
+
help="Data residency region (default: global)",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
enterprise_sub.add_parser(
|
|
47
|
+
"list-tenants",
|
|
48
|
+
help="List all registered tenants",
|
|
49
|
+
).add_argument(
|
|
50
|
+
"--format",
|
|
51
|
+
choices=["text", "json"],
|
|
52
|
+
default="text",
|
|
53
|
+
help="Output format (default: text)",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
enterprise_sub.add_parser(
|
|
57
|
+
"encrypt-config",
|
|
58
|
+
help="Show current encryption configuration",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
enterprise_sub.add_parser(
|
|
62
|
+
"health",
|
|
63
|
+
help="Run health checks on all SpanForge services",
|
|
64
|
+
).add_argument(
|
|
65
|
+
"--format",
|
|
66
|
+
choices=["text", "json"],
|
|
67
|
+
default="text",
|
|
68
|
+
help="Output format (default: text)",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
security_parser = sub.add_parser(
|
|
72
|
+
"security",
|
|
73
|
+
help="Security review & supply-chain scanning",
|
|
74
|
+
)
|
|
75
|
+
security_sub = security_parser.add_subparsers(
|
|
76
|
+
dest="security_command",
|
|
77
|
+
metavar="<action>",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
security_sub.add_parser(
|
|
81
|
+
"owasp",
|
|
82
|
+
help="Run OWASP API Security Top 10 audit",
|
|
83
|
+
).add_argument(
|
|
84
|
+
"--format",
|
|
85
|
+
choices=["text", "json"],
|
|
86
|
+
default="text",
|
|
87
|
+
help="Output format (default: text)",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
security_sub.add_parser(
|
|
91
|
+
"threat-model",
|
|
92
|
+
help="Generate default STRIDE threat model",
|
|
93
|
+
).add_argument(
|
|
94
|
+
"--format",
|
|
95
|
+
choices=["text", "json"],
|
|
96
|
+
default="text",
|
|
97
|
+
help="Output format (default: text)",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
security_sub.add_parser(
|
|
101
|
+
"scan",
|
|
102
|
+
help="Run full security scan (deps + static + secrets)",
|
|
103
|
+
).add_argument(
|
|
104
|
+
"--format",
|
|
105
|
+
choices=["text", "json"],
|
|
106
|
+
default="text",
|
|
107
|
+
help="Output format (default: text)",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
sec_audit = security_sub.add_parser(
|
|
111
|
+
"audit-logs",
|
|
112
|
+
help="Check log file for leaked secrets",
|
|
113
|
+
)
|
|
114
|
+
sec_audit.add_argument(
|
|
115
|
+
"--file",
|
|
116
|
+
default=None,
|
|
117
|
+
metavar="PATH",
|
|
118
|
+
help="Path to log file to audit",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return enterprise_parser, security_parser
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def dispatch_phase11_command(
|
|
125
|
+
args: argparse.Namespace,
|
|
126
|
+
enterprise_parser: argparse.ArgumentParser,
|
|
127
|
+
security_parser: argparse.ArgumentParser,
|
|
128
|
+
) -> int | None:
|
|
129
|
+
"""Dispatch enterprise or security commands when selected."""
|
|
130
|
+
command = getattr(args, "command", None)
|
|
131
|
+
if command == "enterprise":
|
|
132
|
+
return _cmd_enterprise(args, enterprise_parser)
|
|
133
|
+
if command == "security":
|
|
134
|
+
return _cmd_security(args, security_parser)
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _cmd_enterprise(
|
|
139
|
+
args: argparse.Namespace,
|
|
140
|
+
enterprise_parser: argparse.ArgumentParser,
|
|
141
|
+
) -> int:
|
|
142
|
+
"""Handle ``spanforge enterprise`` subcommands."""
|
|
143
|
+
action = getattr(args, "enterprise_command", None)
|
|
144
|
+
|
|
145
|
+
if action == "status":
|
|
146
|
+
return _cmd_enterprise_status(args)
|
|
147
|
+
if action == "register-tenant":
|
|
148
|
+
return _cmd_enterprise_register_tenant(args)
|
|
149
|
+
if action == "list-tenants":
|
|
150
|
+
return _cmd_enterprise_list_tenants(args)
|
|
151
|
+
if action == "encrypt-config":
|
|
152
|
+
return _cmd_enterprise_encrypt_config(args)
|
|
153
|
+
if action == "health":
|
|
154
|
+
return _cmd_enterprise_health(args)
|
|
155
|
+
|
|
156
|
+
enterprise_parser.print_help()
|
|
157
|
+
return 2
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _cmd_enterprise_status(args: argparse.Namespace) -> int:
|
|
161
|
+
"""``spanforge enterprise status`` - show enterprise hardening status."""
|
|
162
|
+
from spanforge.sdk import sf_enterprise
|
|
163
|
+
|
|
164
|
+
status = sf_enterprise.get_status()
|
|
165
|
+
fmt = getattr(args, "format", "text")
|
|
166
|
+
|
|
167
|
+
if fmt == "json":
|
|
168
|
+
import dataclasses
|
|
169
|
+
|
|
170
|
+
print(json.dumps(dataclasses.asdict(status), indent=2, default=str))
|
|
171
|
+
else:
|
|
172
|
+
print("Enterprise Hardening Status")
|
|
173
|
+
print(f" Multi-Tenancy: {'enabled' if status.multi_tenancy_enabled else 'disabled'}")
|
|
174
|
+
print(f" Tenants: {status.tenant_count}")
|
|
175
|
+
print(f" Encryption: {'at-rest' if status.encryption_at_rest else 'off'}")
|
|
176
|
+
print(f" FIPS Mode: {'on' if status.fips_mode else 'off'}")
|
|
177
|
+
print(f" Offline Mode: {'on' if status.offline_mode else 'off'}")
|
|
178
|
+
print(f" Residency: {status.data_residency}")
|
|
179
|
+
return 0
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _cmd_enterprise_register_tenant(args: argparse.Namespace) -> int:
|
|
183
|
+
"""``spanforge enterprise register-tenant`` - register a new tenant."""
|
|
184
|
+
from spanforge.sdk import sf_enterprise
|
|
185
|
+
|
|
186
|
+
tenant = sf_enterprise.register_tenant(
|
|
187
|
+
project_id=args.project_id,
|
|
188
|
+
org_id=args.org_id,
|
|
189
|
+
data_residency=getattr(args, "residency", "global"),
|
|
190
|
+
)
|
|
191
|
+
print(f"[✓] Tenant registered: project={tenant.project_id} org={tenant.org_id}")
|
|
192
|
+
print(f" Residency: {tenant.data_residency}")
|
|
193
|
+
return 0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _cmd_enterprise_list_tenants(args: argparse.Namespace) -> int:
|
|
197
|
+
"""``spanforge enterprise list-tenants`` - list registered tenants."""
|
|
198
|
+
from spanforge.sdk import sf_enterprise
|
|
199
|
+
|
|
200
|
+
tenants = sf_enterprise.list_tenants()
|
|
201
|
+
fmt = getattr(args, "format", "text")
|
|
202
|
+
|
|
203
|
+
if fmt == "json":
|
|
204
|
+
import dataclasses
|
|
205
|
+
|
|
206
|
+
print(json.dumps([dataclasses.asdict(t) for t in tenants], indent=2, default=str))
|
|
207
|
+
elif not tenants:
|
|
208
|
+
print("No tenants registered.")
|
|
209
|
+
else:
|
|
210
|
+
for tenant in tenants:
|
|
211
|
+
print(
|
|
212
|
+
f" {tenant.project_id} (org={tenant.org_id}, residency={tenant.data_residency})"
|
|
213
|
+
)
|
|
214
|
+
return 0
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _cmd_enterprise_encrypt_config(_args: argparse.Namespace) -> int:
|
|
218
|
+
"""``spanforge enterprise encrypt-config`` - show encryption settings."""
|
|
219
|
+
from spanforge.sdk import sf_enterprise
|
|
220
|
+
|
|
221
|
+
enc = sf_enterprise.get_encryption_config()
|
|
222
|
+
import dataclasses
|
|
223
|
+
|
|
224
|
+
print(json.dumps(dataclasses.asdict(enc), indent=2, default=str))
|
|
225
|
+
return 0
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _cmd_enterprise_health(args: argparse.Namespace) -> int:
|
|
229
|
+
"""``spanforge enterprise health`` - run health checks on all services."""
|
|
230
|
+
from spanforge.sdk import sf_enterprise
|
|
231
|
+
|
|
232
|
+
results = sf_enterprise.check_all_services_health()
|
|
233
|
+
fmt = getattr(args, "format", "text")
|
|
234
|
+
|
|
235
|
+
if fmt == "json":
|
|
236
|
+
import dataclasses
|
|
237
|
+
|
|
238
|
+
print(json.dumps([dataclasses.asdict(result) for result in results], indent=2, default=str))
|
|
239
|
+
else:
|
|
240
|
+
all_ok = all(result.ok for result in results)
|
|
241
|
+
for result in results:
|
|
242
|
+
marker = "✓" if result.ok else "✗"
|
|
243
|
+
print(
|
|
244
|
+
f" [{marker}] {result.service}{result.endpoint} - "
|
|
245
|
+
f"{result.status} ({result.latency_ms:.1f}ms)"
|
|
246
|
+
)
|
|
247
|
+
print()
|
|
248
|
+
print(f"Overall: {'HEALTHY' if all_ok else 'DEGRADED'}")
|
|
249
|
+
return 0
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _cmd_security(
|
|
253
|
+
args: argparse.Namespace,
|
|
254
|
+
security_parser: argparse.ArgumentParser,
|
|
255
|
+
) -> int:
|
|
256
|
+
"""Handle ``spanforge security`` subcommands."""
|
|
257
|
+
action = getattr(args, "security_command", None)
|
|
258
|
+
|
|
259
|
+
if action == "owasp":
|
|
260
|
+
return _cmd_security_owasp(args)
|
|
261
|
+
if action == "threat-model":
|
|
262
|
+
return _cmd_security_threat_model(args)
|
|
263
|
+
if action == "scan":
|
|
264
|
+
return _cmd_security_scan(args)
|
|
265
|
+
if action == "audit-logs":
|
|
266
|
+
return _cmd_security_audit_logs(args)
|
|
267
|
+
|
|
268
|
+
security_parser.print_help()
|
|
269
|
+
return 2
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _cmd_security_owasp(args: argparse.Namespace) -> int:
|
|
273
|
+
"""``spanforge security owasp`` - run OWASP API Security audit."""
|
|
274
|
+
from spanforge.sdk import sf_security
|
|
275
|
+
|
|
276
|
+
result = sf_security.run_owasp_audit()
|
|
277
|
+
fmt = getattr(args, "format", "text")
|
|
278
|
+
|
|
279
|
+
if fmt == "json":
|
|
280
|
+
import dataclasses
|
|
281
|
+
|
|
282
|
+
print(json.dumps(dataclasses.asdict(result), indent=2, default=str))
|
|
283
|
+
else:
|
|
284
|
+
verdict = "PASS" if result.pass_ else "FAIL"
|
|
285
|
+
print(f"OWASP API Security Top 10 Audit: {verdict}")
|
|
286
|
+
for cat_id, info in result.categories.items():
|
|
287
|
+
marker = "✓" if info["status"] == "pass" else "✗"
|
|
288
|
+
print(f" [{marker}] {cat_id}: {info['name']}")
|
|
289
|
+
if info.get("detail"):
|
|
290
|
+
print(f" {info['detail']}")
|
|
291
|
+
return 0 if result.pass_ else 1
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _cmd_security_threat_model(args: argparse.Namespace) -> int:
|
|
295
|
+
"""``spanforge security threat-model`` - generate default STRIDE threat model."""
|
|
296
|
+
from spanforge.sdk import sf_security
|
|
297
|
+
|
|
298
|
+
entries = sf_security.generate_default_threat_model()
|
|
299
|
+
fmt = getattr(args, "format", "text")
|
|
300
|
+
|
|
301
|
+
if fmt == "json":
|
|
302
|
+
import dataclasses
|
|
303
|
+
|
|
304
|
+
print(json.dumps([dataclasses.asdict(entry) for entry in entries], indent=2, default=str))
|
|
305
|
+
else:
|
|
306
|
+
print(f"STRIDE Threat Model ({len(entries)} entries):")
|
|
307
|
+
for entry in entries:
|
|
308
|
+
print(f" [{entry.risk_level.upper()}] {entry.service} / {entry.category}")
|
|
309
|
+
print(f" Threat: {entry.threat}")
|
|
310
|
+
print(f" Mitigation: {entry.mitigation}")
|
|
311
|
+
return 0
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _cmd_security_scan(args: argparse.Namespace) -> int:
|
|
315
|
+
"""``spanforge security scan`` - run full security scan."""
|
|
316
|
+
from spanforge.sdk import sf_security
|
|
317
|
+
|
|
318
|
+
result = sf_security.run_full_scan()
|
|
319
|
+
fmt = getattr(args, "format", "text")
|
|
320
|
+
|
|
321
|
+
if fmt == "json":
|
|
322
|
+
import dataclasses
|
|
323
|
+
|
|
324
|
+
print(json.dumps(dataclasses.asdict(result), indent=2, default=str))
|
|
325
|
+
else:
|
|
326
|
+
verdict = "PASS" if result.pass_ else "FAIL"
|
|
327
|
+
print(f"Security Scan: {verdict}")
|
|
328
|
+
print(f" Vulnerabilities: {len(result.vulnerabilities)}")
|
|
329
|
+
print(f" Static findings: {len(result.static_findings)}")
|
|
330
|
+
print(f" Secrets in logs: {result.secrets_in_logs}")
|
|
331
|
+
return 0 if result.pass_ else 1
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _cmd_security_audit_logs(args: argparse.Namespace) -> int:
|
|
335
|
+
"""``spanforge security audit-logs`` - check logs for leaked secrets."""
|
|
336
|
+
from spanforge.sdk import sf_security
|
|
337
|
+
|
|
338
|
+
log_file = getattr(args, "file", None)
|
|
339
|
+
if not log_file:
|
|
340
|
+
print("[✓] No log file specified - clean.")
|
|
341
|
+
return 0
|
|
342
|
+
|
|
343
|
+
try:
|
|
344
|
+
with open(log_file, encoding="utf-8") as fh:
|
|
345
|
+
lines = fh.readlines()
|
|
346
|
+
except OSError as exc:
|
|
347
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
348
|
+
return 2
|
|
349
|
+
|
|
350
|
+
count = sf_security.audit_logs_for_secrets_safe(lines)
|
|
351
|
+
if count:
|
|
352
|
+
print(f"[✗] Found {count} secret(s) in log output!")
|
|
353
|
+
return 1
|
|
354
|
+
|
|
355
|
+
print("[✓] No secrets detected in logs.")
|
|
356
|
+
return 0
|
spanforge/_hooks.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""spanforge._hooks — Global span lifecycle hook registry.
|
|
2
|
+
|
|
3
|
+
Provides a :class:`HookRegistry` for registering callbacks that fire when
|
|
4
|
+
spans of specific operation types start or end. A module-level singleton
|
|
5
|
+
``hooks`` is exported from ``spanforge.__init__`` for convenience.
|
|
6
|
+
|
|
7
|
+
Usage — synchronous hooks::
|
|
8
|
+
|
|
9
|
+
import spanforge
|
|
10
|
+
|
|
11
|
+
@spanforge.hooks.on_llm_call
|
|
12
|
+
def log_llm(span) -> None:
|
|
13
|
+
print(f"LLM call started: {span.name!r} model={span.model!r}")
|
|
14
|
+
|
|
15
|
+
@spanforge.hooks.on_agent_end
|
|
16
|
+
def audit_agent(span) -> None:
|
|
17
|
+
if span.status == "error":
|
|
18
|
+
alert(f"Agent error: {span.error}")
|
|
19
|
+
|
|
20
|
+
Usage — async hooks (for async-first applications)::
|
|
21
|
+
|
|
22
|
+
@spanforge.hooks.on_llm_call_async
|
|
23
|
+
async def async_log_llm(span) -> None:
|
|
24
|
+
await db.log_span(span.span_id, span.model)
|
|
25
|
+
|
|
26
|
+
Hook callbacks receive the :class:`~spanforge._span.Span` object. Start
|
|
27
|
+
hooks fire in ``SpanContextManager.__enter__`` (before the body executes);
|
|
28
|
+
end hooks fire in ``SpanContextManager.__exit__`` (after the body, before
|
|
29
|
+
export).
|
|
30
|
+
|
|
31
|
+
**Thread safety**: ``HookRegistry`` uses a ``threading.RLock`` so hooks can
|
|
32
|
+
be registered from any thread. Synchronous hook *callbacks* are called on
|
|
33
|
+
whatever thread the span context manager runs on. Async hook callbacks are
|
|
34
|
+
scheduled via :func:`asyncio.ensure_future` if a loop is running, otherwise
|
|
35
|
+
they are silently skipped.
|
|
36
|
+
|
|
37
|
+
**Error isolation**: if a hook raises an exception the error is suppressed
|
|
38
|
+
(emitted via ``warnings.warn``) so that hook failures never abort user code.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import asyncio
|
|
44
|
+
import contextlib
|
|
45
|
+
import inspect
|
|
46
|
+
import threading
|
|
47
|
+
import warnings
|
|
48
|
+
from collections.abc import Coroutine
|
|
49
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
50
|
+
|
|
51
|
+
if TYPE_CHECKING:
|
|
52
|
+
from spanforge._span import Span
|
|
53
|
+
|
|
54
|
+
__all__ = ["HookFn", "HookRegistry", "hooks"]
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Type aliases
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
HookFn = Callable[["Span"], None]
|
|
61
|
+
AsyncHookFn = Callable[["Span"], Coroutine[Any, Any, None]]
|
|
62
|
+
|
|
63
|
+
# Hook kind constants — match the operation strings used in SpanPayload.
|
|
64
|
+
_HOOK_AGENT_START = "agent_start"
|
|
65
|
+
_HOOK_AGENT_END = "agent_end"
|
|
66
|
+
_HOOK_LLM_CALL = "llm_call"
|
|
67
|
+
_HOOK_TOOL_CALL = "tool_call"
|
|
68
|
+
|
|
69
|
+
# Map span operation values → hook kind (for "start" hooks the same mapping is
|
|
70
|
+
# used; the distinction between start and end is made by the context manager).
|
|
71
|
+
_LLM_OPERATIONS = frozenset({"chat", "completion", "embedding", "chat_completion", "generate"})
|
|
72
|
+
_TOOL_OPERATIONS = frozenset({"tool_call", "execute_tool"})
|
|
73
|
+
_AGENT_OPERATIONS = frozenset({"invoke_agent", "agent"})
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _classify_span(span: Span) -> str | None:
|
|
77
|
+
"""Return the hook kind for *span*, or ``None`` if no hook applies."""
|
|
78
|
+
op = str(getattr(span, "operation", "") or "")
|
|
79
|
+
if op in _LLM_OPERATIONS or op == "chat":
|
|
80
|
+
return _HOOK_LLM_CALL
|
|
81
|
+
if op in _TOOL_OPERATIONS:
|
|
82
|
+
return _HOOK_TOOL_CALL
|
|
83
|
+
if op in _AGENT_OPERATIONS:
|
|
84
|
+
return _HOOK_AGENT_START # caller differentiates start/end
|
|
85
|
+
# Fallback: if the span name contains a hint use that.
|
|
86
|
+
name = str(getattr(span, "name", "") or "")
|
|
87
|
+
if "llm" in name.lower() or "model" in name.lower():
|
|
88
|
+
return _HOOK_LLM_CALL
|
|
89
|
+
if "tool" in name.lower():
|
|
90
|
+
return _HOOK_TOOL_CALL
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# HookRegistry
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class HookRegistry:
|
|
100
|
+
"""Registry of span lifecycle hooks.
|
|
101
|
+
|
|
102
|
+
Each ``on_*`` method can be used as a **decorator** or called directly
|
|
103
|
+
to register a callback:
|
|
104
|
+
|
|
105
|
+
.. code-block:: python
|
|
106
|
+
|
|
107
|
+
@hooks.on_llm_call
|
|
108
|
+
def my_hook(span): ...
|
|
109
|
+
|
|
110
|
+
# equivalent:
|
|
111
|
+
hooks.on_llm_call(my_hook)
|
|
112
|
+
|
|
113
|
+
Methods:
|
|
114
|
+
on_agent_start: Register a callback fired when any agent-operation span **starts**.
|
|
115
|
+
on_agent_end: Register a callback fired when any agent-operation span **ends**.
|
|
116
|
+
on_llm_call: Register a callback fired at both start and end of LLM spans.
|
|
117
|
+
on_tool_call: Register a callback fired at both start and end of tool spans.
|
|
118
|
+
clear: Remove all registered hooks.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(self) -> None:
|
|
122
|
+
self._lock = threading.RLock()
|
|
123
|
+
self._hooks: dict[str, list[HookFn]] = {
|
|
124
|
+
_HOOK_AGENT_START: [],
|
|
125
|
+
_HOOK_AGENT_END: [],
|
|
126
|
+
_HOOK_LLM_CALL: [],
|
|
127
|
+
_HOOK_TOOL_CALL: [],
|
|
128
|
+
}
|
|
129
|
+
self._async_hooks: dict[str, list[AsyncHookFn]] = {
|
|
130
|
+
_HOOK_AGENT_START: [],
|
|
131
|
+
_HOOK_AGENT_END: [],
|
|
132
|
+
_HOOK_LLM_CALL: [],
|
|
133
|
+
_HOOK_TOOL_CALL: [],
|
|
134
|
+
}
|
|
135
|
+
# Universal span-end hooks: fire for EVERY span regardless of operation.
|
|
136
|
+
self._all_end_hooks: list[HookFn] = []
|
|
137
|
+
|
|
138
|
+
# ------------------------------------------------------------------
|
|
139
|
+
# Registration decorators / methods
|
|
140
|
+
# ------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
def on_agent_start(self, fn: HookFn) -> HookFn:
|
|
143
|
+
"""Register *fn* to fire when an agent-operation span **starts**.
|
|
144
|
+
|
|
145
|
+
Can be used as a decorator::
|
|
146
|
+
|
|
147
|
+
@hooks.on_agent_start
|
|
148
|
+
def cb(span): ...
|
|
149
|
+
"""
|
|
150
|
+
with self._lock:
|
|
151
|
+
self._hooks[_HOOK_AGENT_START].append(fn)
|
|
152
|
+
return fn
|
|
153
|
+
|
|
154
|
+
def on_agent_end(self, fn: HookFn) -> HookFn:
|
|
155
|
+
"""Register *fn* to fire when an agent-operation span **ends**."""
|
|
156
|
+
with self._lock:
|
|
157
|
+
self._hooks[_HOOK_AGENT_END].append(fn)
|
|
158
|
+
return fn
|
|
159
|
+
|
|
160
|
+
def on_llm_call(self, fn: HookFn) -> HookFn:
|
|
161
|
+
"""Register *fn* to fire on LLM-operation spans (start **and** end)."""
|
|
162
|
+
with self._lock:
|
|
163
|
+
self._hooks[_HOOK_LLM_CALL].append(fn)
|
|
164
|
+
return fn
|
|
165
|
+
|
|
166
|
+
def on_tool_call(self, fn: HookFn) -> HookFn:
|
|
167
|
+
"""Register *fn* to fire on tool-call spans (start **and** end)."""
|
|
168
|
+
with self._lock:
|
|
169
|
+
self._hooks[_HOOK_TOOL_CALL].append(fn)
|
|
170
|
+
return fn
|
|
171
|
+
|
|
172
|
+
def on_span_end(self, fn: HookFn) -> HookFn:
|
|
173
|
+
"""Register *fn* to fire when **any** span ends, regardless of operation type.
|
|
174
|
+
|
|
175
|
+
Unlike :meth:`on_agent_end`, :meth:`on_llm_call`, and :meth:`on_tool_call`
|
|
176
|
+
which only fire for operation-classified spans, this hook fires for every
|
|
177
|
+
:class:`~spanforge._span.Span` that exits via :class:`~spanforge._span.SpanContextManager`.
|
|
178
|
+
|
|
179
|
+
Primary use case: collecting all spans in test code via the
|
|
180
|
+
:func:`~spanforge.testing.captured_spans` pytest fixture.
|
|
181
|
+
|
|
182
|
+
Can be used as a decorator::
|
|
183
|
+
|
|
184
|
+
@hooks.on_span_end
|
|
185
|
+
def cb(span): ...
|
|
186
|
+
"""
|
|
187
|
+
with self._lock:
|
|
188
|
+
self._all_end_hooks.append(fn)
|
|
189
|
+
return fn
|
|
190
|
+
|
|
191
|
+
# ------------------------------------------------------------------
|
|
192
|
+
# Async registration decorators / methods
|
|
193
|
+
# ------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
def on_agent_start_async(self, fn: AsyncHookFn) -> AsyncHookFn:
|
|
196
|
+
"""Register an **async** callback to fire when an agent span **starts**.
|
|
197
|
+
|
|
198
|
+
The coroutine is scheduled via :func:`asyncio.ensure_future` when a
|
|
199
|
+
running event loop is detected. If no loop is running the callback is
|
|
200
|
+
silently skipped.
|
|
201
|
+
|
|
202
|
+
Can be used as a decorator::
|
|
203
|
+
|
|
204
|
+
@hooks.on_agent_start_async
|
|
205
|
+
async def cb(span): await db.record_start(span.span_id)
|
|
206
|
+
"""
|
|
207
|
+
with self._lock:
|
|
208
|
+
self._async_hooks[_HOOK_AGENT_START].append(fn)
|
|
209
|
+
return fn
|
|
210
|
+
|
|
211
|
+
def on_agent_end_async(self, fn: AsyncHookFn) -> AsyncHookFn:
|
|
212
|
+
"""Register an **async** callback to fire when an agent span **ends**."""
|
|
213
|
+
with self._lock:
|
|
214
|
+
self._async_hooks[_HOOK_AGENT_END].append(fn)
|
|
215
|
+
return fn
|
|
216
|
+
|
|
217
|
+
def on_llm_call_async(self, fn: AsyncHookFn) -> AsyncHookFn:
|
|
218
|
+
"""Register an **async** callback to fire on LLM spans (start **and** end)."""
|
|
219
|
+
with self._lock:
|
|
220
|
+
self._async_hooks[_HOOK_LLM_CALL].append(fn)
|
|
221
|
+
return fn
|
|
222
|
+
|
|
223
|
+
def on_tool_call_async(self, fn: AsyncHookFn) -> AsyncHookFn:
|
|
224
|
+
"""Register an **async** callback to fire on tool-call spans (start **and** end)."""
|
|
225
|
+
with self._lock:
|
|
226
|
+
self._async_hooks[_HOOK_TOOL_CALL].append(fn)
|
|
227
|
+
return fn
|
|
228
|
+
|
|
229
|
+
def clear(self) -> None:
|
|
230
|
+
"""Unregister all synchronous, async, and universal hooks."""
|
|
231
|
+
with self._lock:
|
|
232
|
+
for key in self._hooks:
|
|
233
|
+
self._hooks[key].clear()
|
|
234
|
+
for key in self._async_hooks:
|
|
235
|
+
self._async_hooks[key].clear()
|
|
236
|
+
self._all_end_hooks.clear()
|
|
237
|
+
|
|
238
|
+
# ------------------------------------------------------------------
|
|
239
|
+
# Internal fire helpers (called by SpanContextManager)
|
|
240
|
+
# ------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
def _fire_start(self, span: Span) -> None:
|
|
243
|
+
"""Fire the appropriate start hooks for *span*."""
|
|
244
|
+
kind = _classify_span(span)
|
|
245
|
+
if kind is None:
|
|
246
|
+
return
|
|
247
|
+
if kind in (_HOOK_LLM_CALL, _HOOK_TOOL_CALL):
|
|
248
|
+
self._fire(kind, span)
|
|
249
|
+
elif kind == _HOOK_AGENT_START:
|
|
250
|
+
self._fire(_HOOK_AGENT_START, span)
|
|
251
|
+
|
|
252
|
+
def _fire_end(self, span: Span) -> None:
|
|
253
|
+
"""Fire the appropriate end hooks for *span*, plus universal span-end hooks."""
|
|
254
|
+
kind = _classify_span(span)
|
|
255
|
+
if kind is not None:
|
|
256
|
+
if kind in (_HOOK_LLM_CALL, _HOOK_TOOL_CALL):
|
|
257
|
+
self._fire(kind, span)
|
|
258
|
+
elif kind == _HOOK_AGENT_START:
|
|
259
|
+
# Re-use agent_end bucket for end hooks.
|
|
260
|
+
self._fire(_HOOK_AGENT_END, span)
|
|
261
|
+
# Always fire universal span-end hooks regardless of classification.
|
|
262
|
+
self._fire_all_end(span)
|
|
263
|
+
|
|
264
|
+
def _fire_all_end(self, span: Span) -> None:
|
|
265
|
+
"""Fire all universal span-end hooks registered via :meth:`on_span_end`."""
|
|
266
|
+
with self._lock:
|
|
267
|
+
callbacks = list(self._all_end_hooks)
|
|
268
|
+
for cb in callbacks:
|
|
269
|
+
try:
|
|
270
|
+
cb(span)
|
|
271
|
+
except Exception as exc:
|
|
272
|
+
with contextlib.suppress(Exception):
|
|
273
|
+
warnings.warn(
|
|
274
|
+
f"spanforge on_span_end hook error in {cb!r}: {exc}",
|
|
275
|
+
UserWarning,
|
|
276
|
+
stacklevel=2,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def _fire(self, kind: str, span: Span) -> None:
|
|
280
|
+
with self._lock:
|
|
281
|
+
callbacks = list(self._hooks.get(kind, []))
|
|
282
|
+
for cb in callbacks:
|
|
283
|
+
try:
|
|
284
|
+
cb(span)
|
|
285
|
+
except Exception as exc:
|
|
286
|
+
with contextlib.suppress(Exception):
|
|
287
|
+
warnings.warn(
|
|
288
|
+
f"spanforge hook error in {cb!r}: {exc}",
|
|
289
|
+
UserWarning,
|
|
290
|
+
stacklevel=2,
|
|
291
|
+
)
|
|
292
|
+
# Fire async hooks if a loop is running.
|
|
293
|
+
self._fire_async(kind, span)
|
|
294
|
+
|
|
295
|
+
def _fire_async(self, kind: str, span: Span) -> None:
|
|
296
|
+
"""Schedule async hook coroutines on the running event loop (if any)."""
|
|
297
|
+
with self._lock:
|
|
298
|
+
async_callbacks = list(self._async_hooks.get(kind, []))
|
|
299
|
+
if not async_callbacks:
|
|
300
|
+
return
|
|
301
|
+
try:
|
|
302
|
+
loop = asyncio.get_running_loop()
|
|
303
|
+
except RuntimeError:
|
|
304
|
+
return # no event loop running — skip async hooks silently
|
|
305
|
+
for cb in async_callbacks:
|
|
306
|
+
try:
|
|
307
|
+
coro = cb(span)
|
|
308
|
+
if inspect.isawaitable(coro):
|
|
309
|
+
_task = asyncio.ensure_future(coro, loop=loop)
|
|
310
|
+
_task.add_done_callback(lambda t: None)
|
|
311
|
+
except Exception as exc:
|
|
312
|
+
with contextlib.suppress(Exception):
|
|
313
|
+
warnings.warn(
|
|
314
|
+
f"spanforge async hook error in {cb!r}: {exc}",
|
|
315
|
+
UserWarning,
|
|
316
|
+
stacklevel=2,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
def __repr__(self) -> str:
|
|
320
|
+
with self._lock:
|
|
321
|
+
counts = {k: len(v) for k, v in self._hooks.items()}
|
|
322
|
+
async_counts = {k: len(v) for k, v in self._async_hooks.items()}
|
|
323
|
+
return f"HookRegistry(sync={counts}, async={async_counts})"
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# ---------------------------------------------------------------------------
|
|
327
|
+
# Module-level singleton
|
|
328
|
+
# ---------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
hooks: HookRegistry = HookRegistry()
|
|
331
|
+
"""Global singleton :class:`HookRegistry` — import and use directly::
|
|
332
|
+
|
|
333
|
+
from spanforge import hooks
|
|
334
|
+
|
|
335
|
+
@hooks.on_llm_call
|
|
336
|
+
def my_callback(span): ...
|
|
337
|
+
"""
|