openscript-sdk 0.1.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 (65) hide show
  1. openscript_sdk/__init__.py +217 -0
  2. openscript_sdk/contracts/__init__.py +82 -0
  3. openscript_sdk/contracts/interceptor.py +98 -0
  4. openscript_sdk/contracts/server_types.py +87 -0
  5. openscript_sdk/contracts/session_graph.py +123 -0
  6. openscript_sdk/contracts/types.py +76 -0
  7. openscript_sdk/events/__init__.py +0 -0
  8. openscript_sdk/events/approvals.py +309 -0
  9. openscript_sdk/events/models.py +26 -0
  10. openscript_sdk/events/store.py +79 -0
  11. openscript_sdk/events/writer.py +100 -0
  12. openscript_sdk/hooks/claude_code.py +479 -0
  13. openscript_sdk/hooks/claude_code_example.yaml +62 -0
  14. openscript_sdk/hooks/claude_code_strict_example.yaml +73 -0
  15. openscript_sdk/hooks/config.py +144 -0
  16. openscript_sdk/hooks/settings_example.json +28 -0
  17. openscript_sdk/hooks/settings_strict_example.json +28 -0
  18. openscript_sdk/integrations/langchain.py +32 -0
  19. openscript_sdk/integrations/langgraph.py +32 -0
  20. openscript_sdk/interceptors/base.py +15 -0
  21. openscript_sdk/interceptors/event_writer.py +131 -0
  22. openscript_sdk/interceptors/pii.py +168 -0
  23. openscript_sdk/interceptors/threat.py +674 -0
  24. openscript_sdk/logging.py +94 -0
  25. openscript_sdk/middleware/middleware.py +543 -0
  26. openscript_sdk/middleware/tool.py +247 -0
  27. openscript_sdk/observability/__init__.py +11 -0
  28. openscript_sdk/observability/metrics.py +105 -0
  29. openscript_sdk/observability/risk.py +54 -0
  30. openscript_sdk/observability/tracing.py +67 -0
  31. openscript_sdk/policies/compliance.py +261 -0
  32. openscript_sdk/policies/config.py +244 -0
  33. openscript_sdk/policies/harmful_request.py +617 -0
  34. openscript_sdk/policies/normalize.py +489 -0
  35. openscript_sdk/policies/output_schema.py +353 -0
  36. openscript_sdk/policies/policies_example.yaml +36 -0
  37. openscript_sdk/policies/redaction.py +359 -0
  38. openscript_sdk/policies/secrets.py +239 -0
  39. openscript_sdk/policies/semantic.py +348 -0
  40. openscript_sdk/policies/semantic_head.py +9 -0
  41. openscript_sdk/policies/session_taint.py +399 -0
  42. openscript_sdk/policies/taint_store.py +111 -0
  43. openscript_sdk/policies/tool_firewall.py +434 -0
  44. openscript_sdk/policies/tools_example.yaml +81 -0
  45. openscript_sdk/policies/toxicity.py +247 -0
  46. openscript_sdk/py.typed +0 -0
  47. openscript_sdk/server/__init__.py +1 -0
  48. openscript_sdk/server/app.py +153 -0
  49. openscript_sdk/server/auth.py +19 -0
  50. openscript_sdk/server/guard.py +126 -0
  51. openscript_sdk/server/guard_example.yaml +55 -0
  52. openscript_sdk/server/routers/__init__.py +0 -0
  53. openscript_sdk/server/routers/approvals.py +69 -0
  54. openscript_sdk/server/routers/guard.py +231 -0
  55. openscript_sdk/server/routers/sessions.py +80 -0
  56. openscript_sdk/server/routers/sse.py +69 -0
  57. openscript_sdk/server/routers/threat.py +119 -0
  58. openscript_sdk/server/static/dashboard/index.html +209 -0
  59. openscript_sdk/server/static/dashboard/session.html +339 -0
  60. openscript_sdk-0.1.0.dist-info/METADATA +225 -0
  61. openscript_sdk-0.1.0.dist-info/RECORD +65 -0
  62. openscript_sdk-0.1.0.dist-info/WHEEL +5 -0
  63. openscript_sdk-0.1.0.dist-info/entry_points.txt +2 -0
  64. openscript_sdk-0.1.0.dist-info/licenses/LICENSE +190 -0
  65. openscript_sdk-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,247 @@
1
+ """SecureTool — the policy pipeline around one tool, instead of around the agent.
2
+
3
+ SecureAgent sees two moments: what the caller sent and what the agent finally
4
+ returned. Everything an agent does in between — the tools it decides to call,
5
+ with the arguments it decides to pass — happens inside ``agent.invoke()`` where
6
+ no policy is looking. A prompt-injected agent does its damage there.
7
+
8
+ SecureTool is the other half. It wraps a single tool callable and runs the same
9
+ before / after pipeline on every call to it, with ``action="tool_call"``, so
10
+ the checks that are about actions (ToolFirewallPolicy, approvals, audit) sit
11
+ where the action is. It imports no agent framework: whatever calls the tool
12
+ calls this instead.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import functools
19
+ import inspect
20
+ from collections.abc import Callable, Mapping, Sequence
21
+ from typing import Any
22
+
23
+ from openscript_sdk.contracts.interceptor import Policy
24
+ from openscript_sdk.contracts.types import ActionContext
25
+ from openscript_sdk.events.approvals import ApprovalStore
26
+ from openscript_sdk.middleware.middleware import SecureAgent
27
+ from openscript_sdk.observability.metrics import MetricsRecorder
28
+ from openscript_sdk.observability.risk import RiskScorer
29
+ from openscript_sdk.observability.tracing import ActionTracer
30
+
31
+ _BY_POSITION_ONLY = (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.VAR_POSITIONAL)
32
+
33
+
34
+ class _ToolPipeline(SecureAgent):
35
+ """SecureAgent's phases with a tool in the middle instead of an agent.
36
+
37
+ A subclass rather than a second pipeline, so there is exactly one
38
+ definition of what a failure mode, an approval and a refusal mean.
39
+ """
40
+
41
+ async def run(
42
+ self, context: ActionContext, fn: Callable[..., Any], approval_id: str
43
+ ) -> ActionContext:
44
+ with self._trace(context):
45
+ context, granted = await self._run_before(context, approval_id)
46
+
47
+ # Read back from the context, not from what the caller passed: a
48
+ # policy may have rewritten an argument (a redacted credential),
49
+ # and the call that runs has to be the call that was checked.
50
+ args = context.input_data.get("args")
51
+ if not isinstance(args, dict):
52
+ raise RuntimeError(
53
+ "a policy left the tool call without a mapping of arguments; refusing to run it"
54
+ )
55
+ if inspect.iscoroutinefunction(fn):
56
+ result = await fn(**args)
57
+ else:
58
+ # Off the event loop, as SecureAgent does for a sync agent. A
59
+ # callable object with an async __call__ comes back from the
60
+ # thread as a coroutine, which is awaited here.
61
+ result = await asyncio.to_thread(functools.partial(fn, **args))
62
+ if inspect.isawaitable(result):
63
+ result = await result
64
+
65
+ context.output_data = {"output": result}
66
+ context = await self._run_after(context, approval_id, granted)
67
+ self._finalize(context)
68
+ return context
69
+
70
+
71
+ class SecureTool:
72
+ """One tool, behind the same policies an agent gets.
73
+
74
+ send = SecureTool(send_email, policies=[ToolFirewallPolicy(rules_path="tools.yaml")])
75
+ await send(to="ops@example.com", body="done") # called like the function
76
+
77
+ Each call becomes an ActionContext with ``action="tool_call"`` and
78
+ ``input_data={"name": ..., "args": {...}}``, where ``args`` holds every
79
+ argument by name *including the tool's own defaults* — a bound on ``amount``
80
+ is checked against the amount the tool will actually use, not skipped
81
+ because the caller left it out. (Which ones were defaults travels beside
82
+ the call, so a rule that refuses unlisted arguments refuses what a caller
83
+ passed and not what the tool's author wrote into its signature.)
84
+
85
+ A policy that denies in the before phase raises ActionBlockedError and the
86
+ tool does not run. One that requires approval raises it with an
87
+ ``approval_id``; once a human has approved, retry with
88
+ ``call(args, approval_id=...)``. The approval is single-use and bound to the
89
+ tool's name and exact arguments, so it cannot be spent on a different
90
+ recipient or a larger amount.
91
+
92
+ A deny in the after phase is a different thing and worth being clear about:
93
+ the tool has already run by then. What is withheld is its *result*, which
94
+ is how a tool's output is kept away from the model, not how its side
95
+ effects are undone.
96
+
97
+ A plain (non-async) tool runs in a worker thread so it cannot stall the
98
+ event loop.
99
+ """
100
+
101
+ def __init__(
102
+ self,
103
+ fn: Callable[..., Any],
104
+ policies: Sequence[Policy],
105
+ *,
106
+ name: str | None = None,
107
+ role: str | None = None,
108
+ agent_id: str = "default",
109
+ session_id: str = "default",
110
+ approval_store: ApprovalStore | None = None,
111
+ writer: Any | None = None,
112
+ risk_scorer: RiskScorer | None = None,
113
+ metrics: MetricsRecorder | None = None,
114
+ tracer: ActionTracer | None = None,
115
+ ) -> None:
116
+ if not callable(fn):
117
+ raise TypeError(f"SecureTool wraps a callable, got {type(fn).__name__}")
118
+ if not policies:
119
+ # SecureAgent falls back to a no-op here. A wrapper called
120
+ # SecureTool that lets every call through is not a default.
121
+ raise ValueError("SecureTool needs at least one policy")
122
+ resolved = name or getattr(fn, "__name__", "")
123
+ if not resolved:
124
+ raise ValueError("this tool has no __name__; pass name=")
125
+
126
+ try:
127
+ signature: inspect.Signature | None = inspect.signature(fn)
128
+ except (TypeError, ValueError):
129
+ # Builtins and some extension callables have none to read.
130
+ signature = None
131
+ if signature is not None:
132
+ for param in signature.parameters.values():
133
+ if param.kind in _BY_POSITION_ONLY:
134
+ raise TypeError(
135
+ f"{resolved}() takes '{param.name}' by position only. Rules are "
136
+ "written against argument names, so every parameter of a guarded "
137
+ "tool has to be passable by name."
138
+ )
139
+
140
+ # Name, docstring and __wrapped__, so a framework that builds a tool
141
+ # schema by inspecting the callable sees the tool and not this class.
142
+ functools.update_wrapper(self, fn, updated=())
143
+ self.name = resolved
144
+ self._fn = fn
145
+ self._signature = signature
146
+ self._role = role
147
+ self._agent_id = agent_id
148
+ self._session_id = session_id
149
+ self._pipeline = _ToolPipeline(
150
+ agent=None,
151
+ policies=policies,
152
+ approval_store=approval_store,
153
+ writer=writer,
154
+ risk_scorer=risk_scorer,
155
+ metrics=metrics,
156
+ tracer=tracer,
157
+ )
158
+
159
+ async def __call__(self, *args: Any, **kwargs: Any) -> Any:
160
+ """Call the tool the way the tool is called."""
161
+ result, _ = await self._run(*self._bind(args, kwargs), "", None, None, None)
162
+ return result
163
+
164
+ async def call(
165
+ self,
166
+ args: Mapping[str, Any] | None = None,
167
+ *,
168
+ approval_id: str = "",
169
+ role: str | None = None,
170
+ agent_id: str | None = None,
171
+ session_id: str | None = None,
172
+ ) -> Any:
173
+ """Call the tool with its arguments as a mapping.
174
+
175
+ The explicit form, for what a plain call has no room for: redeeming an
176
+ approval, or saying which role, agent and session the call belongs to
177
+ when one wrapped tool serves several.
178
+ """
179
+ result, _ = await self.call_with_context(
180
+ args, approval_id=approval_id, role=role, agent_id=agent_id, session_id=session_id
181
+ )
182
+ return result
183
+
184
+ async def call_with_context(
185
+ self,
186
+ args: Mapping[str, Any] | None = None,
187
+ *,
188
+ approval_id: str = "",
189
+ role: str | None = None,
190
+ agent_id: str | None = None,
191
+ session_id: str | None = None,
192
+ ) -> tuple[Any, ActionContext]:
193
+ """Like call(), but also returns the final ActionContext so callers can
194
+ read risk_score, risk_categories and per-policy metadata."""
195
+ return await self._run(*self._bind((), args or {}), approval_id, role, agent_id, session_id)
196
+
197
+ def _bind(
198
+ self, args: tuple[Any, ...], kwargs: Mapping[str, Any]
199
+ ) -> tuple[dict[str, Any], list[str]]:
200
+ """Every argument by name with the tool's defaults filled in, and the
201
+ names of the ones that came from a default rather than from the caller."""
202
+ if self._signature is None:
203
+ if args:
204
+ raise TypeError(
205
+ f"{self.name}() has no readable signature, so its arguments "
206
+ "have to be passed by name"
207
+ )
208
+ return dict(kwargs), []
209
+ # Raises the TypeError the tool itself would have, before any policy runs.
210
+ bound = self._signature.bind(*args, **kwargs)
211
+ passed = set(bound.arguments)
212
+ bound.apply_defaults()
213
+ named = dict(bound.arguments)
214
+ defaulted = [name for name in named if name not in passed]
215
+ for param in self._signature.parameters.values():
216
+ if param.kind is inspect.Parameter.VAR_KEYWORD:
217
+ # def tool(**options): the firewall should see the options, not
218
+ # one argument called "options".
219
+ named.update(named.pop(param.name))
220
+ defaulted = [name for name in defaulted if name != param.name]
221
+ return named, defaulted
222
+
223
+ async def _run(
224
+ self,
225
+ named: dict[str, Any],
226
+ defaulted: list[str],
227
+ approval_id: str,
228
+ role: str | None,
229
+ agent_id: str | None,
230
+ session_id: str | None,
231
+ ) -> tuple[Any, ActionContext]:
232
+ role = role if role is not None else self._role
233
+ # Beside the call, not in it: which arguments were defaulted is a fact
234
+ # about how the call was made, and must not change what an approval of
235
+ # the call is bound to.
236
+ metadata: dict[str, Any] = {"defaulted_args": defaulted}
237
+ if role is not None:
238
+ metadata["role"] = role
239
+ context = ActionContext(
240
+ action="tool_call",
241
+ agent_id=agent_id or self._agent_id,
242
+ session_id=session_id or self._session_id,
243
+ input_data={"name": self.name, "args": named},
244
+ metadata=metadata,
245
+ )
246
+ context = await self._pipeline.run(context, self._fn, approval_id)
247
+ return context.output_data.get("output"), context
@@ -0,0 +1,11 @@
1
+ from openscript_sdk.observability.metrics import MetricsRecorder
2
+ from openscript_sdk.observability.risk import RiskScorer, aggregate_risk
3
+ from openscript_sdk.observability.tracing import ActionTracer, tracer_from_env
4
+
5
+ __all__ = [
6
+ "ActionTracer",
7
+ "MetricsRecorder",
8
+ "RiskScorer",
9
+ "aggregate_risk",
10
+ "tracer_from_env",
11
+ ]
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ if TYPE_CHECKING:
6
+ from openscript_sdk.contracts.types import ActionContext
7
+
8
+ _RISK_BUCKETS = (0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0)
9
+
10
+
11
+ class MetricsRecorder:
12
+ """Prometheus metrics for SecureAgent actions (pip install -e '.[metrics]').
13
+
14
+ Pass one instance per registry: SecureAgent(metrics=MetricsRecorder()).
15
+ Metric families:
16
+
17
+ - openscript_actions_total{decision}: every completed action, by final
18
+ decision (allow/deny/require_approval)
19
+ - openscript_action_risk_score: histogram of the aggregated risk_score
20
+ - openscript_policy_violations_total{policy}: one increment per policy
21
+ category that found something (metadata risk > 0) in an action
22
+ - openscript_injections_blocked_total: threat scorer flagged the input
23
+ - openscript_tool_calls_denied_total: tool firewall rejected a call
24
+ - openscript_pii_redacted_total: an action's output had PII redacted
25
+ - openscript_policy_latency_seconds{policy,phase}: per-hook latency
26
+ (recorded by SecureAgent around each before/after call)
27
+
28
+ Instantiating twice against the same registry raises (Prometheus forbids
29
+ duplicate timeseries) — share the instance, or pass a fresh
30
+ CollectorRegistry in tests.
31
+ """
32
+
33
+ def __init__(self, registry: Any | None = None) -> None:
34
+ try:
35
+ from prometheus_client import REGISTRY, Counter, Histogram
36
+ except ImportError as exc: # pragma: no cover - exercised without the extra
37
+ raise ImportError(
38
+ "MetricsRecorder requires prometheus-client: pip install -e '.[metrics]'"
39
+ ) from exc
40
+
41
+ registry = registry if registry is not None else REGISTRY
42
+ self._actions = Counter(
43
+ "openscript_actions",
44
+ "Actions processed by SecureAgent, by final decision",
45
+ labelnames=("decision",),
46
+ registry=registry,
47
+ )
48
+ self._risk_score = Histogram(
49
+ "openscript_action_risk_score",
50
+ "Aggregated cross-policy risk score per action",
51
+ buckets=_RISK_BUCKETS,
52
+ registry=registry,
53
+ )
54
+ self._violations = Counter(
55
+ "openscript_policy_violations",
56
+ "Actions where a policy category found something (risk > 0)",
57
+ labelnames=("policy",),
58
+ registry=registry,
59
+ )
60
+ self._injections_blocked = Counter(
61
+ "openscript_injections_blocked",
62
+ "Actions flagged by the prompt-injection scorer",
63
+ registry=registry,
64
+ )
65
+ self._tool_calls_denied = Counter(
66
+ "openscript_tool_calls_denied",
67
+ "Tool calls rejected by the tool firewall",
68
+ registry=registry,
69
+ )
70
+ self._pii_redacted = Counter(
71
+ "openscript_pii_redacted",
72
+ "Actions whose output had PII redacted",
73
+ registry=registry,
74
+ )
75
+ self._policy_latency = Histogram(
76
+ "openscript_policy_latency_seconds",
77
+ "Latency of individual policy hooks",
78
+ labelnames=("policy", "phase"),
79
+ registry=registry,
80
+ )
81
+
82
+ def record_action(self, context: ActionContext) -> None:
83
+ """Record final counters for one action; called by SecureAgent at
84
+ every exit point, after risk aggregation."""
85
+ self._actions.labels(decision=context.decision.value).inc()
86
+ self._risk_score.observe(context.risk_score)
87
+
88
+ for category, risk in context.risk_categories.items():
89
+ if risk > 0:
90
+ self._violations.labels(policy=category).inc()
91
+
92
+ threat = context.metadata.get("threat")
93
+ if isinstance(threat, dict) and threat.get("flagged"):
94
+ self._injections_blocked.inc()
95
+
96
+ firewall = context.metadata.get("tool_firewall")
97
+ if isinstance(firewall, dict) and firewall.get("allowed") is False:
98
+ self._tool_calls_denied.inc()
99
+
100
+ pii = context.metadata.get("pii")
101
+ if isinstance(pii, dict) and pii.get("redacted"):
102
+ self._pii_redacted.inc()
103
+
104
+ def observe_policy_latency(self, policy: str, phase: str, seconds: float) -> None:
105
+ self._policy_latency.labels(policy=policy, phase=phase).observe(seconds)
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from openscript_sdk.contracts.types import ActionContext
4
+
5
+
6
+ def aggregate_risk(
7
+ context: ActionContext,
8
+ weights: dict[str, float] | None = None,
9
+ ) -> tuple[float, dict[str, float]]:
10
+ """Aggregate per-policy risk from standardized context.metadata entries.
11
+
12
+ Collects every metadata value shaped like {"risk": float, "category": str,
13
+ ...} (the shape all policies emit); anything else — plain kwargs, strings,
14
+ dicts without both keys — is ignored. When two entries share a category
15
+ the max wins. The total is the per-category sum, weighted by *weights*
16
+ (default 1.0 per category) and capped at 1.0.
17
+
18
+ Pure function of the context; safe to call at any point in the pipeline.
19
+ Returns (risk_score, risk_categories) where risk_categories maps
20
+ category -> unweighted per-category risk.
21
+ """
22
+ categories: dict[str, float] = {}
23
+ for entry in context.metadata.values():
24
+ if not isinstance(entry, dict):
25
+ continue
26
+ risk = entry.get("risk")
27
+ category = entry.get("category")
28
+ if not isinstance(risk, (int, float)) or isinstance(risk, bool):
29
+ continue
30
+ if not isinstance(category, str):
31
+ continue
32
+ clamped = min(1.0, max(0.0, float(risk)))
33
+ categories[category] = max(categories.get(category, 0.0), clamped)
34
+
35
+ weights = weights or {}
36
+ total = sum(risk * weights.get(category, 1.0) for category, risk in categories.items())
37
+ return round(min(1.0, total), 4), categories
38
+
39
+
40
+ class RiskScorer:
41
+ """Writes the aggregated cross-policy risk back onto the ActionContext.
42
+
43
+ SecureAgent runs this after all policies (allow path) and before raising
44
+ ActionBlockedError (deny path), so every action carries a final
45
+ risk_score. *weights* scales individual categories' contribution to the
46
+ total (e.g. {"pii": 0.5} to down-weight redacted PII findings).
47
+ """
48
+
49
+ def __init__(self, weights: dict[str, float] | None = None) -> None:
50
+ self._weights = dict(weights or {})
51
+
52
+ def aggregate(self, context: ActionContext) -> ActionContext:
53
+ context.risk_score, context.risk_categories = aggregate_risk(context, self._weights)
54
+ return context
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from collections.abc import Iterator
5
+ from contextlib import contextmanager
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import structlog
9
+
10
+ if TYPE_CHECKING:
11
+ from openscript_sdk.contracts.types import ActionContext
12
+
13
+ logger = structlog.get_logger(__name__)
14
+
15
+ # Feature flag: set OPENSCRIPT_OTEL=1 (or "true") to have SecureAgent open an
16
+ # OpenTelemetry span per action without passing tracer= explicitly.
17
+ OTEL_ENV_FLAG = "OPENSCRIPT_OTEL"
18
+
19
+
20
+ class ActionTracer:
21
+ """One OpenTelemetry span per SecureAgent action (pip install -e '.[otel]').
22
+
23
+ Uses the ambient tracer provider — exporter setup (OTLP endpoint, which
24
+ Datadog and friends consume) is the host application's job via the
25
+ standard OTEL_* environment variables or explicit SDK configuration;
26
+ with no provider configured the spans are no-ops.
27
+ """
28
+
29
+ def __init__(self, instrumentation_name: str = "openscript") -> None:
30
+ try:
31
+ from opentelemetry import trace
32
+ except ImportError as exc:
33
+ raise ImportError(
34
+ "ActionTracer requires the OpenTelemetry SDK: pip install -e '.[otel]'"
35
+ ) from exc
36
+ self._tracer = trace.get_tracer(instrumentation_name)
37
+
38
+ @contextmanager
39
+ def span(self, context: ActionContext) -> Iterator[Any]:
40
+ with self._tracer.start_as_current_span(f"openscript.{context.action}") as span:
41
+ span.set_attribute("openscript.agent_id", context.agent_id)
42
+ span.set_attribute("openscript.session_id", context.session_id)
43
+ try:
44
+ yield span
45
+ finally:
46
+ # risk/decision are written by SecureAgent before any raise,
47
+ # so blocked actions carry their final values too
48
+ span.set_attribute("openscript.risk_score", context.risk_score)
49
+ span.set_attribute("openscript.decision", context.decision.value)
50
+
51
+
52
+ def tracer_from_env() -> ActionTracer | None:
53
+ """Build an ActionTracer when the OPENSCRIPT_OTEL flag is set.
54
+
55
+ Fails open: with the flag set but the otel packages missing, logs a
56
+ warning and returns None rather than breaking the pipeline.
57
+ """
58
+ if os.environ.get(OTEL_ENV_FLAG, "").lower() not in ("1", "true"):
59
+ return None
60
+ try:
61
+ return ActionTracer()
62
+ except ImportError:
63
+ logger.warning(
64
+ "otel_flag_set_but_sdk_missing",
65
+ hint="pip install -e '.[otel]' to emit spans",
66
+ )
67
+ return None