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.
- tracectrl-0.2.0/PKG-INFO +98 -0
- tracectrl-0.2.0/README.md +83 -0
- {tracectrl-0.1.0 → tracectrl-0.2.0}/pyproject.toml +2 -1
- {tracectrl-0.1.0 → tracectrl-0.2.0}/src/tracectrl/__init__.py +7 -1
- tracectrl-0.2.0/src/tracectrl/protector.py +770 -0
- tracectrl-0.1.0/PKG-INFO +0 -13
- {tracectrl-0.1.0 → tracectrl-0.2.0}/.gitignore +0 -0
- {tracectrl-0.1.0 → tracectrl-0.2.0}/LICENSE +0 -0
- {tracectrl-0.1.0 → tracectrl-0.2.0}/src/tracectrl/_tui.py +0 -0
- {tracectrl-0.1.0 → tracectrl-0.2.0}/src/tracectrl/agent_tagging.py +0 -0
- {tracectrl-0.1.0 → tracectrl-0.2.0}/src/tracectrl/cli.py +0 -0
- {tracectrl-0.1.0 → tracectrl-0.2.0}/src/tracectrl/config.py +0 -0
- {tracectrl-0.1.0 → tracectrl-0.2.0}/src/tracectrl/context.py +0 -0
- {tracectrl-0.1.0 → tracectrl-0.2.0}/src/tracectrl/exporter.py +0 -0
- {tracectrl-0.1.0 → tracectrl-0.2.0}/src/tracectrl/guardrails/__init__.py +0 -0
- {tracectrl-0.1.0 → tracectrl-0.2.0}/src/tracectrl/guardrails/guardrail.py +0 -0
- {tracectrl-0.1.0 → tracectrl-0.2.0}/src/tracectrl/guardrails/judge.py +0 -0
- {tracectrl-0.1.0 → tracectrl-0.2.0}/src/tracectrl/guardrails/strands_hook.py +0 -0
- {tracectrl-0.1.0 → tracectrl-0.2.0}/src/tracectrl/inference.py +0 -0
- {tracectrl-0.1.0 → tracectrl-0.2.0}/src/tracectrl/processor.py +0 -0
- {tracectrl-0.1.0 → tracectrl-0.2.0}/src/tracectrl/schema.py +0 -0
- {tracectrl-0.1.0 → tracectrl-0.2.0}/src/tracectrl/session.py +0 -0
tracectrl-0.2.0/PKG-INFO
ADDED
|
@@ -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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|