trace-agent-server 0.0.1__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.
- trace_agent_server/__init__.py +5 -0
- trace_agent_server/api/__init__.py +1 -0
- trace_agent_server/api/runs.py +1214 -0
- trace_agent_server/cli.py +29 -0
- trace_agent_server/config.py +57 -0
- trace_agent_server/db.py +24 -0
- trace_agent_server/main.py +73 -0
- trace_agent_server/models.py +131 -0
- trace_agent_server/schemas.py +549 -0
- trace_agent_server/services/__init__.py +1 -0
- trace_agent_server/services/analytics.py +305 -0
- trace_agent_server/services/audit_guardrails.py +303 -0
- trace_agent_server/services/compare.py +851 -0
- trace_agent_server/services/dashboard.py +125 -0
- trace_agent_server/services/events.py +131 -0
- trace_agent_server/services/explanation.py +230 -0
- trace_agent_server/services/graph.py +103 -0
- trace_agent_server/services/observability.py +140 -0
- trace_agent_server/services/provider.py +547 -0
- trace_agent_server/services/run_summary.py +363 -0
- trace_agent_server-0.0.1.dist-info/METADATA +21 -0
- trace_agent_server-0.0.1.dist-info/RECORD +25 -0
- trace_agent_server-0.0.1.dist-info/WHEEL +5 -0
- trace_agent_server-0.0.1.dist-info/entry_points.txt +2 -0
- trace_agent_server-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import select
|
|
8
|
+
from sqlalchemy.orm import Session
|
|
9
|
+
|
|
10
|
+
from trace_agent_server.config import Settings
|
|
11
|
+
from trace_agent_server.models import Event, Run
|
|
12
|
+
from trace_agent_server.services.run_summary import build_tool_chain
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
TOKEN_PATTERN = re.compile(r"[a-z0-9]+", re.IGNORECASE)
|
|
16
|
+
STOP_WORDS = {
|
|
17
|
+
"the",
|
|
18
|
+
"and",
|
|
19
|
+
"for",
|
|
20
|
+
"with",
|
|
21
|
+
"that",
|
|
22
|
+
"this",
|
|
23
|
+
"from",
|
|
24
|
+
"into",
|
|
25
|
+
"your",
|
|
26
|
+
"have",
|
|
27
|
+
"need",
|
|
28
|
+
"plan",
|
|
29
|
+
"build",
|
|
30
|
+
"using",
|
|
31
|
+
"use",
|
|
32
|
+
"agent",
|
|
33
|
+
"tool",
|
|
34
|
+
"tools",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _goal_tokens(goal: str) -> set[str]:
|
|
39
|
+
tokens = {token.lower() for token in TOKEN_PATTERN.findall(goal)}
|
|
40
|
+
return {token for token in tokens if len(token) > 2 and token not in STOP_WORDS}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _duration_ms(run: Run) -> int | None:
|
|
44
|
+
if run.ended_at is None:
|
|
45
|
+
return None
|
|
46
|
+
return max(0, int((run.ended_at - run.started_at).total_seconds() * 1000))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _extract_usage(event: Event) -> dict[str, int]:
|
|
50
|
+
usage = event.event_metadata.get("usage", {})
|
|
51
|
+
if not isinstance(usage, dict):
|
|
52
|
+
return {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
|
53
|
+
return {
|
|
54
|
+
"prompt_tokens": int(usage.get("prompt_tokens") or 0),
|
|
55
|
+
"completion_tokens": int(usage.get("completion_tokens") or 0),
|
|
56
|
+
"total_tokens": int(usage.get("total_tokens") or 0),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _pricing_key(provider: str | None, model: str | None) -> list[str]:
|
|
61
|
+
keys: list[str] = []
|
|
62
|
+
if provider and model:
|
|
63
|
+
keys.append(f"{provider}:{model}")
|
|
64
|
+
if model:
|
|
65
|
+
keys.append(model)
|
|
66
|
+
return keys
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build_tool_graph(tool_chain: list[dict[str, Any]]) -> dict[str, Any]:
|
|
70
|
+
nodes: list[dict[str, Any]] = []
|
|
71
|
+
edges: list[dict[str, Any]] = []
|
|
72
|
+
step_to_node: dict[str, str] = {}
|
|
73
|
+
|
|
74
|
+
for index, item in enumerate(tool_chain):
|
|
75
|
+
node_id = f"tool-chain-{item['step_id']}"
|
|
76
|
+
step_to_node[item["step_id"]] = node_id
|
|
77
|
+
nodes.append(
|
|
78
|
+
{
|
|
79
|
+
"id": node_id,
|
|
80
|
+
"type": "tool_chain",
|
|
81
|
+
"label": item["tool_name"],
|
|
82
|
+
"position": {"x": float(index * 220), "y": 80.0 if index % 2 == 0 else 180.0},
|
|
83
|
+
"data": {
|
|
84
|
+
"status": item["status"],
|
|
85
|
+
"durationMs": item["duration_ms"],
|
|
86
|
+
"callId": item["call_id"],
|
|
87
|
+
"summary": item["error_summary"] or item["output_summary"] or item["args_summary"],
|
|
88
|
+
"evidenceIds": item["event_ids"],
|
|
89
|
+
"artifactCount": item["artifact_count"],
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
for index, item in enumerate(tool_chain):
|
|
95
|
+
if index == 0:
|
|
96
|
+
continue
|
|
97
|
+
source_step = item["parent_step_id"] or tool_chain[index - 1]["step_id"]
|
|
98
|
+
source_node = step_to_node.get(source_step)
|
|
99
|
+
target_node = step_to_node[item["step_id"]]
|
|
100
|
+
if source_node is None:
|
|
101
|
+
source_node = step_to_node[tool_chain[index - 1]["step_id"]]
|
|
102
|
+
edges.append(
|
|
103
|
+
{
|
|
104
|
+
"id": f"tool-chain-edge-{source_node}-{target_node}",
|
|
105
|
+
"source": source_node,
|
|
106
|
+
"target": target_node,
|
|
107
|
+
"label": item["status"],
|
|
108
|
+
"animated": item["status"] == "failed",
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
return {"nodes": nodes, "edges": edges}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def build_tool_metrics(events: list[Event], tool_chain: list[dict[str, Any]], settings: Settings) -> list[dict[str, Any]]:
|
|
115
|
+
attribution: dict[str, dict[str, int]] = defaultdict(lambda: {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0})
|
|
116
|
+
for index, event in enumerate(events):
|
|
117
|
+
if event.type != "model.responded":
|
|
118
|
+
continue
|
|
119
|
+
tool_call_count = int(event.event_metadata.get("tool_call_count") or 0)
|
|
120
|
+
if tool_call_count <= 0:
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
requested_events: list[Event] = []
|
|
124
|
+
cursor = index + 1
|
|
125
|
+
while cursor < len(events) and events[cursor].type == "tool.requested":
|
|
126
|
+
requested_events.append(events[cursor])
|
|
127
|
+
cursor += 1
|
|
128
|
+
if not requested_events:
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
usage = _extract_usage(event)
|
|
132
|
+
divisor = max(1, len(requested_events))
|
|
133
|
+
# Split one model turn across the tool calls it produced.
|
|
134
|
+
for requested_event in requested_events:
|
|
135
|
+
key = requested_event.event_metadata.get("call_id") if isinstance(requested_event.event_metadata.get("call_id"), str) else requested_event.step_id
|
|
136
|
+
if not key:
|
|
137
|
+
continue
|
|
138
|
+
attribution[key]["prompt_tokens"] += usage["prompt_tokens"] // divisor
|
|
139
|
+
attribution[key]["completion_tokens"] += usage["completion_tokens"] // divisor
|
|
140
|
+
attribution[key]["total_tokens"] += usage["total_tokens"] // divisor
|
|
141
|
+
|
|
142
|
+
grouped: dict[str, dict[str, Any]] = {}
|
|
143
|
+
for item in tool_chain:
|
|
144
|
+
tool_name = item["tool_name"]
|
|
145
|
+
metric = grouped.setdefault(
|
|
146
|
+
tool_name,
|
|
147
|
+
{
|
|
148
|
+
"tool_name": tool_name,
|
|
149
|
+
"call_count": 0,
|
|
150
|
+
"success_count": 0,
|
|
151
|
+
"failure_count": 0,
|
|
152
|
+
"total_duration_ms": 0,
|
|
153
|
+
"max_duration_ms": 0,
|
|
154
|
+
"artifact_count": 0,
|
|
155
|
+
"attributed_prompt_tokens": 0,
|
|
156
|
+
"attributed_completion_tokens": 0,
|
|
157
|
+
"attributed_total_tokens": 0,
|
|
158
|
+
"estimated_cost_usd": 0.0,
|
|
159
|
+
"providers": set(),
|
|
160
|
+
"models": set(),
|
|
161
|
+
"_cost_available": False,
|
|
162
|
+
},
|
|
163
|
+
)
|
|
164
|
+
metric["call_count"] += 1
|
|
165
|
+
if item["status"] == "succeeded":
|
|
166
|
+
metric["success_count"] += 1
|
|
167
|
+
if item["status"] == "failed":
|
|
168
|
+
metric["failure_count"] += 1
|
|
169
|
+
|
|
170
|
+
duration = int(item["duration_ms"] or 0)
|
|
171
|
+
metric["total_duration_ms"] += duration
|
|
172
|
+
metric["max_duration_ms"] = max(metric["max_duration_ms"], duration)
|
|
173
|
+
metric["artifact_count"] += int(item["artifact_count"] or 0)
|
|
174
|
+
if item["provider"]:
|
|
175
|
+
metric["providers"].add(item["provider"])
|
|
176
|
+
if item["model"]:
|
|
177
|
+
metric["models"].add(item["model"])
|
|
178
|
+
|
|
179
|
+
key = item["call_id"] or item["step_id"]
|
|
180
|
+
usage = attribution.get(key, {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0})
|
|
181
|
+
metric["attributed_prompt_tokens"] += usage["prompt_tokens"]
|
|
182
|
+
metric["attributed_completion_tokens"] += usage["completion_tokens"]
|
|
183
|
+
metric["attributed_total_tokens"] += usage["total_tokens"]
|
|
184
|
+
|
|
185
|
+
pricing = None
|
|
186
|
+
for pricing_key in _pricing_key(item["provider"], item["model"]):
|
|
187
|
+
if pricing_key in settings.model_pricing:
|
|
188
|
+
pricing = settings.model_pricing[pricing_key]
|
|
189
|
+
break
|
|
190
|
+
if pricing:
|
|
191
|
+
prompt_rate = float(pricing.get("prompt_per_1k", 0.0))
|
|
192
|
+
completion_rate = float(pricing.get("completion_per_1k", 0.0))
|
|
193
|
+
metric["estimated_cost_usd"] += (
|
|
194
|
+
usage["prompt_tokens"] / 1000.0 * prompt_rate
|
|
195
|
+
+ usage["completion_tokens"] / 1000.0 * completion_rate
|
|
196
|
+
)
|
|
197
|
+
metric["_cost_available"] = True
|
|
198
|
+
|
|
199
|
+
results: list[dict[str, Any]] = []
|
|
200
|
+
for metric in grouped.values():
|
|
201
|
+
call_count = max(1, metric["call_count"])
|
|
202
|
+
results.append(
|
|
203
|
+
{
|
|
204
|
+
"tool_name": metric["tool_name"],
|
|
205
|
+
"call_count": metric["call_count"],
|
|
206
|
+
"success_count": metric["success_count"],
|
|
207
|
+
"failure_count": metric["failure_count"],
|
|
208
|
+
"total_duration_ms": metric["total_duration_ms"],
|
|
209
|
+
"avg_duration_ms": int(metric["total_duration_ms"] / call_count),
|
|
210
|
+
"max_duration_ms": metric["max_duration_ms"],
|
|
211
|
+
"artifact_count": metric["artifact_count"],
|
|
212
|
+
"attributed_prompt_tokens": metric["attributed_prompt_tokens"],
|
|
213
|
+
"attributed_completion_tokens": metric["attributed_completion_tokens"],
|
|
214
|
+
"attributed_total_tokens": metric["attributed_total_tokens"],
|
|
215
|
+
"estimated_cost_usd": round(metric["estimated_cost_usd"], 4) if metric["_cost_available"] else None,
|
|
216
|
+
"providers": sorted(metric["providers"]),
|
|
217
|
+
"models": sorted(metric["models"]),
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
results.sort(key=lambda item: (-item["attributed_total_tokens"], -item["total_duration_ms"], item["tool_name"]))
|
|
221
|
+
return results
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def build_similar_runs(
|
|
225
|
+
session: Session,
|
|
226
|
+
run: Run,
|
|
227
|
+
current_tool_chain: list[dict[str, Any]],
|
|
228
|
+
*,
|
|
229
|
+
limit: int = 5,
|
|
230
|
+
same_agent_only: bool = False,
|
|
231
|
+
status_filter: str | None = None,
|
|
232
|
+
shared_tools_only: bool = False,
|
|
233
|
+
min_score: float = 0.15,
|
|
234
|
+
) -> list[dict[str, Any]]:
|
|
235
|
+
candidates = list(
|
|
236
|
+
session.scalars(
|
|
237
|
+
select(Run)
|
|
238
|
+
.where(Run.id != run.id)
|
|
239
|
+
.order_by(Run.started_at.desc())
|
|
240
|
+
.limit(40)
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
current_goal_tokens = _goal_tokens(run.goal)
|
|
244
|
+
current_tools = {item["tool_name"] for item in current_tool_chain}
|
|
245
|
+
current_scenario = run.run_metadata.get("scenario")
|
|
246
|
+
results: list[dict[str, Any]] = []
|
|
247
|
+
|
|
248
|
+
for candidate in candidates:
|
|
249
|
+
if same_agent_only and candidate.agent_name != run.agent_name:
|
|
250
|
+
continue
|
|
251
|
+
if status_filter and candidate.status != status_filter:
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
candidate_events = list(session.scalars(select(Event).where(Event.run_id == candidate.id).order_by(Event.seq)))
|
|
255
|
+
candidate_tool_chain = build_tool_chain(candidate_events)
|
|
256
|
+
candidate_tools = {item["tool_name"] for item in candidate_tool_chain}
|
|
257
|
+
candidate_goal_tokens = _goal_tokens(candidate.goal)
|
|
258
|
+
|
|
259
|
+
goal_union = current_goal_tokens | candidate_goal_tokens
|
|
260
|
+
tool_union = current_tools | candidate_tools
|
|
261
|
+
goal_similarity = len(current_goal_tokens & candidate_goal_tokens) / len(goal_union) if goal_union else 0.0
|
|
262
|
+
tool_similarity = len(current_tools & candidate_tools) / len(tool_union) if tool_union else 0.0
|
|
263
|
+
score = 0.45 * goal_similarity + 0.2 * tool_similarity
|
|
264
|
+
|
|
265
|
+
reason_parts: list[str] = []
|
|
266
|
+
if candidate.agent_name == run.agent_name:
|
|
267
|
+
score += 0.2
|
|
268
|
+
reason_parts.append("same agent")
|
|
269
|
+
if current_scenario and candidate.run_metadata.get("scenario") == current_scenario:
|
|
270
|
+
score += 0.15
|
|
271
|
+
reason_parts.append("same scenario")
|
|
272
|
+
shared_tools = sorted(current_tools & candidate_tools)
|
|
273
|
+
if shared_tools_only and not shared_tools:
|
|
274
|
+
continue
|
|
275
|
+
if shared_tools:
|
|
276
|
+
reason_parts.append(f"shared tools: {', '.join(shared_tools[:3])}")
|
|
277
|
+
if goal_similarity > 0:
|
|
278
|
+
reason_parts.append("goal overlap")
|
|
279
|
+
|
|
280
|
+
# Keep the score simple and explainable: goal overlap, shared tools and same execution context.
|
|
281
|
+
if score < min_score:
|
|
282
|
+
continue
|
|
283
|
+
|
|
284
|
+
results.append(
|
|
285
|
+
{
|
|
286
|
+
"run_id": candidate.id,
|
|
287
|
+
"agent_name": candidate.agent_name,
|
|
288
|
+
"goal": candidate.goal,
|
|
289
|
+
"status": candidate.status,
|
|
290
|
+
"started_at": candidate.started_at,
|
|
291
|
+
"ended_at": candidate.ended_at,
|
|
292
|
+
"duration_ms": _duration_ms(candidate),
|
|
293
|
+
"tool_count": candidate.tool_count,
|
|
294
|
+
"error_count": candidate.error_count,
|
|
295
|
+
"retry_count": candidate.retry_count,
|
|
296
|
+
"provider": candidate.provider,
|
|
297
|
+
"model": candidate.model,
|
|
298
|
+
"similarity_score": round(min(score, 1.0), 3),
|
|
299
|
+
"shared_tools": shared_tools,
|
|
300
|
+
"reason": ", ".join(reason_parts) if reason_parts else "similar recent run",
|
|
301
|
+
}
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
results.sort(key=lambda item: (item["similarity_score"], item["started_at"]), reverse=True)
|
|
305
|
+
return results[:limit]
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from trace_agent_server.config import Settings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
|
|
11
|
+
PHONE_RE = re.compile(r"\+?[0-9][0-9\s().-]{7,}[0-9]")
|
|
12
|
+
CREDIT_CARD_RE = re.compile(r"(?:\d[ -]*?){13,19}")
|
|
13
|
+
SECRET_TOKEN_RE = re.compile(r"\b(?:sk-[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{20,})\b")
|
|
14
|
+
|
|
15
|
+
PROMPT_INJECTION_PATTERNS = (
|
|
16
|
+
"ignore previous instructions",
|
|
17
|
+
"ignore all previous instructions",
|
|
18
|
+
"disregard previous instructions",
|
|
19
|
+
"reveal the system prompt",
|
|
20
|
+
"show hidden instructions",
|
|
21
|
+
"developer message",
|
|
22
|
+
"system prompt",
|
|
23
|
+
"act as",
|
|
24
|
+
"jailbreak",
|
|
25
|
+
"do anything now",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class GuardrailCheckResult:
|
|
31
|
+
content: str
|
|
32
|
+
findings: list[dict[str, Any]]
|
|
33
|
+
blocked: bool = False
|
|
34
|
+
block_reason: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AuditGuardrails:
|
|
38
|
+
"""Applies best-effort guardrails for prompt injection and PII leakage.
|
|
39
|
+
|
|
40
|
+
All integrations are optional. If third-party libraries are not available,
|
|
41
|
+
lightweight regex heuristics are used instead.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, settings: Settings, observability: Any | None = None) -> None:
|
|
45
|
+
self.enabled = settings.audit_enable_guardrails
|
|
46
|
+
self.fail_on_prompt_injection = settings.audit_fail_on_prompt_injection
|
|
47
|
+
self.enable_pii_anonymization = settings.audit_enable_pii_anonymization
|
|
48
|
+
self.prompt_injection_threshold = max(0.0, min(1.0, settings.audit_prompt_injection_threshold))
|
|
49
|
+
self.pii_entities = settings.audit_pii_entities
|
|
50
|
+
self.observability = observability
|
|
51
|
+
|
|
52
|
+
self._analyzer = None
|
|
53
|
+
self._anonymizer = None
|
|
54
|
+
self._llm_guard_scanner = None
|
|
55
|
+
self._initialize_optional_engines()
|
|
56
|
+
|
|
57
|
+
def sanitize_messages(
|
|
58
|
+
self,
|
|
59
|
+
messages: list[dict[str, Any]],
|
|
60
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], bool, str | None]:
|
|
61
|
+
if not self.enabled:
|
|
62
|
+
return messages, [], False, None
|
|
63
|
+
|
|
64
|
+
sanitized_messages: list[dict[str, Any]] = []
|
|
65
|
+
findings: list[dict[str, Any]] = []
|
|
66
|
+
blocked = False
|
|
67
|
+
block_reason: str | None = None
|
|
68
|
+
|
|
69
|
+
for index, message in enumerate(messages):
|
|
70
|
+
updated = dict(message)
|
|
71
|
+
content = message.get("content")
|
|
72
|
+
if isinstance(content, str):
|
|
73
|
+
pii_check = self._apply_pii_filters(content)
|
|
74
|
+
injection_findings, injection_score = self._scan_prompt_injection(pii_check.content)
|
|
75
|
+
|
|
76
|
+
for finding in pii_check.findings:
|
|
77
|
+
findings.append({**finding, "scope": "input", "message_index": index})
|
|
78
|
+
for finding in injection_findings:
|
|
79
|
+
findings.append({**finding, "scope": "input", "message_index": index})
|
|
80
|
+
|
|
81
|
+
if self.fail_on_prompt_injection and injection_score >= self.prompt_injection_threshold:
|
|
82
|
+
blocked = True
|
|
83
|
+
block_reason = "prompt_injection"
|
|
84
|
+
|
|
85
|
+
updated["content"] = pii_check.content
|
|
86
|
+
|
|
87
|
+
sanitized_messages.append(updated)
|
|
88
|
+
|
|
89
|
+
self._record_findings(findings, blocked=blocked, block_reason=block_reason)
|
|
90
|
+
return sanitized_messages, findings, blocked, block_reason
|
|
91
|
+
|
|
92
|
+
def sanitize_assistant_message(
|
|
93
|
+
self,
|
|
94
|
+
message: dict[str, Any] | None,
|
|
95
|
+
) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
|
|
96
|
+
if not self.enabled or message is None:
|
|
97
|
+
return message, []
|
|
98
|
+
|
|
99
|
+
updated = dict(message)
|
|
100
|
+
findings: list[dict[str, Any]] = []
|
|
101
|
+
|
|
102
|
+
content = message.get("content")
|
|
103
|
+
if isinstance(content, str):
|
|
104
|
+
pii_check = self._apply_pii_filters(content)
|
|
105
|
+
secret_findings, scrubbed_content = self._scrub_secrets(pii_check.content)
|
|
106
|
+
updated["content"] = scrubbed_content
|
|
107
|
+
|
|
108
|
+
findings.extend({**finding, "scope": "output"} for finding in pii_check.findings)
|
|
109
|
+
findings.extend({**finding, "scope": "output"} for finding in secret_findings)
|
|
110
|
+
|
|
111
|
+
self._record_findings(findings, blocked=False, block_reason=None)
|
|
112
|
+
return updated, findings
|
|
113
|
+
|
|
114
|
+
def _initialize_optional_engines(self) -> None:
|
|
115
|
+
if self.enable_pii_anonymization:
|
|
116
|
+
try:
|
|
117
|
+
from presidio_analyzer import AnalyzerEngine # type: ignore
|
|
118
|
+
from presidio_anonymizer import AnonymizerEngine # type: ignore
|
|
119
|
+
|
|
120
|
+
self._analyzer = AnalyzerEngine()
|
|
121
|
+
self._anonymizer = AnonymizerEngine()
|
|
122
|
+
except Exception:
|
|
123
|
+
self._analyzer = None
|
|
124
|
+
self._anonymizer = None
|
|
125
|
+
|
|
126
|
+
if self.enabled:
|
|
127
|
+
try:
|
|
128
|
+
from llm_guard.input_scanners import PromptInjection # type: ignore
|
|
129
|
+
|
|
130
|
+
self._llm_guard_scanner = PromptInjection(threshold=self.prompt_injection_threshold)
|
|
131
|
+
except Exception:
|
|
132
|
+
self._llm_guard_scanner = None
|
|
133
|
+
|
|
134
|
+
def _apply_pii_filters(self, content: str) -> GuardrailCheckResult:
|
|
135
|
+
if not self.enable_pii_anonymization:
|
|
136
|
+
return GuardrailCheckResult(content=content, findings=[])
|
|
137
|
+
|
|
138
|
+
if self._analyzer is not None and self._anonymizer is not None:
|
|
139
|
+
return self._apply_presidio(content)
|
|
140
|
+
|
|
141
|
+
return self._apply_regex_pii_fallback(content)
|
|
142
|
+
|
|
143
|
+
def _apply_presidio(self, content: str) -> GuardrailCheckResult:
|
|
144
|
+
try:
|
|
145
|
+
analyzer_results = self._analyzer.analyze(
|
|
146
|
+
text=content,
|
|
147
|
+
entities=self.pii_entities or None,
|
|
148
|
+
language="en",
|
|
149
|
+
)
|
|
150
|
+
if not analyzer_results:
|
|
151
|
+
return GuardrailCheckResult(content=content, findings=[])
|
|
152
|
+
|
|
153
|
+
anonymized = self._anonymizer.anonymize(
|
|
154
|
+
text=content,
|
|
155
|
+
analyzer_results=analyzer_results,
|
|
156
|
+
)
|
|
157
|
+
findings = [
|
|
158
|
+
{
|
|
159
|
+
"kind": "pii",
|
|
160
|
+
"severity": "medium",
|
|
161
|
+
"source": "presidio",
|
|
162
|
+
"entity": result.entity_type,
|
|
163
|
+
"score": round(float(result.score), 4),
|
|
164
|
+
"start": int(result.start),
|
|
165
|
+
"end": int(result.end),
|
|
166
|
+
"message": f"Detected {result.entity_type}.",
|
|
167
|
+
}
|
|
168
|
+
for result in analyzer_results
|
|
169
|
+
]
|
|
170
|
+
return GuardrailCheckResult(content=anonymized.text, findings=findings)
|
|
171
|
+
except Exception:
|
|
172
|
+
return self._apply_regex_pii_fallback(content)
|
|
173
|
+
|
|
174
|
+
def _apply_regex_pii_fallback(self, content: str) -> GuardrailCheckResult:
|
|
175
|
+
findings: list[dict[str, Any]] = []
|
|
176
|
+
scrubbed = content
|
|
177
|
+
|
|
178
|
+
for pattern, replacement, entity in (
|
|
179
|
+
(EMAIL_RE, "[redacted:email]", "EMAIL_ADDRESS"),
|
|
180
|
+
(PHONE_RE, "[redacted:phone]", "PHONE_NUMBER"),
|
|
181
|
+
(CREDIT_CARD_RE, "[redacted:card]", "CREDIT_CARD"),
|
|
182
|
+
):
|
|
183
|
+
matches = list(pattern.finditer(scrubbed))
|
|
184
|
+
if not matches:
|
|
185
|
+
continue
|
|
186
|
+
scrubbed = pattern.sub(replacement, scrubbed)
|
|
187
|
+
findings.append(
|
|
188
|
+
{
|
|
189
|
+
"kind": "pii",
|
|
190
|
+
"severity": "medium",
|
|
191
|
+
"source": "regex",
|
|
192
|
+
"entity": entity,
|
|
193
|
+
"count": len(matches),
|
|
194
|
+
"message": f"Detected {len(matches)} {entity} match(es).",
|
|
195
|
+
}
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
return GuardrailCheckResult(content=scrubbed, findings=findings)
|
|
199
|
+
|
|
200
|
+
def _scan_prompt_injection(self, content: str) -> tuple[list[dict[str, Any]], float]:
|
|
201
|
+
findings: list[dict[str, Any]] = []
|
|
202
|
+
max_score = 0.0
|
|
203
|
+
|
|
204
|
+
normalized = content.lower()
|
|
205
|
+
matched = [pattern for pattern in PROMPT_INJECTION_PATTERNS if pattern in normalized]
|
|
206
|
+
if matched:
|
|
207
|
+
score = min(1.0, 0.35 + (0.15 * len(matched)))
|
|
208
|
+
max_score = max(max_score, score)
|
|
209
|
+
findings.append(
|
|
210
|
+
{
|
|
211
|
+
"kind": "prompt_injection",
|
|
212
|
+
"severity": "high" if score >= self.prompt_injection_threshold else "medium",
|
|
213
|
+
"source": "heuristic",
|
|
214
|
+
"score": round(score, 4),
|
|
215
|
+
"patterns": matched,
|
|
216
|
+
"message": "Detected patterns commonly associated with prompt injection.",
|
|
217
|
+
}
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
if self._llm_guard_scanner is not None:
|
|
221
|
+
llm_guard_findings, llm_guard_score = self._scan_with_llm_guard(content)
|
|
222
|
+
findings.extend(llm_guard_findings)
|
|
223
|
+
max_score = max(max_score, llm_guard_score)
|
|
224
|
+
|
|
225
|
+
return findings, max_score
|
|
226
|
+
|
|
227
|
+
def _scan_with_llm_guard(self, content: str) -> tuple[list[dict[str, Any]], float]:
|
|
228
|
+
try:
|
|
229
|
+
scan_result = self._llm_guard_scanner.scan(content)
|
|
230
|
+
except Exception:
|
|
231
|
+
return [], 0.0
|
|
232
|
+
|
|
233
|
+
score = 0.0
|
|
234
|
+
verdict = "unknown"
|
|
235
|
+
if isinstance(scan_result, tuple):
|
|
236
|
+
for value in scan_result:
|
|
237
|
+
if isinstance(value, bool):
|
|
238
|
+
verdict = "safe" if value else "unsafe"
|
|
239
|
+
if isinstance(value, (int, float)):
|
|
240
|
+
score = max(score, float(value))
|
|
241
|
+
if isinstance(value, dict):
|
|
242
|
+
score = max(score, self._extract_score_from_dict(value))
|
|
243
|
+
elif isinstance(scan_result, dict):
|
|
244
|
+
score = self._extract_score_from_dict(scan_result)
|
|
245
|
+
unsafe = scan_result.get("is_valid") is False
|
|
246
|
+
verdict = "unsafe" if unsafe else "safe"
|
|
247
|
+
|
|
248
|
+
if score <= 0:
|
|
249
|
+
return [], 0.0
|
|
250
|
+
|
|
251
|
+
finding = {
|
|
252
|
+
"kind": "prompt_injection",
|
|
253
|
+
"severity": "high" if score >= self.prompt_injection_threshold else "medium",
|
|
254
|
+
"source": "llm_guard",
|
|
255
|
+
"score": round(score, 4),
|
|
256
|
+
"message": f"LLM Guard classified prompt as {verdict}.",
|
|
257
|
+
}
|
|
258
|
+
return [finding], score
|
|
259
|
+
|
|
260
|
+
@staticmethod
|
|
261
|
+
def _extract_score_from_dict(value: dict[str, Any]) -> float:
|
|
262
|
+
candidates = (
|
|
263
|
+
value.get("risk"),
|
|
264
|
+
value.get("risk_score"),
|
|
265
|
+
value.get("score"),
|
|
266
|
+
value.get("confidence"),
|
|
267
|
+
)
|
|
268
|
+
for candidate in candidates:
|
|
269
|
+
if isinstance(candidate, (int, float)):
|
|
270
|
+
return float(candidate)
|
|
271
|
+
return 0.0
|
|
272
|
+
|
|
273
|
+
def _scrub_secrets(self, content: str) -> tuple[list[dict[str, Any]], str]:
|
|
274
|
+
matches = list(SECRET_TOKEN_RE.finditer(content))
|
|
275
|
+
if not matches:
|
|
276
|
+
return [], content
|
|
277
|
+
|
|
278
|
+
scrubbed = SECRET_TOKEN_RE.sub("[redacted:secret]", content)
|
|
279
|
+
findings = [
|
|
280
|
+
{
|
|
281
|
+
"kind": "secret",
|
|
282
|
+
"severity": "high",
|
|
283
|
+
"source": "regex",
|
|
284
|
+
"count": len(matches),
|
|
285
|
+
"message": "Detected possible API tokens in assistant output.",
|
|
286
|
+
}
|
|
287
|
+
]
|
|
288
|
+
return findings, scrubbed
|
|
289
|
+
|
|
290
|
+
def _record_findings(self, findings: list[dict[str, Any]], *, blocked: bool, block_reason: str | None) -> None:
|
|
291
|
+
if self.observability is None:
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
try:
|
|
295
|
+
for finding in findings:
|
|
296
|
+
self.observability.record_guardrail_finding(
|
|
297
|
+
kind=str(finding.get("kind") or "unknown"),
|
|
298
|
+
severity=str(finding.get("severity") or "unknown"),
|
|
299
|
+
)
|
|
300
|
+
if blocked:
|
|
301
|
+
self.observability.record_guardrail_block(block_reason or "blocked")
|
|
302
|
+
except Exception:
|
|
303
|
+
return
|