tracectrl 0.1.0__tar.gz → 0.2.0__tar.gz

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.
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: tracectrl
3
+ Version: 0.2.0
4
+ Summary: TraceCtrl SDK — agentic AI security observability
5
+ Author: CloudsineAI
6
+ License-Expression: Apache-2.0
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: opentelemetry-exporter-otlp-proto-grpc>=1.20.0
10
+ Requires-Dist: opentelemetry-sdk>=1.20.0
11
+ Requires-Dist: rich>=13.0.0
12
+ Requires-Dist: textual>=0.50.0
13
+ Requires-Dist: tracectrl-scanner>=0.1.0
14
+ Description-Content-Type: text/markdown
15
+
16
+ # TraceCtrl SDK
17
+
18
+ Security observability and runtime guardrails for agentic AI. TraceCtrl instruments your agents with OpenTelemetry, captures every LLM call and tool invocation as security-enriched spans, and streams the trace + guardrail violations to the TraceCtrl dashboard in real time.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install tracectrl
24
+ ```
25
+
26
+ For framework integrations:
27
+
28
+ ```bash
29
+ pip install tracectrl-instrumentation-strands # AWS Strands
30
+ pip install tracectrl-instrumentation-agno # Agno
31
+ ```
32
+
33
+ ## Quickstart
34
+
35
+ ```python
36
+ import tracectrl
37
+ from tracectrl.instrumentation.strands import StrandsInstrumentor
38
+
39
+ # Configure once at startup — points the SDK at your TraceCtrl engine
40
+ tracectrl.configure(
41
+ service_name="my-agent",
42
+ endpoint="http://localhost:4317",
43
+ )
44
+
45
+ # Auto-instrument Strands (or replace with the instrumentor for your framework)
46
+ StrandsInstrumentor().instrument()
47
+
48
+ # Your existing agent code — no changes needed
49
+ ```
50
+
51
+ ## Guardrails
52
+
53
+ Two guardrail providers, designed to coexist on the same agent:
54
+
55
+ **1. Built-in LLM judge** — declarative guardrails evaluated by a Bedrock model:
56
+
57
+ ```python
58
+ from tracectrl.guardrails import Guardrail, wrap_agent_with_guardrails
59
+
60
+ no_pii_leak = Guardrail(
61
+ name="no_pii_leak",
62
+ description="Block agent responses containing personally identifiable info.",
63
+ judge_prompt="Does the following response contain PII?\n\n{output}",
64
+ judge_llm=bedrock_model,
65
+ severity="high",
66
+ )
67
+
68
+ wrap_agent_with_guardrails(my_agent, [no_pii_leak])
69
+ ```
70
+
71
+ **2. TraceCtrl Guards (Protector Plus)** — explicit checks against an external LLM firewall covering prompt injection, PII, content moderation, vector similarity, keywords/regex, and system-prompt leakage:
72
+
73
+ ```python
74
+ import tracectrl
75
+
76
+ with tracectrl.guard():
77
+ tracectrl.check_input(user_message)
78
+ response = my_agent(user_message)
79
+ tracectrl.check_output(str(response))
80
+ ```
81
+
82
+ Configure the Protector Plus endpoint and per-domain API key in the TraceCtrl dashboard's Settings page. Calls are async fire-and-forget — they emit OpenTelemetry evaluation spans without blocking the LLM call. Both decisions (`pass` / `fail`) show up in the trace tree and the dashboard's Guardrails page, with failures streamed as live alerts.
83
+
84
+ ## Tagging agents
85
+
86
+ ```python
87
+ from tracectrl import tag_agent
88
+
89
+ tag_agent(my_agent) # reads agent.system_prompt + agent.name automatically
90
+ ```
91
+
92
+ Identity shows up on the dashboard's Agents page.
93
+
94
+ ## Links
95
+
96
+ - [Documentation](https://docs.tracectrl.ai)
97
+ - [Dashboard](https://tracectrl.ai)
98
+ - [GitHub](https://github.com/tracectrl/tracectrl)
@@ -0,0 +1,83 @@
1
+ # TraceCtrl SDK
2
+
3
+ Security observability and runtime guardrails for agentic AI. TraceCtrl instruments your agents with OpenTelemetry, captures every LLM call and tool invocation as security-enriched spans, and streams the trace + guardrail violations to the TraceCtrl dashboard in real time.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install tracectrl
9
+ ```
10
+
11
+ For framework integrations:
12
+
13
+ ```bash
14
+ pip install tracectrl-instrumentation-strands # AWS Strands
15
+ pip install tracectrl-instrumentation-agno # Agno
16
+ ```
17
+
18
+ ## Quickstart
19
+
20
+ ```python
21
+ import tracectrl
22
+ from tracectrl.instrumentation.strands import StrandsInstrumentor
23
+
24
+ # Configure once at startup — points the SDK at your TraceCtrl engine
25
+ tracectrl.configure(
26
+ service_name="my-agent",
27
+ endpoint="http://localhost:4317",
28
+ )
29
+
30
+ # Auto-instrument Strands (or replace with the instrumentor for your framework)
31
+ StrandsInstrumentor().instrument()
32
+
33
+ # Your existing agent code — no changes needed
34
+ ```
35
+
36
+ ## Guardrails
37
+
38
+ Two guardrail providers, designed to coexist on the same agent:
39
+
40
+ **1. Built-in LLM judge** — declarative guardrails evaluated by a Bedrock model:
41
+
42
+ ```python
43
+ from tracectrl.guardrails import Guardrail, wrap_agent_with_guardrails
44
+
45
+ no_pii_leak = Guardrail(
46
+ name="no_pii_leak",
47
+ description="Block agent responses containing personally identifiable info.",
48
+ judge_prompt="Does the following response contain PII?\n\n{output}",
49
+ judge_llm=bedrock_model,
50
+ severity="high",
51
+ )
52
+
53
+ wrap_agent_with_guardrails(my_agent, [no_pii_leak])
54
+ ```
55
+
56
+ **2. TraceCtrl Guards (Protector Plus)** — explicit checks against an external LLM firewall covering prompt injection, PII, content moderation, vector similarity, keywords/regex, and system-prompt leakage:
57
+
58
+ ```python
59
+ import tracectrl
60
+
61
+ with tracectrl.guard():
62
+ tracectrl.check_input(user_message)
63
+ response = my_agent(user_message)
64
+ tracectrl.check_output(str(response))
65
+ ```
66
+
67
+ Configure the Protector Plus endpoint and per-domain API key in the TraceCtrl dashboard's Settings page. Calls are async fire-and-forget — they emit OpenTelemetry evaluation spans without blocking the LLM call. Both decisions (`pass` / `fail`) show up in the trace tree and the dashboard's Guardrails page, with failures streamed as live alerts.
68
+
69
+ ## Tagging agents
70
+
71
+ ```python
72
+ from tracectrl import tag_agent
73
+
74
+ tag_agent(my_agent) # reads agent.system_prompt + agent.name automatically
75
+ ```
76
+
77
+ Identity shows up on the dashboard's Agents page.
78
+
79
+ ## Links
80
+
81
+ - [Documentation](https://docs.tracectrl.ai)
82
+ - [Dashboard](https://tracectrl.ai)
83
+ - [GitHub](https://github.com/tracectrl/tracectrl)
@@ -4,8 +4,9 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "tracectrl"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "TraceCtrl SDK — agentic AI security observability"
9
+ readme = "README.md"
9
10
  requires-python = ">=3.10"
10
11
  license = "Apache-2.0"
11
12
  authors = [{ name = "CloudsineAI" }]
@@ -5,8 +5,14 @@
5
5
  from pkgutil import extend_path
6
6
  __path__ = extend_path(__path__, __name__)
7
7
 
8
- __version__ = "0.1.0"
8
+ __version__ = "0.2.0"
9
9
 
10
10
  from tracectrl.config import configure # noqa: F401
11
11
  from tracectrl.context import ingress # noqa: F401
12
12
  from tracectrl.agent_tagging import tag_agent, tag_agents # noqa: F401
13
+ from tracectrl.protector import ( # noqa: F401
14
+ GuardrailVerdict,
15
+ check_input,
16
+ check_output,
17
+ guard,
18
+ )
@@ -0,0 +1,770 @@
1
+ """TraceCtrl Guards — Protector Plus integration.
2
+
3
+ Public API:
4
+ with tracectrl.guard():
5
+ verdict = tracectrl.check_input(user_msg)
6
+ response = agent(user_msg)
7
+ tracectrl.check_output(str(response))
8
+
9
+ Note on naming: the function is `guard()`, not `guardrails()`, because
10
+ `tracectrl.guardrails` is already a subpackage (the legacy in-SDK LLM-judge
11
+ guardrails). Importing the subpackage rebinds `tracectrl.guardrails` to the
12
+ module object, shadowing any function of the same name on the package
13
+ namespace. Using `guard()` sidesteps that collision.
14
+
15
+ What it does:
16
+ - On context-manager entry: lazily fetches Protector Plus config from the
17
+ engine, starts a single background poster thread, and emits seven
18
+ `tracectrl.guardrail.registered` OpenTelemetry spans (one per Protector
19
+ Plus guardrail) so the engine's existing registry pipeline picks them up.
20
+ - `check_input` / `check_output`: POST to Protector Plus is fire-and-forget
21
+ via the background thread; the call returns IMMEDIATELY with a verdict
22
+ stub. When the POST response arrives, one
23
+ `tracectrl.guardrail.evaluation` span is emitted per flagged sub-guardrail
24
+ (decision='fail'); the engine's existing `update_violations()` pipeline
25
+ then ingests them into `guardrail_violations` with `provider='protector_plus'`.
26
+
27
+ Design notes:
28
+ - The SDK never blocks. The 1.6s LLM judge latency would otherwise stack on
29
+ top of every LLM call. Tradeoff: the verdict object returned synchronously
30
+ has `flagged=False`; callers that genuinely need a synchronous gate can
31
+ call `verdict.wait(timeout=2.0)`.
32
+ - Spans are emitted in the background thread under a manually-captured
33
+ parent context — child of whatever span was active at the
34
+ `check_input/output()` call site, so the trace tree is shaped correctly
35
+ even though the spans appear after the POST returns.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import json
41
+ import logging
42
+ import os
43
+ import queue
44
+ import threading
45
+ import time
46
+ import urllib.error
47
+ import urllib.request
48
+ from contextlib import contextmanager
49
+ from dataclasses import dataclass, field
50
+ from datetime import datetime, timezone
51
+ from typing import Any
52
+
53
+ from opentelemetry import context as otel_context
54
+ from opentelemetry import trace
55
+ from opentelemetry.trace import Status, StatusCode
56
+
57
+ logger = logging.getLogger("tracectrl.protector")
58
+
59
+
60
+ # Maps Protector Plus's internal check names → the TraceCtrl guardrail.name
61
+ # we register and attribute violations to. Order is the canonical display
62
+ # order in the Settings UI.
63
+ PROTECTOR_GUARDRAILS = (
64
+ "llm",
65
+ "keyword",
66
+ "regex",
67
+ "pii",
68
+ "vector",
69
+ "content_moderation",
70
+ "system_prompt_protection",
71
+ )
72
+
73
+ # Endpoint paths on the Protector Plus side (relative to the configured root).
74
+ _INPUT_PATH = "/apikey/api/protectorplus/v1/input-check"
75
+ _OUTPUT_PATH = "/apikey/api/protectorplus/v1/output-check"
76
+
77
+ # Engine HTTP base URL — where we fetch the config from. Derives from
78
+ # TRACECTRL_API_URL (preferred) or falls back to localhost:8000 which matches
79
+ # the default `docker compose` stack.
80
+ _DEFAULT_API_URL = "http://localhost:8000"
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Verdict
85
+ # ---------------------------------------------------------------------------
86
+
87
+
88
+ @dataclass
89
+ class GuardrailVerdict:
90
+ """Result of a check_input/check_output call.
91
+
92
+ Because the actual Protector Plus POST is fire-and-forget on a background
93
+ thread, the fields below are stub values when the verdict is returned to
94
+ the caller. `wait(timeout)` blocks until the background thread fills them
95
+ in, or until the timeout expires — useful if a caller wants to gate on
96
+ the result (despite the v1 'log only' positioning).
97
+ """
98
+
99
+ flagged: bool = False
100
+ execution_time_ms: int | None = None
101
+ scores: dict[str, Any] = field(default_factory=dict)
102
+ error: str | None = None
103
+ _done: threading.Event = field(default_factory=threading.Event, repr=False)
104
+
105
+ def wait(self, timeout: float = 2.0) -> "GuardrailVerdict":
106
+ """Block until the background POST completes or the timeout fires.
107
+
108
+ Returns self so the caller can chain: `v = check_input(msg).wait()`.
109
+ """
110
+ self._done.wait(timeout=timeout)
111
+ return self
112
+
113
+ def __bool__(self) -> bool:
114
+ """`if not check_input(msg):` is intentionally always-truthy in async
115
+ mode — the SDK doesn't gate the LLM call. Users who want gating must
116
+ call .wait() and check .flagged explicitly."""
117
+ return True
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Background runner — singleton
122
+ # ---------------------------------------------------------------------------
123
+
124
+
125
+ @dataclass
126
+ class _Config:
127
+ endpoint_url: str = ""
128
+ api_key: str = ""
129
+ enabled_guardrails: tuple[str, ...] = ()
130
+ fetched_at: float = 0.0
131
+
132
+
133
+ class _ProtectorRunner:
134
+ """Singleton — owns the background POST thread, config cache, and queue.
135
+
136
+ Lifecycle:
137
+ - `ensure_started()` is called by `guardrails()` context manager entry.
138
+ It lazily fetches config (HTTP GET to engine) and starts the worker
139
+ thread. Idempotent.
140
+ - `submit(phase, msg, verdict, parent_ctx)` enqueues a single
141
+ (input|output)-check.
142
+ - Worker thread: pulls (verdict, phase, msg, parent_ctx) tuples,
143
+ POSTs to Protector Plus with 2s timeout, attaches the parent_ctx
144
+ before emitting evaluation spans so they land in the right trace.
145
+ """
146
+
147
+ _instance: "_ProtectorRunner | None" = None
148
+ _instance_lock = threading.Lock()
149
+
150
+ @classmethod
151
+ def get(cls) -> "_ProtectorRunner":
152
+ if cls._instance is not None:
153
+ return cls._instance
154
+ with cls._instance_lock:
155
+ if cls._instance is None:
156
+ cls._instance = _ProtectorRunner()
157
+ return cls._instance
158
+
159
+ # Config is re-fetched from the engine if the cached copy is older than
160
+ # this. Lets operators toggle guardrails on/off in the Settings UI
161
+ # without restarting every running agent — the change takes effect on
162
+ # the next `guard()` entry that crosses the refresh threshold.
163
+ _CONFIG_MAX_AGE_SECONDS = 60
164
+
165
+ def __init__(self) -> None:
166
+ self._config = _Config()
167
+ self._config_lock = threading.Lock()
168
+ self._queue: queue.Queue = queue.Queue(maxsize=1000)
169
+ self._thread: threading.Thread | None = None
170
+ self._stop = threading.Event()
171
+ # _registered tracks (agent_id, guardrail_name) pairs that have
172
+ # already had a registration span emitted in THIS process. We track
173
+ # per-pair (not just per-agent) so enabling a new guardrail in the
174
+ # Settings UI causes that one — and only that one — to register on
175
+ # the next `guard()` entry, leaving the existing ones alone.
176
+ self._registered: set[tuple[str, str]] = set()
177
+ self._registered_lock = threading.Lock()
178
+ self._tracer = trace.get_tracer("tracectrl.protector")
179
+
180
+ # ------- public ----------------------------------------------------------
181
+
182
+ def ensure_started(self) -> _Config:
183
+ """Lazily start the worker thread + refresh stale config.
184
+
185
+ Returns the current config snapshot. Refreshes from the engine if
186
+ either the cache is older than `_CONFIG_MAX_AGE_SECONDS` or the
187
+ worker thread isn't alive — so operators can toggle guardrails in
188
+ the Settings UI and have running agents pick up the change within
189
+ roughly a minute, without process restarts.
190
+
191
+ ALL state checks (thread liveness, config age, refresh, spawn)
192
+ happen inside `_config_lock`. The outer unsynchronised read of
193
+ `self._thread` we had before could allow two concurrent callers to
194
+ both observe a dead thread, both enter the lock sequentially, and
195
+ the second one's stale read of the worker reference could spawn a
196
+ duplicate worker. Single lock entry serialises the whole decision.
197
+ """
198
+ with self._config_lock:
199
+ thread_dead = self._thread is None or not self._thread.is_alive()
200
+ config_stale = (
201
+ time.time() - self._config.fetched_at
202
+ > self._CONFIG_MAX_AGE_SECONDS
203
+ )
204
+ if thread_dead or config_stale:
205
+ self._refresh_config_locked()
206
+ if thread_dead:
207
+ self._stop.clear()
208
+ self._thread = threading.Thread(
209
+ target=self._run,
210
+ name="tracectrl-protector-poster",
211
+ daemon=True,
212
+ )
213
+ self._thread.start()
214
+ return self._config_snapshot()
215
+
216
+ def submit_check(
217
+ self,
218
+ phase: str,
219
+ message: str,
220
+ verdict: GuardrailVerdict,
221
+ parent_ctx: otel_context.Context,
222
+ agent_id: str,
223
+ agent_name: str,
224
+ ) -> None:
225
+ """Enqueue a check. Drops on overflow with a warning.
226
+
227
+ Re-runs `ensure_started()` defensively so a crashed worker thread
228
+ gets resurrected on the next check rather than silently swallowing
229
+ verdicts forever.
230
+ """
231
+ self.ensure_started()
232
+ try:
233
+ self._queue.put_nowait(
234
+ (phase, message, verdict, parent_ctx, agent_id, agent_name)
235
+ )
236
+ except queue.Full:
237
+ logger.warning(
238
+ "tracectrl.protector: queue full (>1000 pending), dropping %s check",
239
+ phase,
240
+ )
241
+ verdict.error = "dropped: queue full"
242
+ verdict._done.set()
243
+
244
+ def register_guardrails_for(self, agent_id: str, agent_name: str) -> None:
245
+ """Emit one `tracectrl.guardrail.registered` span per enabled
246
+ Protector Plus guardrail. Idempotent per (agent_id, guardrail_name)
247
+ within the process.
248
+
249
+ Per-pair tracking lets a newly-enabled guardrail register on the
250
+ next `guard()` entry without re-emitting spans for guardrails that
251
+ were already on file. We also claim each pair only after deciding
252
+ we'll emit, so an early call against an empty config doesn't
253
+ permanently suppress later registrations.
254
+ """
255
+ cfg = self._config_snapshot()
256
+ if not cfg.enabled_guardrails:
257
+ return
258
+ now_iso = datetime.now(timezone.utc).isoformat()
259
+ for guardrail_key in cfg.enabled_guardrails:
260
+ pair = (agent_id, guardrail_key)
261
+ # Atomic check-and-claim — prevents concurrent agents racing on
262
+ # the same pair and emitting duplicate spans.
263
+ with self._registered_lock:
264
+ if pair in self._registered:
265
+ continue
266
+ self._registered.add(pair)
267
+ try:
268
+ with self._tracer.start_as_current_span(
269
+ "tracectrl.guardrail.registered"
270
+ ) as span:
271
+ span.set_attribute("tracectrl.agent.id", agent_id)
272
+ span.set_attribute("tracectrl.agent.name", agent_name)
273
+ span.set_attribute(
274
+ "tracectrl.guardrail.name", f"protector_plus.{guardrail_key}"
275
+ )
276
+ # Protector Plus guardrails all behave as monitoring-mode
277
+ # (no blocking in v1) at varying timings.
278
+ span.set_attribute(
279
+ "tracectrl.guardrail.severity",
280
+ _default_severity_for(guardrail_key),
281
+ )
282
+ span.set_attribute(
283
+ "tracectrl.guardrail.timing",
284
+ _default_timing_for(guardrail_key),
285
+ )
286
+ span.set_attribute("tracectrl.guardrail.mode", "monitoring")
287
+ span.set_attribute(
288
+ "tracectrl.guardrail.judge_model",
289
+ f"protector_plus:{guardrail_key}",
290
+ )
291
+ span.set_attribute(
292
+ "tracectrl.guardrail.description",
293
+ _description_for(guardrail_key),
294
+ )
295
+ span.set_attribute("tracectrl.guardrail.judge_prompt", "")
296
+ span.set_attribute("tracectrl.guardrail.registered_at", now_iso)
297
+ span.set_attribute("tracectrl.guardrail.health", "active")
298
+ span.set_attribute("tracectrl.guardrail.health_reason", "")
299
+ span.set_attribute("tracectrl.guardrail.provider", "protector_plus")
300
+ except Exception:
301
+ logger.debug(
302
+ "failed to emit protector_plus registration span for %s",
303
+ guardrail_key,
304
+ exc_info=True,
305
+ )
306
+
307
+ # ------- internals -------------------------------------------------------
308
+
309
+ def _config_snapshot(self) -> _Config:
310
+ with self._config_lock:
311
+ return _Config(
312
+ endpoint_url=self._config.endpoint_url,
313
+ api_key=self._config.api_key,
314
+ enabled_guardrails=self._config.enabled_guardrails,
315
+ fetched_at=self._config.fetched_at,
316
+ )
317
+
318
+ def _refresh_config_locked(self) -> None:
319
+ """Fetch Protector Plus config from the engine. Caller holds config_lock.
320
+
321
+ When the set of enabled guardrails shrinks (operator disabled one in
322
+ the Settings UI), prune the corresponding pairs from `_registered`.
323
+ Without this, an enable → disable → enable cycle leaves the pair
324
+ cached, suppressing the re-registration span on the second enable
325
+ and producing a stale 'active' registry row that no longer matches
326
+ what's running.
327
+ """
328
+ api_url = os.getenv("TRACECTRL_API_URL", _DEFAULT_API_URL).rstrip("/")
329
+ url = f"{api_url}/api/v1/guardrails/protector-config/sdk"
330
+ old_enabled = set(self._config.enabled_guardrails)
331
+ try:
332
+ req = urllib.request.Request(url, method="GET")
333
+ with urllib.request.urlopen(req, timeout=3) as resp:
334
+ body = json.loads(resp.read().decode("utf-8"))
335
+ self._config = _Config(
336
+ endpoint_url=(body.get("endpoint_url") or "").rstrip("/"),
337
+ api_key=body.get("api_key") or "",
338
+ enabled_guardrails=tuple(body.get("enabled_guardrails") or ()),
339
+ fetched_at=time.time(),
340
+ )
341
+ if not self._config.endpoint_url or not self._config.api_key:
342
+ logger.info(
343
+ "tracectrl.protector: no Protector Plus config in engine; "
344
+ "calls will no-op until configured via the Settings UI"
345
+ )
346
+ except Exception as e: # noqa: BLE001
347
+ # The engine may be down on startup. Don't crash the agent.
348
+ logger.warning(
349
+ "tracectrl.protector: could not fetch config from %s: %s. "
350
+ "Protector Plus checks will no-op until next process start.",
351
+ url,
352
+ e,
353
+ )
354
+ self._config = _Config(fetched_at=time.time())
355
+ # On fetch error, don't prune — we don't know the true state.
356
+ return
357
+
358
+ removed = old_enabled - set(self._config.enabled_guardrails)
359
+ if removed:
360
+ with self._registered_lock:
361
+ self._registered = {
362
+ pair for pair in self._registered if pair[1] not in removed
363
+ }
364
+
365
+ def _run(self) -> None:
366
+ """Worker loop — pull from queue, POST to Protector Plus, emit spans.
367
+
368
+ Defensive: catch BaseException too. A `MemoryError` during JSON
369
+ serialisation (Protector Plus check payloads can be large) was
370
+ leaking past the `except Exception` and silently killing the
371
+ worker, leaving the caller's verdict `_done` event un-set so any
372
+ `.wait()` would hang for its full timeout. We catch, mark the
373
+ verdict failed, and re-raise non-Exception BaseExceptions
374
+ (KeyboardInterrupt, SystemExit) so shutdown signals still work.
375
+ """
376
+ while not self._stop.is_set():
377
+ try:
378
+ item = self._queue.get(timeout=1.0)
379
+ except queue.Empty:
380
+ continue
381
+ verdict = item[2] if len(item) > 2 else None
382
+ try:
383
+ phase, message, vrd, parent_ctx, agent_id, agent_name = item
384
+ self._handle(phase, message, vrd, parent_ctx, agent_id, agent_name)
385
+ except Exception:
386
+ logger.exception("tracectrl.protector worker: unhandled error")
387
+ if verdict is not None and not verdict._done.is_set():
388
+ verdict.error = "worker error"
389
+ verdict._done.set()
390
+ except BaseException as exc:
391
+ # Mark the in-flight verdict failed so callers don't hang
392
+ # on .wait(), then re-raise so the runtime sees the
393
+ # shutdown signal (KeyboardInterrupt/SystemExit/MemoryError).
394
+ if verdict is not None and not verdict._done.is_set():
395
+ verdict.error = f"worker terminated: {type(exc).__name__}"
396
+ verdict._done.set()
397
+ logger.error(
398
+ "tracectrl.protector worker: terminating on %s",
399
+ type(exc).__name__,
400
+ )
401
+ raise
402
+ finally:
403
+ self._queue.task_done()
404
+
405
+ def _handle(
406
+ self,
407
+ phase: str,
408
+ message: str,
409
+ verdict: GuardrailVerdict,
410
+ parent_ctx: otel_context.Context,
411
+ agent_id: str,
412
+ agent_name: str,
413
+ ) -> None:
414
+ cfg = self._config_snapshot()
415
+ if not cfg.endpoint_url or not cfg.api_key:
416
+ verdict.error = "no config"
417
+ verdict._done.set()
418
+ return
419
+
420
+ path = _INPUT_PATH if phase == "input" else _OUTPUT_PATH
421
+ url = cfg.endpoint_url + path
422
+ payload = json.dumps({"message": message}).encode("utf-8")
423
+ req = urllib.request.Request(
424
+ url,
425
+ data=payload,
426
+ method="POST",
427
+ headers={
428
+ "Content-Type": "application/json",
429
+ "X-API-Key": cfg.api_key,
430
+ },
431
+ )
432
+ started = time.perf_counter()
433
+ try:
434
+ with urllib.request.urlopen(req, timeout=5) as resp:
435
+ body = json.loads(resp.read().decode("utf-8"))
436
+ except urllib.error.HTTPError as e:
437
+ verdict.error = f"HTTP {e.code} {e.reason}"
438
+ verdict._done.set()
439
+ self._emit_error_span(phase, agent_id, agent_name, parent_ctx, verdict.error)
440
+ return
441
+ except Exception as e: # noqa: BLE001
442
+ verdict.error = str(e)
443
+ verdict._done.set()
444
+ self._emit_error_span(phase, agent_id, agent_name, parent_ctx, verdict.error)
445
+ return
446
+
447
+ elapsed_ms = int((time.perf_counter() - started) * 1000)
448
+ verdict.flagged = bool(body.get("injection_detected"))
449
+ verdict.execution_time_ms = elapsed_ms
450
+ verdict.scores = body.get("checks") or {}
451
+ verdict._done.set()
452
+
453
+ # For every enabled sub-guardrail, emit one `guardrail.evaluation`
454
+ # span. `decision='fail'` if the check flagged, `'pass'` otherwise.
455
+ # This matches the legacy LLM-judge path (which always emits a span
456
+ # per evaluation regardless of outcome) so trace trees stay legible
457
+ # and the UI shows the guardrail ran on every relevant request, not
458
+ # just on failures. The engine ingester only inserts fail/error
459
+ # rows into `guardrail_violations` — pass rows live in otel_traces
460
+ # only.
461
+ for guardrail_key, check in (body.get("checks") or {}).items():
462
+ if not isinstance(check, dict) or not check.get("enabled"):
463
+ continue
464
+ flagged = _is_check_flagged(guardrail_key, check)
465
+ self._emit_evaluation_span(
466
+ phase=phase,
467
+ guardrail_key=guardrail_key,
468
+ check=check,
469
+ message=message,
470
+ agent_id=agent_id,
471
+ agent_name=agent_name,
472
+ parent_ctx=parent_ctx,
473
+ decision="fail" if flagged else "pass",
474
+ )
475
+
476
+ def _emit_evaluation_span(
477
+ self,
478
+ phase: str,
479
+ guardrail_key: str,
480
+ check: dict,
481
+ message: str,
482
+ agent_id: str,
483
+ agent_name: str,
484
+ parent_ctx: otel_context.Context,
485
+ decision: str = "fail",
486
+ ) -> None:
487
+ """Emit a `tracectrl.guardrail.evaluation` span as a child of the
488
+ captured parent context. `decision` is 'pass' or 'fail' depending on
489
+ whether the sub-check flagged. The engine's update_violations()
490
+ pipeline picks up fail/error rows and writes them to
491
+ guardrail_violations; pass rows stay in otel_traces only."""
492
+ token = otel_context.attach(parent_ctx)
493
+ try:
494
+ with self._tracer.start_as_current_span(
495
+ "tracectrl.guardrail.evaluation"
496
+ ) as span:
497
+ span.set_attribute(
498
+ "tracectrl.guardrail.name", f"protector_plus.{guardrail_key}"
499
+ )
500
+ span.set_attribute("tracectrl.guardrail.decision", decision)
501
+ span.set_attribute("tracectrl.guardrail.provider", "protector_plus")
502
+ span.set_attribute(
503
+ "tracectrl.guardrail.judge_model",
504
+ f"protector_plus:{guardrail_key}",
505
+ )
506
+ span.set_attribute(
507
+ "tracectrl.guardrail.severity",
508
+ _default_severity_for(guardrail_key),
509
+ )
510
+ span.set_attribute(
511
+ "tracectrl.guardrail.timing",
512
+ "pre_input" if phase == "input" else "post_output",
513
+ )
514
+ span.set_attribute(
515
+ "tracectrl.guardrail.reason",
516
+ _reason_for(guardrail_key, check),
517
+ )
518
+ # Evidence is the message that triggered the check, truncated
519
+ # to keep the OTel attribute size sane. The engine renders
520
+ # this verbatim in the violation drawer.
521
+ evidence = message if len(message) <= 2048 else message[:2048] + "…"
522
+ span.set_attribute("tracectrl.guardrail.evidence", evidence)
523
+ # Pack the per-check Protector Plus response so the UI can
524
+ # render the full score breakdown (threshold, entities, etc.)
525
+ # in the Invocations panel. Capped at 8KB — typical
526
+ # Protector Plus check payloads are <1KB but PII responses
527
+ # can carry long entity lists.
528
+ try:
529
+ response_json = json.dumps(check, default=str)
530
+ if len(response_json) > 8000:
531
+ response_json = response_json[:8000] + "...[truncated]"
532
+ span.set_attribute("tracectrl.guardrail.response_json", response_json)
533
+ except (TypeError, ValueError):
534
+ pass
535
+ span.set_attribute(
536
+ "tracectrl.guardrail.evaluated_at",
537
+ datetime.now(timezone.utc).isoformat(),
538
+ )
539
+ span.set_attribute("tracectrl.agent.id", agent_id)
540
+ span.set_attribute("tracectrl.agent.name", agent_name)
541
+ finally:
542
+ otel_context.detach(token)
543
+
544
+ def _emit_error_span(
545
+ self,
546
+ phase: str,
547
+ agent_id: str,
548
+ agent_name: str,
549
+ parent_ctx: otel_context.Context,
550
+ error: str,
551
+ ) -> None:
552
+ """Emit a single decision='error' eval span so transient Protector Plus
553
+ outages surface as a degraded-health signal in the UI rather than
554
+ silently dropping checks."""
555
+ token = otel_context.attach(parent_ctx)
556
+ try:
557
+ with self._tracer.start_as_current_span(
558
+ "tracectrl.guardrail.evaluation"
559
+ ) as span:
560
+ span.set_attribute(
561
+ "tracectrl.guardrail.name", "protector_plus.transport"
562
+ )
563
+ span.set_attribute("tracectrl.guardrail.decision", "error")
564
+ span.set_attribute("tracectrl.guardrail.provider", "protector_plus")
565
+ span.set_attribute(
566
+ "tracectrl.guardrail.judge_model", "protector_plus:transport"
567
+ )
568
+ span.set_attribute("tracectrl.guardrail.severity", "medium")
569
+ span.set_attribute(
570
+ "tracectrl.guardrail.timing",
571
+ "pre_input" if phase == "input" else "post_output",
572
+ )
573
+ span.set_attribute("tracectrl.guardrail.reason", error)
574
+ span.set_attribute("tracectrl.guardrail.evidence", "")
575
+ span.set_attribute("tracectrl.agent.id", agent_id)
576
+ span.set_attribute("tracectrl.agent.name", agent_name)
577
+ span.set_status(Status(StatusCode.ERROR, error))
578
+ finally:
579
+ otel_context.detach(token)
580
+
581
+
582
+ # ---------------------------------------------------------------------------
583
+ # Helpers
584
+ # ---------------------------------------------------------------------------
585
+
586
+
587
+ def _is_check_flagged(guardrail_key: str, check: dict) -> bool:
588
+ """Decide whether a single Protector Plus sub-check is flagged. The
589
+ Protector Plus response shape varies per guardrail — e.g. `injection_detected`
590
+ is the umbrella, but per-check we use:
591
+ - llm: score >= threshold
592
+ - keyword: detected == true
593
+ - regex: detected == true
594
+ - pii: detected == true
595
+ - vector: score is not None and score >= threshold (if present)
596
+ - content_moderation: result == 'UNSAFE' or unsafe == true
597
+ - system_prompt_protection: detected == true
598
+ """
599
+ if not isinstance(check, dict):
600
+ return False
601
+ if not check.get("enabled"):
602
+ return False
603
+
604
+ if guardrail_key == "llm":
605
+ score = check.get("score")
606
+ threshold = check.get("threshold", 0.7)
607
+ return isinstance(score, (int, float)) and score >= threshold
608
+ if guardrail_key == "vector":
609
+ score = check.get("score")
610
+ if score is None:
611
+ return False
612
+ threshold = check.get("threshold", 0.7)
613
+ return isinstance(score, (int, float)) and score >= threshold
614
+ if guardrail_key == "content_moderation":
615
+ return bool(check.get("unsafe")) or check.get("result") == "UNSAFE"
616
+ # keyword / regex / pii / system_prompt_protection
617
+ return bool(check.get("detected"))
618
+
619
+
620
+ def _default_severity_for(guardrail_key: str) -> str:
621
+ if guardrail_key in ("llm", "system_prompt_protection"):
622
+ return "high"
623
+ if guardrail_key in ("content_moderation", "pii"):
624
+ return "high"
625
+ return "medium"
626
+
627
+
628
+ def _default_timing_for(guardrail_key: str) -> str:
629
+ # system_prompt_protection only runs on output; everything else is
630
+ # registered as pre_input but can fire on either phase at runtime.
631
+ if guardrail_key == "system_prompt_protection":
632
+ return "post_output"
633
+ return "pre_input"
634
+
635
+
636
+ def _description_for(guardrail_key: str) -> str:
637
+ return {
638
+ "llm": "Prompt injection scoring via Protector Plus LLM judge (llama4:scout).",
639
+ "keyword": "Exact keyword/phrase blocklist match.",
640
+ "regex": "Regex pattern match against configured patterns.",
641
+ "pii": "NER-based PII detection (names, emails, phone numbers, IC/passport, credit cards).",
642
+ "vector": "Semantic similarity to known injection patterns via bge-m3 + ChromaDB.",
643
+ "content_moderation": "Harmful content classification via Qwen3Guard-4B.",
644
+ "system_prompt_protection": "Detects LLM responses that leak the system prompt.",
645
+ }.get(guardrail_key, "Protector Plus guardrail.")
646
+
647
+
648
+ def _reason_for(guardrail_key: str, check: dict) -> str:
649
+ """Render a human-readable reason for the violation row."""
650
+ if guardrail_key == "llm":
651
+ score = check.get("score")
652
+ threshold = check.get("threshold", 0.7)
653
+ score_str = f"{score:.2f}" if isinstance(score, (int, float)) else str(score)
654
+ return f"LLM judge score {score_str} ≥ threshold {threshold}"
655
+ if guardrail_key == "pii":
656
+ entities = check.get("entities") or []
657
+ if entities:
658
+ return f"PII detected: {', '.join(str(e) for e in entities[:5])}"
659
+ return "PII detected"
660
+ if guardrail_key == "keyword":
661
+ matched = check.get("matched") or []
662
+ return f"Keyword match: {', '.join(str(m) for m in matched[:5])}"
663
+ if guardrail_key == "regex":
664
+ matched = check.get("matched") or []
665
+ return f"Regex match: {', '.join(str(m) for m in matched[:5])}"
666
+ if guardrail_key == "content_moderation":
667
+ category = check.get("category") or "UNSAFE"
668
+ return f"Content moderation: {category}"
669
+ if guardrail_key == "vector":
670
+ score = check.get("score")
671
+ return f"Vector similarity score {score:.2f}" if isinstance(score, (int, float)) else "Vector similarity hit"
672
+ if guardrail_key == "system_prompt_protection":
673
+ return "System prompt leakage detected in output."
674
+ return "Protector Plus flag."
675
+
676
+
677
+ def _resolve_active_agent_identity() -> tuple[str, str]:
678
+ """Pull the currently-active agent id/name off the active span.
679
+
680
+ The Strands instrumentor stamps `agent.name` and TraceCtrl's own enricher
681
+ stamps `tracectrl.agent.id`. If neither is present (e.g. someone called
682
+ `check_input` outside an agent run), fall back to the service name from
683
+ the TracerProvider resource.
684
+ """
685
+ span = trace.get_current_span()
686
+ name = ""
687
+ agent_id = ""
688
+ if span is not None:
689
+ attrs = getattr(span, "attributes", None) or {}
690
+ name = (
691
+ attrs.get("agent.name")
692
+ or attrs.get("tracectrl.agent.name")
693
+ or ""
694
+ )
695
+ agent_id = attrs.get("tracectrl.agent.id") or ""
696
+ if not name:
697
+ # Fallback: TracerProvider resource service.name
698
+ try:
699
+ provider = trace.get_tracer_provider()
700
+ resource = getattr(provider, "resource", None)
701
+ if resource is not None:
702
+ name = resource.attributes.get("service.name", "")
703
+ except Exception:
704
+ pass
705
+ name = name or "unknown-agent"
706
+ if not agent_id or agent_id == "default":
707
+ agent_id = name.lower().replace(" ", "-").replace("_", "-")
708
+ return agent_id, name
709
+
710
+
711
+ # ---------------------------------------------------------------------------
712
+ # Public API
713
+ # ---------------------------------------------------------------------------
714
+
715
+
716
+ @contextmanager
717
+ def guard():
718
+ """Open a Protector Plus / TraceCtrl Guards scope.
719
+
720
+ On entry: fetches config from the engine, starts the background poster
721
+ thread (idempotent), emits 7 registration spans so the engine's guardrail
722
+ registry knows what guardrails this agent has enabled. On exit: no
723
+ teardown — the background thread is a daemon and outlives the scope.
724
+ """
725
+ runner = _ProtectorRunner.get()
726
+ runner.ensure_started()
727
+ agent_id, agent_name = _resolve_active_agent_identity()
728
+ runner.register_guardrails_for(agent_id, agent_name)
729
+ try:
730
+ yield
731
+ finally:
732
+ # No teardown — agent code may issue more checks after this scope
733
+ # closes, and the worker thread is process-lifetime.
734
+ pass
735
+
736
+
737
+ def check_input(message: str) -> GuardrailVerdict:
738
+ """Queue an asynchronous input-check against Protector Plus.
739
+
740
+ Returns a verdict stub IMMEDIATELY (fire-and-forget). The actual
741
+ Protector Plus POST happens on a background thread; when its response
742
+ arrives the verdict is populated and one `tracectrl.guardrail.evaluation`
743
+ span is emitted per flagged sub-guardrail (engine then writes them to
744
+ `guardrail_violations`).
745
+
746
+ Callers that need to *gate* the LLM call on the result must do:
747
+ v = tracectrl.check_input(prompt).wait(timeout=2.0)
748
+ if v.flagged:
749
+ return "blocked"
750
+ """
751
+ return _submit("input", message)
752
+
753
+
754
+ def check_output(message: str) -> GuardrailVerdict:
755
+ """Queue an asynchronous output-check against Protector Plus. See
756
+ `check_input` for semantics."""
757
+ return _submit("output", message)
758
+
759
+
760
+ def _submit(phase: str, message: str) -> GuardrailVerdict:
761
+ runner = _ProtectorRunner.get()
762
+ runner.ensure_started()
763
+ agent_id, agent_name = _resolve_active_agent_identity()
764
+ verdict = GuardrailVerdict()
765
+ # Capture the active OTel context at the call site so the worker thread
766
+ # can re-attach it before emitting eval spans — without this, the spans
767
+ # would land at the root of a fresh trace.
768
+ parent_ctx = otel_context.get_current()
769
+ runner.submit_check(phase, message, verdict, parent_ctx, agent_id, agent_name)
770
+ return verdict
tracectrl-0.1.0/PKG-INFO DELETED
@@ -1,13 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: tracectrl
3
- Version: 0.1.0
4
- Summary: TraceCtrl SDK — agentic AI security observability
5
- Author: CloudsineAI
6
- License-Expression: Apache-2.0
7
- License-File: LICENSE
8
- Requires-Python: >=3.10
9
- Requires-Dist: opentelemetry-exporter-otlp-proto-grpc>=1.20.0
10
- Requires-Dist: opentelemetry-sdk>=1.20.0
11
- Requires-Dist: rich>=13.0.0
12
- Requires-Dist: textual>=0.50.0
13
- Requires-Dist: tracectrl-scanner>=0.1.0
File without changes
File without changes